Post

AI Chat in Mini Program (3)

Integrating iFlytek LLM with streaming API response and optimizing the chat interaction experience.

AI Chat in Mini Program (3)

打字机效果跑得挺好,消息也能发出去,但每次测试都是假数据在那里自演自答——前端部分的交互看起来像那么回事,心里清楚差得远。

想接真实的大模型。先想到调公司内部的接口,但要登录拿 appNamesessionId,麻烦,算了。然后在各大社区转了一圈,好多都要付费,跳过。Gemini 免费,文档也齐全,试了一下能拿到数据,但跑起来不稳定,放弃。最后选了 科大讯飞的 Spark Lite——实名认证后免费调用,token 无限量。说是大模型,更像个检索机器人,不过接口能跑通就够了。

接入讯飞:用 readline 处理流式数据

控制台 创建应用,拿到 APIPassword

keda.png

依赖很简单:node-fetch 发请求,readline 处理数据流,dotenv 读环境变量。环境变量放 .env

1
2
3
URL=https://spark-api-open.xf-yun.com/v1/chat/completions
APIKEY= 你的 APIPassword
PORT=8080

node-fetch 而不是原生 fetch,是因为它返回的 response.body 是 Node.js 原生的 Readable,可以直接丢给 readline 按行解析——处理 SSE 数据流就靠这一点:

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
import fetch from "node-fetch";
import readline from "readline";
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import dotenv from "dotenv";
dotenv.config({ path: ".env" });

const KEY = process.env.GEMINI_API_KEY;
const URL = process.env.URL;

