2026 前端 AI Agent 工程化实战营系列第 7 篇

本文整理自《2026 前端 AI Agent 工程化实战营》课程资料,作为系列文章第 7 篇。

generated-image-1776441227327.png

前五章一路走来,系统已经能做多轮对话、调用工具、向量检索、多 Agent 协作,并把数据从内存推到了生产级数据库。但有一个问题一直被搁置:模型的输出全是纯文本。

不管是需求分析结论、冲突检测结果,还是多 Agent 的汇总报告,最终返回给前端的都是一段 Markdown,或者一段 JSON 字符串。前端拿到之后,要么原样渲染成一段话,要么自己去猜测里面有没有可以结构化展示的内容。这在 demo 阶段完全够用,但一旦你开始做真实的需求管理产品、内部工具或者任何面向用户的 AI 应用,就会发现:用户不想读一堆文字,他们想点按钮、选选项、填表单。

这一章要解决的,就是这件事:让模型的输出不只是文字,而是一套前端可以直接渲染的 UI 协议。模型返回结构化的 JSON,前端根据 type 字段渲染对应的交互组件——选择卡片、确认对话框、表单、进度条、数据表格。用户的操作结果再回传给模型,形成一个完整的 AI 驱动 UI 闭环。

贯穿案例是我们的需求分析系统:产品经理输入一条自然语言需求(如"用户希望能够批量导入 Excel 数据"),系统对需求进行完整性分析、冲突检测、复杂度评估,并生成用户故事。


6.1 纯文本回复的天花板

generated-image-1776440995317.png

先看一个真实的交互场景。产品经理说"我要提一个新需求:用户希望能够批量导入 Excel 数据",系统用第四章的链路跑完之后,返回了这样一段文字:

根据您的描述,需求"批量导入 Excel 数据"(编号 REQ-20240315-001)已完成初步分析。
该需求涉及文件解析、数据校验、批量写入等多个技术环节。

建议的分析维度:
1. 完整性检查:需求描述是否涵盖了所有必要场景(如异常处理、数据格式校验)
2. 冲突检测:是否与现有"单条数据录入"功能存在交互冲突
3. 复杂度评估:初步评估为中等复杂度,预计 8-13 人天

请告诉我您希望先从哪个维度开始深入分析?

这段回复信息完整、逻辑清晰,但从产品体验的角度看,有几个明显的问题:

换一种方式。如果模型返回的不是纯文本,而是这样一段结构化数据:

{
  "type": "selection",
  "title": "请选择分析维度",
  "description": "需求 REQ-20240315-001(批量导入 Excel 数据)已完成初步解析",
  "options": [
    {
      "id": "completeness_check",
      "label": "完整性检查",
      "description": "检查需求描述是否涵盖所有必要场景和边界条件",
      "icon": "🔍"
    },
    {
      "id": "conflict_detection",
      "label": "冲突检测",
      "description": "检测与现有功能的交互冲突和数据一致性问题",
      "icon": "⚡"
    },
    {
      "id": "complexity_estimation",
      "label": "复杂度评估",
      "description": "评估技术实现难度和工期预估",
      "icon": "📊"
    }
  ],
  "allowMultiple": false
}

前端拿到这段 JSON,可以直接渲染成三张卡片。用户点击"完整性检查",前端把 { selectedId: "completeness_check" } 回传给模型,模型不需要做任何自然语言解析,直接拿到一个确定性的选择结果。

这就是本章要做的事:把模型的输出从"给人读的文字"变成"给前端渲染的指令"。


6.2 UI 响应协议设计与 Structured Output 约束

协议设计是整章的基础。一旦定下来,后端和前端就有了统一的约定:后端保证输出符合 Schema,前端保证能渲染每种 type

6.2.1 设计原则

在定义具体类型之前,先明确几条设计原则:

6.2.2 组件类型总览

generated-image-1776441065738.png

type 用途 典型场景 用户操作
text 纯文本或 Markdown 普通对话回复、解释说明 无(仅展示)
selection 单选/多选卡片 选择需求类型、选择分析维度 点击选项,回传 selectedId
form 动态表单 填写需求详情、补充验收标准 填写并提交,回传表单数据
confirmation 确认对话框 确认需求提交、确认分析参数 确认或取消,回传 confirmed
card 信息展示卡片 需求详情、分析报告、处理进度 无(仅展示,可带操作按钮)
steps 步骤进度条 需求分析进度、评审状态 无(仅展示)
table 数据表格 历史需求列表、分析记录 可选行点击,回传 selectedRow
action_buttons 操作按钮组 快捷操作入口、流程分支选择 点击按钮,回传 actionId

6.2.3 TypeScript 类型定义

// services/chat/src/llm/ui-protocol/ui-types.ts(关键结构)

// 每个组件类型都包含 type 字段作为唯一标识,以 selection 和 form 为例:
export interface SelectionResponse {
  type: 'selection';
  title: string;
  options: SelectionOption[];  // { id, label, description?, icon? }
  allowMultiple?: boolean;
}

