本文紧接上文。
5. 创建一个node项目,用来模拟ai接口
我们知道当前端通过调用ai服务接口,以此来获取事件流数据,从而渲染结果,由于通过我的调查,调用ai服务接口需要收费,暂时还没有找到好的ai服务(后续找到即可更新)。
因此这里我们先通过一个定时器用来模拟事件流数据。创建一个ai-node项目, 并初始化package.json,然后安装express依赖,然后创建一个index.js,并写上如下代码:
js
const express = require("express");
const app = express();
app.use((_, res, next) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader(
"Access-Control-Allow-Methods",
"GET, POST, PUT, DELETE, OPTIONS"
);
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
next();
});
app.use(express.json());
app.post("/events", (req, res) => {
// 设置响应头以启动事件源流
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
let counter = 0;
console.log("Received message:", req.body);
// 模拟每秒发送数据的事件流
const interval = setInterval(() => {
counter++;
const data = JSON.stringify({
text: `你说的是: ${req.body?.message}.....${counter}`,
counter,
type: "message",
});
res.write(`data: ${data}\n\n`);
}, 1000);
// 监听连接关闭事件,清理资源
req.on("close", () => {
setTimeout(() => {
clearInterval(interval);
res.end();
}, 10000);
});
});
app.listen(3000, () => {
console.log("Server is running on http://localhost:3000");
});
可以看到我们通过创建一个定时器,然后接收一个用户输入的message(代表用户输入的问题),然后返回。这里为了说明数据的合并,我添加了一个counter值。然后我监听客户端的close事件,延迟10s关闭定时器,用以终止传递数据,当然真实的业务场景应该是前端轮询一个后端服务接口用来判断会话是否已结束。为了方便在本地访问,我也添加了允许跨域的代码。
6. 前端添加合并工具函数
接下来,我们需要添加合并数据的工具函数,由于原理我们已经在前文所描述,这里不做过多解释,代码如下:
ts
export const mergeMessagesByType = <T extends Message>(arr: T[]) => {
const stepMerge = (arr: T[], filterFn: (item: T) => boolean) => {
const temp: Record<
string,
Omit<Message, "name" | "timestamp"> | number | string | boolean
> = {};
let orderTypeId = -1;
const result: T[] = [];
arr.forEach((item, index) => {
const {
name,
timestamp,
isEnd,
isNext,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
messageId,
type = "message",
...rest
} = item;
if (filterFn(item)) {
temp[type] = { type, ...rest };
temp.name = name;
temp.timestamp = timestamp;
temp.isEnd = isEnd || false;
temp.isNext = isNext || false;
temp.type = type;
if (orderTypeId === -1) {
orderTypeId = arr[Math.max(index - 1, 0)].messageId as number;
}
} else {
result.push(item);
}
});
if (Object.keys(temp).length > 0) {
const spliceIndex = result.findIndex(
(item) => item.messageId === orderTypeId
);
result.splice(spliceIndex + 1, 0, {
...temp,
messageId: orderTypeId,
} as T);
}
return result.map((item, index) => ({ ...item, messageId: index + 1 }));
};
return stepMerge(arr, (item) => item?.type === "message");
};
注意这里我是通过这个条件type === 'message'来进行合并,也就是说我们的消息分成2种,第一种就是我们用mock.ts模拟的数据,type我们规定为text,然后是用户问的问题,同样的我们也规定为text,第二种就是通过调用ai服务返回的数据,这里我们通过node来创建一个定时器模拟ai的回答。
对话界面
对话界面如下图所示:
主要包含了3个部分:
- 标题
- 对话渲染。
- 用户交互
其中对话渲染包含头像(机器人和用户),名字(机器人和用户),日期,以及问题/回答。用户交互区域包含一个用户输入问题的多行输入框,以及清空对话和点击发送按钮。
对应组件代码如下:
标题:
tsx
<Typography.Title level={3} style={{ textAlign: "center" }}>
一个模拟的聊天对话界面
</Typography.Title>
对话渲染:
tsx
<Row gutter={[16, 16]}>
{groupedMessages.map((group, idx) => (
<Col span={24} key={idx}>
{group.map((msg, i) => (
<Card key={i} style={{ marginBottom: "10px" }}>
<Row style={{ marginBottom: 15 }}>
<Col span={24} style={{ marginBottom: 6 }}>
<Row align="middle">
{msg.name === "bot" ? (
<RobotOutlined
style={{ fontSize: 25, marginRight: 8 }}
/>
) : (
<UserOutlined
style={{ fontSize: 25, marginRight: 8 }}
/>
)}
<Text strong>{msg.name}</Text>
</Row>
</Col>
<Col style={{ fontSize: "12px", color: "gray" }} span={24}>
{dayjs(msg.timestamp).format("YYYY-MM-DD HH:mm:ss")}
</Col>
</Row>
<Row
style={{
backgroundColor: "#98b7ef",
color: "#fff",
padding: 15,
borderRadius: 10,
}}
>
<RenderContent {...msg} />
</Row>
</Card>
))}
</Col>
))}
</Row>
可以看到,我们通过Row组件分隔,将对话渲染区域分成了2个部分,并且通过Card组件包裹,然后第一个部分就是我们的头像,名字以及日期渲染区域。然后就是我们的回答渲染区域,即RenderContent组件。
再然后是我们的用户交互区:
tsx
<Row style={{ marginTop: "20px" }} gutter={5} align="middle" wrap>
<Col lg={{ span: 21 }} xs={{ span: 24 }}>
<TextArea
rows={2}
value={inputMessage}
onChange={(e) => setInputMessage(e.target.value)}
placeholder="请输入你的问题"
/>
</Col>
<Col lg={{ span: 3 }} xs={{ span: 24 }}>
<Flex justify="flex-end" align="center">
<Space>
<Popconfirm
title="温馨提示"
icon={null}
description="确定要清空所有对话吗?"
onConfirm={handleResetMessage}
okText="确认"
cancelText="取消"
>
<Button
type="primary"
danger
style={{ marginTop: "10px" }}
icon={<DeleteOutlined />}
/>
</Popconfirm>
<Button
type="primary"
style={{ marginTop: "10px" }}
onClick={handleSendMessage}
icon={<SendOutlined />}
/>
</Space>
</Flex>
</Col>
</Row>
RenderContent组件
RenderContent组件就是渲染消息的组件,消息分成了2种,一种是用户的问题,就是一个字符串,还有一种就是我们根据type为message来合并的机器人的回复,这里的回复在真实场景当中渲染是十分复杂,比如可以涉及到markdown相关渲染,然后还可能涉及到基于json-schema来渲染表单等等。但在这里我们模拟的ai服务接口只是一个字符串,因此我们只需要考虑渲染字符串即可。如下:
tsx
import { RenderType } from "../const";
import { Message } from "../types/message";
export interface RenderContentProps {
type?: string;
text?: string;
message?: Message;
}
export const RenderContent = ({ text, message }: RenderContentProps) => {
if (
typeof message === "object" &&
message !== null &&
message?.type === RenderType.MESSAGE
) {
return <>{message?.text}</>;
}
return <>{text}</>;
};
注意: 真实业务场景,我们是需要扩展这个组件的。
比如说我们渲染的是markdown,而且markdown当中还有图片,我们需要添加图片预览,这又该如何实现呢?
实现思路如下:
- 我们通过dom api来获取markdown中的所有图片元素,然后还需要使用MutationObserver api来监听dom的变动。
- 然后我们可以基于antd的受控的preview示例封装一个preview组件,这个组件暴露出src和visible属性即可。
- 给获取到的dom图片元素添加点击事件,然后修改visible和src属性即可。
再比如我们需要基于json-schema来渲染表单呢?这就不得不用到react-jsonschema-form这个库呢。
如果返回的消息内容即message.text是一段jsonschema,我们就可以根据type字段值来判断,从而决定渲染的是这个表单。
滚动元素
由于我们的机器人回答,是一步一步返回数据的,因此我们的页面为了同步,就需要每次返回数据都需要页面滚动到底,这里我们通过在用户交互区前面添加一个div元素,然后监听我们的messages(用以管理消息数据的状态),只要触发了它的变动,就自己调用元素的scrollIntoView方法(这个方法兼容性还是不错的)来滚动到底。这里我们通过一个useRef来管理这个滚动元素。根据以上分析,我们可以写出如下代码:
tsx
{/* 滚动到底部的 div */}
<div ref={endOfMessagesRef} />
// ... 用户交互区组件
tsx
const endOfMessagesRef = useRef<HTMLDivElement>(null); // 用于滚动到底部
useEffect(() => {
if (endOfMessagesRef.current) {
endOfMessagesRef.current.scrollIntoView({ behavior: "smooth" });
}
}, [messages]);
核心逻辑
核心逻辑主要有如下:
- 清空对话。
- 用户输入对话。
- 根据用户的问题,调用ai服务接口请求。
- 接收回来的消息数据只能算是当前会话,根据相关分组工具函数进行分组以及合并工具函数进行合并并渲染。
我们用一个storage状态来缓存会话消息和一个消息状态用来渲染当前交互,再用一个状态用来监听用户的输入,如下:
ts
// 缓存消息数据
const [storeMessages, setStoreMessages] = useStorage<Message[]>(
STORE_MESSAGR_KEY,
getMockMessages()
);
// 用来交互的消息数据
const [messages, setMessages] = useState<Message[]>([]);
// 用户输入信息
const [inputMessage, setInputMessage] = useState<string>("");
接下来,我们监听缓存数据,当然也在页面挂载的时候,添加我们的mock好的数据。如下所示:
ts
useEffect(() => {
if (storeMessages && storeMessages?.length > 0 && !messages.length) {
setMessages(storeMessages!);
}
}, [storeMessages]);
useEffect(() => {
const msgs = getMockMessages();
setStoreMessages(msgs);
}, []);
注意,我们的合并消息需要根据messageId来合并,并且还需要保持消息的渲染顺序不变,因此我们需要给每条消息添加一个messageId,值为它的索引值,修改我们的getMockMessages函数内部。如下:
ts
export const getMockMessages = () => {
return generateMessage().messages.map((message: Message, index: number) => ({
...message,
timestamp: dayjs(message.timestamp).unix() * 1000,
isEnd: false,
messageId: index + 1, // 方便合并数据后保持顺序不变
}));
};
接下来,我们处理一下清空会话的逻辑,这个很简单,就是清除数据即可,当然必要的时候,我们也需要调用后端的服务接口,用来清除对话。如下:
ts
const handleResetMessage = () => {
// 真实业务需要调用后端提供的结束会话服务接口
setMessages([]);
setStoreMessages([]);
};
然后就是我们的会话渲染数据了,当然还有最后一个重点,那就是用户输入问题的交互逻辑,这个容我放在最后,先来看我们的会话渲染数据groupedMessages,我们用useMemo缓存,监听messages是否变动,因为我们是通过修改messages来添加会话消息的。然后只要存在messages,就进行分组合并,代码如下:
ts
const groupedMessages = useMemo(() => {
if (messages.length > 0) {
// 先将会话分组,再进行合并,注意分组的条件
const mergeData = groupByInterval(messages,(item) => item.isNext || item.isEnd).map((group) =>
mergeMessagesByType(group)
);
return mergeData;
}
return [];
}, [messages]);
注意这里我们的分组条件是(item) => item.isNext || item.isEnd
,即2种场景,第一种就是用户输入下一个问题,这也属于下一个会话,我们的合并逻辑也就需要分开,第二种场景就是会话结束。
最后的逻辑---根据用户输入问题来回答
实际上我们就是监听用户输入的数据,然后把用户的提问也当成一个消息数据来渲染,然后发起请求,调用ai服务获取答案。如下所示:
ts
const handleSendMessage = async () => {
if (!inputMessage.trim())
return ewMessage.warning({
content: "请输入内容",
removeClassName: ["animate__bounceOut", "animate__animated"],
startClassName: ["animate__animated", "animate__bounce"],
duration: 4000,
});
const userMessage: Message = {
name: "夕水",
text: inputMessage,
timestamp: new Date().getTime(),
isEnd: false,
type: "text",
isNext: true,
};
setMessages((p) => [...p, { ...userMessage, messageId: p.length + 1 }]);
await getChatbotResponse<Message>(inputMessage, (data) => {
setMessages((prevMessages) => [
...prevMessages,
{
...data,
name: "机器人-毛毛",
timestamp: new Date().getTime(),
isEnd: false,
isNext: false,
messageId: prevMessages.length + 1,
},
]);
});
setInputMessage(""); // 清空输入框
};
这里分成了3个逻辑,第一个逻辑,用户没有输入问题的时候,给出一个消息提示,这里我用到的是我写的消息提示框插件,用法参考官网。
ts
ewMessage.warning({
content: "请输入内容",
removeClassName: ["animate__bounceOut", "animate__animated"],
startClassName: ["animate__animated", "animate__bounce"],
duration: 4000,
})
这里用到了animated.css动画库。
第二个逻辑就是用户的输入当成一条消息添加进去,注意这里我们也要添加messageId。
第三个逻辑就是getChatbotResponse函数了,这个逻辑用来调用ai服务,它的核心逻辑就是调用fetchEventSource api,我们用的是@microsoft/fetch-event-source
。然后我们提供一个回调函数,返回每一次请求的结果,代码如下所示:
ts
import { Message } from "../types/message";
import { parseStr } from "../utils/utils";
import { fetchEventSource } from "@microsoft/fetch-event-source";
export const getChatbotResponse = async <T extends Message>(
message: string,
callback: (data: T) => void
) => {
const ctrl = new AbortController();
return new Promise<T[]>((resolve, reject) => {
const messages: Message[] = [];
// 真实业务场景,我们只需要替换接口路径,然后就是添加headers的api_key以及添加body参数的传入值即可
fetchEventSource(`http://localhost:3000/events`, {
method: "POST",
body: JSON.stringify({ message }),
headers: {
"Content-Type": "application/json",
},
signal: ctrl.signal,
onmessage(event) {
const data = parseStr<T>(event.data);
if (data) {
messages.push(data);
callback?.(data);
}
},
onerror(error) {
ctrl.abort();
reject(error);
},
onclose() {
ctrl.abort();
resolve(messages as T[]);
},
});
});
};
在真实业务场景,我们只需要替换接口路径,然后就是添加headers的api_key以及添加body参数的传入值即可。
最后
将以上代码和前文所有代码综合起来,就得到了我们实现的一个模拟ai聊天对话的项目。最后我们输入一个问题,ai回复只会渲染最终的一个值。如下图所示:
这就是数据合并的意义所在。
总结
通过前文以及本文,我们也了解了前端实现ai聊天会话的三个原理。总结如下:
- 实现机器人的流式回复
- 会话消息的设计以及分组
- 会话消息的合并
也印证了我们这篇文章所描述的核心原理。这说明只要掌握核心原理,实现ai会话也不难。
特别说明: 这个模拟示例源码,我放到了这里,感兴趣可以下载下来,自行运行看看。