export const chatStream = async (messages, res) => {
  console.log(messages);
  const body = {
    model: "lite",
    user: "",
    messages: [messages],
    stream: true,
  };
  const response = await fetch(URL, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${KEY}`,
    },
    body: JSON.stringify(body),
  });

  const rl = readline.createInterface({
    input: response.body,
    crlfDelay: Infinity,
  });
  rl.on("line", (line) => {
    line = line.trim();
    if (line.startsWith("data:")) {
      const jsonStr = line.replace(/^data:\s*/, "");
      if (jsonStr === "[DONE]") return;
      try {
        const data = JSON.parse(jsonStr);
        console.log(data.choices[0].delta);
        if (data.choices[0].delta.content) {
          res.write(JSON.stringify(data.choices[0].delta));
        }
      } catch (e) {
        // 忽略解析错误
      }
    }
  });
  rl.on("close", () => {
    console.log("流处理完成");
    res.end();
  });
};

每次 rl.on("line") 触发,拿到的 data.choices[0].delta 长这样:

1
2
3
4
5
{ role: 'user', content: '你好啊' }
{ role: 'assistant', content: '你好' }
{ role: 'assistant', content: '!有什么' }
{ role: 'assistant', content: '我可以帮助你的' }
{ role: 'assistant', content: '吗?' }

index.js 里挂路由,把 messagesres 传给 chatStream 就行:

1
2
3
4
5
6
7
8
9
10
11
12
13
import { chatStream } from "./chat.js";

app.post("/api/chat/stream", async (req, res) => {
  try {
    const { messages } = req.body;
    if (!messages) {
      return res.status(400).json({ error: "message参数必填" });
    }
    await chatStream(messages, res);
  } catch (error) {
    console.error("API错误:", error);
  }
});

顺手把数据结构对齐了

接入真实接口的同时,把之前的数据格式也改了。原来图方便用的是 {role:'sys', delta:'你好'},跟主流 AI 格式差太远,统一改成 {role:'assistant', content:'你好'}

消息合并逻辑对应调整,核心是判断最新一条消息的 role——相同就追加 content,不同就新增一条:

1
2
3
4
5
6
7
8
9
10
11
12
13
const onHandleChunk = (chunk) => {
  const { content, role = "assistant" } = chunk;
  if (typeof content === "string" && !content?.trim()) return;
  const last = chatList.value[0];
  if (last && last.role === role) {
    last.content += content;
  } else {
    chatList.value.unshift({
      content,
      role,
    });
  }
};

聊天记录的读取用分页,小程序端传 pagepageSize,server 端读 messages.json 做切片:

1
2
3
4
5
const result = await getMessage({
  page: pagination.value.page,
  pageSize: pagination.value.pageSize,
  userId: "xx123",
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// chat.js
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const filePath = path.join(__dirname, "data", "messages.json");

export const getMessage = async (params, res) => {
  const { page, pageSize } = params;
  const data = JSON.parse(fs.readFileSync(filePath, "utf8")).reverse();
  const total = data.length;
  const startIndex = (page - 1) * pageSize;
  const endIndex = startIndex + pageSize;
  const list = data.slice(startIndex, endIndex);
  res.json({ list, total, hasMore: endIndex < total });
};

「打断」比想象中麻烦一点

上一篇说要同时打断两件事:打字机动画和接口请求。打断接口相对简单,调一个「中止接口」就行,后端处理。

打断打字机才是真正要想清楚的地方。在 ai-keyboard 组件里,用户点「打断」时只抛出 stop 事件,不做其他处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
const emit = defineEmits(["send", "stop"]);
const sendMessage = () => {
  if (props.isReplying) {
    emit("stop");
    return;
  }
  if (!inputValue.value.trim()) {
    uni.showToast({ title: "请输入内容", icon: "none" });
    return;
  }
  emit("send", inputValue.value);
  inputValue.value = "";
};

父组件收到 stop,只把 isStop 标记为 true不能直接改 isReplying

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const isStop = ref(false);
const onStop = () => {
  isStop.value = true;
  // isReplying.value = false; // ❌ 不能执行
};

requestTask = wx.request({
  // 其他代码...
  complete: () => {
    console.log("请求结束");
    currentReceivingId.value = null;
    isReplying.value = false; // ✅ 在这里修改
  },
});

原因是:点了「打断」只是说「我不想看了」,但接口还在跑。如果这时候直接把 isReplying 改成 false,发送按钮就恢复可用,用户能发新消息——上一条还没结束,下一条又来了,状态就乱掉了。打断接口的响应也有延迟,所以等 requestTaskcomplete 回调才是最合适的时机。

ai-sys-text 组件收到 isStop 后,打字机直接退出并把当前内容抛出去:

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
const emits = defineEmits(["stopSuccess"]);
const typingText = (text) => {
  if (!text) return;
  if (props.isStop) return;

  clearTimeout(timer);
  const step = () => {
    if (props.isStop) {
      emits("stopSuccess", content.value); // 打断时,把当前已渲染内容抛出
      return;
    }
    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, 30);
    } else {
      if (!props.isReceiving) {
        needTypingEffect.value = false;
        isReplying.value = false;
        emits("stopSuccess", content.value); // 打字完成,也抛出内容
      }
    }
  };
  step();
};

无论是打断还是打字完成,stopSuccess 都会触发。父组件在这里保存记录:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const sendMessage = (message) => {
  isReplying.value = true;
  isWaiting.value = true;
  chatMessage.value = message;
  const obj = { id: Date.now(), role: "user", content: message };
  addMessage(obj);
  saveMessage(obj); // 保存用户发送的内容
  onFetch();
};

const onStopSuccess = (text) => {
  const messages = { role: "assistant", content: text };
  saveMessage(messages); // 保存系统回复的内容
};

键盘上移这个坑

page.png

input 组件默认 adjust-positiontrue,键盘弹起时会把整个页面往上推——推出容器,布局就乱了。改成 false 呢,键盘直接盖住输入框,也不行。

换个思路:既然键盘会盖住页面底部,那就给 ai-keyboard 组件动态加 padding-bottom,让内容主动给键盘让位。

监听 @keyboardheightchange 拿高度,减掉 safe-area-inset-bottom 避免 iPhone 底部双重留白:

1
2
3
4
5
6
7
8
9
const keyboardHeight = ref("");
const onKeyboardheightchange = (e) => {
  const height = e.detail.height ?? 0;
  if (height) {
    keyboardHeight.value = `calc(${height}px - env(safe-area-inset-bottom))`;
  } else {
    keyboardHeight.value = "0px";
  }
};

组件模板里把 padding-bottom 绑上去,adjust-position 设为 false

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<template>
  <view class="ai-keyboard" :style="{ 'padding-bottom': `${keyboardHeight}` }">
    <view class="ai-keyboard__input">
      <input
        type="text"
        :focus="focus"
        placeholder="请输入内容"
        v-model="inputValue"
        @confirm="sendMessage"
        :adjust-position="false"
        @keyboardheightchange="onKeyboardheightchange"
        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>

打断接口的完整实现见源代码,效果如下:

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