export interface FormResponse {
  type: 'form';
  title: string;
  fields: FormField[];         // { name, label, type, required?, options? }
  submitLabel?: string;
}

// 其他组件类型结构类似(完整定义由 AI 生成):
// TextResponse:          { type: 'text', content: string }
// ConfirmationResponse:  { type: 'confirmation', title, summary[], warning? }
// CardResponse:          { type: 'card', title, fields[], actions?[] }
// StepsResponse:         { type: 'steps', steps[], currentStep }
// TableResponse:         { type: 'table', columns[], rows[], selectable? }
// ActionButtonsResponse: { type: 'action_buttons', buttons[], layout? }

// 联合类型:前端根据 type 字段分发渲染
export type UIResponse =
  | TextResponse | SelectionResponse | FormResponse
  | ConfirmationResponse | CardResponse | StepsResponse
  | TableResponse | ActionButtonsResponse;

// 模型完整回复
export interface AIUIResponse {
  message: string;              // 主要文字回复(始终存在)
  components: UIResponse[];     // UI 组件列表(可多个)
  context?: {
    sessionStage?: string;      // 当前流程阶段
    collectedData?: Record<string, unknown>;
  };
}

// 用户操作回传
export interface UIAction {
  componentType: UIResponse['type'];
  payload:
    | { type: 'select'; selectedId: string | string[] }
    | { type: 'submit'; formData: Record<string, unknown> }
    | { type: 'confirm'; confirmed: boolean }
    | { type: 'click'; actionId: string }
    | { type: 'row_select'; rowIndex: number };
}

6.2.4 Zod Schema(供 LangChain Structured Output 使用)

LangChain 的 withStructuredOutput 需要 Zod Schema 来约束模型的输出格式。这里把上面的 TypeScript 类型转换成对应的 Zod 定义:

// services/chat/src/llm/ui-protocol/ui-schemas.ts
import { z } from 'zod';

// 以 selection 为例,每个组件类型都有对应的 Zod Schema
const selectionResponseSchema = z.object({
  type: z.literal('selection'),  // z.literal 确保精确匹配
  title: z.string(),
  options: z.array(z.object({
    id: z.string(), label: z.string(),
    description: z.string().optional(), icon: z.string().optional(),
  })).min(2),
  allowMultiple: z.boolean().optional(),
});
// textResponseSchema, formResponseSchema, confirmationResponseSchema,
// cardResponseSchema, stepsResponseSchema, tableResponseSchema,
// actionButtonsResponseSchema 等均按相同模式定义...

// 关键:用 discriminatedUnion 基于 type 字段精确匹配
export const uiComponentSchema = z.discriminatedUnion('type', [
  textResponseSchema, selectionResponseSchema, formResponseSchema,
  confirmationResponseSchema, cardResponseSchema, stepsResponseSchema,
  tableResponseSchema, actionButtonsResponseSchema,
]);

// 完整 AI 回复 Schema(供 withStructuredOutput 使用)
export const aiUIResponseSchema = z.object({
  message: z.string().describe('主要文字回复'),
  components: z.array(uiComponentSchema),
  context: z.object({
    sessionStage: z.string().optional(),
    collectedData: z.record(z.unknown()).optional(),
  }).optional(),
});

注意:每次将 prompt 交给 AI 生成 spec 文档后,你都需要仔细分析,而不是 AI 给什么就照做。同时,我给你的 prompt 只是初稿,你需要借助 AI 去完善它,并完成你自己的作品。

image.png

6.2.5 用 Structured Output 约束模型输出

generated-image-1776441062768.png

协议和 Schema 都定义好了,接下来把它们用起来。LangChain 的 withStructuredOutput 会把 Zod Schema 作为约束注入模型调用,确保输出严格符合定义的结构。但结构化输出只能约束"格式",不能约束"判断"——模型还需要一份明确的指引来决定在什么业务场景下,应该返回什么类型的组件。

// services/chat/src/llm/ui-protocol/ui-response.service.ts
import { Injectable } from '@nestjs/common';
import { ChatPromptTemplate, MessagesPlaceholder } from '@langchain/core/prompts';
import { createChatModel } from '../model.factory';
import { aiUIResponseSchema } from './ui-schemas';
import type { AIUIResponse } from './ui-types';

const UI_SYSTEM_PROMPT = `你是一名需求分析助手。你的回复必须包含结构化的 UI 组件,让前端可以渲染出友好的交互界面。

## 组件选择指南

根据对话场景,选择合适的组件类型:

1. selection:用户从明确选项中选择(需求类型、分析维度、优先级)
2. form:需要用户补充多个字段信息(需求详情、验收标准)
3. confirmation:即将执行重要操作(确认提交、确认生成)
4. card:展示结构化信息(需求详情、分析报告)
5. steps / table / action_buttons / text:进度、数据、操作入口、纯文本
组合规则:message 必填;components 可多个;常见组合 card + action_buttons
上下文管理:context.sessionStage 跟踪阶段,collectedData 记录已收集数据`;

