小程序 AI 聊天

「小程序别出心裁的更简单的 AI 聊天功能」

Posted by My on November 20, 2025

之前在开发小程序的时候,写了篇笔记 uniapp - AI 聊天页面布局的实现,参杂了些许业务,也说的不全,现在稍微有空了,重新梳理了一些这种聊天方式的心得。

一、实现功能&&框架

这是在小程序里实现的,结合之前的经验,在数据请求上,我选择了 uni.request 这个 API,并启用 enableChunked: true,。为了方便数据请求,启动了本地 nodeJs 服务,对于「历史记录」,使用本地缓存。

这是一个 uniapp 的简单项目,为了使页面美观等,引入了一些「自定义组件」,如头部、底部输入框等。

从总体上来看,要实现的功能就是 ai 聊天,大致有以下几个点:

  • 发送消息的时候,消息内容 自定向上推
  • AI 回复的消息是流式输出,并内容也是向上推。
  • 历史记录加载的时候不卡顿,能衔接流畅。

二、项目结构

我这是新的项目,简单介绍项目结构,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
├───components
│   ├───ai-keyboard // 底部输入框
│   ├───ai-navbar // 头部
│   ├───ai-user-text // 用户消息
│   └───ai-sys-text // 系统消息
├───pages
│   ├───ai // 聊天页面
│   └───index
├───store // 状态管理
├───hooks // 状态管理
├───serve // 本地服务
├───utils
├───App.vue
├───main.js
├───manifest.json
├───pages.json
└───uni.scss

其中 ai-navbar 是自定义头部组件,其实用处不大,而 hooks 里目前只有一个功能,就是计算头部安全区的距离,用处也不大。serve 是为了实现功能而模拟的后端服务,npm start 运行在 http://localhost:3000 的服务,真实开发时,并不需要这个模块。

三、功能实现

整体上包括布局和逻辑两个部分。

1. 页面布局

其实就是一个简单的布局,头部、聊天内容区域、底部输入框。

核心还是 chat-container 这个聊天容器,对聊天内容进行了反转,然后包含了 scroll-view,用于滚动、加载。根据条件,分别渲染 ai-user-textai-sys-text 两个消息内容组件。

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
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
<template>
  <view class="container">
    <ai-navbar title="AI聊天"> </ai-navbar>
    <view class="chat-container">
      <scroll-view
        class="chat-content"
        scroll-y
        :lower-threshold="100"
        @scrolltolower="onScrollToLower"
      >
        <view class="chat-list">
          <view
            class="chat-item"
            v-for="(item, index) in chatList"
            :key="item.id"
            :class="{ user: item.role === 'user', ai: item.role === 'sys' }"
          >
            <ai-user-text v-if="item.role === 'user'" :text="item.delta" />
            <ai-sys-text
              v-if="item.role === 'ai'"
              :text="item.delta"
              :is-receiving="item.id === currentReceivingId"
            />
          </view>
        </view>
      </scroll-view>
    </view>
    <ai-keyboard @send="sendMessage" />
  </view>
</template>

