中文资讯 掘金 AI Wed, 09 Sep 2026 正文 ok 556 words

08 · 前端:Vue 3 + SSE 流式与执行链路可视化

英文标题:08 · 前端:Vue3 + SSE 流式与执行链路可视化

前端虽然常被 Agent 项目忽视,却直接决定用户是否信任并愿意使用系统。本文采用 Vue 3、TypeScript、Vite、Pinia 和 Ant Design Vue,并以原生 fetch + ReadableStream 支持携带 POST 请求体的 SSE 流式对话。通过处理 UTF-8 分包与 TCP 半包问题,前端可稳定消费 Agent 事件流,并将规划、工具调用、反思和人工确认等过程可视化。文章还介绍了引用溯源、可观测 RAG 调试面板、后端代理切换及构建产物优化,对构建企业级 Agent 交互界面具有参考价值。
打开原文相关 5 · 机会 3
media3.2 KB media589 B media8.9 KB

中文

对应代码:web/src/**

前端是很多 Agent 项目被忽略的一环,但它决定了用户敢不敢用。

一、技术选型

技术 理由
Vue 3 + TypeScript 主栈,组合式 API
Vite 秒级启动
Pinia 状态管理(比 Vuex 更轻量)
Ant Design Vue 组件齐全,企业风格
原生 fetch + ReadableStream SSE(见下文)

二、为什么不用 EventSource

EventSource 只支持 GET,而对话需要通过 POST 携带较长的问题文本和 thread_id。

因此使用 fetch + ReadableStream 手动解析 SSE:

export async function* streamChat(payload: ChatPayload): AsyncGenerator<AgentEvent> {
  const resp = await fetch('/api/chat', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(payload)
  })
  const reader = resp.body!.getReader()
  const decoder = new TextDecoder('utf-8')
  let buffer = ''
  while (true) {
    const { value, done } = await reader.read()
    if (done) break
    buffer += decoder.decode(value, { stream: true })
    const parts = buffer.split('\n\n')      
    buffer = parts.pop() ?? ''              
    for (const part of parts) {
      if (!part.trim().startsWith('data:')) continue
      try { yield JSON.parse(part.trim().slice(5)) } catch {}
    }
  }
}

两个关键点:

  1. decoder.decode(value, { stream: true }) —— 处理 UTF-8 中文字符被分包截断的问题。
  2. buffer = parts.pop() —— 处理 TCP 分包导致只收到半个事件的问题。

三、状态设计(Pinia)

state: {
  threadId, messages[], steps[], loading, engine, tokens, iterations
}

steps[] 是执行链路时间线的数据源,由事件流驱动:

consume(ev: AgentEvent, answerId: string) {
  switch (ev.type) {
    case 'token':      this.append(answerId, ev.delta); break      
    case 'plan':       this.steps.push({ label: '规划', detail: 'sql_query → send_alert' }); break
    case 'tool_start': this.steps.push({ label: `调用工具:${ev.tool}` }); break
    case 'tool_end':   last.ok = ev.ok; last.detail += `\n结果:${ev.output}`; break
    case 'reflect':    this.steps.push({ label: '反思', detail: `信息是否足够:是/否` }); break
    case 'hitl':       msg.pendingHITL = ev.pending; break          
    case 'final':      msg.citations = ev.citations; break
  }
}

四、三个交互设计

1. 打字机效果 + 自动滚动

watch(
  () => store.messages.map(m => m.content).join('').length,
  () => nextTick(() => { listRef.value.scrollTop = listRef.value.scrollHeight })
)

2. 执行链路时间线(可观测的前端呈现)

<a-timeline>
  <a-timeline-item v-for="s in store.steps" :color="colorOf(s.type, s.ok)">
    <div>{{ s.label }}</div>
    <div>{{ s.detail }}</div>
  </a-timeline-item>
</a-timeline>

颜色语义:规划为蓝色,工具成功为绿色,工具失败为红色,反思为灰色。

为什么重要:企业用户看到“它在查数据库、它查到了 3 行、它在核对”,才会信任最终答案。黑盒输出 = 不敢用。

3. 引用溯源

答案中的 [1] [2] 会渲染为 Tag,展示来源文档及其章节:

<a-tag v-for="c in msg.citations" :key="c.id" color="blue">
  [{{ c.id }}] {{ c.title }} · {{ c.heading }}
</a-tag>

4. 人工确认卡片(HITL)

<a-alert type="warning" :message="`高风险操作待确认:${msg.pendingHITL.tool}`"
         :description="JSON.stringify(msg.pendingHITL.args)">
  <template #action>
    <a-button type="primary" @click="emit('approve')">确认执行</a-button>
  </template>
</a-alert>

点击后携带 approved=true 重新发起请求,后端才会真正执行操作。

五、知识库调试面板

左侧面板刻意设计为“可观测 RAG”:

[1] 缺陷分级与处理规范 · 缺陷等级定义
    score=0.42  vector=0.31  bm25=8.7
    P0 | 核心功能不可用… | 15 分钟 | 4 小时

同时展示融合分 / 向量分 / BM25 分,调参时可以一眼看出:“这条内容是被向量召回的,还是被关键词检索出来的”。

六、代理配置

server: {
  port: 5173,
  proxy: { '/api': { target: 'http://localhost:8000', changeOrigin: true } }
}

如需连接 Java 服务,只需将 target 改为 http://localhost:8081——同一个前端,两个后端,这也验证了接口契约的一致性。

七、构建产物

npm run build
✓ 3179 modules transformed
dist/index.html                 0.40 kB
dist/assets/index-*.css         4.67 kB │ gzip:  1.80 kB
dist/assets/index-*.js      1,510.09 kB │ gzip: 466.24 kB

(1.5MB 主要来自 ant-design-vue 的全量引入;生产环境可以通过按需引入和分包进行优化。)

Python 后端会自动托管 web/dist,因此即使只启动后端,也能访问完整页面。


下一篇:09 · Java 双栈:Spring Boot + LangChain4j

English / 原文

对应代码:web/src/**

前端是很多 Agent 项目被忽略的一环,但它决定了用户敢不敢用 。

一、技术选型

技术 理由
Vue 3 + TypeScript 主栈,组合式 API
Vite 秒级启动
Pinia 状态管理(比 Vuex 轻)
Ant Design Vue 组件齐全,企业风格
原生 fetch + ReadableStream SSE(见下文)

二、为什么不用 EventSource

EventSource 只支持 GET ,而对话需要 POST 携带较长的问题文本和 thread_id。

所以用 fetch + ReadableStream 手动解析 SSE:

export async function* streamChat(payload: ChatPayload): AsyncGenerator<AgentEvent> {
  const resp = await fetch('/api/chat', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(payload)
  })
  const reader = resp.body!.getReader()
  const decoder = new TextDecoder('utf-8')
  let buffer = ''
  while (true) {
    const { value, done } = await reader.read()
    if (done) break
    buffer += decoder.decode(value, { stream: true })
    const parts = buffer.split('\n\n')      
    buffer = parts.pop() ?? ''              
    for (const part of parts) {
      if (!part.trim().startsWith('data:')) continue
      try { yield JSON.parse(part.trim().slice(5)) } catch {}
    }
  }
}

两个关键点 :

  1. decoder.decode(value, { stream: true }) —— 处理 UTF-8 中文被分包截断
  2. buffer = parts.pop() —— 处理 TCP 分包导致的半个事件

三、状态设计(Pinia)

state: {
  threadId, messages[], steps[], loading, engine, tokens, iterations
}

steps[] 就是执行链路时间线的数据源,由事件流驱动:

consume(ev: AgentEvent, answerId: string) {
  switch (ev.type) {
    case 'token':      this.append(answerId, ev.delta); break      
    case 'plan':       this.steps.push({ label: '规划', detail: 'sql_query → send_alert' }); break
    case 'tool_start': this.steps.push({ label: `调用工具:${ev.tool}` }); break
    case 'tool_end':   last.ok = ev.ok; last.detail += `\n结果:${ev.output}`; break
    case 'reflect':    this.steps.push({ label: '反思', detail: `信息是否足够:是/否` }); break
    case 'hitl':       msg.pendingHITL = ev.pending; break          
    case 'final':      msg.citations = ev.citations; break
  }
}

四、三个交互设计

1. 打字机 + 自动滚动

watch(
  () => store.messages.map(m => m.content).join('').length,
  () => nextTick(() => { listRef.value.scrollTop = listRef.value.scrollHeight })
)

2. 执行链路时间线(可观测的前端呈现)

<a-timeline>
  <a-timeline-item v-for="s in store.steps" :color="colorOf(s.type, s.ok)">
    <div>{{ s.label }}</div>
    <div>{{ s.detail }}</div>
  </a-timeline-item>
</a-timeline>

颜色语义:规划=蓝,工具成功=绿,工具失败=红,反思=灰。

为什么重要 :企业用户看到"它在查数据库、它查到了 3 行、它在核对", 才会信任最终答案。黑盒输出 = 不敢用。

3. 引用溯源

答案里的 [1] [2] 渲染成 Tag,展示来源文档和章节:

<a-tag v-for="c in msg.citations" :key="c.id" color="blue">
  [{{ c.id }}] {{ c.title }} · {{ c.heading }}
</a-tag>

4. 人工确认卡片(HITL)

<a-alert type="warning" :message="`高风险操作待确认:${msg.pendingHITL.tool}`"
         :description="JSON.stringify(msg.pendingHITL.args)">
  <template #action>
    <a-button type="primary" @click="emit('approve')">确认执行</a-button>
  </template>
</a-alert>

点击后带 approved=true 重新请求,后端才真正执行。

五、知识库调试面板

左侧面板故意做成了"可观测 RAG ":

[1] 缺陷分级与处理规范 · 缺陷等级定义
    score=0.42  vector=0.31  bm25=8.7
    P0 | 核心功能不可用… | 15 分钟 | 4 小时

同时展示融合分 / 向量分 / BM25 分 ,调参时能一眼看出: "这条是被向量召回的还是被关键词捞上来的"。

六、代理配置

server: {
  port: 5173,
  proxy: { '/api': { target: 'http://localhost:8000', changeOrigin: true } }
}

想连 Java 服务,把 target 改成 http://localhost:8081 即可—— 同一个前端,两个后端 ,这也验证了接口契约一致。

七、构建产物

npm run build
✓ 3179 modules transformed
dist/index.html                 0.40 kB
dist/assets/index-*.css         4.67 kB │ gzip:  1.80 kB
dist/assets/index-*.js      1,510.09 kB │ gzip: 466.24 kB

(1.5MB 主要是 ant-design-vue 全量引入,生产可按需引入 + 分包优化)

Python 后端会自动托管 web/dist,所以只启动后端也能访问完整页面 。


下一篇:09 · Java 双栈:Spring Boot + LangChain4j