实战案例:微信小程序中实现 ChatGPT 打字机效果(下)

3,142 阅读6分钟

背景

上一篇实战案例:微信小程序中实现 ChatGPT 打字机效果(上) 文章中,实现了如何在微信小程序中获取服务端通过 SSE 返回的数据。本文主要总结了服务端返回的 Markdown 数据如何在小程序中渲染。

如果你对 Web 端实现类似效果感兴趣的话,可以查看: 实战案例:ChatGPT 打字机效果的三种实现方式

技术背景

本文所使用示例涉及的技术栈:

  • Taro 3.x + React
  • 其他库:
    • marked

tips: 默认你已经知道上述库或框架如何使用,本文不会介绍相关技术栈的使用教程。

目录

  • 服务端 SSE 接口示例
  • 核心实现
  • 注意事项

服务端 SSE 接口示例

基于上一篇内容的 SSE 接口,只是将返回的内容变为 Markdown 格式。


```js
const express = require("express");
const cors = require("cors");

const app = express();
app.use(cors());

const PORT = 5000;
const getTime = () => new Date().toLocaleTimeString();
const markdownText = `
# 这是一级标题

## 这是二级标题

### 这是三级标题

这里是一些普通文本。

- 列表项一
- 列表项二
- 列表项三

1. 有序列表项一
2. 有序列表项二
3. 有序列表项三

\`这是一段代码\`

**这是加粗文本**

*这是斜体文本*

[这是一个链接](https://kimi.moonshot.cn)

![这是一张图片](https://lf-web-assets.juejin.cn/obj/juejin-web/xitu_juejin_web/e08da34488b114bd4c665ba2fa520a31.svg)

---

水平线

> 这是一段引用文本

---

- [x] 完成的任务
- [ ] 未完成的任务

| 表头1 | 表头2 | 表头3 |
|--------|--------|--------|
| 单元格1 | 单元格2 | 单元格3 |
| 单元格4 | 单元格5 | 单元格6 |
`;
const contentStr = markdownText.split('')

app.get("/sse", function (req, res) {
  res.writeHead(200, {
    Connection: "keep-alive",
    "Content-Type": "text/event-stream",
    "Cache-Control": "no-cache",
  });
  // 正常的 sse 结束,需要从客户端触发 close 事件,如果从服务端触发,客户端会收到 error 
  req.on('close', function () {
    console.log('close')
    clearInterval(interval)
  })
  let count = 0
  // 此处用计时器来模拟大模型的查询结果
  // 通过发送字符数组的长度,来模拟 SSE 服务的 start、cmpl、done 状态
  const interval = setInterval(() => {
    // 如果前端没有正确触发 SSE 的 close 事件,服务端判断如果数据已发送完成,也会主动关闭事件
    if (count > contentStr.length) {
      res.end()
      clearInterval(interval)
      return
    } else if (count === 0) {
      res.write(
        `data:${JSON.stringify({
          time: getTime(),
          event: 'start',
          content: contentStr[count]
        })}`
      );
      res.write("\n\n");
    } else if (count === contentStr.length) {
      res.write(
        `data:${JSON.stringify({
          time: getTime(),
          event: 'done',
        })}`
      );
      res.write("\n\n");
    }
    else {
      res.write(
        `data:${JSON.stringify({
          time: getTime(),
          event: 'message',
          content: contentStr[count]
        })}`
      );
      res.write("\n\n");
    }
    count++
  }, 100);
});

app.listen(PORT, function () {
  console.log(`Server is running on port ${PORT}`);
});

核心实现

  • 小程序中渲染 Markdown 内容,主要分为两部分,第一部分是将 Markdown 内容转为富文本标签,第二部分是利用小程序的 RichText 组件进行渲染。
  • 示例代码如下:
// 完整代码
import { View, RichText } from "@tarojs/components";
import Taro from "@tarojs/taro";
import { marked } from "marked";
import { useRef, useState } from "react";
import * as TextEncoding from "text-encoding-shim";