@Injectable()
export class UIResponseService {
  private model = createChatModel();
  private structuredModel = this.model.withStructuredOutput(aiUIResponseSchema);

  private prompt = ChatPromptTemplate.fromMessages([
    ['system', UI_SYSTEM_PROMPT],
    new MessagesPlaceholder('history'),
    ['human', '{input}'],
  ]);

  private chain = this.prompt.pipe(this.structuredModel);

  async generateUIResponse(
    input: string,
    history: BaseMessage[] = [],
    context?: Record<string, unknown>,
  ): Promise<AIUIResponse> {
    const enrichedInput = context
      ? `${input}\n\n[当前上下文] ${JSON.stringify(context)}`
      : input;

    return this.chain.invoke({
      input: enrichedInput,
      history,
    });
  }
}

6.2.6 实际调用效果

当产品经理输入"我要提一个新需求"时,模型的返回大致如下:

{
  "message": "好的,请先选择需求类型,以便系统匹配最合适的分析模板。",
  "components": [
    {
      "type": "selection",
      "title": "请选择需求类型",
      "options": [
        { "id": "functional", "label": "功能需求", "description": "新增或修改系统功能", "icon": "⚙️" },
        { "id": "performance", "label": "性能需求", "description": "响应时间、并发量、吞吐率等指标", "icon": "⚡" },
        { "id": "security", "label": "安全需求", "description": "权限控制、数据加密、审计日志等", "icon": "🔒" },
        { "id": "ui_ux", "label": "UI/UX 需求", "description": "界面交互、用户体验优化", "icon": "🎨" }
      ],
      "allowMultiple": false
    }
  ],
  "context": {
    "sessionStage": "select_type",
    "collectedData": {}
  }
}

image 1.png

当产品经理输入"查看需求 REQ-20240315-001"时:

{
  "message": "已查询到该需求的详细信息:",
  "components": [
    {
      "type": "card",
      "title": "需求 REQ-20240315-001",
      "subtitle": "批量导入 Excel 数据",
      "icon": "📊",
      "fields": [
        { "label": "需求状态", "value": "待分析", "type": "status" },
        { "label": "提交时间", "value": "2024-03-15", "type": "date" },
        { "label": "需求类型", "value": "功能需求", "type": "text" },
        { "label": "优先级", "value": "P1 - 高", "type": "status" },
        { "label": "提交人", "value": "张三(PM)", "type": "text" }
      ],
      "actions": [
        { "id": "start_analysis", "label": "开始分析", "variant": "primary" },
        { "id": "view_similar", "label": "查看相似需求", "variant": "secondary" }
      ]
    }
  ]
}

image 2.png

实际上,此时我还没有接入真实数据。AI 自己摸索了一下,给我“胡说八道”编了一个假数据,刚好是一个很好的例子:如果没有强约束,LLM 很可能不会拒绝你。它总是有求必应,却不会在意数据的真伪。 在真实场景里,我们需要做很多约束来消除幻觉。


6.3 完整交互闭环:选择 → 表单 → 确认 → 结果

单个组件能被渲染出来,只是起点。真正的产品体验,是一个完整的交互流程:用户一步步操作,系统一步步推进,直到任务完成。

image 3.png

当你的 spec 开始帮你分析需求时,你往往会面临多种选择。你的每一次选择,都会让最终代码走向不同的实现。但这并不重要,因为我们更关注最终目标。比如我现在用 NestJS 来完成服务端项目,你也可以用 Midway 或其他框架,甚至用 Go 或 Java。

因为我们的最终目的是跑通整条 Agent 链路。只要是高级语言,都可以完成这个需求。你要做的不是把各种技术和语法都学一遍,而是掌握完整的思考流程,并学会借助 AI 把事情做成。所以你至少要精通一门高级语言,也需要持续培养架构思路。最终,你还要知道如何更好地与 AI 配合:既可以用一小段 prompt 完成,也可以自己写 spec,再借助 code agents 去落地。你完全可以选择自己最擅长的编程语言,客户端也不一定非得是 Web。

6.3.1 需求分析流程的四个阶段

flowchart LR
    A["选择需求类型"] -->|selection| B["填写需求详情"]
    B -->|form| C["确认提交分析"]
    C -->|confirmation| D["分析结果"]
    C -->|取消| B
    D -->|action_buttons| E["后续操作"]

generated-image-1776441607781.png

每个阶段对应一种组件类型,用户操作后自动推进到下一阶段。整个过程中,模型不需要再解析自然语言——用户的选择、表单数据、确认操作都是结构化的。

6.3.2 交互状态机实现

// services/chat/src/llm/ui-protocol/ui-flow.service.ts
import { Injectable } from '@nestjs/common';
import type { AIUIResponse, UIAction } from './ui-types';

interface SessionContext {
  stage: string;
  collectedData: Record<string, unknown>;
}

@Injectable()
export class UIFlowService {
  private sessions = new Map<string, SessionContext>();

