feat: stream流响应

This commit is contained in:
Archer
2023-03-10 02:57:13 +08:00
parent 26888e855b
commit 65da4653bc
7 changed files with 73 additions and 88 deletions

View File

@@ -6,17 +6,7 @@ const isDev = process.env.NODE_ENV === 'development';
const nextConfig = {
output: 'standalone',
reactStrictMode: false,
compress: true,
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'docgpt-1301319986.cos.ap-shanghai.myqcloud.com',
port: '',
pathname: '/**'
}
]
}
compress: true
};
module.exports = nextConfig;

View File

@@ -23,6 +23,7 @@
"axios": "^1.3.3",
"crypto": "^1.0.1",
"dayjs": "^1.11.7",
"eventsource-parser": "^0.1.0",
"formidable": "^2.1.1",
"framer-motion": "^9.0.6",
"hyperdown": "^2.4.29",

9
pnpm-lock.yaml generated
View File

@@ -24,6 +24,7 @@ specifiers:
dayjs: ^1.11.7
eslint: 8.34.0
eslint-config-next: 13.1.6
eventsource-parser: ^0.1.0
formidable: ^2.1.1
framer-motion: ^9.0.6
husky: ^8.0.3
@@ -65,6 +66,7 @@ dependencies:
axios: registry.npmmirror.com/axios/1.3.3
crypto: registry.npmmirror.com/crypto/1.0.1
dayjs: registry.npmmirror.com/dayjs/1.11.7
eventsource-parser: registry.npmmirror.com/eventsource-parser/0.1.0
formidable: registry.npmmirror.com/formidable/2.1.1
framer-motion: registry.npmmirror.com/framer-motion/9.0.6_biqbaboplfbrettd7655fr4n2y
hyperdown: registry.npmmirror.com/hyperdown/2.4.29
@@ -4553,6 +4555,13 @@ packages:
engines: {node: '>=0.10.0'}
dev: true
registry.npmmirror.com/eventsource-parser/0.1.0:
resolution: {integrity: sha512-M9QjFtEIkwytUarnx113HGmgtk52LSn3jNAtnWKi3V+b9rqSfQeVdLsaD5AG/O4IrGQwmAAHBIsqbmURPTd2rA==, registry: https://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/eventsource-parser/-/eventsource-parser-0.1.0.tgz}
name: eventsource-parser
version: 0.1.0
engines: {node: '>=14.18'}
dev: false
registry.npmmirror.com/execa/6.1.0:
resolution: {integrity: sha512-QVWlX2e50heYJcCPG0iWtf8r0xjEYfz/OYLGDYH+IyjWezzPNxz63qNFOu0l4YftGWuizFVZHHs8PrLU5p2IDA==, registry: https://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/execa/-/execa-6.1.0.tgz}
name: execa

View File

@@ -369,7 +369,7 @@
}
pre code {
background-color: #222;
background-color: #222 !important;
color: #fff;
width: 100%;
font-family: 'Söhne,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,Noto Sans,sans-serif,Helvetica Neue,Arial,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji';

View File

@@ -26,8 +26,8 @@ const Markdown = ({ source, isChatting }: { source: string; isChatting: boolean
code({ node, inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '');
const code = String(children).replace(/\n$/, '');
return !inline ? (
<Box my={3} borderRadius={'md'} overflow={'hidden'}>
return !inline || match ? (
<Box my={3} borderRadius={'md'} overflow={'hidden'} backgroundColor={'#222'}>
<Flex
py={2}
px={5}

View File

@@ -1,5 +1,5 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { Readable } from 'stream';
import { createParser, ParsedEvent, ReconnectInterval } from 'eventsource-parser';
import { connectToDatabase, ChatWindow } from '@/service/mongo';
import type { ModelType } from '@/types/model';
import { getOpenAIApi, authChat } from '@/service/utils/chat';
@@ -9,21 +9,13 @@ import { ChatItemType } from '@/types/chat';
/* 发送提示词 */
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
res.setHeader('Connection', 'keep-alive');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Content-Type', 'text/event-stream');
const responseData: string[] = [];
const stream = new Readable({
read(size) {
const data = responseData.shift() || null;
this.push(data);
}
});
res.setHeader('Content-Type', 'text/event-stream;charset-utf-8');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('X-Accel-Buffering', 'no');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.on('close', () => {
res.end();
stream.destroy();
});
const { chatId, windowId } = req.query as { chatId: string; windowId: string };
@@ -58,16 +50,14 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const formatPrompts: ChatCompletionRequestMessage[] = filterPrompts.map(
(item: ChatItemType) => ({
role: map[item.obj],
content: item.value.replace(/(\n| )/g, '')
content: item.value.replace(/\n/g, ' ')
})
);
// 第一句话,强调代码类型
formatPrompts.unshift({
role: ChatCompletionRequestMessageRoleEnum.System,
content:
'If the content is code or code blocks, please mark the code type as accurately as possible!'
content: '如果你想返回代码,请务必声明代码的类型!'
});
// 获取 chatAPI
const chatAPI = getOpenAIApi(userApiKey);
const chatResponse = await chatAPI.createChatCompletion(
@@ -78,48 +68,57 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
messages: formatPrompts,
stream: true
},
openaiProxy
{
responseType: 'stream',
httpsAgent: openaiProxy?.httpsAgent
}
);
// 截取字符串内容
const reg = /{"content"(.*)"}/g;
// @ts-ignore
const match = chatResponse.data.match(reg);
if (!match) return;
let AIResponse = '';
// 循环给 stream push 内容
match.forEach((item: string, i: number) => {
try {
const json = JSON.parse(item);
// 开头的换行忽略
if (i === 0 && json.content?.startsWith('\n')) return;
AIResponse += json.content;
const content = json.content.replace(/\n/g, '<br/>'); // 无法直接传输\n
if (content) {
responseData.push(`event: responseData\ndata: ${content}\n\n`);
// res.write(`event: responseData\n`)
// res.write(`data: ${content}\n\n`)
// 解析数据
const decoder = new TextDecoder();
new ReadableStream({
async start(controller) {
// callback
async function onParse(event: ParsedEvent | ReconnectInterval) {
if (event.type === 'event') {
const data = event.data;
if (data === '[DONE]') {
controller.close();
res.write('event: done\ndata: \n\n');
res.end();
// 存入库
await ChatWindow.findByIdAndUpdate(windowId, {
$push: {
content: {
obj: 'AI',
value: AIResponse
}
},
updateTime: Date.now()
});
return;
}
try {
const json = JSON.parse(data);
const content: string = json.choices[0].delta.content || '';
res.write(`event: responseData\ndata: ${content.replace(/\n/g, '<br/>')}\n\n`);
AIResponse += content;
} catch (e) {
// maybe parse error
controller.error(e);
res.end();
}
}
}
const parser = createParser(onParse);
for await (const chunk of chatResponse.data as any) {
parser.feed(decoder.decode(chunk));
}
} catch (err) {
err;
}
});
responseData.push(`event: done\ndata: \n\n`);
// 存入库
(async () => {
await ChatWindow.findByIdAndUpdate(windowId, {
$push: {
content: {
obj: 'AI',
value: AIResponse
}
},
updateTime: Date.now()
});
})();
} catch (err: any) {
let errorText = err;
if (err.code === 'ECONNRESET') {
@@ -143,17 +142,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
}
console.error(errorText);
responseData.push(`event: serviceError\ndata: ${errorText}\n\n`);
res.write(`event: serviceError\ndata: ${errorText}\n\n`);
res.end();
// 删除最一条数据库记录, 也就是预发送的那一条
(async () => {
await ChatWindow.findByIdAndUpdate(windowId, {
$pop: { content: 1 },
updateTime: Date.now()
});
})();
await ChatWindow.findByIdAndUpdate(windowId, {
$pop: { content: 1 },
updateTime: Date.now()
});
}
// 开启 stream 传输
stream.pipe(res);
}

View File

@@ -148,6 +148,7 @@ const Chat = () => {
);
});
event.addEventListener('done', () => {
console.log('done');
clearTimeout(timer);
event.close();
setChatList((state) =>
@@ -324,7 +325,7 @@ const Chat = () => {
height={30}
/>
</Box>
<Box flex={'1 0 0'} w={0} overflowX={'auto'}>
<Box flex={'1 0 0'} w={0} overflowX={'hidden'}>
{item.obj === 'AI' ? (
<Markdown
source={item.value}
@@ -338,16 +339,6 @@ const Chat = () => {
</Box>
))}
</Box>
{/* 空内容提示 */}
{/* {
chatList.length === 0 && (
<>
<Card>
内容太长
</Card>
</>
)
} */}
<Box
m={media('20px auto', '0 auto')}
w={media('100vw', '100%')}