小程序对接SSE接口记录

5,704 阅读4分钟

背景

公司的小程序需要实现一个智能在线问诊的功能,这就需要接入大模型。由于调用第三方大模型的接口返回比较慢,所以后端采用了数据流的形式返回给前端,由于是第一次接触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分多之后,返回为空 模拟器测试: 企业微信截图_17204914398466.png

真机:

image.png

疑惑

请求无返回?为什么呢?
猜测1:接口有问题 解决方案:使用apifox测试接口 => 访问正常

image.png 猜测2:客户端无法解析响应体
解决方案:
1. 上网找资料:
全是说小程序支持sse的,除了解码部分有点问题,其他都没问题。放几篇各位感受一下:
a: 微信小程序对接SSE接口记录-csdn
b: 小程序支持sse吗-微信开发者社区
开始疑惑,怀疑人生,为啥别人这么顺利?
猜测3:返回内容虽然都是流的形式,是不是sse和普通数据流不一样?
如何对接流式接口中发现,他们的Content-Type似乎并不是sse(如下图)

企业微信截图_17204900674463.png

于是我把疑惑抛给了后台老师,后台老师说他们是服务器拿到所有数据之后,再以流的形式给前台,而不是真正的使用sse接口下发到前台。\

2. web-view

由于小程序中又没有EventSource, 微信官方的api好像也无法支持sse,于是只能转为内嵌web-view的形式实现了。

使用h5的话就简单多了

解决

技术栈使用react + antd-mobile + microsoft/fetch-event-source

主要代码如下:\

  1. 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;
      },
    });
  };
  1. 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]);
  1. 用户刷新界面,或者返回到小程序,都需要保存聊天历史记录上下文
// 重载页面
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]);
  1. 小程序中
<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事件中是不允许调用接口的,所以保存历史记录,需要放到小程序中做。
 }
},

image.png

结束语

小程序中到底支不支持sse接口,这个还是让人很无语的,实测下来是只是支持流式数据,而不是sse,但是官方又说支持,期待有做过这方面的同学能指点一二,不胜感激。因为感觉腾讯元宝这种ai助手确实是在小程序原生中实现的,可能还是自己技术太菜。
第一次折腾小程序,有很多不懂的,谢谢同事和朋友的帮助~阿里嘎多٩(๑òωó๑)۶