Browse Source

feat: close #580 export messages as image

Yidadaa 1 year ago
parent
commit
4dad7f2ab6

+ 9 - 44
app/components/chat.tsx

@@ -7,7 +7,6 @@ import RenameIcon from "../icons/rename.svg";
 import ExportIcon from "../icons/share.svg";
 import ReturnIcon from "../icons/return.svg";
 import CopyIcon from "../icons/copy.svg";
-import DownloadIcon from "../icons/download.svg";
 import LoadingIcon from "../icons/three-dots.svg";
 import PromptIcon from "../icons/prompt.svg";
 import MaskIcon from "../icons/mask.svg";
@@ -53,7 +52,7 @@ import { IconButton } from "./button";
 import styles from "./home.module.scss";
 import chatStyle from "./chat.module.scss";
 
-import { ListItem, Modal, showModal, showToast } from "./ui-lib";
+import { ListItem, Modal } from "./ui-lib";
 import { useLocation, useNavigate } from "react-router-dom";
 import { LAST_INPUT_KEY, Path, REQUEST_TIMEOUT_MS } from "../constant";
 import { Avatar } from "./emoji";
@@ -61,49 +60,12 @@ import { MaskAvatar, MaskConfig } from "./mask";
 import { useMaskStore } from "../store/mask";
 import { useCommand } from "../command";
 import { prettyObject } from "../utils/format";
+import { ExportMessageModal } from "./exporter";
 
 const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
   loading: () => <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 - 0
app/components/exporter.module.scss

@@ -0,0 +1,212 @@
+.message-exporter {
+  &-body {
+    margin-top: 20px;
+  }
+}
+
+.export-content {
+  white-space: break-spaces;
+  padding: 10px !important;
+}
+
+.steps {
+  background-color: var(--gray);
+  border-radius: 10px;
+  overflow: hidden;
+  padding: 5px;
+  position: relative;
+  box-shadow: var(--card-shadow) inset;
+
+  .steps-progress {
+    $padding: 5px;
+    height: calc(100% - 2 * $padding);
+    width: calc(100% - 2 * $padding);
+    position: absolute;
+    top: $padding;
+    left: $padding;
+
+    &-inner {
+      box-sizing: border-box;
+      box-shadow: var(--card-shadow);
+      border: var(--border-in-light);
+      content: "";
+      display: inline-block;
+      width: 0%;
+      height: 100%;
+      background-color: var(--white);
+      transition: all ease 0.3s;
+      border-radius: 8px;
+    }
+  }
+
+  .steps-inner {
+    display: flex;
+    transform: scale(1);
+
+    .step {
+      flex-grow: 1;
+      padding: 5px 10px;
+      font-size: 14px;
+      color: var(--black);
+      opacity: 0.5;
+      transition: all ease 0.3s;
+
+      display: flex;
+      align-items: center;
+      justify-content: center;
+
+      $radius: 8px;
+
+      &-finished {
+        opacity: 0.9;
+      }
+
+      &:hover {
+        opacity: 0.8;
+      }
+
+      &-current {
+        color: var(--primary);
+      }
+
+      .step-index {
+        background-color: var(--gray);
+        border: var(--border-in-light);
+        border-radius: 6px;
+        display: inline-block;
+        padding: 0px 5px;
+        font-size: 12px;
+        margin-right: 8px;
+        opacity: 0.8;
+      }
+
+      .step-name {
+        font-size: 12px;
+      }
+    }
+  }
+}
+
+.preview-actions {
+  margin-bottom: 20px;
+  display: flex;
+  justify-content: space-between;
+
+  button {
+    flex-grow: 1;
+    &:not(:last-child) {
+      margin-right: 10px;
+    }
+  }
+}
+
+.image-previewer {
+  .preview-body {
+    border-radius: 10px;
+    padding: 20px;
+    box-shadow: var(--card-shadow) inset;
+    background-color: var(--gray);
+
+    .chat-info {
+      background-color: var(--second);
+      padding: 20px;
+      border-radius: 10px;
+      margin-bottom: 20px;
+      display: flex;
+      justify-content: space-between;
+      align-items: flex-end;
+      position: relative;
+      overflow: hidden;
+
+      @media screen and (max-width: 600px) {
+        flex-direction: column;
+        align-items: flex-start;
+
+        .icons {
+          margin-bottom: 20px;
+        }
+      }
+
+      .logo {
+        position: absolute;
+        top: 0px;
+        left: 0px;
+        transform: scale(2);
+      }
+
+      .main-title {
+        font-size: 20px;
+        font-weight: bolder;
+      }
+
+      .sub-title {
+        font-size: 12px;
+      }
+
+      .icons {
+        margin-top: 10px;
+        display: flex;
+        align-items: center;
+
+        .icon-space {
+          font-size: 12px;
+          margin: 0 10px;
+          font-weight: bolder;
+          color: var(--primary);
+        }
+      }
+
+      .chat-info-item {
+        font-size: 12px;
+        color: var(--primary);
+        padding: 2px 15px;
+        border-radius: 10px;
+        background-color: var(--white);
+        box-shadow: var(--card-shadow);
+
+        &:not(:last-child) {
+          margin-bottom: 5px;
+        }
+      }
+    }
+
+    .message {
+      margin-bottom: 20px;
+      display: flex;
+
+      .avatar {
+        margin-right: 10px;
+      }
+
+      .body {
+        border-radius: 10px;
+        padding: 8px 10px;
+        max-width: calc(100% - 104px);
+        box-shadow: var(--card-shadow);
+        border: var(--border-in-light);
+      }
+
+      &-assistant {
+        .body {
+          background-color: var(--white);
+        }
+      }
+
+      &-user {
+        flex-direction: row-reverse;
+
+        .avatar {
+          margin-right: 0;
+        }
+
+        .body {
+          background-color: var(--second);
+          margin-right: 10px;
+        }
+      }
+    }
+  }
+
+  .default-theme {
+  }
+}

