forked from XiaoMo/ChatGPT-Next-Web
feat: add export to .md button
This commit is contained in:
parent
64e331a3e3
commit
bab470d000
@ -1,7 +1,6 @@
|
|||||||
import { OpenAIApi, Configuration } from "openai";
|
import { OpenAIApi, Configuration } from "openai";
|
||||||
import { ChatRequest } from "./typing";
|
import { ChatRequest } from "./typing";
|
||||||
|
|
||||||
const isProd = process.env.NODE_ENV === "production";
|
|
||||||
const apiKey = process.env.OPENAI_API_KEY;
|
const apiKey = process.env.OPENAI_API_KEY;
|
||||||
|
|
||||||
const openai = new OpenAIApi(
|
const openai = new OpenAIApi(
|
||||||
@ -16,16 +15,7 @@ export async function POST(req: Request) {
|
|||||||
const completion = await openai!.createChatCompletion(
|
const completion = await openai!.createChatCompletion(
|
||||||
{
|
{
|
||||||
...requestBody,
|
...requestBody,
|
||||||
},
|
}
|
||||||
isProd
|
|
||||||
? {}
|
|
||||||
: {
|
|
||||||
proxy: {
|
|
||||||
protocol: "socks",
|
|
||||||
host: "127.0.0.1",
|
|
||||||
port: 7890,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return new Response(JSON.stringify(completion.data));
|
return new Response(JSON.stringify(completion.data));
|
||||||
|
@ -328,4 +328,8 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
right: 30px;
|
right: 30px;
|
||||||
bottom: 10px;
|
bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-content {
|
||||||
|
white-space: break-spaces;
|
||||||
}
|
}
|
@ -23,9 +23,13 @@ import DeleteIcon from "../icons/delete.svg";
|
|||||||
import LoadingIcon from "../icons/three-dots.svg";
|
import LoadingIcon from "../icons/three-dots.svg";
|
||||||
import MenuIcon from "../icons/menu.svg";
|
import MenuIcon from "../icons/menu.svg";
|
||||||
import CloseIcon from "../icons/close.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 { Message, SubmitKey, useChatStore, Theme } from "../store";
|
||||||
import { Settings } from "./settings";
|
import { Settings } from "./settings";
|
||||||
|
import { showModal } from "./ui-lib";
|
||||||
|
import { copyToClipboard, downloadAs } from "../utils";
|
||||||
|
|
||||||
export function Markdown(props: { content: string }) {
|
export function Markdown(props: { content: string }) {
|
||||||
return (
|
return (
|
||||||
@ -208,7 +212,10 @@ export function Chat(props: { showSideBar?: () => void }) {
|
|||||||
<IconButton
|
<IconButton
|
||||||
icon={<ExportIcon />}
|
icon={<ExportIcon />}
|
||||||
bordered
|
bordered
|
||||||
title="导出聊天记录为 Markdown(开发中)"
|
title="导出聊天记录"
|
||||||
|
onClick={() => {
|
||||||
|
exportMessages(session.messages, session.topic)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -294,6 +301,22 @@ function useSwitchTheme() {
|
|||||||
}, [config.theme]);
|
}, [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() {
|
export function Home() {
|
||||||
const [createNewSession] = useChatStore((state) => [state.newSession]);
|
const [createNewSession] = useChatStore((state) => [state.newSession]);
|
||||||
const loading = !useChatStore?.persist?.hasHydrated();
|
const loading = !useChatStore?.persist?.hasHydrated();
|
||||||
|
@ -29,6 +29,7 @@
|
|||||||
transform: translateY(10px);
|
transform: translateY(10px);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
to {
|
to {
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@ -56,3 +57,68 @@
|
|||||||
.list .list-item:last-child {
|
.list .list-item:last-child {
|
||||||
border: 0;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,5 +1,8 @@
|
|||||||
import styles from "./ui-lib.module.scss";
|
import styles from "./ui-lib.module.scss";
|
||||||
import LoadingIcon from "../icons/three-dots.svg";
|
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: {
|
export function Popover(props: {
|
||||||
children: JSX.Element;
|
children: JSX.Element;
|
||||||
@ -46,4 +49,43 @@ export function Loading() {
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center"
|
justifyContent: "center"
|
||||||
}}><LoadingIcon /></div>
|
}}><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>)
|
||||||
}
|
}
|
@ -6,7 +6,7 @@
|
|||||||
--primary: rgb(29, 147, 171);
|
--primary: rgb(29, 147, 171);
|
||||||
--second: rgb(231, 248, 255);
|
--second: rgb(231, 248, 255);
|
||||||
--hover-color: #f3f3f3;
|
--hover-color: #f3f3f3;
|
||||||
--bar-color: var(--primary);
|
--bar-color: rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
/* shadow */
|
/* shadow */
|
||||||
--shadow: 50px 50px 100px 10px rgb(0, 0, 0, 0.1);
|
--shadow: 50px 50px 100px 10px rgb(0, 0, 0, 0.1);
|
||||||
@ -25,7 +25,7 @@
|
|||||||
--second: rgb(27 38 42);
|
--second: rgb(27 38 42);
|
||||||
--hover-color: #323232;
|
--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);
|
--border-in-light: 1px solid rgba(255, 255, 255, 0.192);
|
||||||
}
|
}
|
||||||
@ -82,7 +82,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
--bar-width: 1px;
|
--bar-width: 5px;
|
||||||
width: var(--bar-width);
|
width: var(--bar-width);
|
||||||
height: var(--bar-width);
|
height: var(--bar-width);
|
||||||
}
|
}
|
||||||
@ -162,4 +162,17 @@ input[type="range"]::-webkit-slider-thumb:hover {
|
|||||||
|
|
||||||
div.math {
|
div.math {
|
||||||
overflow-x: auto;
|
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
app/icons/copy.svg
Normal file
1
app/icons/copy.svg
Normal file
@ -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>
|
After Width: | Height: | Size: 1010 B |
1
app/icons/download.svg
Normal file
1
app/icons/download.svg
Normal file
@ -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>
|
After Width: | Height: | Size: 1.7 KiB |
@ -12,7 +12,7 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="zh-Hans-CN">
|
<html lang="en">
|
||||||
<meta
|
<meta
|
||||||
name="viewport"
|
name="viewport"
|
||||||
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"
|
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import type { ChatRequest, ChatReponse } from "./api/chat/typing";
|
import type { ChatRequest, ChatReponse } from "./api/chat/typing";
|
||||||
import { Message } from "./store";
|
import { Message } from "./store";
|
||||||
|
|
||||||
|
const TIME_OUT_MS = 30000
|
||||||
|
|
||||||
const makeRequestParam = (
|
const makeRequestParam = (
|
||||||
messages: Message[],
|
messages: Message[],
|
||||||
options?: {
|
options?: {
|
||||||
@ -52,7 +54,7 @@ export async function requestChatStream(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const reqTimeoutId = setTimeout(() => controller.abort(), 10000);
|
const reqTimeoutId = setTimeout(() => controller.abort(), TIME_OUT_MS);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/chat-stream", {
|
const res = await fetch("/api/chat-stream", {
|
||||||
@ -78,7 +80,7 @@ export async function requestChatStream(
|
|||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
// handle time out, will stop if no response in 10 secs
|
// 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();
|
const content = await reader?.read();
|
||||||
clearTimeout(resTimeoutId);
|
clearTimeout(resTimeoutId);
|
||||||
const text = decoder.decode(content?.value);
|
const text = decoder.decode(content?.value);
|
||||||
|
21
app/utils.ts
21
app/utils.ts
@ -9,3 +9,24 @@ export function trimTopic(topic: string) {
|
|||||||
|
|
||||||
return s.join("");
|
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);
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user