从 AI 聊天组件源码复盘工程化架构:MVVM、解耦、Provider 与 SSE 流式响应

从 AI 聊天组件源码复盘工程化架构:MVVM、解耦、Provider 与 SSE 流式响应

前言

最近在学习一个 HarmonyOS / ArkTS 里的 AI 聊天组件模块。刚开始看这类项目时,很容易被大量文件名和层级绕晕:页面入口、聊天组件、状态管理、Controller、Provider、HttpClient、SSE、卡片解析、会话管理等内容混在一起,看完一遍很快就忘。

后来我换了一种方式,不再死记每个文件细节,而是先从"用户发送一条消息"这个主流程出发,把整个模块按职责拆开理解。这样再回头看代码,就能知道每个文件大概站在哪一层、负责什么事情。

这篇文章主要记录这次源码学习过程中的架构理解,尽量只总结通用技术思想,不涉及具体公司业务、真实接口、内部库、服务地址和敏感配置。


一、先从整体链路看这个模块

一个 AI 聊天模块并不只是一个输入框加一个消息数组。完整一点的业务组件通常会包含:

text 复制代码
页面入口
  ↓
聊天 UI 容器
  ↓
状态中心 ViewModel
  ↓
业务流程 Controller
  ↓
AI 平台 Provider
  ↓
网络请求 HttpClient
  ↓
SSE 流式响应
  ↓
状态回写
  ↓
UI 自动刷新

可以抽象成这条主线:

text 复制代码
AgentChatPage
  ↓
AgentChatComp
  ↓
ChatViewModel
  ↓
ChatController
  ↓
AgentProvider / XxxProvider
  ↓
HttpClient

这条链路是理解整个模块的核心。只要能说清楚它,后面再去看具体文件就不会完全迷路。


二、每一层分别负责什么

我先用一句口诀概括:

text 复制代码
Page 负责入口
Comp 负责 UI
ViewModel 负责状态
Controller 负责编排
Provider 负责平台适配
HttpClient 负责请求
Model 负责数据结构
Parser 负责解析
Card 负责展示

展开来看:

层级 主要职责
Page 页面入口、路由注册、Provider 创建、业务配置注入
View / Comp UI 组合、消息列表、输入框、抽屉、Loading、语音蒙层等
ViewModel 保存页面状态,给 UI 提供状态和方法入口
Controller 编排发送消息、停止生成、重试、会话切换等业务流程
Provider 适配不同 AI 平台的接口和协议
HttpClient 封装 GET、POST、上传、SSE、请求中断、日志等底层网络能力
Model 定义消息、会话、卡片、配置、返回结果等数据结构
Parser 把服务端返回的结构化数据解析成前端可用模型
Card 把解析后的业务卡片渲染成 UI

这种分层的目的不是为了"看起来高级",而是为了避免所有逻辑都堆在一个页面文件里。

如果不拆层,一个页面里可能会同时出现:

text 复制代码
UI 布局
输入框状态
消息数组
HTTP 请求
SSE 解析
AI 平台协议
错误处理
会话管理
卡片解析
埋点逻辑
业务跳转

短期可能能跑,但后期维护会非常痛苦。


三、Page:入口和配置装配层

页面入口层通常不负责真正的聊天业务。它更像一个"装配器"。

它主要做几件事:

text 复制代码
1. 注册页面路由
2. 初始化 AI Provider 配置
3. 创建具体 Provider 实例
4. 构造 ChatConfig
5. 定义卡片 Builder / Loading Builder
6. 渲染核心聊天组件 AgentChatComp

伪代码大概是这样:

ts 复制代码
@ComponentV2
export struct AgentChatPage {
  @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,
      loadingBuilder: this.loadingBuilder
    })
  }
}

这里有一个很重要的点:

text 复制代码
Page 创建 Provider,但不直接发送消息。

页面入口负责把能力传进去,真正的发送流程后面会交给 ChatController

ChatConfig 的作用

通用聊天组件不能把所有业务行为写死,所以通常会通过配置对象注入业务差异。

比如:

text 复制代码
欢迎语
推荐问题
动态推荐问题加载方法
链接点击回调
埋点回调
业务跳转回调
抽屉配置
Loading 样式
错误页样式

这样做的好处是:聊天组件保持通用,外部页面根据业务需要传入不同配置。


四、AgentChatComp:真正的聊天 UI 容器

AgentChatComp 可以理解为整个聊天页面的核心 UI 容器。

它主要做四类事情:

text 复制代码
1. 接收外部传入的 provider、chatConfig、Builder 等参数
2. 创建 ChatViewModel
3. 初始化页面级 Controller
4. 组合聊天页面 UI

