Post

AI Chat in Mini Program (2)

Continued exploration of AI chat in Mini Program: refining the interaction and handling edge cases.

AI Chat in Mini Program (2)

上一篇把基本布局和数据获取跑通了,但 AI 返回的内容都是 markdown 字符串,直接展示就是一堆 **加粗**## 标题 这类符号——要想办法解析渲染出来。

先把空数据和加载状态处理了

post-ai-7.png

这两件事比较简单,顺手做了。

空数据页面抽成独立组件 ai-empty,当 chatList 为空时展示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<template>
  <view class="ai-empty">
    <image class="img" src="@/static/chat.png" mode="widthFix"></image>
    <view class="text">让我们开始聊天吧</view>
  </view>
</template>

<style lang="scss" scoped>
.ai-empty {
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  .img {
    width: 450rpx;
  }
  .text {
    font-size: 60rpx;
    margin-top: 50rpx;
    color: #e467b2;
    font-weight: bold;
  }
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<template>
  <view class="chat-container">
    <scroll-view
      v-if="chatList.length > 0"
      class="chat-content"
      :scroll-top="scrollTop"
      scroll-y
      :lower-threshold="100"
      @scrolltolower="onScrollToLower"
    >
      <!-- 其他代码 -->
    </scroll-view>
    <ai-empty v-else></ai-empty>
  </view>
</template>

加载状态也抽了个 ai-loading 组件,用弹跳动画表示「等待回复中」。发消息后显示,收到第一条数据后隐藏:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
<template>
  <view class="loading">
    <view class="loading-item"></view>
    <view class="loading-item"></view>
    <view class="loading-item"></view>
    <view class="loading-item"></view>
  </view>
</template>

<style lang="scss" scoped>
.loading {
  display: flex;
  align-items: center;
  width: 700rpx;
  height: 56rpx;
  padding: 0 15rpx;
}

.loading {
  .loading-item {
    border-radius: 50%;
    position: relative;
    width: 30rpx;
    height: 30rpx;
    margin: 0 6rpx;
    animation: loading 800ms infinite;
    &:nth-child(1) {
      background-color: #ec2e8b;
    }
    &:nth-child(2) {
      animation-delay: 100ms;
      background-color: #5dddd8;
    }
    &:nth-child(3) {
      animation-delay: 200ms;
      background-color: #fafafa;
    }
    &:nth-child(4) {
      animation-delay: 300ms;
      background-color: #8bc34a;
    }
    &:nth-child(5) {
      animation-delay: 400ms;
      background-color: #ffeb3b;
    }
  }
}

@keyframes loading {
  0% {
    top: 0;
  }
  25% {
    top: 5rpx;
  }
  50% {
    top: -15rpx;
  }
  75% {
    top: 5rpx;
  }
  100% {
    top: -1rpx;
  }
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
const isWaiting = ref(false);

const sendMessage = (message) => {
  isWaiting.value = true;
  // 其他代码
};

if (requestTask.onChunkReceived) {
  requestTask.onChunkReceived(async (res) => {
    isWaiting.value = false;
    // 其他代码
  });
}

markdown 渲染,以及两个绕不过去的 bug

模拟的数据是流式输出的 markdown 片段:

1
2
3
4
'{"role": "ai", "delta": "## 岳阳楼记\\n\\n"}',
'{"role": "ai", "delta": "**庆历四年春**,滕子京谪守巴陵郡。"}',
'{"role": "ai", "delta": "越明年,政通人和,百废具兴。"}',
'{"role": "ai", "delta": "乃重修岳阳楼,增其旧制,刻唐贤今人诗赋于其上,属予作文以记之。\\n\\n"}',

解析用 marked,渲染用 mp-html,两个依赖一起装:

1
pnpm add marked mp-html

ai-sys-text 组件里,打字机每输出一个字符就调一次 marked 转成 HTML,塞给 mp-html 渲染:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import marked from "marked";
import mpHtml from "mp-html/dist/uni-app/components/mp-html/mp-html.vue";

const typingText = (text) => {
  if (!text) return;
  clearTimeout(timer);
  const step = () => {
    if (typingIndex.value < text.length) {
      content.value = text.slice(0, ++typingIndex.value);
      htmlContent.value = marked(content.value);
      timer = setTimeout(step, 20);
    } else {
      needTypingEffect.value = false;
    }
  };
  step();
};
1
2
3
4
5
<template>
  <view class="ai-sys-text">
    <mp-html :tag-style="style" :content="htmlContent" />
  </view>
</template>

样式做了简单定制,避免纯黑文本贴着默认行间距太难看:

1
2
3
4
5
6
7
8
9
10
11
12
13
const style = {
  h1: "line-height:1.5;color:#FE2BC2;margin:30rpx 0",
  h2: "line-height:1.5;color:#ff37c6;margin:30rpx 0",
  h3: "line-height:1.5;color:#ff45ca;margin:20rpx 0",
  h4: "line-height:1.5;color:#ff54ce;margin:15rpx 0",
  h5: "line-height:1.5;color:#ff67d4;margin:15rpx 0",
  ul: "padding-top:10rpx; padding-bottom:10rpx",
  ol: "padding-top:10rpx; padding-bottom:10rpx",
  li: "line-height:1.8;color:#333",
  p: "line-height:1.8;color:#333",
  strong: "color:#FD4E30;",
  hr: "border: none; border-top: 1px solid #EFEFEF; margin: 15px 0;",
};

跑起来之后发现两个渲染 bug。第一个是加粗的问题:打字机逐字输出,遇到 **加粗** 时,输到第一个 **marked 还没看到闭合符,就直接把 ** 显示出来,等后面的 ** 到了内容才突然变粗。第二个是列表前的内容会被误判成标题:有序/无序列表前面有文字时,marked 会把前面那段错误解析成 h1h2 之类,字体突然变大再变回来,一闪一闪。

两个 bug 都出在「字符串不完整就送去解析」这件事上。 借助 AI 写了一个 completeMarkdown 工具函数,在交给 marked 之前先把未闭合的 markdown 标记补全(完整实现在源码里)。打字机改成先预处理再渲染:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { completeMarkdown } from "@/utils/completeMarkdown";

const typingText = (text) => {
  if (!text) return;
  clearTimeout(timer);
  const step = () => {
    if (typingIndex.value < text.length) {
      content.value = text.slice(0, ++typingIndex.value);
      const completedContent = completeMarkdown(content.value);
      htmlContent.value = marked(completedContent);
      timer = setTimeout(step, 60);
    } else {
      needTypingEffect.value = false;
    }
  };
  step();
};

「打断回复」该打断什么

加完打字机之后,想着把「打断」功能也加上——发消息后按钮变成「停止」,点了就打断 AI 的回复。

看起来简单,仔细想不对:到底是打断接口,还是打断打字机?

两个不是一回事,因为打字机始终比接口慢。

如果打断的是接口:接口停了,但打字机还在跑。比如《静夜思》,接口被打断在「举头望明月」,但页面刚打到「疑是地上霜」,画面上内容还会继续往下走——用户会懵:「我不是已经打断了吗?」

如果打断的是打字机:又有另一个问题。假如接口已经全部输出完,打字机才跑到一半,用户这时候打断——页面是停了,但如果之前是「接口完成就保存记录」,历史记录里早就存了完整内容。下次进聊天一看,打断没生效。

这两个方案都有问题,但换个角度,「前端打印」恰好给了一个出路:不在接口完成时保存记录,改在打字机完成时保存——打断了就保存截止那一刻已经显示的内容,没打断就打字完了再保存。两种情况都对得上用户实际看到的内容。

这是下一篇要实现的部分,这里先把按钮状态切换做了。

从发消息到打字结束这整个过程,用 isReplying 来表示。ai-sys-text 组件双向绑定它,打字过程中维持 true,打字完成置 false

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const isReplying = defineModel("isReplying");

const typingText = (text) => {
  if (!text) return;
  clearTimeout(timer);
  const step = () => {
    isReplying.value = true;
    if (typingIndex.value < text.length) {
      content.value = text.slice(0, ++typingIndex.value);
      const completedContent = completeMarkdown(content.value);
      htmlContent.value = marked(completedContent);
      timer = setTimeout(step, 20);
    } else {
      needTypingEffect.value = false;
      isReplying.value = false;
    }
  };
  step();
};

发消息时主动置 true,把状态传给 ai-keyboard 控制按钮显示:

1
2
3
4
5
6
7
8
9
10
11
12
const sendMessage = (message) => {
  isReplying.value = true;
  isWaiting.value = true;
  chatMessage.value = message;
  const obj = {
    id: Date.now(),
    role: "user",
    delta: message,
  };
  addMessage(obj);
  onFetch();
};
1
2
3
4
<template>
  <!-- 其他代码 -->
  <ai-keyboard :is-replying="isReplying" @send="sendMessage" />
</template>

ai-keyboardisReplyingtrue 时发送逻辑直接 return,图标切成停止样式:

1
2
3
4
5
6
const sendMessage = () => {
  if (props.isReplying) {
    return;
  }
  // 其他代码
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<template>
  <view class="ai-keyboard">
    <view class="ai-keyboard__input">
      <input
        type="text"
        placeholder="请输入内容"
        v-model="inputValue"
        @confirm="sendMessage"
        placeholder-style="color: #79A5BE;"
      />
      <view class="ai-keyboard__input-send" @click="sendMessage">
        <text class="iconfont icon-tingzhi" v-if="isReplying"></text>
        <text class="iconfont icon-send-s" v-else></text>
      </view>
    </view>
  </view>
</template>

目前效果如下,源代码devmain 分支:

This post is licensed under CC BY 4.0 by the author.