多智能体任务可视化界面

分享臭臭🐈最近做的一个小项目:让多智能体协作的任务有「可视化界面」,不仅能看,还能玩,还能复用!

🎯 项目初衷

平时做工具或者多智能体系统,总遇到这些问题:

  • 用户输入了指令,但任务拆解很乱 → 不好追踪
  • 多步骤结果堆在一起 → 完全看不懂谁做了什么
  • 历史记录零散 → 回溯超级麻烦

💡 臭臭🐈想做一个界面,让它可以:

  • 自动把用户的 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 → 保证复用性
这套方法适合任何多智能体任务系统前端,保证可视化、可追踪、可复用,从原型到产品级闭环 💪

相关推荐
whisper2 小时前
#新手必看!Map.size 和 Object.keys().length 的区别,看完再也不混淆
前端
秋天的一阵风2 小时前
【LeetCode 刷题系列|第 3 篇】详解大数相加:从模拟竖式到简洁写法的优化之路🔢
前端·算法·面试
假面骑士阿猫2 小时前
TRAE配置OpenSpec实现SDD规范驱动开发
前端·人工智能·代码规范
哈哈哈哈哈哈哈哈8532 小时前
WSL + Tailscale 导致 apt update 卡 0% 的解决方案
前端
JYeontu2 小时前
程序员都是这样剪视频的?
前端
小雨cc5566ru2 小时前
基于Nodejs+vue+ElementUI的大学生课程排课管理系统设计
前端·vue.js·elementui
qq_8406122332 小时前
Nodejs+vue+ElementUI框架的家政服务评价系统 保洁员预约系统的设计与实现
前端·vue.js·elementui
计算机应用技术三班-欧婷2 小时前
ElementUI从入门到实战全攻略
前端·vue.js·ui
永恒毕设程序2 小时前
基于ssm+vue基于elementui的工厂物料管理系统设计与实现【开题+程序+论文】
前端·vue.js·elementui