+ 398 - 0
app/components/exporter.tsx

@@ -0,0 +1,398 @@
+import { ChatMessage, useAppConfig, useChatStore } from "../store";
+import Locale from "../locales";
+import styles from "./exporter.module.scss";
+import { List, ListItem, Modal, showToast } from "./ui-lib";
+import { IconButton } from "./button";
+import { copyToClipboard, downloadAs } from "../utils";
+
+import CopyIcon from "../icons/copy.svg";
+import LoadingIcon from "../icons/three-dots.svg";
+import ChatGptIcon from "../icons/chatgpt.svg";
+import ShareIcon from "../icons/share.svg";
+
+import DownloadIcon from "../icons/download.svg";
+import { useMemo, useRef, useState } from "react";
+import { MessageSelector, useMessageSelector } from "./message-selector";
+import { Avatar } from "./emoji";
+import { MaskAvatar } from "./mask";
+import dynamic from "next/dynamic";
+
+import { toBlob, toPng } from "html-to-image";
+
+const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
+  loading: () => <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>
+    </>
+  );
+}

+ 0 - 5
app/components/home.module.scss

@@ -558,11 +558,6 @@
   }
 }
 
-.export-content {
-  white-space: break-spaces;
-  padding: 10px !important;
-}
-
 .loading-content {
   display: flex;
   flex-direction: column;

+ 2 - 2
app/components/markdown.tsx

@@ -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 - 0
app/components/message-selector.module.scss

@@ -0,0 +1,55 @@
+.message-selector {
+  .message-filter {
+    display: flex;
+
+    .search-bar {
+      max-width: unset;
+      flex-grow: 1;
+    }
+
+    .filter-item:not(:last-child) {
+      margin-right: 10px;
+    }
+  }
+
+  .messages {
+    margin-top: 20px;
+    border-radius: 10px;
+    border: var(--border-in-light);
+    overflow: hidden;
+
+    .message {
+      display: flex;
+      align-items: center;
+      padding: 8px 10px;
+      cursor: pointer;
+
+      &-selected {
+        background-color: var(--second);
+      }
+
+      &:not(:last-child) {
+        border-bottom: var(--border-in-light);
+      }
+
+      .avatar {
+        margin-right: 10px;
+      }
+
+      .body {
+        flex-grow: 1;
+        max-width: calc(100% - 40px);
+
+        .date {
+          font-size: 12px;
+          line-height: 1.2;
+          opacity: 0.5;
+        }
+
+        .content {
+          font-size: 12px;
+        }
+      }
+    }
+  }
+}

+ 211 - 0
app/components/message-selector.tsx

@@ -0,0 +1,211 @@
+import { useEffect, useState } from "react";
+import { ChatMessage, useAppConfig, useChatStore } from "../store";
+import { Updater } from "../typing";
+import { IconButton } from "./button";
+import { Avatar } from "./emoji";
+import { MaskAvatar } from "./mask";
+import Locale from "../locales";
+
+import styles from "./message-selector.module.scss";
+
+function useShiftRange() {
+  const [startIndex, setStartIndex] = useState<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>
+  );
+}

