分享臭臭🐈最近做的一个小项目:让多智能体协作的任务有「可视化界面」,不仅能看,还能玩,还能复用!
🎯 项目初衷
平时做工具或者多智能体系统,总遇到这些问题:
- 用户输入了指令,但任务拆解很乱 → 不好追踪
- 多步骤结果堆在一起 → 完全看不懂谁做了什么
- 历史记录零散 → 回溯超级麻烦
💡 臭臭🐈想做一个界面,让它可以:
- 自动把用户的 prompt 拆成多个SubAgent
- 每条子任务的执行状态、结果都能流式展示
- 折叠卡片、点赞、置顶、复制...随便加
- 历史记录完整展示,回溯轻松
- 新工具来了,前端不用动,只要生成 SubAgent 对象
📝 数据结构设计
核心思路:统一 Task 会话 → SubAgent 步骤 协议,每个 SubAgent 都是可复用的最小单元
TS
interface SubAgent {
id: string; // id
name: string; // 调用工具名称
status: 'pending' | 'running' | 'success' | 'failed'; // 步骤状态
output?: string; // AI 输出
}
interface Task {
id: string; // 会话 id
prompt: string; // user prompt
subAgents: SubAgent[]; // subAgent 列表
interactions?: { like: number; dislike: number }; // 会话点赞点踩
}
核心思想:面板对应 一个 Task → 多个 SubAgent → 可复用 UI render
⚡ 核心功能模块
1️⃣ Timeline 步骤条
- 当前步骤高亮
- 完成 ✅ / 失败 ❌
- 可扩展更多状态(暂停/重试)
2️⃣ 中间结果展示
- 每个子任务输出单独折叠卡片
- 自动折叠完成步骤
- 点击展开详细结果
3️⃣ 历史记录回放
- Task 完成后存入历史记录
- 按 Task → SubAgent 渲染
- 上下文完整、连续
4️⃣ 用户交互
- 点赞 / 点踩 / 置顶 / 复制
🔄 数据流可视化
scss
用户输入 prompt
│
▼
Task 创建 (Task 对象)
│
▼
LLM 拆解任务 → 多子任务 (SubAgent)
│
▼
前端组装 SubAgent
├─ 输出折叠卡片
├─ 状态更新 (running → success/failed)
└─ 自动折叠完成步骤
│
▼
面板渲染
├─ Timeline
├─ 中间结果卡片
└─ 历史记录
│
▼
用户交互 (点赞/复制/置顶)
SubAgent 结构统一,前端不管新工具还是新任务,都能直接渲染
💻 核心代码示例
后端模拟 WebSocket 执行任务
TS
const express = require('express');
const http = require('http');
const WebSocket = require('ws');
const { v4: uuidv4 } = require('uuid');
const app = express();
const server = http.createServer(app);
const wss = new WebSocket.Server({ server });
// 模拟旅游能力指令列表
const travelAbilities = ['/bookFlight', '/planItinerary', '/bookHotel', '/recommendRestaurant'];
const ABILITY_TITLE = {
'/bookFlight': '购票',
'/planItinerary': '行程规划',
'/bookHotel': '酒店预订',
'/recommendRestaurant': '美食推荐',
};
function getAbilityTitle(name) {
return ABILITY_TITLE[name] || '能力任务';
}
// 生成步骤
function generateSteps(prompt) {
return travelAbilities.map((cmd, idx) => ({
id: `${idx + 1}`,
name: cmd,
status: 'pending',
input: prompt,
}));
}
function getStepOutput(step) {
switch (step.name) {
case '/bookFlight':
return [
`【${getAbilityTitle(step.name)}】根据您提交的需求「${step.input}」,已比对多条航线与含税总价,并完成出票前的座位与行李额度确认。`,
'',
'**预订摘要**',
'- 去程:建议早班直飞,可减少中转延误风险;若预算敏感,可选傍晚经停航班,通常便宜 8%~15%。',
'- 返程:优先与去程同一联盟航司,便于累计里程与统一改签规则。',
'- 行李:经济舱默认 23kg×1;如需冲浪板/雪具,请提前 24h 在航司官网申报尺寸。',
'',
'**出行提示**',
'行前请核对护照有效期(通常需返程后仍有 6 个月以上)、签证/过境政策与航班动态。若遇航变,系统会推送备选方案;您也可在「我的订单」里一键改签或退票(以票规为准)。',
'',
'祝旅途顺利。如需升舱、选座或接送机,我可以继续为您安排。',
].join('\n');
case '/planItinerary':
return [
`【${getAbilityTitle(step.name)}】结合您的偏好与约束「${step.input}」,下面是一份可执行的行程草案(可按体力与天气微调)。`,
'',
'**第 1 天|抵达与市区轻量级熟悉**',
'中午前后落地后,先放行李、简短休息。下午安排 2~3 小时的步行半径活动:地标拍照 + 本地咖啡/书店,避免第一天过度暴走。晚餐选人气小店但更建议错峰 30 分钟到店。',
'',
'**第 2 天|主题深度游**',
'上午预留核心景点(尽早入园可明显少排队)。午餐就近简餐。下午安排一个「动线连贯」的区域:博物馆/滨水步道/市集三选二,控制在 12k 步以内。晚上可预约观景位或轻酒吧,作为行程高光。',
'',
'**第 3 天|近郊或一日往返**',
'若天气好:近郊自然风光或古镇一日;若下雨:改为室内展览 + 购物 + spa。回程预留宽裕时间,避免误机/误车。',
'',
'**实用清单**',
'交通卡/电子票提前绑定;充电宝与舒适鞋必备;把酒店地址存成离线地图截图。需要我把每日时间点、预估花费与订位链接整理成表格吗?',
].join('\n');
case '/bookHotel':
return [
`【${getAbilityTitle(step.name)}】已根据「${step.input}」筛选出区位、评分与价格带都更稳的几家,并完成占房与价格锁定(以最终确认号为准)。`,
'',
'**推荐入住策略**',
'- 若行程以徒步/赶早班为主:优先「离枢纽近 + 早餐好」;若以休闲为主:优先「景观/泳池/安静街区」。',
'- 入住高峰季建议选可免费取消的弹性价,便于后续改期;淡季可锁定最优预付价。',
'',
'**酒店侧注意事项**',
'到店时间若晚于 22:00,请提前备注或电话确认保留;需要婴儿床/加床/高楼层/远离电梯,也建议在订单里写明。',
'',
'**周边配套**',
'步行 10 分钟内通常能覆盖便利店、药店与地铁入口;我把「夜宵清单」和「最近医院/派出所」也附在确认函备注里,需要的话我可以继续发您。',
].join('\n');
case '/recommendRestaurant':
return [
`【${getAbilityTitle(step.name)}】围绕「${step.input}」,我从口碑稳定性、排队成本、预算区间三个维度挑了一组餐厅(含备选)。`,
'',
'**A 店|稳妥之选(适合家庭/商务)**',
'菜品出品稳定、服务节奏可控;建议提前预约窗边或半包。招牌菜可让店员按人数配一份「轻量版」,避免浪费。',
'',
'**B 店|本地味更浓(适合想吃点特色)**',
'高峰段可能需要等位;错峰 11:30 前或 13:30 后体验更好。若对辣度/忌口敏感,下单时强调「少油少盐」。',
'',
'**C 店|氛围感强(适合纪念日/拍照)**',
'座位紧俏,建议锁定时段并确认是否有最低消费。酒水单可能比菜价更「惊喜」,可先问今日杯卖推荐。',
'',
'需要我生成「人均预算 + 必点 + 避坑」对照表,或直接尝试帮订位吗?',
].join('\n');
default:
return [
`【${getAbilityTitle(step.name)}】已处理您的请求「${step.input}」。`,
'',
'本次能力节点的输出为占位说明:如果您在客户端新增了更多子能力,请在服务端为对应 `name` 分支补充更贴近业务的 mock 文本,以便联调流式展示与断线重连等场景。',
'',
'如需我扩展步骤列表、增加失败重试或并发子任务,也可以继续说明您的协议字段,我会对齐数据结构一起改。',
].join('\n');
}
}
/** 模拟 Cursor / IDE 助手:逐段渐显,标点与换行处停顿略长 */
const STREAM = {
baseDelayMin: 14,
baseDelayMax: 48,
punctExtraMin: 90,
punctExtraMax: 220,
newlineExtraMin: 120,
newlineExtraMax: 320,
/** 合并进同一条 update 的最小间隔,避免每字一条 update */
updateMinIntervalMs: 32,
/** 距离上次 update 至少累计这么多字再补发一条 */
updateEveryChars: 5,
};
/**
* 流式输出单步正文:通过 stream_delta 增量 + 节流 update(step.output 逐渐变长)
*/
async function streamStepContent(ws, step, fullText) {
if (ws.readyState !== WebSocket.OPEN) return;
step.output = '';
ws.send(JSON.stringify({ type: 'stream_start', stepId: step.id }));
await new Promise((r) => setTimeout(r, 80 + Math.random() * 120));
const chars = Array.from(fullText);
let lastUpdateAt = Date.now();
let charsSinceUpdate = 0;
const sendStepProgress = () => {
if (ws.readyState !== WebSocket.OPEN) return;
ws.send(
JSON.stringify({
type: 'update',
step: { ...step, status: 'running', streaming: true },
})
);
lastUpdateAt = Date.now();
charsSinceUpdate = 0;
};
for (let i = 0; i < chars.length; i += 1) {
if (ws.readyState !== WebSocket.OPEN) break;
const ch = chars[i];
step.output += ch;
ws.send(JSON.stringify({ type: 'stream_delta', stepId: step.id, delta: ch }));
charsSinceUpdate += 1;
const now = Date.now();
if (
charsSinceUpdate >= STREAM.updateEveryChars ||
now - lastUpdateAt >= STREAM.updateMinIntervalMs
) {
sendStepProgress();
}
let delay =
STREAM.baseDelayMin + Math.random() * (STREAM.baseDelayMax - STREAM.baseDelayMin);
if (/[。!?;,、:]/.test(ch)) {
delay += STREAM.punctExtraMin + Math.random() * (STREAM.punctExtraMax - STREAM.punctExtraMin);
}
if (ch === '\n') {
delay += STREAM.newlineExtraMin + Math.random() * (STREAM.newlineExtraMax - STREAM.newlineExtraMin);
}
if (Math.random() < 0.06) {
delay += 40 + Math.random() * 100;
}
await new Promise((r) => setTimeout(r, delay));
}
if (ws.readyState !== WebSocket.OPEN) return;
if (charsSinceUpdate > 0) {
sendStepProgress();
}
ws.send(JSON.stringify({ type: 'stream_end', stepId: step.id }));
}
// 模拟执行步骤
async function executeTask(task, ws) {
for (const step of task.steps) {
step.status = 'running';
step.output = '';
ws.send(JSON.stringify({ type: 'update', step: { ...step, streaming: true } }));
await new Promise((res) =>
setTimeout(res, 500 + Math.random() * 500)
);
const fullText = getStepOutput(step);
await streamStepContent(ws, step, fullText);
if (ws.readyState !== WebSocket.OPEN) break;
step.status = 'success';
step.output = fullText;
ws.send(JSON.stringify({ type: 'update', step: { ...step, streaming: false } }));
}
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'complete', task }));
}
}
wss.on('connection', (ws) => {
console.log('客户端已连接');
ws.on('message', (message) => {
const prompt = message.toString();
const task = { id: uuidv4(), prompt, steps: generateSteps(prompt) };
executeTask(task, ws);
});
});
server.listen(4000, () => {
console.log('WebSocket 后端运行在 ws://localhost:4000');
});
前端组装 SubAgent
1️⃣ ws监听
TS
useEffect(() => {
if (!wsRef.current) {
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onmessage = (event) => {
const msg = JSON.parse(event.data) as
| { type: 'update'; step: Step }
| { type: 'complete'; task: TaskModel }
| { type: 'chunk'; stepId: string; chunk: string; name?: string }
| { type: 'step_done'; stepId: string; status?: Step['status']; name?: string };
if (msg.type === 'chunk') {
dispatch(appendStepChunk({ id: msg.stepId, chunk: msg.chunk, name: msg.name }));
} else if (msg.type === 'step_done') {
dispatch(
finalizeStepStream({
id: msg.stepId,
status: msg.status ?? 'success',
name: msg.name,
})
);
} else if (msg.type === 'update') {
dispatch(updateStep(msg.step));
} else if (msg.type === 'complete') {
dispatch(setTask(msg.task));
}
};
}
}, [dispatch]);
2️⃣ 更新AI输出
TS
const taskSlice = createSlice({
name: 'task',
initialState,
reducers: {
beginGeneration(state, action: PayloadAction<{ prompt: string }>) {
const { prompt } = action.payload;
state.ui.lastUserPrompt = prompt;
state.ui.isThinking = true;
state.task = {
id: `task-${Date.now()}`,
prompt,
steps: [],
};
},
setTask(state, action: PayloadAction<Task>) {
state.task = action.payload;
state.ui.isThinking = false;
},
updateStep(state, action: PayloadAction<Step>) {
if (!state.task) return;
const payload = action.payload;
const idx = state.task.steps.findIndex((s) => s.id === payload.id);
if (idx >= 0) state.task.steps[idx] = payload;
else state.task.steps.push(payload);
state.ui.isThinking = false;
},
appendStepChunk(state, action: PayloadAction<{ id: string; name?: string; chunk: string }>) {
if (!state.task) return;
const { id, chunk, name } = action.payload;
let step = state.task.steps.find((s) => s.id === id);
if (!step) {
step = {
id,
name: name ?? '生成中',
status: 'running',
output: '',
};
state.task.steps.push(step);
}
step.output = (step.output || '') + chunk;
step.status = 'running';
state.ui.isThinking = false;
},
finalizeStepStream(
state,
action: PayloadAction<{ id: string; status?: Step['status']; name?: string }>
) {
if (!state.task) return;
const { id, status = 'success', name } = action.payload;
const step = state.task.steps.find((s) => s.id === id);
if (step) {
step.status = status;
if (name) step.name = name;
}
},
feedbackTaskLike(state) {
if (!state.task) return;
const cur = state.task.interactions ?? { like: 0, dislike: 0 };
state.task.interactions = { ...cur, like: cur.like + 1 };
},
feedbackTaskDislike(state) {
if (!state.task) return;
const cur = state.task.interactions ?? { like: 0, dislike: 0 };
state.task.interactions = { ...cur, dislike: cur.dislike + 1 };
},
},
});
🧩 难点 & 解决方案
| 难点 | 解决方案 |
|---|---|
| 指令拆分不明确 | 前端封装 SubAgent 对象,每条完整问答一个 SubAgent |
| 多指令回答展示混乱 | 折叠卡片,自动收起完成步骤 |
| 历史记录碎片化 | Task 作为完整记录 → SubAgent 全部包含 |
| 大量步骤性能 | 虚拟列表 + 局部渲染 + 流式节流 |
🔄 可复用设计理念
- 统一 Task → SubAgent 协议
- 面板组件复用:Timeline / StepCard / HistoryPanel
- 动态工具扩展:新工具只要生成 SubAgent 对象 → UI 无需改
核心:数据结构统一,UI 只负责渲染SubAgent → 保证复用性
这套方法适合任何多智能体任务系统前端,保证可视化、可追踪、可复用,从原型到产品级闭环 💪
