HarmonyOS AI 聊天模块架构复盘:从 UI、状态、Controller 到 Provider、SSE 与业务卡片
本文是一次 AI 聊天模块源码学习后的架构复盘。内容已做脱敏处理,不涉及公司项目名、内部接口、真实业务参数、内部库名、服务地址、鉴权字段等敏感信息。文中只保留通用组件架构、工程化分层思想和可复用的学习总结。
一、为什么要写这篇复盘
最近在学习一个 HarmonyOS AI 聊天模块。刚开始看源码时,很容易被大量文件和目录绕晕:页面入口、聊天组件、ViewModel、Controller、Provider、HttpClient、Parser、MessageList、InputBar、业务卡片、会话抽屉等全部出现。
如果只是一行行看代码,很容易出现一种感觉:
text
当时好像看懂了,但隔一会儿就忘了。
后来把项目拆成多个小节分析,才逐渐发现它的主线其实很清楚:
text
用户输入
↓
InputBar
↓
ChatViewModel
↓
ChatController
↓
AgentProvider / PlatformProvider
↓
HttpClient.stream
↓
SSE 流式返回
↓
CardParser / MessageConverter
↓
ChatItem / AgentCard
↓
MessageList / BotBubble
↓
业务卡片组件
这篇文章就是把这个主线重新整理一遍,重点记录其中涉及到的架构思想:
text
MVVM
Controller 业务编排
Provider 适配器
请求统一封装
SSE 流式响应
数据转换层
组件化
Builder 扩展点
解耦
HAR 共享包
防御式编程
二、整体架构地图
一个完整的 AI 聊天模块,通常不是一个简单页面,而是一套完整的聊天能力封装。它大致可以拆成这些层:
text
pages/
页面入口,负责路由注册、Provider 创建、配置注入
view/
UI 组件层,负责聊天页面展示
viewmodel/
状态管理层,负责保存页面状态
controller/
业务流程编排层,负责发送消息、会话切换、停止生成等流程
api/
AI 平台适配层,负责把统一能力转换成具体平台接口
utils/
工具层,负责请求封装、卡片解析、消息转换等
model/
数据模型层,负责定义消息、会话、卡片、配置等结构
view/cards/
业务卡片展示层,负责把结构化数据渲染成业务 UI
可以先记一个口诀:
text
Page 负责入口
Comp 负责 UI
ViewModel 负责状态
Controller 负责编排
Provider 负责平台适配
HttpClient 负责请求
Parser 负责解析
Card 负责展示
这也是后面分析所有文件时的主线。
三、页面入口层:只负责装配,不负责核心逻辑
页面入口层可以理解成"组装器"。它一般负责:
text
注册路由
创建 Provider
配置 ChatConfig
传入业务卡片 Builder
传入 loading / error UI
渲染聊天组件
入口页面通常会做类似这样的事情:
ts
@ComponentV2
export struct ChatPage {
@Local provider: AgentProvider | null = null
aboutToAppear(): void {
const config = new ProviderConfig()
config.userId = 'current_user_id'
this.provider = new SomeAIProvider(config)
}
build() {
AgentChatComp({
provider: this.provider,
chatConfig: this.buildChatConfig(),
cardsBuilder: this.cardsBuilder,
mixedCardsBuilder: this.mixedCardsBuilder,
loadingBuilder: this.loadingBuilder
})
}
}
入口页的重点不是"处理聊天",而是"把聊天模块需要的能力传进去"。
它不应该直接做:
text
发送 AI 请求
解析 SSE
维护 chatHistory
处理会话分页
上传附件
解析业务卡片 JSON
这些能力应该放到后面的 Controller、Provider、HttpClient、Parser 等层里。
一句话总结:
text
页面入口负责装配能力,不负责聊天核心流程。
四、聊天组件总入口:接参数、建状态、启 Controller、搭 UI
真正的聊天 UI 容器一般是一个类似 AgentChatComp 的组件。
它接收入口页传进来的:
text
provider
chatConfig
cardsBuilder
mixedCardsBuilder
loadingBuilder
networkErrorBuilder
背景配置
自定义卡片类型
它内部会创建一个共享的 ViewModel:
ts
@Local vm: ChatViewModel = new ChatViewModel()
然后把这个 vm 分发给所有子组件:
text
MessageList
InputBar
ConversationDrawer
LoadingOverlay
VoiceMaskOverlay
FloatingButtons
QuickQuestionsCard
它还会初始化页面级 Controller,例如:
text
new PageController(vm, chatConfig)
pageController.start(context, provider)
UI 结构大概是:
text
Stack 根容器
├── 背景层
├── 主内容层
│ ├── MessageList
│ ├── QuickQuestionsCard
│ ├── FloatingButtons
│ ├── InputBar
│ ├── VoiceMaskOverlay
│ └── LoadingOverlay
└── ConversationDrawer
所以聊天组件总入口的职责是:
text
接收外部参数
创建 ViewModel
初始化 Controller
组合聊天 UI
处理安全区、键盘、抽屉、蒙层等页面级结构
一句话记忆:
text
AgentChatComp = 接参数 + 建 vm + 启 controller + 搭 UI。
五、MVVM:UI 和业务之间的状态桥梁
这个模块最明显的架构思想就是 MVVM。
MVVM 可以拆成:
text
View:负责 UI 展示和用户交互
ViewModel:负责状态和交互入口
Model:负责数据结构
在聊天模块里可以对应为:
text
View:
ChatPage
AgentChatComp
MessageList
InputBar
BotBubble
UserBubble
ConversationDrawer
ViewModel:
ChatViewModel
Model:
ChatItem
AgentCard
ConversationInfo
ChatConfig
AgentResult
AgentAttachment
需要特别注意:
text
Model 不是 Controller。
Model 是数据结构,比如一条消息长什么样、一个卡片有哪些字段、一个会话有哪些字段。
Controller 是业务流程,比如发送消息、切换会话、停止生成。
可以这样记:
text
View = 页面长什么样
ViewModel = 页面现在是什么状态
Model = 数据本身长什么样
Controller = 业务流程怎么跑
六、ChatViewModel:当前聊天模块的状态中心
ChatViewModel 是整个聊天模块的状态中心。
它通常保存:
text
userInput:当前输入框内容
chatHistory:聊天消息列表
loading:AI 是否正在生成
conversationId:当前会话 ID
conversations:会话列表
quickPhrases:推荐问题
pendingAttachments:待发送附件
showDrawer:会话抽屉是否显示
initialLoaded:首屏是否加载完成
loadFailed:是否加载失败
UI 组件通过它读取状态:
text
InputBar 读取和修改 userInput
MessageList 读取 chatHistory
ConversationDrawer 读取 conversations
LoadingOverlay 读取 initialLoaded / loadFailed
BotBubble 读取 ChatItem.content / ChatItem.cards
Controller 通过它回写状态:
text
发送消息后更新 chatHistory
开始请求时设置 loading = true
结束请求后设置 loading = false
切换会话后更新 conversationId 和 chatHistory
完整关系是:
text
用户操作 UI
↓
UI 调用 vm 方法
↓
vm 转发给 Controller
↓
Controller 执行业务流程
↓
Controller 更新 vm 状态
↓
UI 根据 vm 状态自动刷新
一句话:
text
UI 调 vm,Controller 改 vm,vm 变了 UI 刷新。
七、Controller 层:复杂业务流程不要塞进 ViewModel
标准 MVVM 中,ViewModel 可能会承担一部分业务逻辑。但在复杂聊天模块里,如果把所有发送、会话、语音、附件、重试、停止生成都写进 ViewModel,ViewModel 会非常膨胀。
所以项目中会额外拆出 Controller 层。
常见 Controller:
text
ChatController:
负责发送消息、停止生成、重试、流式回复、错误收尾
ConversationController:
负责会话列表、切换会话、加载历史、分页、删除会话
VoiceInputController:
负责语音输入、录音状态、语音识别
一句话:
text
ViewModel 管状态,Controller 管流程。
1. ChatController 发送流程
用户点击发送后,大致流程是:
text
InputBar 调用 vm.sendMessage()
↓
ViewModel 转给 ChatController.sendMessage()
↓
读取 vm.userInput
↓
创建用户消息 ChatItem
↓
先写入 vm.chatHistory,让用户消息立即上屏
↓
清空 vm.userInput
↓
设置 vm.loading = true
↓
创建 AI 回复占位消息
↓
调用 provider.sendMessage()
↓
接收 onDelta / onMessage / onReplyComplete
↓
持续更新 AI 占位消息
↓
回复完成后恢复 loading
这里有一个关键点:
text
用户消息是先进入 chatHistory,不是等 AI 请求成功后再显示。
AI 回复则通过一个占位消息逐步更新。
2. ConversationController 会话流程
会话切换流程大致是:
text
用户打开会话抽屉
↓
点击某个历史会话
↓
vm.switchConversation(conversationId)
↓
ConversationController 处理切换
↓
更新 vm.conversationId
↓
清空旧 chatHistory
↓
Provider 拉历史消息
↓
MessageConverter 转成 ChatItem[]
↓
写入 vm.chatHistory
↓
MessageList 展示历史消息
一句话:
text
ChatController 管消息,ConversationController 管会话。
八、Provider 层:平台适配与面向接口编程
AI 聊天模块可能接入不同 AI 平台。
如果聊天组件直接依赖具体平台,例如:
ts
const provider = new SomeAIProvider()
那么组件就被某个平台绑定死了。
更好的做法是抽象一个统一 Provider:
ts
export abstract class AgentProvider {
abstract getName(): string
abstract sendMessage(
message: string,
conversationId: string,
attachments: AgentAttachment[],
onDelta: (delta: string, fullText: string) => void,
onStatus?: (status: string) => void,
onMessage?: (content: string, msgId: string) => void,
onReplyComplete?: () => void
): Promise<AgentResult>
}
具体平台实现它:
ts
export class PlatformProvider extends AgentProvider {
getName(): string {
return 'SomePlatform'
}
async sendMessage(...): Promise<AgentResult> {
// 具体平台请求逻辑
}
}
上层组件只依赖抽象:
ts
@Param provider: AgentProvider | null = null
这样就实现了:
text
聊天组件不关心底层是哪个 AI 平台
只关心传入对象是否满足 AgentProvider 规范
这就是解耦和面向接口编程。
一句话:
text
组件依赖抽象,不依赖具体实现。
九、abstract 的意义:只定规则,不干具体活
在 Provider 抽象中会用到 abstract。
ts
abstract class AgentProvider {
abstract getName(): string
abstract sendMessage(...): Promise<AgentResult>
}
abstract 表示:
text
只定义规范,不提供完整实现。
抽象类不能直接 new:
ts
const provider = new AgentProvider() // 不允许
它的作用是约束子类:
text
任何 AI Provider 都必须有 getName()
任何 AI Provider 都必须有 sendMessage()
具体怎么发送,由子类自己实现。
一句话:
text
abstract = 只定规则,不干具体活。
十、Provider 和 HttpClient 的区别
这一点很容易混。
可以直接背:
text
Provider 管协议,HttpClient 管网络。
Provider 管什么?
Provider 是平台适配层,负责:
text
创建会话
拼接某个平台需要的请求体
处理附件 fileId
调用 HttpClient.stream 发起请求
解析平台返回的 event 类型
把 delta / completed / error 转成项目内部结果
通过 onDelta / onMessage 回调给 ChatController
拉会话列表
拉历史消息
取消生成
Provider 知道某个平台的返回事件是什么意思。
HttpClient 管什么?
HttpClient 是底层网络工具,负责:
text
get
post
put
upload
stream
abortStream
SSE event/data 基础解析
请求中断
日志脱敏
基础错误处理
HttpClient 不关心某个平台的 event 是业务文本、卡片还是错误。
它只负责把网络数据拆出来。
十一、HttpClient 与 SSE 流式响应
普通 HTTP 通常是:
text
客户端请求一次
服务端返回一次完整结果
请求结束
SSE 是:
text
客户端发起一次请求
服务端在这条连接里持续推送 event/data
客户端每收到一段就处理一段
直到完成事件返回
AI 回复能一点点显示,就是因为服务端把完整回复拆成很多小片段返回。
SSE 常见格式:
text
event: message.delta
data: {"content":"你好"}
event: message.delta
data: {"content":",我是 AI 助手"}
event: message.completed
data: {"finish_reason":"stop"}
前端处理流程:
text
HttpClient.stream 发起流式请求
↓
不断收到二进制数据块
↓
转成字符串
↓
放入 sseBuffer
↓
按 \n\n 拆完整 SSE 事件
↓
解析 event 和 data
↓
回调给 Provider
为什么要 buffer?
因为网络数据块不一定刚好是一条完整 SSE 事件。
可能服务端发的是:
text
event: xxx
data: {"content":"你好"}
客户端实际收到的是:
text
第 1 块:event: x
第 2 块:xx\ndata: {"content"
第 3 块::"你好"}\n\n
所以必须拼包。
一句话:
text
网络数据块不等于完整 SSE 事件,所以要先 buffer 拼包再解析。
十二、停止生成:不只是把 loading 改成 false
AI 聊天里经常有"停止生成"。
完整流程不是:
ts
loading = false
而是:
text
用户点击停止生成
↓
InputBar 调用 vm.stopGenerate()
↓
ChatController.stopGenerate()
↓
Provider.cancelChat()
↓
HttpClient.abortStream() 中断本地 SSE
↓
Provider 通知服务端取消当前生成
↓
保留已生成内容
↓
恢复 loading 状态
还要区分:
text
用户主动停止
网络异常中断
用户主动停止不应该提示网络错误。
所以可以定义类似:
text
StreamAbortedError
用于区分主动取消和真实异常。
十三、CardParser 与 MessageConverter:数据解析和转换层
AI 返回的不一定都是普通文本,也可能是结构化 JSON。
例如:
json
{
"cardType": "poi",
"title": "示例地点",
"data": {
"address": "示例地址"
}
}
如果直接展示,用户会看到 JSON。
所以需要解析层。
text
CardParser:
JSON → AgentCard
MessageConverter:
ServerMessage → ChatItem
它们的意义是:
text
让 UI 不直接依赖服务端原始数据结构。
完整转换链路:
text
服务端原始内容
↓
CardParser 解析结构化卡片
↓
AgentCard[]
↓
MessageConverter 转成 ChatItem
↓
写入 chatHistory
↓
MessageList 统一渲染
一句话:
text
CardParser 管卡片解析,MessageConverter 管消息转换。
十四、MessageList 与 BotBubble:消息渲染层
当数据已经进入:
text
vm.chatHistory
UI 层就开始渲染。
MessageList 负责:
text
读取 vm.chatHistory
遍历 ChatItem
根据 role 分发到不同气泡组件
伪代码:
ts
ForEach(this.vm.chatHistory, (item: ChatItem) => {
if (item.role === 'user') {
UserBubble({ item })
} else if (item.role === 'assistant') {
BotBubble({ item })
} else {
SystemBubble({ item })
}
})
BotBubble 负责 AI 回复展示:
text
展示普通文本 content
展示流式生成中的内容
展示状态
展示 AgentCard[]
调用 cardsBuilder / mixedCardsBuilder 渲染业务卡片
一句话:
text
MessageList 管列表,BotBubble 管 AI 气泡。
十五、InputBar:输入和发送入口
InputBar 是用户直接操作的输入栏。
它负责:
text
绑定 vm.userInput
点击发送调用 vm.sendMessage()
根据 vm.loading 切换发送 / 停止按钮
管理附件入口
接入语音入口
处理键盘避让
处理底部安全区
它不负责真正发送请求。
完整链路是:
text
用户输入文字
↓
InputBar 更新 vm.userInput
↓
用户点击发送
↓
InputBar 调用 vm.sendMessage()
↓
ChatController 执行发送流程
一句话:
text
InputBar 触发发送,ChatController 真正发送。
十六、ConversationController 与 ConversationDrawer:会话管理
聊天模块不只是发一条消息,还需要管理历史会话。
ConversationDrawer 是 UI:
text
展示会话列表
展示当前选中会话
点击历史会话
点击新建会话
点击删除会话
ConversationController 是流程:
text
同步会话列表
切换会话
加载历史消息
分页加载更多
新建会话
删除会话
清空会话
会话切换流程:
text
用户点击历史会话
↓
ConversationDrawer 调 vm.switchConversation(conversationId)
↓
ConversationController 处理切换
↓
更新 vm.conversationId
↓
清空旧 chatHistory
↓
Provider 拉历史消息
↓
MessageConverter 转 ChatItem[]
↓
写入 vm.chatHistory
↓
MessageList 展示新会话
一个重要细节是异步防护:
text
请求返回时,要判断返回的 conversationId 是否仍然是当前会话。
防止旧请求晚返回,覆盖新会话数据。
一句话:
text
ChatController 管消息,ConversationController 管会话。
十七、view/cards:业务卡片展示层
AI Agent 和普通聊天机器人最大的区别是:
text
普通聊天机器人主要返回文本
AI Agent 可以返回文本 + 结构化数据 + 可执行业务动作
业务卡片就是这个结构化数据的 UI 展示。
完整链路:
text
AI 返回 JSON
↓
CardParser.parse()
↓
AgentCard
↓
ChatItem.cards
↓
BotBubble
↓
cardsBuilder / mixedCardsBuilder
↓
RouteCard / PoiCard / TicketCard / UnknownCard
业务卡片层一般会有:
text
地点卡片
路线卡片
票务卡片
服务卡片
推荐卡片
未知卡片兜底
卡片组件只负责展示和抛出点击事件:
text
Card 负责 UI
Handler / 回调负责跳转或业务动作
一句话:
text
JSON → AgentCard → Card UI → 业务跳转。
十八、HAR 共享包与模块复用
HarmonyOS 中可以把这类聊天能力做成 HAR 共享包。
HAR 可以理解成:
text
HarmonyOS 里的共享代码包 / 组件库包 / 模块包
它不是 exe,也不是独立运行程序。
更像:
text
前端 npm package
Android AAR
Java JAR
HAR 可以封装:
text
公共组件
工具方法
业务模块
页面能力
网络请求封装
数据模型
资源文件
其他模块通过依赖和 import 使用。
可以这样理解:
text
HAR 是工程结构上的模块拆分
解耦是代码设计上的职责拆分
一句话:
text
HAR 负责复用,解耦负责降低依赖。
十九、Builder 与 Config:通用组件的扩展点
通用聊天组件不能把所有业务都写死。
所以它会通过:
text
ChatConfig
cardsBuilder
mixedCardsBuilder
loadingBuilder
networkErrorBuilder
onLinkClick
onTrackEvent
onCardClick
把业务差异交给外部页面。
例如:
ts
@Builder
cardsBuilder(cards: AgentCard[]) {
PoiCardList({
cards: cards.filter(item => item.cardType === 'poi'),
onCardClick: this.onCardClick
})
}
这样聊天组件只负责通用聊天能力,业务页面决定具体卡片怎么展示、点击后怎么跳转。
一句话:
text
通用组件提供插槽,业务页面注入差异。
二十、解耦思想总结
解耦不是简单地把代码拆成多个文件。
真正的解耦是:
text
每一层只知道自己必须知道的东西。
例如:
text
InputBar 不知道 AI 请求怎么发
MessageList 不知道 SSE 怎么解析
BotBubble 不知道平台协议
Provider 不知道 UI 怎么画
HttpClient 不知道业务事件含义
Card 组件不直接解析原始 JSON
每层职责:
text
Page:入口和配置
View:展示
ViewModel:状态
Controller:流程
Provider:平台协议
HttpClient:网络请求
Parser:解析转换
Card:业务展示
一句话:
text
把 UI、状态、流程、协议、请求、解析、展示拆开,各自负责自己的事情。
二十一、最容易混淆的几个点
1. Model 不是 Controller
text
Model = 数据结构
Controller = 业务流程
例如:
text
ChatItem / AgentCard / ConversationInfo 是 Model
ChatController / ConversationController 是 Controller
2. Provider 不是 HttpClient
text
Provider 管平台协议
HttpClient 管底层网络
3. UI 不是业务流程
text
InputBar 触发发送,但不负责完整发送
ConversationDrawer 触发切换,但不负责完整切换
4. SSE 不是持续创建会话
text
SSE 是一次流式请求中,服务端持续推送数据片段。
5. 用户消息先上屏
text
用户消息先写入 chatHistory
AI 回复通过占位消息逐步更新
二十二、最终总流程图
text
ChatPage
页面入口 / Provider 创建 / ChatConfig 配置
↓
AgentChatComp
UI 总容器 / 创建 vm / 启动 controller
↓
ChatViewModel
状态中心 / userInput / chatHistory / loading
↓
InputBar
用户输入 / 点击发送 / 调用 vm.sendMessage
↓
ChatController
创建用户消息 / AI 占位 / 调用 Provider / 更新状态
↓
AgentProvider / PlatformProvider
平台适配 / 请求体 / SSE 事件解析
↓
HttpClient
get / post / upload / stream / abort
↓
SSE
event/data 流式返回
↓
CardParser / MessageConverter
JSON → AgentCard
ServerMessage → ChatItem
↓
ChatViewModel.chatHistory
状态更新
↓
MessageList / BotBubble
按 role 渲染气泡
content 展示文本
cards 展示卡片
↓
view/cards
具体业务卡片 UI
二十三、最终一句话总结
这套 AI 聊天模块的核心不是"页面怎么画",而是如何把 UI、状态、业务流程、平台协议、网络请求、数据解析和业务卡片展示拆清楚。
最终可以概括为:
text
通过 ChatPage 做入口装配,
通过 AgentChatComp 搭建聊天 UI,
通过 ChatViewModel 管理响应式状态,
通过 ChatController 编排发送流程,
通过 Provider 屏蔽 AI 平台差异,
通过 HttpClient 统一网络请求和 SSE,
通过 CardParser / MessageConverter 转换数据,
通过 MessageList / BotBubble / Card 组件完成展示。
再压缩一句:
text
用户操作 UI,UI 调 ViewModel,ViewModel 转 Controller,Controller 调 Provider,Provider 调 HttpClient,结果回写 ViewModel,UI 自动刷新。
这就是当前阶段对 HarmonyOS AI 聊天模块架构的完整理解。