Browse Source

feat: add export to .md button

Yifei Zhang 2 years ago
parent
commit
bab470d000

+ 1 - 11
app/api/chat/route.ts

@@ -1,7 +1,6 @@
 import { OpenAIApi, Configuration } from "openai";
 import { ChatRequest } from "./typing";
 
-const isProd = process.env.NODE_ENV === "production";
 const apiKey = process.env.OPENAI_API_KEY;
 
 const openai = new OpenAIApi(
@@ -16,16 +15,7 @@ export async function POST(req: Request) {
     const completion = await openai!.createChatCompletion(
       {
         ...requestBody,
-      },
-      isProd
-        ? {}
-        : {
-            proxy: {
-              protocol: "socks",
-              host: "127.0.0.1",
-              port: 7890,
-            },
-          }
+      }
     );
 
     return new Response(JSON.stringify(completion.data));

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

@@ -328,4 +328,8 @@
   position: absolute;
   right: 30px;
   bottom: 10px;
+}
+
+.export-content {
+  white-space: break-spaces;
 }

+ 24 - 1
app/components/home.tsx

@@ -23,9 +23,13 @@ import DeleteIcon from "../icons/delete.svg";
 import LoadingIcon from "../icons/three-dots.svg";
 import MenuIcon from "../icons/menu.svg";
 import CloseIcon from "../icons/close.svg";
+import CopyIcon from "../icons/copy.svg";
+import DownloadIcon from "../icons/download.svg";
 
 import { Message, SubmitKey, useChatStore, Theme } from "../store";
 import { Settings } from "./settings";