  private getContext(sessionId: string): SessionContext {
    if (!this.sessions.has(sessionId)) {
      this.sessions.set(sessionId, { stage: 'init', collectedData: {} });
    }
    return this.sessions.get(sessionId)!;
  }

  /** 处理用户的自然语言输入 */
  async handleInput(sessionId: string, input: string): Promise<AIUIResponse> {
    const ctx = this.getContext(sessionId);

    // 判断用户意图,进入对应流程
    if (input.includes('需求') || input.includes('功能')) {
      // 从输入中提取需求描述
      ctx.collectedData.rawInput = input;
      ctx.stage = 'select_type';
      return this.buildSelectType(ctx);
    }

    if (input.includes('查看') || input.includes('查询')) {
      return this.buildRequirementCard(input);
    }

    // 默认:返回常用服务入口
    return {
      message: '请问有什么可以帮您?',
      components: [{
        type: 'action_buttons',
        title: '常用功能',
        buttons: [
          { id: 'new_req', label: '提交新需求', icon: '📝', variant: 'primary' },
          { id: 'view_reqs', label: '查看需求列表', icon: '📋', variant: 'secondary' },
          { id: 'search_similar', label: '搜索相似需求', icon: '🔍', variant: 'ghost' },
          { id: 'help', label: '使用帮助', icon: '💬', variant: 'ghost' },
        ],
        layout: 'horizontal',
      }],
    };
  }

  /** 处理用户的 UI 操作 */
  async handleAction(
    sessionId: string,
    action: UIAction,
  ): Promise<AIUIResponse> {
    const ctx = this.getContext(sessionId);

    switch (ctx.stage) {
      case 'select_type':
        return this.onTypeSelected(ctx, action);
      case 'fill_detail':
        return this.onFormSubmitted(ctx, action);
      case 'confirm':
        return this.onConfirmation(ctx, action);
      default:
        return this.handleButtonAction(ctx, action);
    }
  }

  // ====== Stage 1: 选择需求类型 ======

  private buildSelectType(ctx: SessionContext): AIUIResponse {
    return {
      message: '请先选择需求类型,以便系统匹配最合适的分析模板。',
      components: [{
        type: 'selection',
        title: '请选择需求类型',
        options: [
          { id: 'functional', label: '功能需求',
            description: '新增或修改系统功能', icon: '⚙️' },
          { id: 'performance', label: '性能需求',
            description: '响应时间、并发量、吞吐率等指标', icon: '⚡' },
          { id: 'security', label: '安全需求',
            description: '权限控制、数据加密、审计日志等', icon: '🔒' },
          { id: 'ui_ux', label: 'UI/UX 需求',
            description: '界面交互、用户体验优化', icon: '🎨' },
        ],
        allowMultiple: false,
      }],
      context: {
        sessionStage: 'select_type',
        collectedData: ctx.collectedData,
      },
    };
  }

  private onTypeSelected(
    ctx: SessionContext, action: UIAction,
  ): AIUIResponse {
    if (action.payload.type !== 'select') {
      return this.buildSelectType(ctx);
    }
    ctx.collectedData.reqType = action.payload.selectedId;
    ctx.stage = 'fill_detail';
    return this.buildDetailForm(ctx);
  }

  // ====== Stage 2~4:与 Stage 1 模式相同(buildXxx 构建 UI → onXxx 处理操作) ======

  // Stage 2: buildDetailForm() → form 组件(标题、描述、优先级、验收标准、补充说明)
  //          onFormSubmitted() → Object.assign(ctx.collectedData, formData)
  //          → ctx.stage = 'confirm' | 取消回退到 'select_type'

  // Stage 3: buildConfirmation() → confirmation 组件(操作摘要 + 警告信息)
  //          onConfirmation() → confirmed ? ctx.stage='result' : 回退 'fill_detail'

  // Stage 4: buildResult() → steps + card + action_buttons 组合
  //          steps: 分析流程各阶段状态
  //          card: 需求详情(标题、时间、复杂度、状态)
  //          action_buttons: 生成用户故事 / 查看报告 / 同步 Jira

  // ====== 按钮操作处理 ======
  // handleButtonAction: 根据 actionId 分发到对应流程
  // buildRequirementCard: 返回 card 组件展示需求详情
}

6.3.3 路由层

// services/chat/src/llm/ui-protocol/ui-chat.controller.ts
import { Body, Controller, Post } from '@nestjs/common';
import { UIFlowService } from './ui-flow.service';
import type { UIAction } from './ui-types';

@Controller('api/ui-chat')
export class UIChatController {
  constructor(private uiFlow: UIFlowService) {}

  @Post('chat')
  async chat(@Body() body: { sessionId: string; input: string }) {
    return this.uiFlow.handleInput(body.sessionId, body.input);
  }

  @Post('action')
  async action(@Body() body: { sessionId: string; action: UIAction }) {
    return this.uiFlow.handleAction(body.sessionId, body.action);
  }
}

🧪 验证步骤(对应 6.3)

用同一个 sessionId 走完完整需求分析流程:

# Stage 0: 初始入口(返回常用服务按钮)
curl -X POST http://localhost:3001/api/ui-chat/chat \
  -H "Content-Type: application/json" \
  -d '{"sessionId":"ui-demo","input":"你好"}'

# Stage 1: 触发需求分析流程(返回 selection 组件)
curl -X POST http://localhost:3001/api/ui-chat/chat \
  -H "Content-Type: application/json" \
  -d '{"sessionId":"ui-demo","input":"我要提一个新需求:用户希望能够批量导入 Excel 数据"}'

# Stage 2: 选择需求类型(返回 form 组件)
curl -X POST http://localhost:3001/api/ui-chat/action \
  -H "Content-Type: application/json" \
  -d '{"sessionId":"ui-demo","action":{"componentType":"selection","payload":{"type":"select","selectedId":"functional"}}}'

# Stage 3: 提交表单(返回 confirmation 组件)
curl -X POST http://localhost:3001/api/ui-chat/action \
  -H "Content-Type: application/json" \
  -d '{"sessionId":"ui-demo","action":{"componentType":"form","payload":{"type":"submit","formData":{"title":"批量导入 Excel 数据","description":"用户希望能通过上传 Excel 文件批量导入数据,支持 .xlsx 和 .csv 格式","priority":"P1","acceptanceCriteria":"支持 1 万行以内数据导入,异常数据自动标记"}}}}'

# Stage 4: 确认提交(返回 steps + card + action_buttons)
curl -X POST http://localhost:3001/api/ui-chat/action \
  -H "Content-Type: application/json" \
  -d '{"sessionId":"ui-demo","action":{"componentType":"confirmation","payload":{"type":"confirm","confirmed":true}}}'

验收标准

image 4.png

image 5.png

后续图太多了,我就不粘贴了。按这个步骤,可以让 AI 写一段自动化测试的 E2E 测试脚本。我懒得写,如果你也不想写和验证,我们就直接开始开发前端,后端和前端到时候一起调整,速度会更快一些。


6.4 前端组件渲染层:ComponentRenderer

后端负责产出 UI 指令,前端负责把指令渲染成真实的交互界面。这一节的核心是 ComponentRenderer:一个根据 type 字段自动分发渲染的映射层。

6.4.1 组件映射器

// clients/chat-web/src/components/ai-ui/ComponentRenderer.tsx
import React from 'react';
import { SelectionCard } from './SelectionCard';
import { DynamicForm } from './DynamicForm';
import { ConfirmationDialog } from './ConfirmationDialog';
import { InfoCard } from './InfoCard';
import { StepsProgress } from './StepsProgress';
import { DataTable } from './DataTable';
import { ActionButtons } from './ActionButtons';
import type { UIResponse, UIAction } from '@/types/ui-types';

interface Props {
  component: UIResponse;
  onAction: (action: UIAction) => void;
}

export function ComponentRenderer({ component, onAction }: Props) {
  switch (component.type) {
    case 'text':
      return (
        <div className="prose prose-sm max-w-none">
          {component.content}
        </div>
      );

    case 'selection':
      return (
        <SelectionCard
          title={component.title}
          description={component.description}
          options={component.options}
          allowMultiple={component.allowMultiple}
          onSelect={(selectedId) =>
            onAction({
              componentType: 'selection',
              payload: { type: 'select', selectedId },
            })
          }
        />
      );

    // form, confirmation, card, steps, table, action_buttons 同理:
    // 将 component 的 props 传给对应子组件,用户操作通过 onAction 统一回传
    // 例如:form → onSubmit → { type: 'submit', formData }
    //       confirmation → onConfirm → { type: 'confirm', confirmed: true }
    //       card → onAction → { type: 'click', actionId }

    default:
      return null;
  }
}

6.4.2 选择卡片组件示例

// clients/chat-web/src/components/ai-ui/SelectionCard.tsx
import React, { useState } from 'react';

interface Option {
  id: string;
  label: string;
  description?: string;
  icon?: string;
  disabled?: boolean;
}

interface Props {
  title: string;
  description?: string;
  options: Option[];
  allowMultiple?: boolean;
  onSelect: (selectedId: string | string[]) => void;
}

export function SelectionCard({
  title, description, options, allowMultiple, onSelect,
}: Props) {
  const [selected, setSelected] = useState<string[]>([]);

  const handleClick = (id: string) => {
    if (allowMultiple) {
      const next = selected.includes(id)
        ? selected.filter((s) => s !== id)
        : [...selected, id];
      setSelected(next);
    } else {
      // 单选:直接触发
      onSelect(id);
    }
  };

  // 渲染选项卡片网格 + 多选确认按钮(样式代码省略)
  return (
    <div>
      <h3>{title}</h3>
      <div className="grid grid-cols-2 gap-2">
        {options.map((opt) => (
          <button key={opt.id} onClick={() => handleClick(opt.id)}>
            {opt.icon} {opt.label}
          </button>
        ))}
      </div>
      {allowMultiple && selected.length > 0 && (
        <button onClick={() => onSelect(selected)}>确认选择</button>
      )}
    </div>
  );
}