典型结构大概是:

text 复制代码
Stack 根容器
├── 背景层
├── MessageList 消息列表
├── QuickQuestionsCard 推荐问题
├── FloatingButtons 浮动按钮
├── InputBar 输入框
├── VoiceMaskOverlay 语音蒙层
├── LoadingOverlay 加载/错误蒙层
└── ConversationDrawer 会话抽屉

它和 Page 的区别是:

text 复制代码
AgentChatPage:页面入口,负责业务配置和 Provider 创建
AgentChatComp:聊天主体容器,负责创建 vm、启动 controller、组合 UI

可以这样记:

text 复制代码
Page 负责把能力传进来
Comp 负责把聊天模块跑起来

五、MVVM:UI 和业务之间的状态桥梁

这个模块里最明显的架构思想是 MVVM。

MVVM 可以拆成:

text 复制代码
View:页面和组件,负责展示和用户交互
ViewModel:状态中心,负责连接 UI 和业务
Model:数据结构,负责定义数据长什么样

对应到聊天模块中:

text 复制代码
View:AgentChatComp、MessageList、InputBar、BotBubble、ConversationDrawer
ViewModel:ChatViewModel
Model:ChatItem、AgentCard、ConversationInfo、ChatConfig、AgentResult 等

需要注意的是:

text 复制代码
ChatController 不是 Model。

Controller 是额外拆出来的业务流程层。Model 是数据结构,比如一条消息、一张卡片、一个会话、一个配置对象。

ChatViewModel 为什么是桥

ChatViewModel 是 UI 和业务之间的桥。

对 UI 来说:

text 复制代码
UI 通过 vm 读取状态
UI 通过 vm 调用方法

例如:

text 复制代码
InputBar 读取 / 修改 vm.userInput
MessageList 读取 vm.chatHistory
LoadingOverlay 读取 vm.loading / vm.loadFailed
ConversationDrawer 读取 vm.conversations

对 Controller 来说:

text 复制代码
Controller 执行业务后,回写 vm 状态

例如:

text 复制代码
更新 chatHistory
更新 loading
更新 conversationId
更新 quickPhrases
更新 showDrawer

完整链路可以记成:

text 复制代码
用户操作 UI
  ↓
UI 调用 vm 方法
  ↓
vm 转发给 Controller
  ↓
Controller 执行业务
  ↓
Controller 更新 vm 状态
  ↓
UI 根据 vm 状态自动刷新

一句话总结:

text 复制代码
UI 调 vm,Controller 改 vm,vm 变了 UI 刷新。

六、ChatController:发送消息流程的总指挥

ChatController 的核心职责是:编排一次聊天发送流程。

用户点击发送后,不是 UI 自己去拼参数、发请求、解析结果,而是走 Controller。

流程大概是:

text 复制代码
用户点击发送
  ↓
InputBar 调用 vm.sendMessage()
  ↓
ViewModel 转发给 ChatController.sendMessage()
  ↓
ChatController 读取 vm.userInput
  ↓
校验输入和 loading 状态
  ↓
创建用户消息 ChatItem
  ↓
先把用户消息加入 vm.chatHistory
  ↓
清空 vm.userInput
  ↓
设置 vm.loading = true
  ↓
创建 AI 回复占位消息
  ↓
调用 provider.sendMessage()
  ↓
接收 onDelta / onMessage / onReplyComplete 回调
  ↓
持续更新 AI 占位消息内容
  ↓
如果有结构化数据,则解析成卡片
  ↓
回复完成后恢复 loading

这里有两个关键点。

1. 用户消息先入历史记录

用户点击发送后,用户消息会先加入 chatHistory,让页面立即显示。

不是等 AI 接口返回成功后,再把用户消息和 AI 回复一起放进去。

这样用户体验更自然:

text 复制代码
我点了发送
  ↓
我的消息马上出现在聊天列表里
  ↓
AI 开始思考和回复

2. AI 回复先创建占位消息

AI 回复不是一开始就有完整内容,而是先创建一条空的 AI 消息,再通过流式回调不断更新。

text 复制代码
创建 AI 占位消息
  ↓
收到第一个 delta,更新 content
  ↓
收到第二个 delta,继续更新 content
  ↓
最终形成完整回复

这和自己写 Demo 时用 setInterval 模拟打字机效果很像,只不过真实项目里数据来自服务端 SSE 流。


七、Provider:用抽象屏蔽不同 AI 平台

AI 聊天模块可能会接入不同平台,比如某个 AI 平台、内部大模型服务、Mock 服务等。

如果聊天组件直接依赖某个平台实现,就会被具体平台绑死。