<style lang="scss" scoped>
.container {
  width: 100vw;
  height: 100vh;
  display: flex;
  flex-direction: column;
  overflow: hidden;
  background: linear-gradient(180deg, #f07e88 0%, #d6ebfb 61.76%, #fff 100%);
  padding-bottom: env(safe-area-inset-bottom);

  .chat-item {
    width: 700rpx;
    transform: rotateX(180deg);
    &.user {
      display: flex;
      justify-content: flex-end;
    }
    &.ai {
      display: flex;
      justify-content: flex-start;
    }
  }
  .loading-tip {
    width: 100%;
    padding: 20rpx 0;
    display: flex;
    justify-content: center;
    align-items: center;
    transform: rotateX(180deg);
    .loading-text {
      font-size: 24rpx;
      color: #999;
    }
  }
  .chat-container {
    flex: 1;
    overflow: hidden;
    .chat-content {
      -webkit-overflow-scrolling: touch;
      box-sizing: border-box;
      height: 100%;
      overflow-y: auto;
      transform: rotateX(180deg);
      .chat-list {
        width: 100%;
        min-height: 100%;
        display: flex;
        flex-direction: column;
        align-items: center;
        justify-content: flex-end;
        gap: 20rpx;
      }
    }
  }
}
</style>

TIP:为什么要反转聊天内容?

容器的默认行为是 1. 内容会从上面自然地向下输出 2.上拉加载内容时,内容能流畅的衔接起来。

这是由我的需求是 1. 消息向上推出 2. 加载记录时,消息从上面能流畅衔接。 基于此,我才用反转容器的方法来实现。

不用这种方法的弊端,后面讲。

现在布局页面如下:

2. 发送&&接收消息

我简单的分为发送消息、接收消息、拼接消息三个部分。 发送消息时,发送消息到后端,并将消息插入到 chatList 中。 接收消息时,从后端接收流式数据,并将数据拼接起来,插入到 chatList 中。

(1). 发送消息

1
<ai-keyboard @send="sendMessage" />
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 发送消息时,将消息插入到 chatList 中
const addMessage = (messageItem) => {
  chatList.value.unshift(messageItem); // 因为反转了聊天内容,所以要插入到数组的开头
};

// 发送消息
const sendMessage = (message) => {
  chatMessage.value = message;
  const obj = {
    id: Date.now(),
    role: "user",
    delta: message,
  };
  addMessage(obj); // 插入用户消息
  onFetch(); // 发送请求
};

(2). 接收消息

其实这一块是很简单的,这个只是请求数据而已,有些人把「数据请求」和「数据处理」封装成一个方法,完全是把业务逻辑耦合在一起了,对于我来说,不太喜欢这种方式。 函数编程,我更喜欢抽离各种模块…

在微信小程序中,不支持 sse ,但是提供了 enableChunked: true, (详见文档)

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
const onFetch = () => {
  console.log("开始请求,API地址:", apiUrl);

  // 创建一个唯一 ID 用于标识这次对话
  const messageId = Date.now();
  currentReceivingId.value = messageId;

  // 先创建一个空的 AI 消息占位
  const aiMessage = {
    id: messageId,
    role: "ai",
    delta: "",
  };
  addMessage(aiMessage);

  const requestTask = wx.request({
    url: `${apiUrl}/chat`,
    method: "POST",
    enableChunked: true, // 启用分块传输
    data: {
      message: chatMessage.value,
    },
    success: (res) => {
      console.log("✅ 请求完成", res);
    },
    fail: (err) => {
      console.error("❌ 请求失败", err);
    },
    complete: () => {
      console.log("⭕ 请求结束");
      // 请求结束后,清除当前接收状态
      // 但打字机效果会在组件内部继续完成
      currentReceivingId.value = null;
      saveMessage("chatMessages", chatList.value);
    },
  });

  // 监听数据返回
  if (requestTask.onChunkReceived) {
    requestTask.onChunkReceived(async (res) => {
      console.log("收到分块数据:", res);
    });
  }
};

如上,完全是一个很简单的请求,只做请求处理。

currentReceivingId.value 是当前正在接收消息的 ID,用于标识当前正在接收的消息, 区分当前消息和历史消息,为「打字机」效果提供判断依据。

(3). 拼接消息

这里需要做一些准备,主要是将后端返回的流式数据拼接起来,输出完整的内容。我这里分为两个过程,一是将 buffer 转为 string, 二是将内容拼接。

涉及到两个方法,arrayBufferToStringprocessor.value.enqueue 。以前的业务中,AI 那边不能很好的处理流式数据,存在多条数据“合并”在一起的情况,导致前端显示异常,因此封装了 processor.value.enqueue 来处理。 AI 返回的数据可能如下:

1
2
3
4
 // 1. [{...},{...}]
 // 2. {...}
 // 3. {...},{...}
 // 4. {...}{...}

所以,如果你们的 AI 那边能很好的处理流式数据,就不需要封装 processor.value.enqueue 了。

两个方法,详见源代码

接收到「分块数据」后,就应该处理数据。

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
// 注册对象
const processor = ref(new ChunkProcessor(onHandleChunk));

// 处理分块数据
const onHandleChunk = (chunk) => {
  const { delta, role = "ai" } = chunk; // 设置默认 role 为 "ai"
  if (typeof delta === "string" && !delta?.trim()) return;
  const last = chatList.value[0];
  if (last && last.role === role) {
    last.delta += delta;
  } else {
    chatList.value.unshift({
      delta,
      role,
    });
  }
  console.log(chatList.value);
};

const onFetch = () => {
  // .... 其他代码

  // 监听数据返回
  if (requestTask.onChunkReceived) {
    requestTask.onChunkReceived(async (res) => {
      try {
        const text = await arrayBufferToString(res.data);
        await processor.value.enqueue(text);
      } catch (error) {
        console.error("❌ 解析失败", error);
      }
    });
  }
};

到这里,整个流程就已经实现了,能发送消息、接收消息,并把消息拼接起来渲染到页面上。

3. 打字机效果

(1). 打字机效果实现

ai-sys-text 组件中,实现打字机效果。

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
67
68
69
70
71
72
73
74
75
76
77
78
79
80
<script setup>
import { ref, watch, onBeforeUnmount } from "vue";

const props = defineProps({
  text: {
    type: String,
    default: "",
  },
  isReceiving: {
    type: Boolean,
    default: false, // 是否正在接收中(需要打字效果)
  },
});

const content = ref("");
let timer = null;
const typingIndex = ref(0);
const needTypingEffect = ref(false); // 标记是否需要打字效果

const typingText = (text) => {
  clearTimeout(timer);
  // 继续从当前位置打字
  const step = () => {
    if (typingIndex.value < text.length) {
      content.value = text.slice(0, ++typingIndex.value);
      timer = setTimeout(step, 30);
    } else {
      // 打字完成后,标记不再需要打字效果
      needTypingEffect.value = false;
    }
  };
  step();
};

const handlerText = (text) => {
  // 历史消息直接显示全部内容
  content.value = text;
  typingIndex.value = text.length;
};

watch(
  () => props.text,
  (newVal) => {
    if (props.isReceiving || needTypingEffect.value) {
      // 正在接收中或需要继续打字效果
      typingText(newVal);
    } else {
      // 历史消息,直接显示
      handlerText(newVal);
    }
  },
  { immediate: true }
);

// 监听 isReceiving 变化
watch(
  () => props.isReceiving,
  (newVal, oldVal) => {
    console.log("isReceiving 变化:", oldVal, "->", newVal);
    if (!newVal && oldVal) {
      // 从接收中变成接收完成,继续保持打字效果直到完成
      needTypingEffect.value = true;
      typingText(props.text);
    } else if (newVal && !oldVal) {
      // 重新开始接收
      needTypingEffect.value = true;
      typingIndex.value = 0;
      typingText(props.text);
    }
  }
);

// 组件卸载时清除定时器
onBeforeUnmount(() => {
  if (timer) {
    clearTimeout(timer);
    timer = null;
  }
});
</script>

对于打字机,目前我有两个观点或者说思路。

  • 前端实现

前端拿到拼接的数据后,如上,通过定时器或延时器,制定一个时间间隔,依次显示每个字符,实现打字机效果。不过有个弊端,即这个时间间隔是固定的,那么会出现流式数据已经「请求结束」,触发了 complate 方法,但是前端还在「打印」的现象。

  • 后端实现

即不处理,后端返回什么,前端直接显示什么。但这种有一种「卡顿」的弊端,如果依次返回的数据是 你好,这是模拟的数据。,则在页面上「突现」3 个、5 个、3 个字符,会有一种卡吨的效果。如果能让后端将数据处理成均匀的,即每条流式数据的内容都是差不多且精简的,如只有一到两个字,那才不会出现卡顿的现象。

但是,一般情况下,后端都是调用 AI 模型,只做数据的转发,因此「后端实现」的这种方法,并不太理想。

(2). 输出与打字效果预览

4. 加载历史消息

使用「页面反转」最重要的一点就是要实现「历史消息」的加载。

因为这里是演示效果,所以我封装了 mockAPI 来模拟后端返回数据的过程,包括获取历史消息和保存消息。

(1). 保存消息

由于业务和技术的不同,保存消息有不同的方式,这里我简单介绍一下。

比如是由后端进行的,那后端接收到用户发送的消息后,就保存记录,向用户发送消息后,就保存记录,这样就保存了一组 user/ai 的消息内容,但就是用户发送失败的消息不回存在记录中。

如果是前端保存,差异也是用户消息这一块,到底是用户一发送消息就调用方法保存记录,还是等待接口响应了再调用方法保存记录。这其实就是业务决定的,这里不搞那么复杂了。

我这里是在 complate 方法中保存消息的,即一来一回后才保存消息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const onFetch = () => {
  // ... 其他代码
  const requestTask = wx.request({
    url: `${apiUrl}/chat`,
    method: "POST",
    enableChunked: true, // 启用分块传输
    data: {
      message: chatMessage.value,
    },
    success: (res) => {
      console.log("✅ 请求完成", res);
    },
    fail: (err) => {
      console.error("❌ 请求失败", err);
    },
    complete: () => {
      currentReceivingId.value = null;
      saveMessage("chatMessages", chatList.value); // 保存消息
    },
  });

  // ... 其他代码
};

注意:这里的 saveMessage 方法是我封装的一个方法,用于保存消息到本地存储。

(2). 加载历史消息

主要是在 「页面初始化」和「手动加载」 时加载历史消息。

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
const getMessageList = async (isLoadMore = false) => {
  // 防止重复加载
  if (loading.value) return;

  // 如果是加载更多,检查是否还有更多数据
  if (isLoadMore && !pagination.value.hasMore) {
    console.log("没有更多数据了");
    return;
  }

  try {
    if (isLoadMore) {
      pagination.value.page += 1;
    } else {
      loading.value = true;
      pagination.value.page = 1;
    }

    const result = await getMessage("chatMessages", {
      page: pagination.value.page,
      pageSize: pagination.value.pageSize,
    });
    console.log("result", result.data);

    if (result.code === 200) {
      // 更新分页信息
      pagination.value.total = result.data.total;
      pagination.value.hasMore = result.data.hasMore;

      if (isLoadMore) {
        // 加载更多:追加到列表末尾
        chatList.value.push(...result.data.list);
      } else {
        // 初次加载:替换列表
        chatList.value = result.data.list;
      }
    }
  } catch (error) {
    // 加载失败时回退页码
    if (isLoadMore) {
      pagination.value.page -= 1;
    }
  } finally {
    loading.value = false;
  }
};

const onScrollToLower = () => {
  getMessageList(true);
};

onLoad(() => {
  getMessageList();
});

注意:由于页面翻转了,所以顶部的加载使用 onScrollToLower 方法。

注意:这里的 getMessage 方法是我封装的一个方法,用于从本地存储获取消息。

我的数据结构中,是使用 hasMore 来判断是否还有更多数据的。

5.整体功能预览

其实到这里,AI 聊天的基本功能已经实现完成了,就跟 第一点 说的那样,实现消息上推,历史消息加载,打字机效果。

四、布局分析

在之前的开发中,我也是试错了好几遍,才找到一个比较合适的布局。那为什么使用「翻转」,不使用的弊端是什么?

1. 上推

第一种想法应该是「滚动」,即一添加消息,就让页面滚动到最底部,可用scroll-into-viewscroll-top,那在实操过程中发现, scroll-top 的值必须是最大的一个动态值,且在用户消息这点是可以实现上推的,但是在 AI 消息这点,会出现「吞消息」的现象,即有部分消息是在聊天容器之外的,尤其是当「打字机」和「md」语法结合时,这种情况异常明显,会有部分内容被吃掉,上推不自然。

2. 下拉

这是历史消息的加载,当上滑到这一页的「最后一条」时,需要加载消息,如果不做处理,直接加载第二页,往数组里插入数据,那这是会「突变」的,第一页的数据直接被下推,所以想法就是「记录」第一页最后一条数据的「位置」,当向数组插入第二页数据时,马上滚动到记录的那个「位置」,这样就不会突变了。

基于此逻辑,在实操过程中,突变到那个「位置」时,页面会闪动一下,无论如何我都无法处理这个闪动,所以并不能很流畅地衔接第一页和第二页之间的数据。

3. 初始化

当进入页面获取第一页历史数据时,使用 scroll-top 滚动到最后,会出现一个短暂的滚动现象,给人一种「这是滚动过来」的感觉。

4. 总结

综上,有这样的差异:

功能 普通方式 翻转
上推 ❌ 需要用 id 记录位置,并滚动 ✅ 不需要做处理
下拉 ❌ 会有突变现象 ✅ 自然衔接
初始化 ❌ 会有短暂的滚动现象 ✅ 直接出现在底部

所以,多次试验之后,采用 「翻转」 这种方法,需要做处理的地方,仅仅是数据插入的时候,使用 unshift 插入到前面。

当然了,我是基于这三种情况,才采用的「翻转」这种方法。有的业务需求是不需要加载历史记录,或者说历史记录保存在本地,只有几条,并需要加载、分页请求,那这种情况下,处理好一点,还是能用「滚动」的方式去实现视图定位的。

五、优化

当前只是实现了基本功能,其实要有一些优化。

1. 复制消息

包括用户和 AI 消息的复制,主要是弹出一个框,实现复制功能,难点在于弹窗的「位置」。用户的消息比较少,这里以 AI 的消息为例:

  • 消息较长,在顶部(被顶部吃掉一部分)。

    弹窗应该在消息的内部的顶部。

  • 消息较短,在顶部

    弹窗应该在消息的底部。

  • 消息较长,在中间

    弹窗应该在消息的内部。

  • 消息较长,在底部(被底部吃掉一部分)。

    弹窗应该在消息的内部的顶部。

  • 消息较短,在底部

    弹窗应该在消息的顶部。

这是根据具体的需求来实现的,当然实际可能不是这样,但肯定会有顶部、中部、底部这三种情况。

2.禁发消息

当发送消息后,ai 还没回复消息时,应该禁用发送按钮,防止重复发送。如果不禁用,可能会出现多条 ai 消息几乎同时回复的情况。

3. 重新发送

有些会给「最后一条」ai 消息添加「重新生成」的功能。需要注意几点:

  • 知道哪个是最后一条消息

  • 约定重新生成的方法。

    到底是重新发送上一条消息,还是约定一个类型,让后端重新返回消息。 不管怎样,再次发送的消息都不要加入「消息数组」中,也不要保存历史记录。且根据需要,生成的两条 ai 消息,是都展示还是只展示最新的。

六、源代码

代码放在 miniprogram-ai 项目中,有兴趣的可下载看看。