uniapp - AI 聊天中的md组件

一、前言

之前已经完成了 AI 聊天页面的整体布局,也说明了消息是由数组渲染渲染的,现在就来处理这些消息的容器(组件)。

二、需求分析

项目中的需求是聊天内容里要有文件、图片、文本等内容。这篇文章主要是处理【文本】,按照AI聊天的特性,文本可以分为纯文本和 markdown 格式的文本,比较有难度就是 md 文本,要实现字符串拼接、打字效果和转为富文本。

三、组件实现

根据目前的需求,该组件的功能有展示 md 格式的内容、进行打字效果和展示底部的操作栏(重试、复制)。

1. 组件布局

传统的上下布局,上部为渲染 md 内容,下部为操作栏。

html 复制代码
<template>
  <view class="chat-box">
    <view class="content" v-if="htmlOutput"> </view>
    <view class="funtion" v-if="showRibbon"> </view>
  </view>
</template>

2. md 转换插件

这个是一个很常用的插件了------------ marked,npm 导入就可以了,能把 md 格式的文本转为html,如把 # 你好,转为 <h1>你好</h1>

在组件内引入,import { marked } from "marked";

vue 复制代码
    this.htmlOutput = marked(this.fullText); // 转换md为html

3. html 渲染组件

考察了一阵子,mpHtml 成功入围! 可以在 clound 的插件市场里找到,npm 导入就可以了。

在页面上导入并注册,对了,老项目用的是 vue2,因此需注册。

html 复制代码
 <view class="content" v-if="htmlOutput">
      <mp-html :content="htmlOutput" />
 </view>
vue 复制代码
<script>
import { marked } from "marked";
import mpHtml from "mp-html/dist/uni-app/components/mp-html/mp-html.vue";

export default {
  components: {
    mpHtml,
  },
};
</script>

到此,可以模拟一下 md 格式的数据进行测试。

4. 打字机效果

在父组件中会将将同个类型的内容进行拼接,如有两条流式数据:

json 复制代码
{"data":"我叫"}
{"data":"灰太狼"}

在接收到第一条数据时,就将内容插入数组,然后数组的最后一条的类型和即将收到的类型进行比较,如果是同种类型,即拼接。所以数组的最后一项的内容是一直在拼接的,因此在 md 组件中能监听到内容的变化。

在 md 组件中监听,获取的两次内容分别为:我叫我叫灰太狼

js 复制代码
export default {
  props: {
    markdown: {
      type: String,
      default: "",
    },
  },
  components: {
    mpHtml,
  },
  data() {
    return {
      fullText: "", // 全部文本
      htmlOutput: "", // html 数据
      typingIndex: 0, // 文字索引
      isFineshed: false
    };
  },
  watch: {
    markdown: {
      handler(newVal, oldVal) {
        this.typeText(newVal);
      },
      immediate: true,
    },
  },
  methods: {
      typeText(targetText) {
      // 清除之前可能存在的定时器,避免多个定时器同时运行
      clearTimeout(this._typingTimer);
      // 标记打字未完成
      this.isFineshed = false;
      // 定义一个内部函数 step,用于执行单个字符的显示操作
      const step = () => {
        // 检查当前索引是否小于目标文本的长度
        if (this.typingIndex < targetText.length) {
          // 截取从开始到当前索引加 1 的文本片段,更新 fullText
          this.fullText = targetText.slice(0, ++this.typingIndex);
          // 使用 marked 库将当前截取的文本转换为 HTML 格式,更新 htmlOutput
          this.htmlOutput = marked(this.fullText);
          // 设置一个定时器,30 毫秒后再次调用 step 函数,继续显示下一个字符
          this._typingTimer = setTimeout(step, 30);
        } else {
          // 当索引达到目标文本长度时,标记打字完成
          this.isFineshed = true;
        }
      };
      // 首次调用 step 函数,开始打字效果
      step();
    },
  },
};