export default function Index() {
  const nodeData = useRef("");
  const [showContent, setShowContent] = useState("");
  const getRequestTask = () => {
    return Taro.request({
      url: "http://localhost:5000/sse",
      method: "GET",
      enableChunked: true,
      success: (res) => {
        console.log("---->", res);
      },
    });
  };
  const handleClick = () => {
    const requestTask = getRequestTask();
    requestTask.onChunkReceived((res) => {
      const uint8Array = new Uint8Array(res.data);
      const str = new TextEncoding.TextDecoder("utf-8").decode(uint8Array);
      const dataStr = str.split("data:").slice(1)[0];
      try {
        const rst = JSON.parse(dataStr);
        if (rst.event === "message") {
          nodeData.current += rst.content;
          setShowContent(renderMarkdown(nodeData.current));
        }
      } catch (error) {
        console.log("error", error);
      }
    });
  };

  const renderMarkdown = (markdown: string) => {
    marked.setOptions({
      gfm: true,
      breaks: true,
    });
    try {
      return marked.parse(markdown);
    } catch (error) {
      console.log("markdown 转换失败", error);
      return markdown;
    }
  };

  return (
    <View className="index">
      <View onClick={handleClick}>点我发起请求</View>
      <RichText nodes={showContent} />
    </View>
  );
}

  • 代码解析

    • requestTask.onChunkReceived 方法中,利用 TextEncoding.TextDecoder 方法将 Unit8Array 格式的数据转为 String 格式数据,然后再利用 JSON.parse 方法转为我们常用的对象,方便获取对应的值。
    • 如果你的 Markdown 内容中的 \n 没有正常显示,可以对 marked 对象设置如下属性:
    marked.setOptions({
      gfm: true,
      breaks: true,
    });
    
    • 此处没有像实际项目中做完整的 error 处理,也没有对 event 完整状态做处理,请在实际项目中自行优化。
  • 效果演示

图1-1.gif

  • 效果解析
    • 本人项目中的需求还未出现比较复杂的 Markdown 格式的渲染要求,因此对一些特殊内容的渲染并未深入研究。
    • 小程序中不支持直接使用 a 标签的跳转链接,因此如果有特殊要求,需要进一步调研,本文暂不深究。
  • 至此,结合上一篇文章 实战案例:微信小程序中实现 ChatGPT 打字机效果(上) ,我们完整的实现了在微信小程序中的 ChatGPT 打字机效果。

注意事项

  • 在安装 marked 包时,建议使用 4.3.0 版本,否则会出现解析错误,类似MiniProgramError\nSyntaxError: Invalid regular expression: 的报错。
  • 真机预览时,如果遇到无法上传的问题,请打开微信开发者工具的“本地设置->将JS编译成ES5”。
  • 不同的大模型返回数据的速度和内容并不一致,如果前端想要更加丝滑的体验,则可以再做一层优化:即基于现有方案的同时,再配合前端定时器,来保证数据可以保持匀速的打印效果。具体代码,可以查看实战案例:ChatGPT 打字机效果的三种实现方式中“拓展优化”部分。

杂谈

  • 在做技术调研时,先尝试了之前 Web 的 react-markdown 库,发现在小程序编译时,会报错,无法正常渲染,单独使用 react-markdown 依赖的 remark 库,也遇到类似问题,在微信开发者工具可以渲染,在真机无法正常渲染。在使用 marked 降级版本后,猜想也许将 remark 库也做一些降级,没准也可以支持,但没有深入研究,感兴趣的同学可以做尝试。
  • 富文本和 Markdown 在小程序的渲染,还对一些其他的库做了简单的调研,这里分享给大家,感兴趣的同学可以深入做尝试:
  • 之所以没有用上述两个库,主要原因:
    • 基于 Taro 最新版本的支持没有可以直接使用的版本,需要自己在阅读源码后定制化开发,也许未来会花时间做深入研究。
    • 当前需求对 Markdown 的定制化拓展要求暂时不高,因此以尽快实现功能为目的,没有深入。
  • 如果大家的实际项目中对 Markdown 或 HTML 富文本的定制化处理要求比较高,则可以考虑从上面两个开源库入手。
  • 除了在前端渲染 Markdown 外,可以考虑在服务端将 Markdown 格式的内容处理为 HTML 节点,这样可以在小程序中直接使用 RichText 组件渲染,尤其是遇到一些跨平台的小程序,也许这个方案会省去很多处理兼容性问题的时间。

参考文档

浏览知识共享许可协议

知识共享许可协议
本作品采用知识共享署名-相同方式共享 4.0 国际许可协议进行许可。