对应代码:
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:
ts
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') // SSE 以空行分隔
buffer = parts.pop() ?? '' // 保留不完整的尾巴
for (const part of parts) {
if (!part.trim().startsWith('data:')) continue
try { yield JSON.parse(part.trim().slice(5)) } catch {}
}
}
}
两个关键点:
decoder.decode(value, { stream: true })------ 处理 UTF-8 中文被分包截断buffer = parts.pop()------ 处理 TCP 分包导致的半个事件
三、状态设计(Pinia)
ts
state: {
threadId, messages[], steps[], loading, engine, tokens, iterations
}
steps[] 就是执行链路时间线的数据源,由事件流驱动:
ts
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. 打字机 + 自动滚动
ts
watch(
() => store.messages.map(m => m.content).join('').length,
() => nextTick(() => { listRef.value.scrollTop = listRef.value.scrollHeight })
)
2. 执行链路时间线(可观测的前端呈现)
vue
<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,展示来源文档和章节:
vue
<a-tag v-for="c in msg.citations" :key="c.id" color="blue">
[{{ c.id }}] {{ c.title }} · {{ c.heading }}
</a-tag>
4. 人工确认卡片(HITL)
vue
<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":
ini
[1] 缺陷分级与处理规范 · 缺陷等级定义
score=0.42 vector=0.31 bm25=8.7
P0 | 核心功能不可用... | 15 分钟 | 4 小时
同时展示融合分 / 向量分 / BM25 分,调参时能一眼看出: "这条是被向量召回的还是被关键词捞上来的"。
六、代理配置
ts
server: {
port: 5173,
proxy: { '/api': { target: 'http://localhost:8000', changeOrigin: true } }
}
想连 Java 服务,把 target 改成 http://localhost:8081 即可------ 同一个前端,两个后端,这也验证了接口契约一致。
七、构建产物
bash
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,所以只启动后端也能访问完整页面。