背景
公司的小程序需要实现一个智能在线问诊的功能,这就需要接入大模型。由于调用第三方大模型的接口返回比较慢,所以后端采用了数据流的形式返回给前端,由于是第一次接触SSE形式的接口,踩了不少坑,特地记录一下
尝试过程
1. RequestTask
使用wx.request发起 HTTPS 网络请求
wx.request会返回一个请求任务对象RequestTask
,微信官方说在wx.request中开启enableChunked
之后,会在响应头中开启 transfer-encoding: chunked
,直接支持流式传输,只要在RequestTask
中调用onChunkReceived
方法即可监听Transfer-Encoding Chunk Received
事件。
直接狂喜,立即推:
const requestTask = wx.request({
url: "https://xxx",
enableChunked: true,
header: {
"Content-Type": "application/json;charset=UTF-8",
},
method: "POST",
responseType: "text",
data: params,
timeout: 200000,
success: (res) => {
console.info("发送成功: ", res);
},
fail(err) {
console.log(err, "err");
wx.showToast({
title: "发送失败,请重试",
icon: "error",
});
},
});
// 监听 Transfer-Encoding Chunk Received
requestTask.onChunkReceived((res) => {});
requestTask.onHeadersReceived((response) => {});
结果发现,后台接口一直在等待响应,直到超时:
如下图:等待1分多之后,返回为空 模拟器测试:
真机:
疑惑
请求无返回?为什么呢?
猜测1:接口有问题
解决方案:使用apifox测试接口 => 访问正常
猜测2:客户端无法解析响应体
解决方案:
1. 上网找资料:
全是说小程序支持sse的,除了解码部分有点问题,其他都没问题。放几篇各位感受一下:
a: 微信小程序对接SSE接口记录-csdn
b: 小程序支持sse吗-微信开发者社区
开始疑惑,怀疑人生,为啥别人这么顺利?
猜测3:返回内容虽然都是流的形式,是不是sse和普通数据流不一样?
在如何对接流式接口中发现,他们的Content-Type
似乎并不是sse(如下图)
于是我把疑惑抛给了后台老师,后台老师说他们是服务器拿到所有数据之后,再以流的形式给前台,而不是真正的使用sse接口下发到前台。\
2. web-view
由于小程序中又没有EventSource, 微信官方的api好像也无法支持sse,于是只能转为内嵌web-view的形式实现了。
使用h5的话就简单多了
解决
技术栈使用react + antd-mobile + microsoft/fetch-event-source
主要代码如下:\
- sse
import { fetchEventSource } from "@microsoft/fetch-event-source";
const responseRef = useRef(""); // AI回答的内容
// searchKey: 问题
const sseLink = (searchKey: any) => {
// 对话列表 question:提问;answer:大模型回复
chatList.push({
question: searchKey.trim(),
answer: "",
});
let createTime: string = dayjs(new Date()).format("YYYY-MM-DD HH:mm:ss");
setLoading(true);
let requestParams = {
terminal: "terminal",
largeModel: "BAIDU",
userCode: customerId,
prompt: searchKey.trim() || "",
window: `window`,
};
const ctrlAbout: any = new AbortController();
//建立 sse连接; getAnswer:url
fetchEventSource(getAnswer, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(requestParams),
signal: ctrlAbout.signal,
onmessage(e: any) {
//说明当前问题sse回答结束
if (e.data === "[DONE]") {
ctrlAbout.abort();
responseRef.current = ""; //清空答案
//查询成功结束后重置搜索的问题
setSearchKey("");
setLoading(false); //结束loading
localKeyRef.current = ""; //结束 重置localKey
chatList[chatList.length - 1].createTime = createTime;
chatList[chatList.length - 1].showLike = true;
chatList[chatList.length - 1].useful = 0;
chatList[chatList.length - 1].answerDone = true;
console.log("chatList: ", chatList);
setChatList([...chatList]);
return;
}
//说明有消息过来
if (e.data !== "[DONE]" && e.data) {
let responseString = e.data;
// 空格符替换
if (responseString.includes("(@n@)")) {
responseString = responseString.replaceAll("(@n@)", "\n");
}
responseRef.current += responseString;
let result = {
question: searchKeyRef.current.trim(), //查询语
answer: responseRef.current, //返回值
};
chatList[chatList.length - 1] = result;
setChatList([...chatList]);
}
},
onerror(error: any) {
// 处理错误
ctrlAbout.abort();
setLoading(false);
throw error;
},
});
};
- ai回复,自动滚动到底部
// gptRef: 聊天界面
useEffect(() => {
if (gptRef.current) {
const chatListElement = gptRef.current;
const isScrolledToBottom =
chatListElement.scrollHeight - chatListElement.clientHeight <=
chatListElement.scrollTop + 1;
if (isScrolledToBottom) {
gptRef.current.scrollIntoView({
behavior: "smooth",
block: "end",
inline: "nearest",
});
}
}
}, [chatList.length]);
- 用户刷新界面,或者返回到小程序,都需要保存聊天历史记录上下文
// 重载页面
const reloadHandler = (e: any) => {
if (animate.isReload) return;
// 刷新动画
setAnimate((pre) => ({
...pre,
isReload: true,
}));
setTimeout(() => {
if (chatList?.length) saveRecord(); // 保存
window.location.reload();
}, 900);
};
// 用户返回小程序
useDeepCompareEffect(() => {
window.history.pushState(null, "", "#");
window.addEventListener(
"popstate", // 再次向历史堆栈添加一个条目,这样做的目的是阻止用户实际导航离开当前页面
(e: any) => {
//为了避免只调用一次
window.history.pushState(null, "", "#");
if (
window.navigator.userAgent.toLocaleLowerCase().includes("miniprogram")
) {
wx.miniProgram.postMessage({
data: chatList, // 通过postMessage向小程序环境发送`chatList`数据。
});
wx.miniProgram.navigateBack({
url: "", // 使小程序返回上一个页面。
});
}
},
false // 表示事件捕获阶段不使用。
);
window.addEventListener("unload", () => {
wx.miniProgram.postMessage({
data: chatList, // 当页面卸载时,同样通过`postMessage`发送`chatList`数据。
});
});
}, [chatList]);
- 小程序中
<web-view src="{{ url }}" bindmessage="getMessage"/>
async onShow() {
// 等待 app 的 onLaunch 完成 然后获取全局变量的值globalData
await app.onLaunch()
const customerId = encodeURIComponent(app.globalData?.userInfo?.openId);
const headPic = encodeURIComponent(app.globalData?.userInfo?.headPic);
const url = `${webView_GPT}?customerId=${customerId}&headPic=${headPic}`
this.setData({
url,
})
},
// 获取消息
async getMessage(e) {
const customerId = app.globalData?.userInfo?.openId;
const { data } = e.detail
if (data && data.length && data[data.length - 1].length) {
const paramsData = data[data.length - 1].map((item) => ({
terminal: 'terminal',
largeModel: 'BAIDU',
userCode: customerId,
window: `window`,
question: item.question,
answer: item.answer,
id: item.id || '',
questionType: 0,
useful: item.useful,
createTime: item.createTime,
}))
await saveChatRecord(paramsData); // 从web-view跳转回小程序,无法触发react生命周期,只会触发unload事件。但是unload事件中是不允许调用接口的,所以保存历史记录,需要放到小程序中做。
}
},
结束语
小程序中到底支不支持sse接口,这个还是让人很无语的,实测下来是只是支持流式数据,而不是sse,但是官方又说支持,期待有做过这方面的同学能指点一二,不胜感激。因为感觉腾讯元宝这种ai助手确实是在小程序原生中实现的,可能还是自己技术太菜。
第一次折腾小程序,有很多不懂的,谢谢同事和朋友的帮助~阿里嘎多٩(๑òωó๑)۶