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