+ 20 - 1
app/locales/cn.ts

@@ -36,11 +36,30 @@ const cn = {
     },
   },
   Export: {
-    Title: "导出聊天记录为 Markdown",
+    Title: "分享聊天记录",
     Copy: "全部复制",
     Download: "下载文件",
+    Share: "分享到 ShareGPT",
     MessageFromYou: "来自你的消息",
     MessageFromChatGPT: "来自 ChatGPT 的消息",
+    Format: {
+      Title: "导出格式",
+      SubTitle: "可以导出 Markdown 文本或者 PNG 图片",
+    },
+    IncludeContext: {
+      Title: "包含面具上下文",
+      SubTitle: "是否在消息中展示面具上下文",
+    },
+    Steps: {
+      Select: "选取",
+      Preview: "预览",
+    },
+  },
+  Select: {
+    Search: "搜索消息",
+    All: "选取全部",
+    Latest: "最近十条",
+    Clear: "清除选中",
   },
   Memory: {
     Title: "历史摘要",

+ 20 - 1
app/locales/en.ts

@@ -37,11 +37,30 @@ const en: RequiredLocaleType = {
     },
   },
   Export: {
-    Title: "All Messages",
+    Title: "Export Messages",
     Copy: "Copy All",
     Download: "Download",
     MessageFromYou: "Message From You",
     MessageFromChatGPT: "Message From ChatGPT",
+    Share: "Share to ShareGPT",
+    Format: {
+      Title: "Export Format",
+      SubTitle: "Markdown or PNG Image",
+    },
+    IncludeContext: {
+      Title: "Including Context",
+      SubTitle: "Export context prompts in mask or not",
+    },
+    Steps: {
+      Select: "Select",
+      Preview: "Preview",
+    },
+  },
+  Select: {
+    Search: "Search",
+    All: "Select All",
+    Latest: "Select Latest",
+    Clear: "Clear",
   },
   Memory: {
     Title: "Memory Prompt",

+ 2 - 1
package.json

@@ -13,12 +13,13 @@
     "proxy-dev": "sh ./scripts/init-proxy.sh && proxychains -f ./scripts/proxychains.conf yarn dev"
   },
   "dependencies": {
-    "@hello-pangea/dnd": "^16.2.0",
     "@fortaine/fetch-event-source": "^3.0.6",
+    "@hello-pangea/dnd": "^16.2.0",
     "@svgr/webpack": "^6.5.1",
     "@vercel/analytics": "^0.1.11",
     "emoji-picker-react": "^4.4.7",
     "fuse.js": "^6.6.2",
+    "html-to-image": "^1.11.11",
     "mermaid": "^10.1.0",
     "next": "^13.4.3",
     "node-fetch": "^3.3.1",

+ 5 - 0
yarn.lock

@@ -3215,6 +3215,11 @@ hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2:
   dependencies:
     react-is "^16.7.0"
 
+html-to-image@^1.11.11:
+  version "1.11.11"
+  resolved "https://registry.npmmirror.com/html-to-image/-/html-to-image-1.11.11.tgz#c0f8a34dc9e4b97b93ff7ea286eb8562642ebbea"
+  integrity sha512-9gux8QhvjRO/erSnDPv28noDZcPZmYE7e1vFsBLKLlRlKDSqNJYebj6Qz1TGd5lsRV+X+xYyjCKjuZdABinWjA==
+
 human-signals@^4.3.0:
   version "4.3.1"
   resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-4.3.1.tgz#ab7f811e851fca97ffbd2c1fe9a958964de321b2"