初步这么实现打字机效果。梳理一下这个逻辑。假设只有一个流式,监听时,targetText="我叫",假设没有清除定时器,此时 typingIndex 自增,则截取的字符为 ,等待 30ms 后再次截取,就截取的字符就为 我叫,所以在页面上的效果就是立即输出 ,等待 30ms 后,输出 我的

当有两个流式,没有清除定时器时,则两个定时器同时输出,则内容会出现冲突,不信可试试。因此,必须 清除定时器

到此,打字机功能就初步完成了。

5. 历史数据

即时聊天是需要 打字机效果 ,历史记录则不需要,因此条件判断处理一下就可以了。

js 复制代码
export default {
  props: {
    markdown: {
      type: String,
      default: "",
    },
  },
  components: {
    mpHtml,
  },
  data() {
    return {
      fullText: "", // 全部文本
      htmlOutput: "", // html 数据
      typingIndex: 0, // 文字索引
      isFineshed: false
    };
  },
  watch: {
    markdown: {
      handler(newVal, oldVal) {
         if (this.isHistory) {
          this.handelText(newVal);
        } else {
          this.typeText(newVal);
        }
      },
      immediate: true,
    },
  },
  methods: {
    // 直接转换,不用打字
    handelText(newVal) {
      this.htmlOutput = marked(newVal);
      this.isFineshed = true;
    },
      typeText(targetText) {
      // 清除之前可能存在的定时器,避免多个定时器同时运行
      clearTimeout(this._typingTimer);
      // 标记打字未完成
      this.isFineshed = false;
      // 定义一个内部函数 step,用于执行单个字符的显示操作
      const step = () => {
        // 检查当前索引是否小于目标文本的长度
        if (this.typingIndex < targetText.length) {
          // 截取从开始到当前索引加 1 的文本片段,更新 fullText
          this.fullText = targetText.slice(0, ++this.typingIndex);
          // 使用 marked 库将当前截取的文本转换为 HTML 格式,更新 htmlOutput
          this.htmlOutput = marked(this.fullText);
          // 设置一个定时器,30 毫秒后再次调用 step 函数,继续显示下一个字符
          this._typingTimer = setTimeout(step, 30);
        } else {
          // 当索引达到目标文本长度时,标记打字完成
          this.isFineshed = true;
        }
      };
      // 首次调用 step 函数,开始打字效果
      step();
    },
  },
};

6. 操作栏展示

目前,操作栏的按钮有 重新生成和复制。这个操作栏,按需求是最后一条才展示。从逻辑上出发,应当是最后一条且流式输出结束且打字结束。

也许会有疑问,使用 打字结束这个判断不就可以了吗?为什么加上 流式输出结束。

确实,当流式输出很快,快过打字时,确实可以,这时组件里拿到的就是全部的内容了,打字结束就意味着整个内容已经渲染完成。 但是, 当某个两个流式间隔时间较长时,在组件内部的打字已经结束,如果仅靠打字结束来判断,此时就会展示操作栏。接着流式又输出了,组件又继续打字,此时操作栏消失了,在视觉上就是操作栏一闪一闪的情况。

因此,需要保证是最后一条且流式输出结束且打字结束。

js 复制代码
  computed: {
   showRibbon() {
     return this.isLast && this.isFineshed && this.isComplete;
   },
 },

四、总结

到此,md 组件已经封装结束,完善一下 md 格式中的行间距等等,就差不多了。该组件的功能是实现聊天过程中的打字机输出和最后一条展示操作栏。

html 复制代码
<template>
 <view class="chat-box">
   <mp-html
     :tag-style="style"
     :content="htmlOutput"
     v-if="htmlOutput"
     @linktap.prevent="onLinkTap"
   />
   <view class="funtion" v-if="showRibbon"> </view>
 </view>
</template>
js 复制代码
<script>
import { marked } from "marked";
import mpHtml from "mp-html/dist/uni-app/components/mp-html/mp-html.vue";

