[鸿蒙NEXT] 基于鸿蒙 rcp 模块 + Coze API 实现流式响应AI对话 打字机效果

864 阅读6分钟

引言

在智能对话应用中,为了实现实时响应,可以使用流式响应技术,即服务端逐条发送数据流事件,使得客户端可以即时渲染并呈现消息,减少等待时间。

PixPin_2024-11-14_14-09-38.gif

本篇文章的重点是利用rcp模块实现流式响应,相比轮询请求对用户更加友好,也更难一点。

主要技术及介绍

扣子 开发文档

OnDataReceive请求回调

rcp 网络请求模块

Coze api 返回结构

PixPin_2024-11-14_14-45-32.png 我使用的是coze平台搭建的智能体的api,其他平台大概差不多: (。

在流式响应中,服务端不会一次性发送所有数据,而是以数据流的形式逐条发送数据给客户端,数据流中包含对话过程中触发的各种事件(event),直至处理完毕或处理中断。处理结束后,服务端会通过 conversation.message.completed 事件返回拼接后完整的模型回复信息。各个事件的说明可参考流式响应事件

流式响应允许客户端在接收到完整的数据流之前就开始处理数据,例如在对话界面实时展示智能体的回复内容,减少客户端等待模型完整回复的时间。

扣子api返回响应示例:

event:conversation.chat.created
data:{"id":"7436978328604753946","conversation_id":"7436978328604737562","bot_id":"7421457483137105931","created_at":1731556459,"last_error":{"code":0,"msg":""},"status":"created","usage":{"token_count":0,"output_count":0,"input_count":0},"section_id":"7436978328604737562"}

event:conversation.chat.in_progress
data:{"id":"7436978328604753946","conversation_id":"7436978328604737562","bot_id":"7421457483137105931","created_at":1731556459,"last_error":{"code":0,"msg":""},"status":"in_progress","usage":{"token_count":0,"output_count":0,"input_count":0},"section_id":"7436978328604737562"}

event:conversation.message.delta
data:{"id":"7436978350943387685","conversation_id":"7436978328604737562","bot_id":"7421457483137105931","role":"assistant","type":"answer","content":"您好","content_type":"text","chat_id":"7436978328604753946","section_id":"7436978328604737562"}

event:conversation.message.delta
data:{"id":"7436978350943387685","conversation_id":"7436978328604737562","bot_id":"7421457483137105931","role":"assistant","type":"answer","content":"呀","content_type":"text","chat_id":"7436978328604753946","section_id":"7436978328604737562"}

还有更多...
参数​类型​说明​
event​String​当前流式返回的数据包事件。详细说明可参考 流式响应事件。​
data​Object​消息内容。其中,chat 事件和 message 事件的格式不同。​- chat 事件中,data 为 Chat Object。​- message 事件中,data 为 Message Object。​

服务端按数据流形式逐条发送内容。各事件分别触发,如conversation.chat.createdconversation.message.deltaconversation.message.completed等,客户端根据事件类型逐步处理数据。

鸿蒙 rcp 请求模块

据说和axios更像,比http更好?

OnDataReceive 请求回调

实现的一个重点是创建回调.在回调中实时接收数据处理数据

const customHttpEventsHandler: rcp.HttpEventsHandler = {
  onDataReceive: (incomingData: ArrayBuffer) => {
      //接收buffer数据流,转换格式,处理...
     
const tracingConfig: rcp.TracingConfiguration = {
  httpEventsHandler: customHttpEventsHandler
};  //这是什么,看不懂,文档就这么写的~
      
//将回调作为请求对象的参数
const session = rcp.createSession({ requestConfiguration: { tracing: tracingConfig } });

//发送请求
session.fetch(request).then((rep: rcp.Response) => {
    //没什么用了

实现流程

1. 请求配置

准备请求头,请求体数据。 不同api要求的参数格式不一样。

const chatRequest: ChatRequest = {
  bot_id: '742145748313***你的bot id',
  user_id: '114514',
  stream: true,  //开启流式响应
  auto_save_history: true,
  additional_messages: [
    {
      role: 'user',
      content: text,  //函数传入的参数
      content_type: 'text'
    }
  ]
};

const url = "https://api.coze.cn/v3/chat";
const request = new rcp.Request(url, "POST", {
  'Authorization': 'Bearer pat_3O7LumMTJwaW*****密钥',
  'Content-Type': 'application/json'
}, chatRequest);

2. 创建回调函数,实时处理二进制流数据

通过textDecoder.decodeToString将二进制流转成字符串,再转成对象

因为返回的每份结构还有换行,所以看起来很麻烦(这部分由claude-3.5-sonnet By cursor生成😎)

@State rspData: string = ''  //状态变量,不必多说
@State buffer: string = ''; // 用于存储未处理完的数据
const customHttpEventsHandler: rcp.HttpEventsHandler = {
  onDataReceive: (incomingData: ArrayBuffer) => {
    const textDecoder = util.TextDecoder.create('utf-8');
    const chunk = textDecoder.decodeToString(new Uint8Array(incomingData));

    // 将新数据追加到buffer
    this.buffer += chunk;

    // 按行处理数据
    const lines = this.buffer.split('\n');
    // 保留最后一个可能不完整的行
    this.buffer = lines[lines.length - 1];

    // 处理完整的行
    for (let i = 0; i < lines.length - 1; i++) {
      const line = lines[i].trim();
      if (!line) {
        continue;
      }

      console.info('114514收到原始数据:', line);

      if (line.startsWith('data:')) {
        try {
          const jsonStr = line.slice(5); // 移除 'data:' 前缀
          if (jsonStr === '[DONE]') {
            console.info('114514流式响应完成');
            continue;
          }

          const data: Message = JSON.parse(jsonStr);

          // 处理delta消息
          if (data.content && data.type === 'answer') {
            this.rspData += data.content;

            this.chatHistory[this.chatHistory.length - 1] = {
              content: this.rspData,
              type: 'bot'
            } as ChatHistory;

            console.info('114514当前累积内容:', this.rspData);
          }

        } catch (e) {
          console.error('114514解析错误:', e);
        }
      }
    }

    return incomingData.byteLength;
  },
  onDataEnd: () => {
    // Custom logic for handling data transfer completion
    this.rspData = ''
    console.info('114514' + "请求结束");
  },
};

const tracingConfig: rcp.TracingConfiguration = {
  httpEventsHandler: customHttpEventsHandler
};

3. 创建请求对象,发起请求

// Use the configuration in the session creation
const session = rcp.createSession({ requestConfiguration: { tracing: tracingConfig } });

session.fetch(request).then((rep: rcp.Response) => {
  console.info('114514' + `Response succeeded: ${rep}`);
}).catch((err: BusinessError) => {
  console.error('114514' + `Response err: Code is ${err.code}, message is ${JSON.stringify(err)}`);
});

4. 可扩展内容

会话流中不同内容类型触发不同的事件。例如知识库召回、工具调用(function_call)等事件,可以通过判断type字段处理特定内容,之后有时间写一下~🤪。

演示

简单演示一下,界面由ai生成😿💔

PixPin_2024-11-14_14-32-40.gif PixPin_2024-11-14_14-39-47.gif

代码

代码仅用于验证流式输出功能实现,界面随便写的😇😇

import { rcp } from '@kit.RemoteCommunicationKit';
import { promptAction, window } from '@kit.ArkUI';
import { http } from '@kit.NetworkKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { ChatRequest, Message } from '../../Model/DataModel';
import { util } from '@kit.ArkTS';

@Builder
function Rcp_StreamViewBuilder() {
  NavDestination() {
    Rcp_StreamView()
  }
  .hideTitleBar(false)
}

interface ChatHistory {
  content: string
  type: 'user' | 'bot'
}

@Entry
@Component
export struct Rcp_StreamView {
  aboutToAppear(): void {
    // window.getLastWindow(getContext())
    //   .then((win) => {
    //     win.getUIContext().setKeyboardAvoidMode(KeyboardAvoidMode.RESIZE)
    //   })
  }

  @State rspData: string = ''
  @State buffer: string = ''; // 用于存储未处理完的数据
  @State chatHistory: ChatHistory[] = []
  @State inputText: string = '' // 新增输入框的内容状态
  scroller: Scroller = new Scroller()
  StartChat = async (text: string): Promise<void> => {
    const chatRequest: ChatRequest = {
      bot_id: '742960328318我是蔡徐坤',
      user_id: '114514',
      stream: true,
      auto_save_history: true,
      additional_messages: [
        {
          role: 'user',
          content: text,
          content_type: 'text'
        }
      ]
    };

    const url = "https://api.coze.cn/v3/chat";
    const request = new rcp.Request(url, "POST", {
      'Authorization': 'Bearer pat_3O7LumMTJwaWLX4f我是蔡徐坤',
      'Content-Type': 'application/json'
    }, chatRequest);

    const customHttpEventsHandler: rcp.HttpEventsHandler = {
      onDataReceive: (incomingData: ArrayBuffer) => {
        const textDecoder = util.TextDecoder.create('utf-8');
        const chunk = textDecoder.decodeToString(new Uint8Array(incomingData));

        // 将新数据追加到buffer
        this.buffer += chunk;

        // 按行处理数据
        const lines = this.buffer.split('\n');
        // 保留最后一个可能不完整的行
        this.buffer = lines[lines.length - 1];

        // 处理完整的行
        for (let i = 0; i < lines.length - 1; i++) {
          const line = lines[i].trim();
          if (!line) {
            continue;
          }

          console.info('114514收到原始数据:', line);

          if (line.startsWith('data:')) {
            try {
              const jsonStr = line.slice(5); // 移除 'data:' 前缀
              if (jsonStr === '[DONE]') {
                console.info('114514流式响应完成');
                continue;
              }

              const data: Message = JSON.parse(jsonStr);

              // 处理delta消息
              if (data.content && data.type === 'answer') {
                this.rspData += data.content;

                this.chatHistory[this.chatHistory.length - 1] = {
                  content: this.rspData,
                  type: 'bot'
                } as ChatHistory;

                console.info('114514当前累积内容:', this.rspData);
              }

            } catch (e) {
              console.error('114514解析错误:', e);
            }
          }
        }

        return incomingData.byteLength;
      },
      onDataEnd: () => {
        // Custom logic for handling data transfer completion
        this.rspData = ''
        console.info('114514' + "请求结束");
      },
    };

    const tracingConfig: rcp.TracingConfiguration = {
      httpEventsHandler: customHttpEventsHandler
    };

    // Use the configuration in the session creation
    const session = rcp.createSession({ requestConfiguration: { tracing: tracingConfig } });

    session.fetch(request).then((rep: rcp.Response) => {
      console.info('114514' + `Response succeeded: ${rep}`);
    }).catch((err: BusinessError) => {
      console.error('114514' + `Response err: Code is ${err.code}, message is ${JSON.stringify(err)}`);
    });

  }

  build() {
    Column() {
      // 聊天内容区域
      Scroll(this.scroller) {
        Column() {
          // AI回复气泡
          ForEach(this.chatHistory, (item: ChatHistory, index) => {
            if (item.type == 'bot') {
              Row() {
                Image($r('app.media.app_icon'))
                  .width(40)
                  .height(40)
                  .borderRadius(20)
                  .margin({ right: 10 })
                  .syncLoad(true)

                Text(item.content)
                  .fontSize(16)
                  .backgroundColor('#FFFFFF')
                  .padding(12)
                  .borderRadius(12)
                  .margin({ right: 50 })
              }
              .width('100%')
              .margin({ top: 10, bottom: 10 })
              .alignItems(VerticalAlign.Top)
            } else {
              Row() {
                Text(item.content)
                  .fontSize(16)
                  .backgroundColor('#ffc2ffb4')
                  .padding(12)
                  .borderRadius(12)
                  .margin({ right: 12 })

                Image($r('app.media.app_icon'))
                  .width(40)
                  .height(40)
                  .borderRadius(20)
                  .margin({ right: 10 })
                  .syncLoad(true)
              }
              .width('100%')
              .justifyContent(FlexAlign.End)
              .margin({ top: 10, bottom: 10 })
              .alignItems(VerticalAlign.Top)
            }
          })
        }
        .width('100%')
        .padding({ left: 16, right: 16 })
      }
      .align(Alignment.Top)
      .layoutWeight(1)
      .width('100%')
      .backgroundColor('#F5F5F5')

      // 底部输入区域
      Row() {
        // 输入框
        TextInput({ placeholder: '请输入消息...', text: $$this.inputText })
          .fontSize(16)
          .backgroundColor('#FFFFFF')
          .borderRadius(20)
          .padding({ left: 16, right: 16 })
          .height(40)
          .layoutWeight(1)
          .onSubmit(async () => {
            if (!this.inputText.trim()) {
              promptAction.showToast({ message: '请输入内容喵~' })
              return
            }
            // TODO: 更新chatRequest中的content
            await this.StartChat(this.inputText)
            this.chatHistory.push({ content: this.inputText, type: 'user' })
            this.chatHistory.push({ content: '思考中...', type: 'bot' })
            this.inputText = '' // 发送后清空输入框
            this.scroller.scrollEdge(Edge.Bottom)
          })

        // 发送按钮
        Button('发送', { type: ButtonType.Normal })
          .width(80)
          .height(40)
          .margin({ left: 12 })
          .borderRadius(20)
          .backgroundColor('#007AFF')
          .onClick(async () => {
            if (!this.inputText.trim()) {
              promptAction.showToast({ message: '请输入内容喵~' })
              return
            }
            // TODO: 更新chatRequest中的content
            await this.StartChat(this.inputText)
            this.chatHistory.push({ content: this.inputText, type: 'user' })
            this.chatHistory.push({ content: '思考中...', type: 'bot' })
            this.inputText = '' // 发送后清空输入框
            this.scroller.scrollEdge(Edge.Bottom)
          })
      }
      .width('100%')
      .padding(16)
      .backgroundColor('#FFFFFF')
    }
    .width('100%')
    .height('100%')
  }
}
// 定义请求的数据结构
export interface AdditionalMessage {
  role: 'user' | 'bot';
  content: string;
  content_type: 'text' | 'image' | 'video';
}

export interface ChatRequest {
  bot_id: string;
  user_id: string;
  stream: boolean;
  auto_save_history: boolean;
  additional_messages: AdditionalMessage[];
}

export interface Message {
  id: string;
  conversation_id: string;
  bot_id: string;
  chat_id: string;
  role: 'user' | 'assistant';
  content: string;
  content_type: 'text' | 'object_string' | 'card';
  created_at: number;
  updated_at: number;
  type: 'question' | 'answer' | 'function_call' | 'tool_output' | 'tool_response' | 'follow_up' | 'verbose';
}