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|nullnullaboutToAppear():void{constconfignewProviderConfig()config.userIdcurrent_user_idthis.providernewSomeAIProvider(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 背景配置 自定义卡片类型它内部会创建一个共享的 ViewModelLocalvm:ChatViewModelnewChatViewModel()然后把这个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。五、MVVMUI 和业务之间的状态桥梁这个模块最明显的架构思想就是 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聊天消息列表 loadingAI 是否正在生成 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 调 vmController 改 vmvm 变了 UI 刷新。七、Controller 层复杂业务流程不要塞进 ViewModel标准 MVVM 中ViewModel 可能会承担一部分业务逻辑。但在复杂聊天模块里如果把所有发送、会话、语音、附件、重试、停止生成都写进 ViewModelViewModel 会非常膨胀。所以项目中会额外拆出 Controller 层。常见 ControllerChatController 负责发送消息、停止生成、重试、流式回复、错误收尾 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 平台。如果聊天组件直接依赖具体平台例如constprovidernewSomeAIProvider()那么组件就被某个平台绑定死了。更好的做法是抽象一个统一 ProviderexportabstractclassAgentProvider{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):PromiseAgentResult}具体平台实现它exportclassPlatformProviderextendsAgentProvider{getName():string{returnSomePlatform}asyncsendMessage(...):PromiseAgentResult{// 具体平台请求逻辑}}上层组件只依赖抽象Paramprovider:AgentProvider|nullnull这样就实现了聊天组件不关心底层是哪个 AI 平台 只关心传入对象是否满足 AgentProvider 规范这就是解耦和面向接口编程。一句话组件依赖抽象不依赖具体实现。九、abstract 的意义只定规则不干具体活在 Provider 抽象中会用到abstract。abstractclassAgentProvider{abstractgetName():stringabstractsendMessage(...):PromiseAgentResult}abstract表示只定义规范不提供完整实现。抽象类不能直接 newconstprovidernewAgentProvider()// 不允许它的作用是约束子类任何 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 改成 falseAI 聊天里经常有“停止生成”。完整流程不是loadingfalse而是用户点击停止生成 ↓ 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.roleuser){UserBubble({item})}elseif(item.roleassistant){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(itemitem.cardTypepoi),onCardClick:this.onCardClick})}这样聊天组件只负责通用聊天能力业务页面决定具体卡片怎么展示、点击后怎么跳转。一句话通用组件提供插槽业务页面注入差异。二十、解耦思想总结解耦不是简单地把代码拆成多个文件。真正的解耦是每一层只知道自己必须知道的东西。例如InputBar 不知道 AI 请求怎么发 MessageList 不知道 SSE 怎么解析 BotBubble 不知道平台协议 Provider 不知道 UI 怎么画 HttpClient 不知道业务事件含义 Card 组件不直接解析原始 JSON每层职责Page入口和配置 View展示 ViewModel状态 Controller流程 Provider平台协议 HttpClient网络请求 Parser解析转换 Card业务展示一句话把 UI、状态、流程、协议、请求、解析、展示拆开各自负责自己的事情。二十一、最容易混淆的几个点1. Model 不是 ControllerModel 数据结构 Controller 业务流程例如ChatItem / AgentCard / ConversationInfo 是 Model ChatController / ConversationController 是 Controller2. Provider 不是 HttpClientProvider 管平台协议 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 组件完成展示。再压缩一句用户操作 UIUI 调 ViewModelViewModel 转 ControllerController 调 ProviderProvider 调 HttpClient结果回写 ViewModelUI 自动刷新。这就是当前阶段对 HarmonyOS AI 聊天模块架构的完整理解。