复盘 AI Coding 工作台的流式链路:从 Sender 输入到 SSE 分流,再到 BubbleList 和右侧预览

复盘 AI Coding 工作台的流式链路:从 Sender 输入到 SSE 分流,再到 BubbleList 和右侧预览

前面我已经分别拆过 BubbleListSenderThoughtChainAssistantMessageContent 这些组件。

所以这篇不再逐个介绍组件 API,而是换一个更接近真实工程复盘的视角:一次用户请求从输入框发出后,前端如何建立 SSE 连接,如何把后端流式事件分流成正文、推理摘要、工具状态,最后如何驱动聊天区和右侧预览更新。

先给结论:这套链路解决的不是"怎么显示一条消息"

如果只是做普通聊天室,事情很简单:

txt 复制代码
用户输入一句话
后端返回一段文本
前端 append 到消息列表

但 AI Coding 工作台不一样。

一次请求里,后端可能连续推这些事件:

txt 复制代码
answer      AI 正文
reasoning   模型显式推理摘要
thought     工具调用状态
status      后端生命周期状态
artifact    生成产物信息
done        流结束
error       业务错误

如果前端没有一条清晰的流式链路,最后很容易变成:

txt 复制代码
status 被拼进聊天正文
工具调用状态和 AI 回复混在一起
页面产物塞进 bubble
组件到处判断后端 type
SSE 客户端既管连接又管业务

所以这个工作台的核心不是"把消息显示出来",而是建立一条稳定的数据管道:

txt 复制代码
用户输入
  -> 创建或定位会话
  -> 插入 user message
  -> 插入 assistant 占位 message
  -> 建立 SSE 连接
  -> 解析后端事件
  -> 分流成 content / reasoningContent / thought
  -> 合并进 assistant message
  -> BubbleList 响应式渲染
  -> AssistantMessageContent 拆层展示
  -> InspectorPanel 根据 appId 和构建状态刷新右侧页面预览

这篇文章就按这条链路往下拆。

整体调用链

先看一张文字版架构图:

txt 复制代码
ChatComposer(Sender)
  用户输入、选择技能、点击发送
        |
        v
ChatWorkspace
  把 submit 事件继续抛给页面
        |
        v
WorkbenchPage
  调用 useWorkbenchChat.handleSubmit
        |
        v
useWorkbenchChat
  创建应用、插入消息、建立 SSE、合并流式 chunk
        |
        v
streamChatToGenCode
  拼接口 URL,配置 SSE callbacks
        |
        v
createSseStream
  建立 EventSource,监听 message / done / business-error
        |
        v
parseChatStreamMessage
  把 MessageEvent<string> 转成业务 chunk
        |
        v
parseChatStreamPayload
  根据 type 分流 answer / reasoning / thought / ignored
        |
        v
useWorkbenchChat.onChunk
  content 追加正文,reasoningContent 追加摘要,thought 按 id 合并
        |
        v
ChatMessageList(BubbleList)
  根据 message.role 选择 user/assistant 渲染规则
        |
        v
AssistantMessageContent
  ThoughtChain + ReasoningChain + MarkdownRenderer
        |
        v
InspectorPanel
  生成结束后等待构建,iframe 展示页面产物

这条链路里,每个模块只负责自己那一段。

我的判断是:AI 工作台最怕的不是代码多,而是边界乱。

SSE 连接、协议解析、消息状态、气泡渲染、右侧预览如果混在一起,后面加一个工具状态都会牵一堆文件。

第一步:ChatComposer 采集用户意图

入口是中间底部输入框 ChatComposer

它用的是 ant-design-x-vueSender

vue 复制代码
<Sender
  v-model:value="inputValue"
  :loading="running"
  :onSubmit="handleSubmit"
  :onCancel="() => emit('cancel')"
  :autoSize="{ minRows: 2, maxRows: 6 }"
  :placeholder="placeholder"
>

但这里有一个重点:ChatComposer 发出去的不是一个裸字符串。

在 AI Coding 模式下,用户可能选择了技能:

txt 复制代码
生成页面
优化样式
拆分组件
修复问题
写文档

所以提交时抛出去的是结构化 payload:

ts 复制代码
emit('submit', {
  message: text,
  skillKeys: isChatMode.value ? [] : [...selectedSkillKeys.value],
  skills: selectedSkills.value.map(skill => ({
    key: skill.key,
    name: skill.name,
    category: skill.category,
    prompt: skill.prompt,
  })),
});

对应类型是:

ts 复制代码
export interface ComposerSubmitPayload {
  message: string;
  skillKeys: AgentSkillKey[];
  skills: Pick<AgentSkill, 'key' | 'name' | 'category' | 'prompt'>[];
}

这一步的本质是:

txt 复制代码
Sender 负责输入体验
ChatComposer 负责把用户输入转成"意图对象"

为什么要这么做?

因为后续 useWorkbenchChat 不应该再去猜:

txt 复制代码
当前是不是 Chat 模式?
用户选了哪些技能?
技能 prompt 是什么?

这些上下文应该在提交时就结构化好。

第二步:事件向上抛到 WorkbenchPage

ChatComposer 不直接调接口。

它通过 ChatWorkspace 把事件向上抛:

vue 复制代码
<ChatComposer
  :running="running"
  :capability="conversation.capability"
  @submit="$emit('submit', $event)"
  @cancel="$emit('cancel')"
/>

ChatWorkspace 也不处理业务,它只是中间聊天区的骨架:

vue 复制代码
<main class="zc-main chat-workspace">
  <ChatHeader />
  <ChatMessageList />
  <ChatComposer />
</main>

真正处理提交的是页面入口 WorkbenchPage

vue 复制代码
<ChatWorkspace
  :conversation="activeConversation"
  :running="isRunning"
  :load-more-history="loadMoreConversationMessages"
  @submit="handleSubmit"
  @cancel="handleCancel"
/>

handleSubmit 来自:

ts 复制代码
const {
  activeConversation,
  isRunning,
  handleSubmit,
  handleCancel,
} = useWorkbenchChat();

也就是说:

txt 复制代码
组件负责 emit
页面负责接线
业务逻辑放在 composable

这也是我很推荐的 Vue 组织方式。

第三步:useWorkbenchChat 处理会话和消息占位

用户点击发送后,进入 useWorkbenchChat.handleSubmit

它先判断当前是否已经在 running:

ts 复制代码
if (runningKey.value || creatingApp.value) {
  return;
}

然后拿当前会话:

ts 复制代码
let conversation = activeConversation.value;

如果是一个全新的草稿会话,还没有后端 appId,就先创建应用:

ts 复制代码
if (!conversation.appId) {
  const createdConversation = await createConversationForFirstMessage(conversation, payload);
  if (!createdConversation) {
    return;
  }
  conversation = createdConversation;
}

拿到 appId 后,进入真正的流式请求:

ts 复制代码
startStream(conversation, payload);

startStream 里,前端先做一个非常关键的动作:插入两条本地消息

ts 复制代码
conversation.messages = [
  ...conversation.messages,
  {
    key: `${now}-user`,
    role: 'user',
    content: payload.message,
    skillKeys: payload.skillKeys,
  },
  {
    key: assistantKey,
    role: 'assistant',
    content: '',
    loading: false,
    typing: { step: 2, interval: 24, suffix: '|' },
    skillKeys: payload.skillKeys,
  },
];

第一条是用户消息。

第二条是 assistant 占位消息。

为什么要先插入 assistant 空消息?

因为 SSE 是一段一段来的。

后面所有流式增量都要持续合并到同一条 assistant 消息上:

txt 复制代码
第 1 个 answer chunk -> 追加到 assistant.content
第 2 个 answer chunk -> 继续追加
第 1 个 reasoning chunk -> 追加到 assistant.reasoningContent
第 1 个 thought -> 合并到 assistant.thoughts
第 2 个 thought -> 根据 id 更新 assistant.thoughts

如果不提前插入这条占位消息,流式过程中每来一个 chunk 都新建一条消息,聊天区会碎掉。

所以 assistantKey 是后续流式合并的锚点。

第四步:streamChatToGenCode 准备 SSE 请求

startStream 里会调用:

ts 复制代码
closeStream.value = streamChatToGenCode(
  {
    appId: conversation.appId,
    message: payload.message,
  },
  {
    onChunk,
    onDone,
    onError,
  },
);

streamChatToGenCode 在 API 层。

它做两件事:

第一,拼出接口 URL:

ts 复制代码
const url = createApiUrl('/app/chat/gen/code', {
  appId: params.appId,
  message: params.message,
});

第二,把这个 URL 交给通用 SSE client:

ts 复制代码
return createSseStream<ChatToGenCodeStreamChunk>({
  url,
  parseMessage: parseChatStreamMessage,
  parseBusinessError,
  onMessage: handlers.onChunk,
  onDone: handlers.onDone,
  onError: handlers.onError,
});

注意这个分层:

txt 复制代码
streamChatToGenCode
  知道业务接口路径
  知道业务错误怎么解析
  知道 message 要用 parseChatStreamMessage 解析

createSseStream
  不知道这是 chat 还是 gen code
  只知道如何建立 SSE

这就是 API 层应该做的事:把业务接口和通用客户端接起来。

第五步:createSseStream 建立 EventSource

createSseStream 是一个通用 SSE 客户端。

核心代码是:

ts 复制代码
const eventSource = new EventSource(options.url, {
  withCredentials: options.withCredentials ?? true,
});

它监听四类事件。

第一类,普通 message:

ts 复制代码
eventSource.onmessage = (event) => {
  const message = options.parseMessage(event);
  if (message !== undefined && message !== null) {
    options.onMessage(message);
  }
};

第二类,业务错误:

ts 复制代码
eventSource.addEventListener(
  options.businessErrorEventName ?? 'business-error',
  (event) => {
    const error = options.parseBusinessError
      ? options.parseBusinessError(event as MessageEvent<string>)
      : new Error('生成过程中出现错误');
    emitError(error);
  },
);

第三类,完成事件:

ts 复制代码
eventSource.addEventListener(options.doneEventName ?? 'done', () => {
  close();
  options.onDone();
});

第四类,连接异常:

ts 复制代码
eventSource.onerror = () => {
  emitError(new Error('SSE 连接中断'));
};

这里有一个非常重要的边界:

txt 复制代码
createSseStream 不解析业务 type

它不关心:

txt 复制代码
answer
reasoning
thought
tool_request
status
artifact

它只负责:

txt 复制代码
建连
监听
关闭
错误
回调

这让它可以复用到别的 SSE 场景。

第六步:chatStreamEvents 把后端事件分流

真正理解后端业务事件的是 chatStreamEvents.ts

它的最终输出类型只有三个字段:

ts 复制代码
export interface ChatToGenCodeStreamChunk {
  content?: string;
  reasoningContent?: string;
  thought?: AgentThought;
}

也就是说,不管后端推什么,前端聊天状态最终只吃这三种增量:

字段 含义 最终展示
content AI 正文 MarkdownRenderer
reasoningContent 显式推理摘要 ChatReasoningChain
thought 工具调用状态 ChatThoughtChain

6.1 message 入口

createSseStream 收到 MessageEvent<string> 后,会调用:

ts 复制代码
parseChatStreamMessage(event)

内部先解析 JSON:

ts 复制代码
export function parseChatStreamMessage(event: MessageEvent<string>) {
  return parseChatStreamPayload(parseJsonData(event.data));
}

如果后端发的是 JSON:

json 复制代码
{"type":"answer","d":"你好"}

就解析成对象。

如果后端直接发纯文本:

txt 复制代码
你好

也不会炸,会走纯文本兜底。

6.2 事件类型分组

chatStreamEvents 先定义几组事件。

AI 正文:

ts 复制代码
const ANSWER_EVENT_TYPES = new Set([
  'answer',
  'chunk',
  'ai_response',
  'assistant_partial',
  'assistant_final',
]);

推理摘要:

ts 复制代码
const REASONING_EVENT_TYPES = new Set([
  'thinking',
  'thinking_summary',
  'reasoning',
  'reasoning_summary',
]);

工具状态:

ts 复制代码
const THOUGHT_EVENT_TYPES = new Set([
  'thought',
  'agent_thought',
]);

迁移期兼容旧工具事件:

ts 复制代码
const LEGACY_TOOL_START_EVENT_TYPES = new Set([
  'tool_request',
  'tool_call',
  'tool_start',
]);

const LEGACY_TOOL_END_EVENT_TYPES = new Set([
  'tool_result',
  'tool_executed',
  'tool_end',
]);

6.3 核心分流逻辑

核心函数是:

ts 复制代码
export function parseChatStreamPayload(payload: unknown): ChatToGenCodeStreamChunk | null {
  if (!isJsonRecord(payload)) {
    return parseNestedPayload(payload);
  }

  const eventType = normalizeEventType(payload.type ?? payload.event);

  if (eventType === 'done' || eventType === 'error') {
    return null;
  }

  const directText = pickText(payload, [
    'd',
    'content',
    'data',
    'message',
    'text',
    'delta',
    'output',
  ]);

  if (REASONING_EVENT_TYPES.has(eventType)) {
    return directText === undefined ? null : { reasoningContent: directText };
  }

  if (ANSWER_EVENT_TYPES.has(eventType)) {
    return directText === undefined ? null : { content: directText };
  }

  const thought = parseThoughtPayload(payload, eventType);
  if (thought) {
    return { thought };
  }

  if (IGNORED_EVENT_TYPES.has(eventType)) {
    return null;
  }

  return fallbackParse(payload);
}

这段逻辑对应一句话:

txt 复制代码
先判断明确类型,再进入兜底解析

6.4 Thought 统一成 AgentThought

工具调用事件最终会变成:

ts 复制代码
export interface AgentThought {
  id: string;
  type: 'tool_call';
  status: 'pending' | 'success' | 'error';
  title: string;
  description?: string;
  toolName?: string;
  toolLabel?: string;
  input?: unknown;
  output?: unknown;
  createdAt?: string;
}

比如后端发:

json 复制代码
{
  "type": "thought",
  "d": {
    "id": "call_1",
    "type": "tool_call",
    "status": "pending",
    "toolName": "writeFile",
    "input": {
      "relativeFilePath": "src/App.vue"
    }
  }
}

前端得到:

ts 复制代码
{
  thought: {
    id: 'call_1',
    type: 'tool_call',
    status: 'pending',
    title: '调用工具:writeFile',
    toolName: 'writeFile',
    input: {
      relativeFilePath: 'src/App.vue',
    },
  },
}

这就是协议防腐层的价值:UI 层不直接感知后端旧字段。

第七步:onChunk 合并到 assistant message

createSseStream 解析出 chunk 后,会调用:

ts 复制代码
onMessage: handlers.onChunk

也就是回到 useWorkbenchChat.startStream 里的 onChunk

这一步是真正更新消息状态的地方:

ts 复制代码
onChunk: (chunk) => {
  const target = findConversation(conversationKey);
  if (!target) {
    return;
  }

  replaceMessage(target, assistantKey, item => ({
    ...item,
    loading: false,
    content: `${item.content}${chunk.content ?? ''}`,
    reasoningContent: `${item.reasoningContent ?? ''}${chunk.reasoningContent ?? ''}`,
    thoughts: chunk.thought
      ? mergeThoughts(item.thoughts, chunk.thought)
      : item.thoughts,
  }));
}

这里有三条流:

txt 复制代码
chunk.content
  -> 追加到 assistant.content

chunk.reasoningContent
  -> 追加到 assistant.reasoningContent

chunk.thought
  -> 按 id 合并到 assistant.thoughts

正文和推理摘要是字符串流,所以用追加。

Thought 是结构化事件,所以不能简单 push,要按 id 合并:

ts 复制代码
function mergeThoughts(
  currentThoughts: AgentMessage['thoughts'] = [],
  nextThought: NonNullable<AgentMessage['thoughts']>[number],
) {
  const existingIndex = currentThoughts.findIndex(thought => thought.id === nextThought.id);

  if (existingIndex < 0) {
    return [...currentThoughts, nextThought];
  }

  return currentThoughts.map((thought, index) => {
    return index === existingIndex ? { ...thought, ...nextThought } : thought;
  });
}

为什么要合并?

因为一次工具调用通常分两段:

txt 复制代码
pending:开始调用
success:调用完成

它们应该是同一个 Thought item 的状态变化,而不是两条独立记录。

所以后端必须保证:

txt 复制代码
tool_request 和 tool_executed 使用同一个 id

这就是工具调用可视化能否做好的关键。

第八步:BubbleList 根据 role 渲染消息

conversation.messages 更新以后,Vue 响应式触发 ChatMessageList 更新。

消息列表用的是:

vue 复制代码
<BubbleList
  ref="bubbleListRef"
  :items="conversation.messages"
  :roles="roles"
  :auto-scroll="true"
  :on-scroll="handleScroll"
>

这里的 rolesBubbleList 的消息角色规则。

用户消息:

ts 复制代码
if (message.role === 'user') {
  return userRole;
}

AI 消息:

ts 复制代码
return {
  ...assistantRole,
  messageRender: (content: unknown) => h(AssistantMessageContent, {
    message: {
      ...message,
      key: message.key ?? 'assistant-message',
      role: 'assistant',
      content: String(content ?? ''),
      reasoningContent: typeof message.reasoningContent === 'string'
        ? message.reasoningContent
        : undefined,
      thoughts: Array.isArray(message.thoughts)
        ? message.thoughts as AgentThought[]
        : undefined,
    } as AgentMessage,
  }),
};

这一步完成了一个重要分工:

txt 复制代码
BubbleList
  负责消息队列、头像、左右布局、滚动、footer actions

AssistantMessageContent
  负责 assistant 气泡内部到底展示什么

所以你在模板里找不到:

vue 复制代码
<AssistantMessageContent />

因为它不是模板直接写的,而是在 messageRender 里通过:

ts 复制代码
h(AssistantMessageContent, { message })

动态渲染。

这种写法很适合 AI 消息,因为 assistant 气泡的内部结构一定会越来越复杂。

第九步:AssistantMessageContent 把 AI 气泡拆成三层

AssistantMessageContent 接到标准化后的 AgentMessage

它只做展示分层:

vue 复制代码
<div class="assistant-message-content">
  <ChatThoughtChain v-if="thoughts.length" :thoughts="thoughts" />

  <ChatReasoningChain
    v-if="reasoningContent"
    :content="reasoningContent"
    :active="reasoningActive"
  />

  <MarkdownRenderer v-if="answerContent" :content="answerContent" />
</div>

这三层对应前面 SSE 分流出来的三个字段:

txt 复制代码
thought
  -> ChatThoughtChain

reasoningContent
  -> ChatReasoningChain

content
  -> MarkdownRenderer

这就是整条链路最漂亮的地方。

后端事件经过 chatStreamEvents 分流以后,到 UI 层刚好是一一对应:

后端事件 chunk 字段 message 字段 组件
answer content content MarkdownRenderer
reasoning reasoningContent reasoningContent ChatReasoningChain
thought thought thoughts ChatThoughtChain
status null 不进入 message 不展示
artifact null 不进入 bubble 右侧预览/产物区

这就是前端链路设计的核心:数据在什么层被分流,就决定了 UI 在什么层保持简单。

第十步:ThoughtChain 只展示工具过程

ChatThoughtChain 里最终把 AgentThought[] 转成 ThoughtChain items:

ts 复制代码
const items = computed(() => {
  return visibleThoughts.value.map((thought) => {
    const detail = stringifyThoughtDetail(thought);

    return {
      key: thought.id,
      icon: getThoughtIcon(thought),
      title: thought.title,
      description: thought.description,
      status: thought.status,
      content: detail
        ? h(MarkdownRenderer, { content: `\`\`\`json\n${detail}\n\`\`\`` })
        : undefined,
    };
  });
});

这里有两个设计判断。

第一个,ThoughtChain 不是拿来展示模型私有思维链的。

它展示的是:

txt 复制代码
AI 调用了什么工具
工具是否完成
入参是什么
输出是什么
失败在哪里

第二个,input/output 默认折叠在详情里。

用户平时只看:

txt 复制代码
调用工具:writeFile
工具完成:writeFile

需要排查时再展开 JSON。

这比直接把工具参数和返回值拼进 AI 正文干净很多。

这样前端就不需要理解后端内部事件名。

这条链路里每个组件的真正位置

最后把所有组件放回链路里看。

模块 在链路中的位置 真正职责
ChatComposer 输入起点 采集 message + skills
Sender 输入 UI 多行输入、发送、取消、loading
ChatWorkspace 聊天区容器 组合 header/list/composer
WorkbenchPage 页面接线层 左中右布局,连接 composable
useWorkbenchChat 状态大脑 会话、消息、SSE、合并、错误
streamChatToGenCode 业务 API 层 拼 URL,配置 SSE callbacks
createSseStream 通用 SSE 客户端 建连、监听、关闭、错误
chatStreamEvents 协议防腐层 后端事件分流成 chunk
ChatMessageList 消息列表 BubbleList、roles、滚动、footer
BubbleList 气泡引擎 根据 items + roles 渲染消息
AssistantMessageContent AI 气泡内容中枢 Thought + Reasoning + Markdown
ChatThoughtChain 工具状态展示 展示 tool_call 的 pending/success/error
ChatReasoningChain 推理摘要展示 展示 reasoningContent
MarkdownRenderer 正文展示 渲染 AI markdown 正文

用一句话概括:

txt 复制代码
输入由 ChatComposer 发起
流式连接由 createSseStream 维护
业务事件由 chatStreamEvents 分流
消息状态由 useWorkbenchChat 合并
聊天 UI 由 BubbleList 承载
AI 气泡由 AssistantMessageContent 拆层

我认为这套设计最值得保留的三个点

1. SSE client 和业务解析分离

createSseStream 不懂业务,只负责连接。

chatStreamEvents 不管连接,只负责协议翻译。

这让整个流式系统可以扩展,也容易排查问题。

当你看到页面显示错了,先问:

txt 复制代码
是后端事件发错?
还是 chatStreamEvents 分流错?
还是 useWorkbenchChat 合并错?
还是 AssistantMessageContent 展示错?

这条排查路径非常清楚。

2. assistant message 是结构化对象,不是字符串

AI 回复不是只有:

ts 复制代码
content: string

而是:

ts 复制代码
{
  content: string;
  reasoningContent?: string;
  thoughts?: AgentThought[];
}

这一步让 UI 有能力区分:

txt 复制代码
正文
推理摘要
工具过程

后面加文件变更、引用来源、diff、部署信息,也可以继续扩这个结构。

小结

这套前端框架真正串起来的是一条流式数据链路:

txt 复制代码
Sender 输入
  -> ComposerSubmitPayload
  -> useWorkbenchChat.handleSubmit
  -> startStream 插入消息占位
  -> streamChatToGenCode
  -> createSseStream 建立 EventSource
  -> parseChatStreamMessage
  -> parseChatStreamPayload 分流
  -> onChunk 合并到 assistant message
  -> BubbleList 根据 roles 渲染
  -> AssistantMessageContent 拆出 Thought / Reasoning / Markdown

如果只看单个组件,会觉得它们都不复杂。

但真正有价值的是它们之间的边界:

txt 复制代码
连接归连接
协议归协议
状态归状态
气泡归气泡
AI 内容归 AI 内容
页面产物归页面产物

这几个边界守住以后,项目继续加能力才不会散。

我觉得这就是一个 AI Coding 工作台前端最重要的底层骨架。它不是为了炫组件,而是为了让一次复杂的 AI 生成过程,从输入到输出都能被稳定、清晰、可维护地承载下来。

相关推荐
一拳不是超人7 分钟前
一个没测暗色模式的 Bug,吃掉了我一半 谷歌扩展用户
前端·javascript·程序员
liangshanbo121523 分钟前
React useState 函数式更新面试题整理
前端·react.js·前端框架
涛涛ing28 分钟前
为什么你的页面在 Safari 上总出问题?Interop 2027 正在解决这个 20 年老毛病
前端
不一样的少年_41 分钟前
WebP 压缩到底在干嘛?小白也能看懂的原理拆解
前端·后端·图片资源
问心无愧051342 分钟前
ctf show web 177
前端·笔记
不一样的少年_1 小时前
PNG/JPG 如何变成 WebP?真相不是改后缀!
前端·后端·图片资源
杉氧1 小时前
RN 性能调优指南:重渲染(Re-renders)控制与长列表(FlatList)优化
android·前端·react native
一_个前端1 小时前
[JS] 一站式搞定 PDF、图片、Dom弹窗、表格的浏览器打印功能
前端
JavaGuide1 小时前
阿里 Qoder 又开源了一个专门给 Claude Code、Codex 做“体检”的项目
前端·后端
不一样的少年_1 小时前
JPEG 压缩到底在干嘛?小白也能看懂的 8 步拆解
前端·后端·图片资源