不好的写法是:

ts 复制代码
const provider = new SomeConcreteProvider()
provider.sendMessage()

这样以后换平台,就要改聊天组件。

更好的方式是定义一个抽象 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 SomeAIProvider extends AgentProvider {
  getName(): string {
    return 'SomeAI'
  }

  async sendMessage(...): Promise<AgentResult> {
    // 这里写具体平台的请求和协议解析
  }
}

聊天组件只依赖抽象:

ts 复制代码
@Param provider: AgentProvider | null = null

这样组件就不关心底层到底是哪一个 AI 平台。

只要外部传入的对象满足 AgentProvider 规范,它就能工作。

这就是解耦和面向接口编程。

可以这样记:

text 复制代码
组件不关心具体实现类,只关心传入对象是否满足它依赖的接口或抽象类。

八、abstract:只定规则,不干具体活

在 Provider 抽象里会看到 abstract

例如:

ts 复制代码
abstract class AgentProvider {
  abstract getName(): string
  abstract sendMessage(...): Promise<AgentResult>
}

abstract 的意思是:抽象的,只定义规范,不提供完整实现。

抽象类不能直接 new:

ts 复制代码
const provider = new AgentProvider() // 不允许

它的作用是规定子类必须实现哪些方法。

例如:

text 复制代码
任何 AI Provider 都必须有 getName()
任何 AI Provider 都必须有 sendMessage()

具体怎么发请求,由子类自己实现。

一句话记忆:

text 复制代码
abstract = 只定规则,不干具体活。

在架构上的意义是:

text 复制代码
上层依赖抽象规则
下层提供具体实现
从而降低耦合

九、Provider 和 HttpClient 的区别

这里很容易混。

可以用一句话区分:

text 复制代码
Provider 管协议,HttpClient 管网络。

Provider 管什么

Provider 是平台适配层,负责"某个平台怎么用"。

它通常会处理:

text 复制代码
平台接口路径
请求 body 格式
会话创建
文件上传后的 file_id 使用
SSE event 类型含义
平台错误码
取消生成
会话列表
历史消息
推荐问题

比如:

text 复制代码
哪个 event 是文本增量?
哪个 event 是完整消息?
哪个 event 表示错误?
返回的 JSON 怎么转成 AgentResult?

这些都属于 Provider。

HttpClient 管什么

HttpClient 是底层网络封装。

它通常负责:

text 复制代码
GET
POST
PUT
upload
stream
abortStream
请求日志
敏感信息脱敏
超时处理
SSE 基础 event/data 解析

它不关心这个 event 是不是 AI 文本,也不关心业务卡片怎么展示。

例如:

text 复制代码
HttpClient:我只把流里的 event 和 data 拆出来
Provider:我知道这些 event 和 data 在某个平台里代表什么
Controller:我决定怎么更新聊天状态
UI:我负责展示更新后的状态

十、SSE:AI 回复为什么能一点点显示

SSE 全称是 Server-Sent Events。

普通 HTTP 是:

text 复制代码
客户端请求一次
  ↓
服务端处理完
  ↓
一次性返回完整结果
  ↓
请求结束

SSE 是:

text 复制代码
客户端发起一次请求
  ↓
服务端不一次性返回完整答案
  ↓
而是在这条连接里不断推送 event/data
  ↓
客户端每收到一段就更新一次 UI
  ↓
直到服务端发送 completed / done 事件
  ↓
请求结束

一个简化的 SSE 内容可能长这样:

text 复制代码
event: message.delta
data: {"content":"你好"}

event: message.delta
data: {"content":",我是 AI 助手"}

event: message.completed
data: {"finish_reason":"stop"}

在 UI 层,前端会先创建一条 AI 占位消息:

text 复制代码
assistant: ''

然后每收到一个 delta,就更新这条消息:

text 复制代码
assistant: '你好'
assistant: '你好,我是 AI 助手'

所以用户看到的效果就是 AI 在一点点打字。

在项目里的链路是:

text 复制代码
ChatController 创建 AI 占位消息
  ↓
Provider 调用 HttpClient.stream()
  ↓
HttpClient 接收 SSE 数据流
  ↓
解析 event / data
  ↓
Provider 判断 event 类型
  ↓
触发 onDelta(delta, fullText)
  ↓
ChatController 更新 AI 占位消息 content
  ↓
ViewModel 状态变化
  ↓
MessageList / BotBubble 自动刷新

可以这样记:

text 复制代码
SSE 负责后端一点点给
onDelta 负责前端一点点收
AI 占位消息负责页面一点点显示

十一、为什么需要 buffer 拼包

