HarmonyOS AI聊天架构:MVVM与SSE实战解析
从 HarmonyOS AI 聊天模块剖析工程化架构:MVVM、Controller、Provider、请求封装与 SSE
一、前言
最近集中研究了 HarmonyOS 上一个 AI 聊天模块的源码。
初次接触这类项目,最直观的感受就是文件繁多、层级复杂:页面、组件、ViewModel、Controller、Provider、HttpClient、Parser、Model 一应俱全。如果从某个方法开始逐行追踪,极易在层层调用中迷失。
后来意识到,钻研这类业务模块,一上来就钻入细节是大忌。正确的做法是先厘清整体链路。
这个聊天模块表面上是 AI 对话页面,实际承载了页面入口、聊天 UI、状态管理、业务流程控制、AI 平台适配、请求封装、SSE 流式响应、会话管理、业务卡片渲染等一系列能力。
从架构分层角度看,可以抽象为如下链路:
页面入口↓聊天组件↓状态中心↓业务流程控制器↓AI 平台适配层↓网络请求封装↓SSE 流式响应↓结果回写状态↓UI 自动刷新
映射到代码层面,大致对应:
Page↓View↓ViewModel↓Controller↓Provider↓HttpClient
本文就聊聊当前 AI 聊天模块的设计思路。重点不在某个业务细节,而是总结其中体现的工程化理念:MVVM、组件化、解耦、Provider 适配器、请求统一封装、SSE 流式处理、状态驱动 UI。
二、整体架构概览
一个规范的 AI 聊天模块绝不会压缩在一个页面文件里,必然会拆分为多个层次。
整体结构大致如下:
pages/页面入口,负责路由注册和业务配置view/UI 组件层,负责聊天页面展示viewmodel/状态管理层,负责保存页面状态controller/业务流程编排层,负责发送消息、会话切换、语音输入等流程api/AI 平台适配层,负责对接不同 AI 平台utils/工具封装层,负责请求、解析、转换等能力model/数据模型层,负责定义消息、会话、卡片等结构constant/常量和协议层,负责统一管理接口路径、事件类型、状态码等
一句话概括:将不同职责的代码分门别类,各司其职。
这样拆分后,每一层的职责都很清晰。UI、状态、请求、协议解析、错误处理等逻辑不再混杂在一个页面文件里,避免了混乱。
三、页面入口层:负责装配,不负责核心逻辑
页面入口层通常只处理几件事:
- 注册路由
- 创建 AI Provider
- 配置聊天组件的参数
- 注入业务回调
- 渲染聊天组件
它的定位很简单:更像是一个“装配工”,而非聊天逻辑的核心。
例如:
@ComponentV2export struct ChatPage {@Local provider: AgentProvider | null = nullaboutToAppear(): 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,loadingBuilder: this.loadingBuilder})}private buildChatConfig(): ChatConfig {const config = new ChatConfig()config.welcomeMessage = '你好,我是你的 AI 助手'config.quickPhrases = ['推荐问题 1', '推荐问题 2']return config}}
关键要点是:
页面入口层不应直接处理发送 AI 请求、解析 SSE、维护消息数组、会话分页、卡片 JSON 解析、附件上传等复杂逻辑。
这些逻辑应交给后面的组件层、状态层、Controller 层和 Provider 层。
页面入口的主要工作就这几项:
创建 Provider配置 ChatConfig传入 Builder挂载聊天组件
这样一来,页面相当轻量,后续业务变更时维护成本也低。
四、聊天组件层:UI 总容器
聊天组件层负责搭建聊天页面的整体 UI 骨架。
一个完整的聊天页面通常包含:
- 消息列表
- 底部输入框
- 推荐问题
- 会话抽屉
- 加载蒙层
- 语音输入蒙层
- 浮动按钮
- 业务卡片
其结构大致如下:
Stack 根容器├── 背景层├── 主内容层│ ├── MessageList│ ├── QuickQuestionsCard│ ├── FloatingButtons│ ├── InputBar│ ├── VoiceMaskOverlay│ └── LoadingOverlay└── ConversationDrawer
聊天组件通常接收外部传入的配置:
@Param provider: AgentProvider | null = null@Param chatConfig: ChatConfig = new ChatConfig()@Param bgColor: ResourceColor = ''@BuilderParam cardsBuilder: (cards: AgentCard[]) => void = emptyCardsBuilder@BuilderParam loadingBuilder: () => void = defaultLoadingBuilder
同时,组件内部会创建一个 ViewModel 作为状态中心:
@Local vm: ChatViewModel = new ChatViewModel()
然后把这个 vm 分发给各个子组件:
MessageList({ vm: this.vm })InputBar({ vm: this.vm })ConversationDrawer({ vm: this.vm })LoadingOverlay({ vm: this.vm })VoiceMaskOverlay({ vm: this.vm })
可见,聊天组件本身的职责非常明确:
接收外部能力创建状态中心初始化 Controller组合聊天 UI把 ViewModel 分发给子组件
它自身不直接发请求,也不直接解析 AI 协议。
五、MVVM:UI 和业务之间加一层状态中介
这个模块体现最明显的架构思想就是 MVVM。
MVVM 拆开看就是三层:
Model 数据模型ViewUI 展示ViewModel 状态和交互中介
对应到聊天模块:
View:AgentChatCompMessageListInputBarBotBubbleConversationDrawerViewModel:ChatViewModelModel:ChatItemChatMessageAgentCardConversationInfoChatConfig
MVVM 的核心价值在于:UI 组件不再直接与业务流程和网络请求耦合,而是通过 ViewModel 这个中间层交互。
比如用户点击发送按钮时,UI 组件不会自己拼请求、调接口、解析数据,而是调用 ViewModel 暴露的方法:
this.vm.sendMessage()
然后 ViewModel 再把操作交给真正的执行者——Controller:
async sendMessage(): Promise
业务处理完成后,Controller 再回写 ViewModel 的状态:
this.vm.chatHistory = nextMessagesthis.vm.loading = false
ViewModel 状态变化后,UI 自动刷新。
所以,MVVM 的重点不是“多建一个类”,而是把 UI 和业务流程明确分隔开。
可以记成一句话:UI 只和 ViewModel 对话,ViewModel 只和 Controller 对话,Controller 只和 Provider 对话。
六、ChatViewModel:聊天页面的状态中心
ViewModel 是整个聊天页面的状态中心。
它通常需要管理这些状态:
- 用户输入内容
- 聊天消息列表
- 当前会话 ID
- 会话列表
- loading 状态
- 初始化状态
- 错误状态
- 推荐问题
- 待发送附件
- 语音面板状态
- 滚动状态
例如:
@ObservedV2export class ChatViewModel {@Trace userInput: string = ''@Trace chatHistory: ChatItem[] = []@Trace loading: boolean = false@Trace conversationId: string = ''@Trace conversations: ConversationInfo[] = []@Trace quickPhrases: string[] = []@Trace pendingAttachments: AttachmentInfo[] = []@Trace showDrawer: boolean = false@Trace initialLoaded: boolean = false@Trace loadFailed: boolean = false}
这里有两个关键点值得注意:
@ObservedV2 修饰类@Trace 修饰需要被 UI 追踪的状态字段
也就是说:
ViewModel 里的状态变化↓依赖它的 UI 组件自动刷新
例如:
InputBar 修改 vm.userInputMessageList 读取 vm.chatHistoryConversationDrawer 读取 vm.conversationsLoadingOverlay 读取 vm.initialLoaded / vm.loadFailedVoiceMaskOverlay 读取 vm.showVoicePanel
这就是典型的状态驱动 UI。
可以记住一个原则:把 ViewModel 当作一根“数据总线”,所有 UI 组件都从它这里读取和更新状态。
七、Controller 层:复杂业务流程不要塞进 ViewModel
在简单页面里,ViewModel 直接处理一些业务逻辑也说得过去。
但在 AI 聊天这类模块中,如果所有逻辑都塞进 ViewModel,它会迅速变成一个臃肿的大类。
因为一次发送消息可能涉及非常多步骤:
校验输入处理附件清空输入框追加用户消息设置 loading创建 AI 回复占位调用 AI 接口处理 SSE delta处理完整消息解析卡片处理停止生成处理失败重试同步会话收集埋点恢复状态
这些如果都堆在 ViewModel 里,维护成本会极高。
因此,单独拆出 Controller 层来处理这些复杂流程:
ChatController负责发送消息、停止生成、重试、流式回复ConversationController负责会话列表、会话切换、删除会话、分页加载VoiceInputController负责语音输入、录音状态、语音识别ProgressiveRevealController负责卡片或内容的渐进展示
这个分层设计的思路很清晰:让 Controller 去做“脏活累活”,ViewModel 只负责状态管理。
发送消息的大致流程:
InputBar 点击发送↓vm.sendMessage()↓ChatController.sendMessage()↓读取 vm.userInput↓创建用户消息↓写入 vm.chatHistory↓设置 vm.loading = true↓调用 provider.sendMessage()↓onDelta 更新 AI 气泡↓onMessage 处理完整消息和卡片↓onReplyComplete 收尾↓vm.loading = false
这样做的好处是:UI 不关心请求细节,ViewModel 不承载复杂流程,Controller 专门负责把一次业务动作完整跑下来。
八、Provider 层:平台适配与面向接口编程
AI 聊天模块可能接入不同平台,如 Coze、Dify、OpenAI、MockProvider 或公司内部的 AI 服务。
如果 UI 组件直接依赖某个特定平台实现,就会产生强耦合。
比如下面这种写法就不太理想:
const provider = new CozeProvider()provider.sendMessage()
这样组件就和 Coze 绑死。以后换成 Dify 或内部 AI 服务,就不得不修改组件代码。
更好的方式是抽象出一个统一的接口或抽象类:
export abstract class AgentProvider {abstract getName(): stringabstract 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
然后让具体平台去继承:
export class CozeProvider extends AgentProvider {getName(): string {return 'Coze'}async sendMessage(...): Promise
这样一来,上层组件只依赖抽象:
@Param provider: AgentProvider | null = null
整个逻辑就变成了:
传 CozeProvider,就接 Coze传 DifyProvider,就接 Dify传 MockProvider,就可以做测试
这就是面向接口编程的魅力,也是解耦的精髓。
一句话总结:上层只认接口,不认实现。
九、abstract 的意义:只定规则,不干具体活
在 Provider 的抽象中,经常看到 abstract 关键字。
例如:
abstract class AgentProvider {abstract getName(): stringabstract sendMessage(...): Promise
abstract 的含义很明确:
抽象类不能直接 new:
const provider = new AgentProvider() // 不允许
它的作用就是给子类“立规矩”,规定它们必须实现哪些方法。
比如:
任何 AI Provider 都必须有 getName()任何 AI Provider 都必须有 sendMessage()
至于具体怎么实现,交给子类去决定:
class CozeProvider extends AgentProvider {getName(): string {return 'Coze'}async sendMessage(...): Promise
所以,abstract 在架构设计上的作用是:
定义统一规范约束子类实现让上层依赖抽象,而不是依赖具体类
一句话记牢:abstract 只定规则,不干具体活。
十、解耦:依赖抽象,而不是依赖具体实现
解耦不是简单地“多拆几个文件”。
真正的解耦,是降低模块之间的依赖关系,让它们可以独立变化。
一个理想的分层关系应该是:
UI 层不关心请求细节Controller 不关心底层 HTTP 实现Provider 不关心 UI 怎么展示HttpClient 不关心业务含义Parser 不关心卡片怎么显示
例如:
ChatController:我要发送消息,但我不关心你是 Coze 还是 OpenAI。Provider:我知道某个平台接口怎么调用,但我不关心 UI 怎么展示。HttpClient:我只负责发请求和解析基础流,不关心这是不是 AI 消息。MessageList:我只负责展示消息,不关心这条消息怎么从服务端来的。
如果不解耦,代码很容易演变成:
一个页面里写 UI一个页面里写状态一个页面里写请求一个页面里写 SSE一个页面里写卡片解析一个页面里写错误处理一个页面里写埋点
短期可能能跑,但后期基本寸步难行。
解耦后的结构则是这样:
Page入口和配置View展示ViewModel 状态Controller流程Provider平台协议HttpClient请求Parser解析Model 数据结构
解耦的意义在于:每一层都可以独立修改、独立测试、独立复用。
十一、HAR 共享包:模块复用的工程结构
在 HarmonyOS 工程里,AI 聊天模块可以做成一个 HAR 共享包。
HAR 可以理解为:
它不是可执行程序,不能双击运行。
它更接近于:
前端里的 npm packageAndroid 里的 AARJava 里的 JAR
HAR 通常用来封装这些内容:
- 公共组件
- 工具方法
- 业务模块
- 页面能力
- 网络请求封装
- 数据模型
- 资源文件
例如在模块入口统一导出能力:
export { AgentChatComp } from './view/AgentChatComp'export { AgentProvider } from './api/AgentProvider'export { CozeProvider } from './api/CozeProvider'export { HttpClient } from './utils/HttpClient'export { ChatViewModel } from './viewmodel/ChatViewModel'
主项目里只需要这样引入:
import { AgentChatComp, CozeProvider } from 'ai_chat_module'
HAR 的价值非常明确:
复用模块化隔离业务减少重复代码方便维护
这里顺便区分一下 HAR 和解耦的区别:
HAR 是工程结构上的模块拆分解耦是代码设计上的职责拆分
两者相辅相成,但解决的问题维度不同。
十二、HttpClient:网络请求统一出口
在真实项目里,任何 HTTP 请求都不应该散落在各个角落,必须有一个统一的请求封装层。
一个合格的请求封装类通常会提供这些方法:
get()post()put()upload()stream()abortStream()
它负责处理的公共问题包括:
- 普通请求
- 文件上传
- SSE 流式请求
- 请求中断
- 请求日志
- 敏感信息脱敏
- 超时处理
- 错误处理
说白了,HttpClient 就是一个“门面”,把所有网络请求的公共逻辑都集中在这里。
这样就可以统一处理请求头、日志、错误、超时、中断等公共问题,Provider 层就再也不用关心这些基础工作了。
1. 普通请求
普通请求的流程很直接:
传入 URL传入 header传入 body发出请求拿到响应返回结果
2. 文件上传
文件上传通常会走 multipart/form-data 格式。
流程大概是:
读取本地文件↓构造 multipart/form-data↓上传到文件接口↓拿到 file_id↓聊天请求里传 file_id
也就是说,带附件聊天时,通常不是直接把本地路径传给 AI 接口,而是先上传获取 file_id,再在消息中引用。
3. SSE 流式请求
AI 打字机效果,一般不是前端用定时器模拟输出的,而是服务端通过 SSE 不断推送内容。
普通 HTTP 是:
请求一次返回一次结束
SSE 更像是:
请求一次服务端不断返回 event前端不断解析并更新 UI
十三、SSE 流式响应:AI 打字机效果的底层基础
SSE 全称是 Server-Sent Events。
常见的格式大概是这样的:
event: conversation.message.deltadata: {"content":"你好"}event: conversation.message.deltadata: {"content":",我是 AI 助手"}event: conversation.message.completeddata: {"finish_reason":"stop"}
AI 流式回复的流程就是:
服务端返回一小段↓前端解析一小段↓更新 AI 气泡↓再返回一小段↓再更新 AI 气泡
用户看到的视觉效果就是“AI 正在打字”。
不过这里有一个重要的细节:
服务端可能发送的是一个完整的事件:
event: xxxdata: {"content":"你好"}
但是客户端实际接收时,可能会因为 TCP 分包被拆成几块:
第 1 块:event: x第 2 块:xxdata: {"content"第 3 块::"你好"}
所以,前端不能拿到一块数据就直接解析,而是要做 buffer 拼包。
大致流程是:
1. 接收二进制数据块2. 转成字符串3. 放进 sseBuffer4. 按空行 拆完整事件5. 解析 event 和 data6. 回调给 Provider
这就是流式请求封装层的核心工作。
可以这样理解:SSE 解析的本质就是边接收、边拼包、边解析、边回调。
十四、停止生成:不是只改 UI 状态
AI 聊天里常见的“停止生成”功能,一开始很容易以为只是把 loading 设为 false 就行了。
但实际远不止这么简单。
一个完整的停止流程应该包含:
用户点击停止生成↓Controller 调用 stopGenerate()↓Provider 调用 cancelChat()↓HttpClient.abortStream() 中断本地 SSE↓Provider 通知服务端取消当前生成↓保留已经生成的部分文本↓恢复 loading 状态
这里还要区分两种不同的场景:
用户主动停止网络异常中断
用户主动停止时,不应该弹出“网络错误”的提示。
网络异常中断时,才需要提示失败或允许重试。
为此,可以定义一个特殊错误类型:
StreamAbortedError
用来区分用户主动取消和真正的异常。
十五、请求日志与敏感信息脱敏
在真实项目里,请求日志非常有价值,无论调试还是线上问题排查。
但日志绝对不能随意打印敏感信息。
常见的敏感字段包括:
authorizationcookietokenopenIdx-api-keyset-cookie
请求封装层应该统一做好脱敏处理。
比如日志中只显示:
authorization: *** (len=32)
而不是打印完整的真实 token。
这体现的是一种工程安全意识:日志可以打,但关键信息必须先脱敏。
这也是 Demo 代码和真实业务代码之间一个很显著的区别。
十六、数据模型:让消息、会话、卡片结构清晰
AI 聊天模块里常见的数据模型包括:
ChatItemUI 上展示的一条消息ChatMessage普通消息数据AgentCardAI 返回的业务卡片ConversationInfo会话信息ChatConfig聊天组件配置AgentResultAI 返回结果AgentAttachment附件信息
这些模型的意义在于:让各个层之间传递数据时有统一的结构,而不是到处传递字典或 JsonObject。
例如:
export class ChatItem {role: string = ''content: string = ''time: string = ''cards: AgentCard[] = []attachments: AttachmentInfo[] = []}
这样,UI 层、Controller 层、Provider 层之间传递数据时,每个字段的含义都非常明确。
十七、数组更新与响应式刷新
在 ArkUI 的响应式场景中,数组的更新方式需要特别注意。
比如直接这样写:
this.chatHistory.push(newItem)
有时不如替换数组引用来得稳定可靠。
更推荐的做法是:
this.chatHistory = this.chatHistory.concat(newItem)
或者:
this.chatHistory = this.chatHistory.map(item => {if (item.id === targetId) {return {...item,content: newContent}}return item})
核心思想是:用新的数组引用去触发 UI 更新,而不是依赖对原数组的原地修改。
这和聊天打字机效果也密切相关。
比如 AI 回复时,如果只是修改同一个对象里的 content,但列表的 key 没变,有时 UI 不一定能按预期刷新。
可以通过下面几种方式来保证刷新稳定:
更新数组引用更新对象引用合理设置 ForEach key
十八、Builder 参数:让组件支持自定义 UI
聊天模块中经常需要支持各种业务卡片。
不同业务返回的卡片可能完全不同:
路线卡片景点卡片票务卡片商品卡片订单卡片推荐问题卡片
如果聊天组件内部写死所有卡片 UI,这个组件就很难复用到其他业务场景。
更好的方式是通过 Builder 参数来注入:
@BuilderParam cardsBuilder: (cards: AgentCard[]) => void
外部页面自己决定这些卡片怎么画:
@BuildercardsBuilder(cards: AgentCard[]) {RouteCardList({cards: cards.filter(item => item.cardType === 'route')})PoiCardList({cards: cards.filter(item => item.cardType === 'poi')})}
这样一来,聊天组件的职责就很简单了:
我有一批 cards我把它交给外部 cardsBuilder
它完全不关心具体业务卡片长什么样。
这也是一种解耦:
聊天组件负责通用聊天能力业务页面负责具体业务卡片展示
十九、ChatConfig:把业务差异配置化
一个通用聊天组件不应该写死所有业务行为。
通过 ChatConfig 可以注入不同业务需要的配置:
- 欢迎语
- 推荐问题
- 动态推荐问题加载
- 抽屉配置
- 链接点击回调
- 埋点回调
- 业务跳转回调
- loading 样式
- 错误样式
例如:
const config = new ChatConfig()config.welcomeMessage = '你好,我是 AI 助手'config.quickPhrases = ['问题 1', '问题 2']config.onLinkClick = (url, text) => {// 业务页面决定怎么打开链接}config.onTrackEvent = (event) => {// 业务页面决定怎么上报埋点}
这样,组件内部完全不用关心具体业务平台怎么跳转、怎么埋点、怎么生成推荐问题。
总结一下:把变化的逻辑配置化,把不变的能力组件化。
二十、完整主链路
到这里,可以把整个 AI 聊天模块的主链路完整串起来了:
用户输入问题↓InputBar 触发发送↓ChatViewModel 暴露 sendMessage 入口↓ChatController 编排发送流程↓创建用户消息,写入 chatHistory↓设置 loading 状态↓调用 AgentProvider.sendMessage↓具体 Provider 构造平台请求↓HttpClient.stream 发起 SSE 请求↓服务端不断返回 event/data↓HttpClient 解析出 eventType 和 eventData↓Provider 解析平台协议↓onDelta 回调给 ChatController↓ChatController 更新 AI 消息内容↓ChatViewModel 状态变化↓MessageList / BotBubble 自动刷新↓回复完成后收尾,恢复 loading
这条链路是整个模块的“主线任务”,理解它就抓住了模块的骨架。
只要能把这条链路讲清楚,大多数 AI 聊天项目的结构就都不会看乱。
二十一、这套架构体现的核心思想
1. MVVM
View 负责展示ViewModel 负责状态Model 负责数据结构
2. Controller 编排
复杂业务流程不要全部塞进 ViewModel,Controller 负责把一次完整业务流程串起来。
3. Provider 适配器
上层依赖统一接口,不同 AI 平台各自实现自己的 Provider。
4. 请求统一封装
所有网络请求集中在 HttpClient,统一处理:
日志错误上传SSE中断超时脱敏
5. 组件化
大页面拆成小组件:
消息列表输入栏抽屉气泡卡片蒙层
每个组件只负责自己的展示和交互。
6. 配置化
业务差异通过 ChatConfig 和 BuilderParam 注入,通用组件不写死业务逻辑。
7. 解耦
UI 不关心请求请求不关心 UIProvider 不关心展示HttpClient 不关心业务语义
二十二、可以背下来的架构口诀
Page 负责入口Comp 负责 UIViewModel 负责状态Controller 负责编排Provider 负责平台适配HttpClient 负责请求Parser 负责解析Model 负责数据结构Card 负责展示
再精简一下就是:
UI 看 vm,Controller 改 vm,Provider 接平台,HttpClient 发请求。
最核心的一句:
数据从 ViewModel 流出,操作从 Controller 流入。
二十三、实习阶段应该怎么读这种项目
第一次接触公司项目,不要妄想一次性看懂所有文件。
建议按这个顺序来:
第一遍:看目录结构,知道每个目录大概干什么第二遍:看页面入口,知道从哪里进来第三遍:看核心组件,知道 UI 怎么组合第四遍:看 ViewModel,知道状态有哪些第五遍:看 Controller,知道一次业务流程怎么跑第六遍:看 Provider,知道接口平台怎么适配第七遍:看 HttpClient,知道请求和 SSE 怎么封装第八遍:看 Parser 和 Card,知道数据怎么渲染成业务 UI
切记:不要一开始就陷进某个很长的文件里。
应该先建立项目地图,问自己这几个问题:
入口在哪?状态在哪?发送从哪开始?请求在哪发?结果回到哪里?UI 怎么刷新?
主线清晰之后,再逐个文件去深入细节。
二十四、当前阶段学习总结
通过这一阶段的源码阅读,对一个 AI 聊天模块的工程化设计有了更清晰的认识。
它绝不是简单地在页面里写一个输入框和一个消息数组,而是通过多层架构把复杂能力拆解得井井有条:
页面入口负责装配聊天组件负责 UI 总装ViewModel 负责状态管理Controller 负责业务流程Provider 负责 AI 平台适配HttpClient 负责网络请求和 SSEModel 负责数据结构Builder 负责业务 UI 扩展
这套结构背后,是很多常见的工程化思想:
MVVM解耦面向接口编程组件化配置化请求统一封装状态驱动 UI
对于实习阶段来说,目前最重要的不是把每个方法都背下来,而是先理解:
为什么要这么分层?每一层负责什么?一次发送消息从 UI 到请求再回到 UI 是怎么流转的?
理解了这些,再去看具体的方法实现,就不会那么轻易地迷失方向了。
二十五、最终总结
一个复杂 AI 聊天模块的核心,不只是“页面怎么画”,而是如何把 UI、状态、业务流程、接口协议、网络请求、数据解析拆清楚。
其中:
MVVM 解决 UI 和状态之间的关系Controller 解决复杂业务流程编排的问题Provider 解决不同 AI 平台适配的问题HttpClient 解决请求统一封装和 SSE 流式响应的问题Builder 和 Config 解决组件可扩展和业务差异注入的问题HAR 共享包解决模块级复用的问题
最终形成的结构可以概括为:
通过 ViewModel 打通 UI 和状态通过 Controller 编排业务流程通过 Provider 屏蔽平台差异通过 HttpClient 统一网络请求通过 Config 和 Builder 保持组件可复用
这就是目前从 AI 聊天模块源码中总结出来的主要架构结论。