export default {
  props: {
    markdown: {
      type: String,
      default: "",
    },
    msgId: {
      type: String | Number,
      default: "",
    },
    isLast: {
      type: Boolean,
      default: false,
    },
    isHistory: {
      type: Boolean,
      default: false,
    },
    isComplete: {
      type: Boolean,
      default: false,
    },
  },
  components: {
    mpHtml,
  },
  data() {
    return {
      fullText: "",
      htmlOutput: "",
      typingIndex: 0,
      isFineshed: false,
      style: {
        h2: "line-height:1.5;color:#000;margin:30rpx 0",
        h3: "line-height:1.5;color:#000;margin:20rpx 0",
        h4: "line-height:1.5;color:#000;margin:15rpx 0",
        h5: "line-height:1.5;color:#000;margin:15rpx 0",
        ul: "padding-top:10rpx; padding-bottom:10rpx",
        ol: "padding-top:10rpx; padding-bottom:10rpx",
        li: "line-height:1.8;color:#000",
        p: "line-height:1.8;color:#000",
        hr: "border: none; border-top: 1px solid #EFEFEF; margin: 15px 0;",
      },
    };
  },
  computed: {
    showRibbon() {
      return this.isLast && this.isFineshed && this.isComplete;
    },
  },
  watch: {
    markdown: {
      handler(newVal, oldVal) {
        const md = newVal.replace(/(\n[-*+]\s.+?)(?=\n\S)/g, "$1\n");
        if (this.isHistory) {
          this.handelText(md);
        } else {
          this.typeText(md);
        }
      },
      immediate: true,
    },
  },
  methods: {
    handelText(newVal) {
      this.htmlOutput = marked(newVal);
      this.isFineshed = true;
    },
    typeText(targetText) {
      // 中断并重新打字(简化逻辑)
      clearTimeout(this._typingTimer);
      this.isFineshed = false;
      const step = () => {
        if (this.typingIndex < targetText.length) {
          this.fullText = targetText.slice(0, ++this.typingIndex);
          this.htmlOutput = marked(this.fullText);
          this._typingTimer = setTimeout(step, 30);
        } else {
          this.isFineshed = true;
        }
      };
      step();
    },
  },
  beforeDestroy() {
    clearTimeout(this._typingTimer);
  },
};
</script>

五、 拓展与优化

目前这个组件仅是针对文本类型的输出进行处理的,那些 md 中的图片、表格这些,都还没处理(一个原因是没这个需求哈哈哈)。还有一个优化,当文本有链接时,如 md 格式 这篇文章的地址是[uniapp - AI 聊天中的md格式](https://juejin.cn/editor/drafts/7527970960784703523),现在是每个字符都会进行打字,因此这个地址也会一个字一个字的显示出来,直到显示结束, marked 插件将其识别为 链接后,才显示其文本 uniapp - AI 聊天中的md格式。所以应该优化为遇到链接时,直接输出其文本。

相关推荐
拾光拾趣录7 分钟前
WebSocket:断线、心跳与重连
前端·websocket
阿眠31 分钟前
vue3实现web端和小程序端个人签名
前端·小程序·apache
哎呦薇44 分钟前
从开发到发布:手把手教你将Vue组件上传npm
前端·vue.js
Z7676_1 小时前
静态路由技术
服务器·前端·javascript
慧一居士1 小时前
npm 和 npx 区别对比
前端
用户3802258598241 小时前
vue3源码解析:生命周期
前端·vue.js·源码阅读
遂心_1 小时前
前端路由进化论:从传统页面到React Router的SPA革命
前端·javascript
前端菜鸟杂货铺1 小时前
前端首屏优化及可实现方法
前端
遂心_1 小时前
React Fragment与DocumentFragment:提升性能的双剑合璧
前端·javascript·react.js
ze_juejin1 小时前
ionic、flutter、uniapp对比
前端