SSE 是流式数据,网络返回的数据块不一定刚好是一条完整事件。

服务端可能发送:

text 复制代码
event: message.delta
data: {"content":"你好"}

但客户端实际收到时可能是:

text 复制代码
第 1 块:event: mess
第 2 块:age.delta\ndata: {"cont
第 3 块:ent":"你好"}\n\n

所以不能收到一块就立刻当完整数据解析。

正确做法是:

text 复制代码
1. 收到数据块
2. 转成字符串
3. 放进 sseBuffer
4. 按空行 \n\n 拆完整事件
5. 解析 event 和 data
6. 回调给 Provider

一句话:

text 复制代码
网络数据块不等于完整 SSE 事件,所以要先拼包再解析。

十二、停止生成不是只改 loading

AI 聊天里常见"停止生成"。

它不是简单地:

ts 复制代码
loading = false

更完整的流程是:

text 复制代码
用户点击停止生成
  ↓
Controller 调用 stopGenerate()
  ↓
Provider 调用 cancelChat()
  ↓
HttpClient.abortStream() 中断本地 SSE
  ↓
Provider 通知服务端取消当前生成
  ↓
保留已经生成的部分文本
  ↓
恢复 loading 状态

还需要区分:

text 复制代码
用户主动停止
网络异常中断

用户主动停止不应该提示"网络错误"。

所以通常会定义类似 StreamAbortedError 的错误类型,用于表示这是主动取消,不是真正的异常。


十三、Model:数据结构不是业务流程

前面容易混的一点是:Model 不是 Controller。

Model 是数据结构,比如:

text 复制代码
ChatItem:一条聊天消息
ConversationInfo:一个会话信息
AgentCard:一个业务卡片
ChatConfig:聊天配置
AgentResult:AI 返回结果
AgentAttachment:附件信息

例如:

ts 复制代码
export class ChatItem {
  role: string = ''
  content: string = ''
  time: string = ''
  cards: AgentCard[] = []
}

它只定义数据长什么样,不负责发送消息。

ChatController 是业务流程层,它负责一轮消息发送怎么跑。

可以这样记:

text 复制代码
Model = 数据本身长什么样
Controller = 业务流程怎么跑

十四、Builder 和 Config:让组件更通用

聊天组件通常会支持业务卡片,比如路线卡片、商品卡片、订单卡片、推荐卡片等。

如果把这些具体业务 UI 全写死在聊天组件里,组件就很难复用。

更好的方式是通过 Builder 参数把具体卡片渲染交给外部:

ts 复制代码
@BuilderParam cardsBuilder: (cards: AgentCard[]) => void

外部页面可以自己决定:

ts 复制代码
@Builder
cardsBuilder(cards: AgentCard[]) {
  RouteCardList({
    cards: cards.filter(item => item.cardType === 'route')
  })

  PoiCardList({
    cards: cards.filter(item => item.cardType === 'poi')
  })
}

聊天组件只负责把卡片数据交出去:

text 复制代码
我有 cards
我交给 cardsBuilder
至于怎么展示,由外部决定

这也是解耦。

同样,ChatConfig 可以把欢迎语、推荐问题、埋点、链接点击、业务跳转等差异配置化。

可以总结为:

text 复制代码
Config 负责注入行为差异
Builder 负责注入 UI 差异

十五、HAR 共享包与解耦

在 HarmonyOS 项目中,这类聊天模块可以封装成 HAR 共享包。

HAR 可以理解成 HarmonyOS 里的共享代码包或组件库包。

它不是 exe,也不是可以直接双击运行的程序,更类似:

text 复制代码
前端里的 npm package
Android 里的 AAR
Java 里的 JAR

HAR 通常用于封装:

text 复制代码
公共组件
工具方法
业务模块
页面能力
请求封装
数据模型
资源文件

它解决的是工程结构层面的复用。

而解耦解决的是代码设计层面的依赖关系。

可以这样区分:

text 复制代码
HAR 让代码从工程结构上独立出来
解耦让代码从职责关系上独立出来

两者可以一起使用。

例如:

text 复制代码
把 AI 聊天模块做成 HAR
  ↓
主工程通过 import 使用它暴露的组件和能力
  ↓
模块内部再通过 ViewModel、Controller、Provider、HttpClient 分层解耦

十六、目前我对这套架构的理解

经过这几节源码学习,我对这个 AI 聊天模块的理解可以总结成:

text 复制代码
它不是一个简单页面,而是一个完整的 AI 聊天能力模块。

Page 负责入口和业务配置。
AgentChatComp 负责搭建聊天 UI。
ChatViewModel 负责保存状态。
ChatController 负责编排发送消息流程。
AgentProvider 定义统一 AI 能力。
具体 Provider 负责适配某个 AI 平台协议。
HttpClient 负责底层请求和 SSE 流式响应。
Model 负责定义消息、会话、卡片等数据结构。

用户点击发送后,完整链路是:

text 复制代码
InputBar 调 vm.sendMessage
  ↓
ViewModel 转给 ChatController
  ↓
Controller 先把用户消息写进 chatHistory
  ↓
Controller 创建 AI 占位消息
  ↓
Controller 调 Provider
  ↓
Provider 调 HttpClient.stream 发起 SSE
  ↓
服务端不断返回 delta
  ↓
Provider 解析事件并回调 Controller
  ↓
Controller 更新 AI 占位消息
  ↓
ViewModel 状态变化
  ↓
MessageList 自动刷新

最终形成完整对话。


十七、复习口诀

最后整理一版方便记忆的口诀:

text 复制代码
Page 负责入口
Comp 负责 UI
ViewModel 负责状态
Controller 负责编排
Provider 负责平台适配
HttpClient 负责请求
Model 负责数据结构
Parser 负责解析
Card 负责展示

再短一点:

text 复制代码
UI 调 vm,Controller 改 vm,Provider 接平台,HttpClient 发请求。

发送流程口诀:

text 复制代码
先加用户消息
再加 AI 占位
Provider 发请求
SSE 回 delta
Controller 改消息
ViewModel 驱动 UI

分层边界口诀:

text 复制代码
Provider 管协议
HttpClient 管网络
Model 管数据结构
Controller 管业务流程

十八、学习感受

这次源码学习最大的感受是:复杂项目不能只靠"看一遍"。看完很快忘是正常的,关键是要反复回忆主链路。

比起死记每个方法,更重要的是先回答几个问题:

text 复制代码
入口在哪?
UI 在哪?
状态在哪?
业务流程在哪?
请求在哪?
AI 平台协议在哪?
服务端结果怎么回到 UI?

当这些问题能答出来后,再去看某个具体文件,才会知道它在整个架构中的位置。

目前对我来说,最重要的是先掌握这条主线:

text 复制代码
AgentChatPage
  ↓
AgentChatComp
  ↓
ChatViewModel
  ↓
ChatController
  ↓
AgentProvider / XxxProvider
  ↓
HttpClient
  ↓
SSE
  ↓
ViewModel
  ↓
UI

这条线记住后,再继续往下分析卡片解析、消息转换、业务卡片渲染,就会顺很多。


总结

一个工程化的 AI 聊天模块,本质上是在解决几个问题:

text 复制代码
UI 如何拆分?
状态如何管理?
业务流程如何编排?
不同 AI 平台如何适配?
流式响应如何处理?
数据如何转换成 UI?
模块如何复用?

这套架构的核心不是某个具体 API,而是分层思想:

text 复制代码
通过 ViewModel 隔开 UI 和状态,
通过 Controller 承接复杂业务流程,
通过 Provider 屏蔽不同 AI 平台差异,
通过 HttpClient 统一网络请求,
通过 Config 和 Builder 保持组件可扩展,
通过 Model 和 Parser 让数据结构清晰。

以后再看类似项目时,可以先不急着钻细节,先找这几层:

text 复制代码
入口层
UI 层
状态层
流程层
平台适配层
请求层
数据模型层
解析层

这样就不会一看到大量文件就迷路。

相关推荐
Maimai108082 小时前
TanStack Table 入门:为什么它是 React 表格开发里的“表格引擎”
前端·javascript·react.js·架构·前端框架·reactjs
踩着两条虫2 小时前
VTJ.PRO 开源 AI 低代码引擎深度评测大纲
前端·低代码·开源软件
你听得到113 小时前
从 Figma 走查到 AI 可验证产物:我如何重构客户端 UI 交付链路
前端·vue.js·flutter
Moment3 小时前
开发Agent为什么必须先做意图识别?
前端·后端·面试
小糖学代码3 小时前
LLM系列:1.python入门:12.异常处理(Exceptions)
前端·人工智能·python·深度学习
追忆3183 小时前
我为什么自己做了一个密码生成工具:聊聊 Web Crypto API 在前端随机数生成中的实践
前端
葬送的代码人生3 小时前
从零到一:AI 全栈开发入门 —— 构建一个简单的用户聊天系统
前端·javascript·架构
NIIBLE3 小时前
全栈日记之工程化设计(webpack)
前端·webpack·前端工程化
Larcher3 小时前
新手入门:从前端三件套到动态数据渲染
前端·后端·代码规范