复盘 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 生成过程,从输入到输出都能被稳定、清晰、可维护地承载下来。

相关推荐
了不起的小明1 小时前
SwiftMesh:本地 3D 模型查看器,加密交付 + 转盘录屏一站式搞定,开源免费
前端
默_笙1 小时前
👍 我的代码被 ESLint 抓包了:单引号、var、没分号——全被当场点名
前端·javascript
搞个锤子哟1 小时前
vue项目引入icon的问题
前端
树上有只程序猿1 小时前
当低代码遇上AI,一个开发者的真实感悟
前端
郭邯1 小时前
从零手写一个 UA 解析工具:我用 AI 辅助开发,踩了这些坑
前端
Heo1 小时前
怎么保证缓存和数据库一致性?
前端·后端·面试
摇曳的精灵2 小时前
前端渲染优化
前端·前端渲染优化
WILLF2 小时前
前端视角:Python requests vs JS axios
前端·python
半个落月2 小时前
在浏览器里运行 DeepSeek-R1:React 对话界面与安全渲染(三)
前端·人工智能·react.js