6.4.3 聊天容器

// clients/chat-web/src/components/ai-ui/AIChatContainer.tsx
import React, { useState } from 'react';
import { ComponentRenderer } from './ComponentRenderer';
import type { AIUIResponse, UIAction } from '@/types/ui-types';

interface ChatMessage {
  role: 'user' | 'ai';
  content: string;
  components?: AIUIResponse['components'];
}

export function AIChatContainer({ sessionId }: { sessionId: string }) {
  const [messages, setMessages] = useState<ChatMessage[]>([]);
  const [input, setInput] = useState('');
  const [loading, setLoading] = useState(false);

  const addAIMessage = (response: AIUIResponse) => {
    setMessages((prev) => [
      ...prev,
      {
        role: 'ai',
        content: response.message,
        components: response.components,
      },
    ]);
  };

  // 处理文本输入
  const handleSend = async () => {
    if (!input.trim()) return;
    setMessages((prev) => [...prev, { role: 'user', content: input }]);
    setInput('');
    setLoading(true);

    const res = await fetch('/api/ui-chat/chat', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ sessionId, input }),
    });
    const data: AIUIResponse = await res.json();
    addAIMessage(data);
    setLoading(false);
  };

  // 处理 UI 操作
  const handleAction = async (action: UIAction) => {
    setLoading(true);
    const res = await fetch('/api/ui-chat/action', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ sessionId, action }),
    });
    const data: AIUIResponse = await res.json();
    addAIMessage(data);
    setLoading(false);
  };

  // 渲染:消息列表 + 输入框(样式代码省略)
  return (
    <div>
      {messages.map((msg, i) => (
        <div key={i}>
          <p>{msg.content}</p>
          {msg.components?.map((comp, j) => (
            <ComponentRenderer key={j} component={comp} onAction={handleAction} />
          ))}
        </div>
      ))}
      <input onKeyDown={(e) => e.key === 'Enter' && handleSend()} />
      <button onClick={handleSend}>发送</button>
    </div>
  );
}

🧪 验证步骤(对应 6.4)

注意:我已将流程改为完全由 LLM 进行操作,因此与示例代码有所不同,具体可参考项目的 feat/ui 分支。

注意:我新增了数据库模型配置功能,因此你需要先完成模型配置。推荐使用超哥的中转站https://api.amux.ai/

image 6.png

