当前为游客模式。公开章节: 00, 01 。关注「公众号」后可解锁: 03, 04 。其余章节仍需登录。
目录导航

02 渲染第一个节点组件

这一篇只关心前端交互:

  1. 调 A2A
  2. 收事件
  3. 渲染一个最小组件

本文内容对照提交:

  • data-agent-frontend/src/App.vue
  • data-agent-frontend/src/components/toy-hello-node.vue

1. 本文目标

  • 前端通过 Agent Card 建立 A2A 客户端
  • 发送一条消息并消费流式事件
  • TOY_HELLO_NODE 渲染为一个独立 Vue 组件

2. 最小数据结构

建议先用最简单的消息结构:

interface ToyStep {
  id: string
  name: string
  content: string
  status: 'pending' | 'success'
}

提交里实际就是用这个结构的一个 reactive 对象来保存当前步骤:

const currentStep = reactive<ToyStep>({
  content: '',
  id: '',
  name: '',
  status: 'pending',
})

只保留 UI 渲染必需字段,这样第一版不会被复杂状态拖住。

3. 建立 A2A 客户端并发送

当前提交不是 createFromUrl(...),而是先通过项目里的 api.a2acontroller.agentJson() 拿到 Agent Card,再用 createFromAgentCard(...) 创建客户端:

const factory = new ClientFactory()
client.value = await factory.createFromAgentCard(
  (await api.a2acontroller.agentJson()) as AgentCard,
)

这样做的好处是:

  1. Agent Card 获取走现有前端 API 封装
  2. 不需要手写发现 URL 拼接
  3. 前后端联调时更容易统一代理路径

发送消息的代码则和最小示例一致:

const stream = client.sendMessageStream({
  message: {
    messageId: crypto.randomUUID(),
    role: 'user',
    kind: 'message',
    parts: [{ kind: 'text', text: userInput.value }],
  },
})

这里第一版只传一个 text part,后面接 SQL Agent 或 Graph 的时候再扩展 message parts。

4. 消费流式事件

核心只处理两类:

  1. artifact-update:更新步骤内容
  2. status-update(completed):结束 loading

示例:

for await (const event of stream) {
  if (event.kind === 'artifact-update') {
    const artifact = event.artifact
    if (artifact.name !== 'TOY_HELLO_NODE') continue
    const textPart = artifact.parts.find((p) => p.kind === 'text')
    if (textPart) {
      currentStep.name = artifact.name
      currentStep.content += textPart.text
      currentStep.status = 'pending'
    }
  }
  if (event.kind === 'status-update' && event.status.state === 'completed') {
    currentStep.status = 'success'
  }
}

这里和提交保持一致,有两个值得注意的点:

  1. currentStep.name = artifact.name

这是后面模板里 v-if="currentStep.name === 'TOY_HELLO_NODE'" 的前提,不写这句页面不会渲染节点组件。

  1. currentStep.content += textPart.text

这里故意用累加而不是覆盖,是在给后续真正的流式增量输出留接口。现在 toy demo 可能只来一段文本,但改成逐 token 或逐片段推送时,这段代码不用改。

如果你想更稳一点,发送前可以顺手重置状态:

currentStep.name = ''
currentStep.content = ''
currentStep.status = 'pending'

这样连续发送多轮消息时不会把上一轮内容混进来。

5. 渲染第一个组件

新增组件 toy-hello-node.vue

<script setup lang="ts">
defineProps<{ content: string; status: 'pending' | 'success' }>()
</script>

<template>
  <div class="toy-node">
    <div class="toy-node__title">Toy Hello Node</div>
    <div class="toy-node__content">{{ content || '等待数据...' }}</div>
    <div class="toy-node__status">{{ status }}</div>
  </div>
</template>

父组件里实际是按“当前步骤名是否匹配”来渲染:

<toy-hello-node
  v-if="currentStep.name === 'TOY_HELLO_NODE'"
  :content="currentStep.content"
  :status="currentStep.status"
/>

这就是为什么后端和前端都要统一使用 TOY_HELLO_NODE 这个名字。
这个节点名本质上就是第一版 UI 路由键。

当前提交里的完整页面骨架其实非常短:

<template>
  <div>
    <toy-hello-node
      v-if="currentStep.name === 'TOY_HELLO_NODE'"
      :content="currentStep.content"
      :status="currentStep.status"
    />
    <el-input
      v-model="userInput"
      placeholder="请输入内容"
      @keydown.enter="handleSend"
    />
  </div>
</template>

这版的重点不是样式,而是先证明:

A2A 事件 -> 前端状态 -> Vue 组件渲染

这条链路已经通了。

6. 验收标准

满足以下 3 条即完成:

  1. 输入一句话后,页面出现 Toy Hello Node
  2. 组件里能看到后端返回文本
  3. 请求结束后状态从 pending 变成 success

7. 常见问题

1) 页面没有任何节点

  • artifact.name 与前端判断不一致
  • 流里没有命中 artifact-update
  • currentStep.name 没有在事件处理中赋值

2) 一直 pending

  • 没收到 status-update(completed)
  • 事件循环异常提前结束
  • 你引入了 loading 状态,但完成时没有同步关闭

3) 文本为空

  • 后端 artifact 不是 text part
  • 前端读取 part 时过滤条件写错
  • 发送前没有重置 currentStep.content,导致你误判了本轮结果