摘要
这是本系列的第三章,在此之前我们先回顾一下上一篇文章:
# 从零实现一个GPT 【React + Express】--- 【2】实现对话流和停止生成
在这一篇里,我们实现了前端对话流的基本交互,可以做到问答以及停止生成的效果:

但是会发现从效果上来看,似乎很丑,因为都挤在一起了,没有一点格式。
这个时候我们要看一下模型返回的内容是不是没有格式的,看一下接口你就会发现,其实模型返回的内容是markdown的内容,所以我们前端在处理的时候需要对其转换一下的。
本章重点
- 引入React Markdown,代码高亮
- 实现模型记忆
- 实现新建对话
引入ReactMarkdown
来到DialogCardList组件,之前我们只是通过一个div把answer包起来了,现在我们用ReactMarkdown给他包起来:
首先安装一下依赖:
js
npm i react-markdown
然后修改我们的组件:
js
// DialogCardList/index.tsx
import ReactMarkDown from 'react-markdown';
// 其他代码
return (
<div className={styles.scrollContainer}>
<div className={styles.dialogCardList}>
{dialogCardListStore.dialogCardList.map((item) => {
return (
<div className={styles.dialogCard} key={item.cardId}>
<div className={styles.question}>
<p>{item.question}</p>
</div>
<div className={styles.answer}>
<ReactMarkDown>
{item.answer}
</ReactMarkDown>
</div>
</div>
);
})}
</div>
</div>
);
这个时候在看一下效果,就发现现在的回答是有格式的了:

实现代码高亮
虽然我们引入了markdown,但是读者可以尝试输入这样一段query:"帮我写一段冒泡排序"。
会发现ReactMarkdown并不会对代码做高亮处理,但是呢,ReactMarkDown组件对外暴露了components属性,用户处理不同类型的标签,例如code类型。所以这里我们给ReactMarkdown加上components属性。
js
<ReactMarkDown components={{ code: getCode }}>
{item.answer}
</ReactMarkDown>
然后我们实现getCode方法,这里为了实现代码高亮,我们引入react-syntax-highlighter
先安装一下依赖:
js
npm i react-syntax-highlighter
现在我们就可以实现getCode方法了:
js
import SyntaxHighlighter from 'react-syntax-highlighter';
import { hybrid } from 'react-syntax-highlighter/dist/esm/styles/hljs';
const getCode = (params: any) => {
const { inline, className, children, ...props } = params;
const match = /language-(\w+)/.exec(className || '');
return !inline && match ? (
<SyntaxHighlighter
className={styles.codeBlock}
language={match[1]}
PreTag="div"
style={hybrid}
{...props}
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
) : (
<code className={className} {...props}>
{children}
</code>
);
};
这样,我们的代码高亮就也实现了。

这部分的代码提交记录:
后端处理模型记忆
其实现在,如果读者多尝试几次,会发现一个很不正常的问题,比如你这么发送query。
【曹操是谁?】
【模型回答: 曹操是。。。。。】
【他的儿子是谁】
【模型回答:请提供具体的人物。。。。。】
这体现了,模型对历史的对话是不保留记忆的,那如果模型不保留,我们就要给他保留。并且每次发送下一个问题的时候,把之前模型的回答都给模型传过去。这样模型就可以根据以往的回答,对历史保存记忆了。
之前我们使用模型的时候,是这么调用的:
js
const stream = await client.chat.completions.create({
messages: [
{ role: 'system', content: '你是一个风趣幽默的中文助手' },
{ role: 'user', content: message },
],
model: 'gpt-3.5-turbo',
stream: true,
max_tokens: 5000, // 控制生成的 token 数
});
这里介绍一下,role字段的类型:
- system: 代表系统字段,就是一个初始化模型的字段。
- user:代表用户输入的query。
- assistant:代表模型输出的内容。
那如果我们把之前用户的提问和模型的回答组成一对,然后全放在messsages里面,模型不就能够把之前的对话记下来了吗。
但是我们又不能全记,应该是只记录当前会话的历史,比如用户创建了一个新的对话。那么之前对话里的历史就不应该存下来。所以这里我们要有一个sessionId的概念。
我们现在来模拟一下整个流程:
- 前端第一次发送sse请求,paloyd为用户输入的query
- 后端接受sse请求,发现没有sessionId,创建一个sessionId通过major返回。
- 前端接收到sessionId保存下来
- 前端第二次发送sse请求,paloyd为用户输入的query和sessionId
- 后端接受sse请求,发现有sessionId,将上一轮的问答传给模型
- 重复第四步
- 前端新建一个对话,将sessionId清空,回到第一步
现在我们就可以对后端的getChat方法进行改造了:
js
// chat.js
let historyList = [];
const getChat = async (message, sessionId ,res) => {
try {
const majorData = {id: Date.now()};
if (!sessionId) {
sessionId = Date.now();
majorData.sessionId = sessionId;
historyList = [];
}
const stream = await client.chat.completions.create({
messages: [
{ role: 'system', content: '你是一个风趣幽默的中文助手' },
...historyList,
{ role: 'user', content: message },
],
model: 'gpt-3.5-turbo',
stream: true,
max_tokens: 5000, // 控制生成的 token 数
});
const eventName = 'major';
res.write(`event: ${eventName}\n`);
res.write(`data: ${JSON.stringify(majorData)}\n\n`);
let answer = '';
for await (const part of stream) {
const eventName = 'message';
if (Object.keys(part.choices[0]?.delta || {}).length > 0) {
res.write(`event: ${eventName}\n`);
res.write(`data: ${JSON.stringify(part.choices[0].delta)}\n\n`);
answer += part.choices[0].delta.content || '';
}
}
historyList.push({
role: 'user',
content: message,
});
historyList.push({
role: 'assistant',
content: answer,
});
console.log(historyList)
res.end(); // 结束连接
} catch (error) {
console.error('Error during OpenAI API call:', error);
res.end(); // 结束连接
}
};
后端这部分的提交记录如下:
前端处理新建对话
后端实现完了我们就来完善一下前端内容,首先我们要修改我们的connectSSE方法,sendData里有一个参数是sessionId,当前session第一次发送不携带,后续每次发送都需要携带该参数:
同时在major的callback里,我们要将sessionId存在store里面。
js
// DialogInput/index.tsx
const majorCallback = (major: Major) => {
dialogCardListStore.changeLastId(major.id);
if (major.sessionId) {
dialogCardListStore.setSessionId(major.sessionId);
}
};
if (dialogCardListStore.sessionId) {
data.sessionId = dialogCardListStore.sessionId;
}
connectSSE(url, data, {
message: messageCallback,
major: majorCallback,
close: closeCallback,
});
这个时候,你就可以发送一段连续的query了:

可以看到,模型对之前的内容保留了记忆。
最后来到久违的sidebar组件,增加一个新建对话的按钮并且绑定个事件:
js
// Sidebar/index.tsx
const newSessionClick = () => {
dialogCardListStore.clear();
}
然后在实现一下clear方法:
js
// DialogCardList/store.ts
clear: () => set(() => ({ dialogCardList: [], sessionId: '' })),
这部分的提交记录如下: