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 { 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));
|
||||
|
@ -329,3 +329,7 @@
|
||||
right: 30px;
|
||||
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 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();
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
@ -47,3 +50,42 @@ export function Loading() {
|
||||
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>)
|
||||
}
|
@ -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);
|
||||
}
|
||||
@ -163,3 +163,16 @@ 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
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;
|
||||
}) {
|
||||
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"
|
||||
|
@ -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
app/utils.ts
21
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);
|
||||
}
|
Loading…
Reference in New Issue
Block a user