在浏览器中打开前端页面(默认 http://localhost:3000),按以下步骤操作,验证前端组件渲染和交互闭环:

Step 1:初始加载 — 欢迎消息

操作:打开页面(新建会话或进入空会话)

验收标准

image 7.png

Step 2:触发澄清流程 — SelectionCard 渲染

操作:在输入框中输入简短内容(如"你好"或"我要提需求")并回车

验收标准

image 8.png

Step 3:选择需求类型 — DynamicForm 渲染

操作:点击"功能需求 ⚙️"卡片

验收标准

image 9.png

Step 4:提交表单 — ConfirmationDialog 渲染

操作

验收标准

image 10.png

Step 5:确认提交 — Steps + InfoCard + ActionButtons 组合渲染

操作:点击"确认提交"按钮

验收标准

image 11.png

Step 6:回退测试 — 返回 SelectionCard

操作

验收标准

Step 7:异常组件 Fallback

操作

验收标准

[不支持的组件类型: unknown_widget]

6.5 工程化要点与生产建议

协议和闭环都跑通之后,在真正上线之前,还有几个工程化层面的问题值得关注。

6.5.1 组件版本管理

// 在协议中加入版本号
export interface AIUIResponse {
  version: '1.0';            // 协议版本
  message: string;
  components: UIResponse[];
  context?: { /* ... */ };
}

当你需要新增组件类型或修改字段时,通过版本号来区分。前端可以根据版本号做兼容处理:

if (response.version === '1.0') {
  // 渲染 v1 组件
} else {
  // fallback 到纯文本
}

6.5.2 未知组件的 Fallback

前端遇到不认识的 type 时,不应该崩溃,而是降级到文本展示:

// ComponentRenderer 中的 default 分支
default:
  console.warn(`Unknown component type: ${component.type}`);
  return (
    <div className="p-3 bg-gray-50 rounded text-sm text-gray-500">
      [不支持的组件类型: {component.type}]
    </div>
  );

6.5.3 后处理校验层

模型输出通过 Zod Schema 约束了格式,但业务逻辑的正确性需要额外校验:

function validateUIResponse(response: AIUIResponse): AIUIResponse {
  // 1. 确保 message 不为空
  if (!response.message?.trim()) {
    response.message = '正在为您处理...';
  }

  // 2. 过滤掉空选项的 selection
  response.components = response.components.filter((comp) => {
    if (comp.type === 'selection' && comp.options.length < 2) return false;
    if (comp.type === 'form' && comp.fields.length === 0) return false;
    return true;
  });

  // 3. 限制单次返回的组件数量
  if (response.components.length > 5) {
    response.components = response.components.slice(0, 5);
  }

  return response;
}

6.5.4 Streaming 适配:text streaming + component batching

generated-image-1776441063383.png

结构化 UI 组件与流式输出之间存在天然矛盾:Markdown 文字可以逐 Token 到达,但组件必须拿到完整 JSON 才能渲染(否则一个半截的 formselection 根本没法用)。实践下来最顺手的折中方案是 “text streaming + component batching”——文字流着走,组件整包发。

┌────────────────────────────────────────────────┐
│  Markdown 内容:逐 Token 流式输出              │
│  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━    │
│  → 用户立即看到 AI 思考过程                    │
│                                                │
│  UI 组件:完整 JSON 到达后批量渲染             │
│  ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓    │
│  ┃ [Selection] [Form] [Confirmation]     ┃    │
│  ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛    │
│  → 组件需要完整数据才能正常工作                │
└────────────────────────────────────────────────┘

下面按照实际架构的六个关键改造点展开说明,串起从协议 → 后端编排 → SSE 传输 → 前端状态 → 渲染 → 进度指示的完整流程。

① 流式协议设计(ui-types.ts

先定义一个统一的流式消息信封,让后端所有推送都走同一个通道,前端只需要根据 messageType 做分发。

// services/chat/src/llm/ui-protocol/ui-types.ts
export interface StreamMessage {
  messageType: 'markdown' | 'ui' | 'meta' | 'progress' | 'done' | 'error';
  timestamp: string;
  payload:
    | MarkdownPayload
    | UIPayload
    | MetaPayload
    | ProgressPayload
    | ErrorPayload
    | null;
}

// Markdown 载荷 —— 支持流式片段
export interface MarkdownPayload {
  content: string;
  isChunk: boolean;       // 标记是否为流式片段(增量累积)
  messageId?: string;
}

// UI 载荷 —— 完整批量渲染
export interface UIPayload {
  messageId: string;
  components: UIResponse[];         // 完整组件数组
  thinking?: string;
  interactionState?: ComponentInteractionState;
}

这一层带来的变化

② 后端流式编排(orchestrator.service.ts

关键是区分两类 Agent:JSON Agent 静默收集Markdown Agent 逐 Token 推送

// services/chat/src/llm/orchestrator.service.ts
async *streamOrchestrate(...): AsyncGenerator<OrchestratorStreamEvent> {
  // Phase 1: JSON Agent(extractAgent / clarifyAgent)不对外流式显示
  for await (const chunk of extractStream) {
    extractRaw += chunk;  // 静默收集,不 yield
  }

  // Phase 2: Markdown Agent(analysisAgent / riskAgent / summaryAgent)逐 Token 推送
  const analysisStream = await agents.analysis.stream(...);
  for await (const chunk of analysisStream) {
    analysisResult += content;
    yield { type: 'token', content, agent: 'analysisAgent' };
  }

  // Phase 3: UI 组件整包返回(必须等完整数据)
  yield {
    type: 'final',
    result: {
      responseType: 'ui',
      uiResponse: selectionResponse,
    },
  };
}

取舍理由

③ SSE 流式传输(conversation.controller.ts

用 Server-Sent Events 作为长连接载体,一个连接上混传 markdown / ui / progress / done 多种消息类型。

// services/chat/src/conversation/conversation.controller.ts
res.flushHeaders();  // 立即建立流式连接
let persistedContent = '';

for await (const event of stream) {
  switch (event.type) {
    case 'token': {
      const chunk: StreamMessage = {
        messageType: 'markdown',
        timestamp: new Date().toISOString(),
        payload: { content: event.content, isChunk: true },
      };
      res.write(formatSSE(chunk));
      persistedContent += event.content;  // 累积完整内容,用于最终持久化
      break;
    }
    case 'agent_start': {
      const progress: StreamMessage = {
        messageType: 'progress',
        timestamp: new Date().toISOString(),
        payload: { agent, step, totalSteps, status: 'started' },
      };
      res.write(formatSSE(progress));
      break;
    }
    case 'final': {
      if (event.result.responseType === 'ui') {
        const uiMessage: StreamMessage = {
          messageType: 'ui',
          timestamp: new Date().toISOString(),
          payload: {
            messageId,
            components: event.result.uiResponse.messages,
            thinking: event.result.uiResponse.thinking,
          },
        };
        res.write(formatSSE(uiMessage));
      }
      break;
    }
  }
}
// 流结束后一次性落库,避免写入半截数据
await saveMessage({ content: persistedContent, ... });

这一层带来的变化

④ 前端状态管理(ai-ui.store.ts

Zustand store 把流式消息已完成消息分开存,避免频繁修改 messages 数组导致整个列表重渲染。

// clients/chat-web/src/stores/ai-ui.store.ts
updateStreamingMessage: (content, uiResponse) => set((state) => {
  const existing = state.streamingMessage || {
    id: `temp-${Date.now()}`,
    role: 'assistant',
    content: '',
    timestamp: new Date(),
    isStreaming: true,
    messageType: 'markdown',
  };

  return {
    streamingMessage: {
      ...existing,
      content: existing.content + content,                      // Markdown 增量累积
      uiResponse: uiResponse || existing.uiResponse,            // UI 组件覆盖写入
      messageType: uiResponse ? 'ui' : existing.messageType,    // 动态切换类型
    },
    isStreaming: true,
  };
}),

体验变化

⑤ 前端流式渲染(ChatView.tsx

使用 @microsoft/fetch-event-source 解析 SSE,根据 messageType 分发到不同 store action。

// clients/chat-web/src/views/ChatView.tsx
await fetchEventSource(`${CHAT_API_URL}/api/conversations/${activeSessionId}/chat`, {
  onmessage(event) {
    const msg = JSON.parse(event.data) as StreamMessage;

    switch (msg.messageType) {
      case 'markdown': {
        const payload = msg.payload as MarkdownPayload;
        appendToLastAssistantMessage(activeSessionId, payload.content);
        updateStreamingMessage(payload.content);  // 逐 Token 更新
        break;
      }
      case 'ui': {
        const uiPayload = msg.payload as UIPayload;
        updateStreamingMessage('', {
          messages: uiPayload.components,         // 完整组件批量渲染
          thinking: uiPayload.thinking,
        });
        break;
      }
      case 'progress': {
        const p = msg.payload as ProgressPayload;
        setProgress({ agent: p.agent, step: p.step, totalSteps: p.totalSteps, status: p.status });
        break;
      }
      case 'done':
        finalizeAIUIStreaming();  // 把 streamingMessage 合并进 messages
        break;
    }
  },
  onerror(err) {
    // 优雅降级:关闭流、提示用户重试
  },
});

这一层带来的变化

⑥ 进度指示器(ThinkingIndicator.tsx

进度条是把"多 Agent 流水线"这件事可视化给用户看的关键组件。

image 12.png

// clients/chat-web/src/components/ai-ui/ThinkingIndicator.tsx
export function ThinkingIndicator({ progress }) {
  const percentage = progress ? (progress.step / progress.totalSteps) * 100 : 0;

  return (
    <div>
      {/* 动态显示当前 Agent 名称 */}
      <div>{progress ? progress.agentDisplayName : 'AI 正在思考中'}</div>

      {/* 进度条 */}
      {progress && (
        <div style={{ width: `${percentage}%` }}>
          {/* 渐变动画 + 脉冲效果 */}
        </div>
      )}

      {/* 百分比徽章 */}
      <div>{Math.round(percentage)}%</div>
    </div>
  );
}

体验变化

优化前 vs 优化后

阶段 ❌ 优化前(一次性返回) ✅ 优化后(分段流式)
发送消息后的 0~5 秒 黑盒等待,无任何反馈 进度条 0%,“需求提取中…”
5~15 秒 继续等待 进度条推进到 40%,Markdown 逐字输出
15~30 秒 继续等待 80% → 100%,UI 组件整包渲染,可交互
用户感受 😰 卡住了?崩溃了?要等多久? ✅ 看得到进度、看得到内容、能马上操作

关键设计取舍

维度 设计决策 理由
JSON Agent 不流式显示,静默收集 中间 JSON 不可读、不可解析,暴露反而降低专业感
Markdown Agent 逐 Token 流式 提供即时反馈,显著降低感知延迟
UI 组件 整包批量渲染 组件需完整数据(如表单字段、选项列表)才能正常工作
进度通知 5 步 Pipeline 事件 把多 Agent 链路的"黑盒"可视化,增强透明度
持久化时机 流式结束后一次性写入 避免半截数据污染数据库,同时减少写入次数

性能优化亮点

  1. 前端状态分离streamingMessagemessages 解耦,避免每个 Token 都触发整个消息列表的重渲染
  2. 后端流式缓冲persistedContent 累积完整内容后一次性落库,减少数据库写入次数
  3. SSE 长连接复用:单个连接承载 markdown / ui / progress / meta 多种消息类型,免去多次 HTTP 握手开销
  4. 组件交互状态记录interactionState 记录用户已提交的操作,自动禁用对应组件,防止重复提交导致的数据一致性问题

6.6 本章小结

generated-image-1776441066478.png

这一章围绕同一个核心目标——让 AI 的输出从纯文本升级为可交互的 UI 指令——把第五章已经落好的工程能力,进一步转化成了用户真正能感知、能操作、能完成任务的交互体验:

本章的核心收获

写在最后🧪

这里是言萧凡的 AI 编程实验室。 我会在这里持续记录和分享 AI 工具、编程实践,以及那些值得沉淀下来的高效工作方法。 不只聊概念,也尽量分享能直接上手、能够复用的经验。 希望这间小小的实验室,能陪你一起探索、实践和成长。2026 年,一起进步。

有兴趣的话可以添加我的微信号一起交流,不仅是编程也可以是畅谈人生。