+import { showModal } from "./ui-lib";
+import { copyToClipboard, downloadAs } from "../utils";
 
 export function Markdown(props: { content: string }) {
   return (
@@ -208,7 +212,10 @@ export function Chat(props: { showSideBar?: () => void }) {
             <IconButton
               icon={<ExportIcon />}
               bordered
-              title="导出聊天记录为 Markdown(开发中)"
+              title="导出聊天记录"
+              onClick={() => {
+                exportMessages(session.messages, session.topic)
+              }}
             />
           </div>
         </div>
@@ -294,6 +301,22 @@ function useSwitchTheme() {
   }, [config.theme]);
 }
 
+function exportMessages(messages: Message[], topic: string) {
+  const mdText = `# ${topic}\n\n` + messages.map(m => {
+    return m.role === 'user' ? `## ${m.content}` : m.content.trim()
+  }).join('\n\n')
+  const filename = `${topic}.md`
+
+  showModal({
+    title: "导出聊天记录为 Markdown", children: <div className="markdown-body">
+      <pre className={styles['export-content']}>{mdText}</pre>
+    </div>, actions: [
+      <IconButton icon={<CopyIcon />} bordered text="全部复制" onClick={() => copyToClipboard(mdText)} />,
+      <IconButton icon={<DownloadIcon />} bordered text="下载文件" onClick={() => downloadAs(mdText, filename)} />
+    ]
+  })
+}
+
 export function Home() {
   const [createNewSession] = useChatStore((state) => [state.newSession]);
   const loading = !useChatStore?.persist?.hasHydrated();

+ 66 - 0
app/components/ui-lib.module.scss

@@ -29,6 +29,7 @@
     transform: translateY(10px);
     opacity: 0;
   }
+
   to {
     transform: translateY(0);
     opacity: 1;
@@ -56,3 +57,68 @@
 .list .list-item:last-child {
   border: 0;
 }
+
+
+
+.modal-container {
+  box-shadow: var(--card-shadow);
+  background-color: var(--white);
+  border-radius: 12px;
+  width: 50vw;
+
+  --modal-padding: 20px;
+
+  .modal-header {
+    padding: var(--modal-padding);
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    border-bottom: var(--border-in-light);
+
+    .modal-title {
+      font-weight: bolder;
+      font-size: 16px;
+    }
+
+    .modal-close-btn {
+      cursor: pointer;
+
+      &:hover {
+        filter: brightness(1.2);
+      }
+    }
+  }
+
+  .modal-content {
+    height: 40vh;
+    padding: var(--modal-padding);
+    overflow: auto;
+  }
+
+  .modal-footer {
+    padding: var(--modal-padding);
+    display: flex;
+    justify-content: flex-end;
+
+    .modal-actions {
+      display: flex;
+      align-items: center;
+
+      .modal-action {
+        &:not(:last-child) {
+          margin-right: 20px;
+        }
+      }
+    }
+  }
+}
+
+@media only screen and (max-width: 600px) {
+  .modal-container {
+    width: 90vw;
+
+    .modal-content {
+      height: 50vh;
+    }
+  }
+}

+ 42 - 0
app/components/ui-lib.tsx

@@ -1,5 +1,8 @@
 import styles from "./ui-lib.module.scss";
 import LoadingIcon from "../icons/three-dots.svg";
+import CloseIcon from "../icons/close.svg";
+import { createRoot } from 'react-dom/client'
+import { IconButton } from "./button";
 
 export function Popover(props: {
   children: JSX.Element;
@@ -46,4 +49,43 @@ export function Loading() {
     alignItems: "center",
     justifyContent: "center"
   }}><LoadingIcon /></div>
+}
+
+interface ModalProps {
+  title: string,
+  children?: JSX.Element,
+  actions?: JSX.Element[],
+  onClose?: () => void,
+}
+export function Modal(props: ModalProps) {
+  return <div className={styles['modal-container']}>
+    <div className={styles['modal-header']}>
+      <div className={styles['modal-title']}>{props.title}</div>
+
+      <div className={styles['modal-close-btn']} onClick={props.onClose}>
+        <CloseIcon />
+      </div>
+    </div>
+
+    <div className={styles['modal-content']}>{props.children}</div>
+
+    <div className={styles['modal-footer']}>
+      <div className={styles['modal-actions']}>
+        {props.actions?.map(action => <div className={styles['modal-action']}>{action}</div>)}
+      </div>
+    </div>
+  </div>
+}
+
+export function showModal(props: ModalProps) {
+  const div = document.createElement('div')
+  div.className = "modal-mask";
+  document.body.appendChild(div)
+
+  const root = createRoot(div)
+  root.render(<Modal {...props} onClose={() => {
+    props.onClose?.();
+    root.unmount();
+    div.remove();
+  }}></Modal>)
 }

+ 16 - 3
app/globals.scss

@@ -6,7 +6,7 @@
   --primary: rgb(29, 147, 171);
   --second: rgb(231, 248, 255);
   --hover-color: #f3f3f3;
-  --bar-color: var(--primary);
+  --bar-color: rgba(0, 0, 0, 0.1);
 
   /* shadow */
   --shadow: 50px 50px 100px 10px rgb(0, 0, 0, 0.1);
@@ -25,7 +25,7 @@
   --second: rgb(27 38 42);
   --hover-color: #323232;
 
-  --bar-color: var(--primary);
+  --bar-color: rgba(255, 255, 255, 0.1);
 
   --border-in-light: 1px solid rgba(255, 255, 255, 0.192);
 }
@@ -82,7 +82,7 @@ body {
 }
 
 ::-webkit-scrollbar {
-  --bar-width: 1px;
+  --bar-width: 5px;
   width: var(--bar-width);
   height: var(--bar-width);
 }
@@ -162,4 +162,17 @@ input[type="range"]::-webkit-slider-thumb:hover {
 
 div.math {
   overflow-x: auto;
+}
+
+.modal-mask {
+  z-index: 9999;
+  position: fixed;
+  top: 0;
+  left: 0;
+  height: 100vh;
+  width: 100vw;
+  background-color: rgba($color: #000000, $alpha: 0.5);
+  display: flex;
+  align-items: center;
+  justify-content: center;
 }

+ 1 - 0
app/icons/copy.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" viewBox="0 0 16 16" fill="none"><defs><rect id="path_0" x="0" y="0" width="16" height="16" /></defs><g opacity="1" transform="translate(0 0)  rotate(0 8 8)"><mask id="bg-mask-0" fill="white"><use xlink:href="#path_0"></use></mask><g mask="url(#bg-mask-0)" ><path  id="路径 1" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(4.333333333333333 1.6666666666666665)  rotate(0 5 5)" d="M0,2.48L0,0.94C0,0.42 0.42,0 0.94,0L9.06,0C9.58,0 10,0.42 10,0.94L10,9.06C10,9.58 9.58,10 9.06,10L7.51,10 " /><path  id="路径 2" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(1.6666666666666665 4.333333333333333)  rotate(0 5 5)" d="M0.94,0C0.42,0 0,0.42 0,0.94L0,9.06C0,9.58 0.42,10 0.94,10L9.06,10C9.58,10 10,9.58 10,9.06L10,0.94C10,0.42 9.58,0 9.06,0L0.94,0Z " /></g></g></svg>

+ 1 - 0
app/icons/download.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" viewBox="0 0 16 16" fill="none"><defs><rect id="path_0" x="0" y="0" width="16" height="16" /></defs><g opacity="1" transform="translate(0 0)  rotate(0 8 8)"><mask id="bg-mask-0" fill="white"><use xlink:href="#path_0"></use></mask><g mask="url(#bg-mask-0)" ><path  id="路径 1" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(2 2)  rotate(0 6 6)" d="M1,12L11,12C11.55,12 12,11.55 12,11L12,1C12,0.45 11.55,0 11,0L1,0C0.45,0 0,0.45 0,1L0,11C0,11.55 0.45,12 1,12Z " /><path  id="路径 2" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(1.3333333333333333 10.333333333333332)  rotate(0 6.666666666666666 0.6666666666666666)" d="M0,0L3.67,0L4.33,1.33L9,1.33L9.67,0L13.33,0 " /><path  id="路径 3" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(14 8.666666666666666)  rotate(0 0 1.6666666666666665)" d="M0,3.33L0,0 " /><path  id="路径 4" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(6 7.333333333333333)  rotate(0 2 1)" d="M0,0L2,2L4,0 " /><path  id="路径 5" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(8 4)  rotate(0 0 2.6666666666666665)" d="M0,5.33L0,0 " /><path  id="路径 6" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(2 8.666666666666666)  rotate(0 0 1.6666666666666665)" d="M0,3.33L0,0 " /></g></g></svg>

+ 1 - 1
app/layout.tsx

@@ -12,7 +12,7 @@ export default function RootLayout({
   children: React.ReactNode;
 }) {
   return (
-    <html lang="zh-Hans-CN">
+    <html lang="en">
       <meta
         name="viewport"
         content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"

+ 4 - 2
app/requests.ts

@@ -1,6 +1,8 @@
 import type { ChatRequest, ChatReponse } from "./api/chat/typing";
 import { Message } from "./store";
 
+const TIME_OUT_MS = 30000
+
 const makeRequestParam = (
   messages: Message[],
   options?: {
@@ -52,7 +54,7 @@ export async function requestChatStream(
   });
 
   const controller = new AbortController();
-  const reqTimeoutId = setTimeout(() => controller.abort(), 10000);
+  const reqTimeoutId = setTimeout(() => controller.abort(), TIME_OUT_MS);
 
   try {
     const res = await fetch("/api/chat-stream", {
@@ -78,7 +80,7 @@ export async function requestChatStream(
 
       while (true) {
         // handle time out, will stop if no response in 10 secs
-        const resTimeoutId = setTimeout(() => finish(), 10000);
+        const resTimeoutId = setTimeout(() => finish(), TIME_OUT_MS);
         const content = await reader?.read();
         clearTimeout(resTimeoutId);
         const text = decoder.decode(content?.value);

+ 21 - 0
app/utils.ts

@@ -9,3 +9,24 @@ export function trimTopic(topic: string) {
 
   return s.join("");
 }
+
+export function copyToClipboard(text: string) {
+  navigator.clipboard.writeText(text).then(res => {
+    alert('复制成功')
+  }).catch(err => {
+    alert('复制失败,请赋予剪切板权限')
+  })
+}
+
+export function downloadAs(text: string, filename: string) {
+  const element = document.createElement('a');
+  element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text));
+  element.setAttribute('download', filename);
+
+  element.style.display = 'none';
+  document.body.appendChild(element);
+
+  element.click();
+
+  document.body.removeChild(element);
+}