Files
maestro/app/src/adapt.js
T
wangjia a688b6977e feat: agent 实时输出流(SSE)
B-② SSE 实时流(⑧ ★ GET /api/tasks/:id/stream):

后端:
- GET /api/tasks/:id/stream:SSE 跟随任务最近一条 run 的 transcript 文件,
  新写入的 agent 消息逐行推送(活跃 run=实时;已结束=回放后 end);
  500ms 轮询 tail(按 offset 增量读),run 转非 started 即结束流,客户端断开即停

前端:
- StreamModal(EventSource 消费 SSE,summarizeMessage 把 SDK 消息→可读行:
  助手文本/⚙工具调用/■终态/·maestro 元事件,自动滚动到底)
- AgentSection agent 行可点击 → 打开实时流模态;adaptActiveAgents 带出 taskId

验证:typecheck 干净;前端 build 通过;dev 渲染 0 console 错误。
(实时效果需有运行中的 agent;端到端随 daemon 重启验证。)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 17:01:58 +08:00

213 lines
8.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 适配层:把真实 API 响应(src/model 的 camelCase 行)映射成各 surface 期望的形状
// (即 design/ui_kits/console/data.js 的 mock 字段名)。纯函数,便于测试与替换。
const ARCHIVE_STATUSES = new Set(['done', 'cancelled']);
const GATE_STATUSES = new Set(['plan_review', 'spec_review', 'exec_review']);
// 项目名 → 稳定色相(0..360),与 mock 的 hue 字段对齐
export function hueFor(name) {
let h = 0;
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) % 360;
return h;
}
const prio = (n) => 'P' + (n ?? 2);
const shortPath = (p) => (p || '').replace(/^\/Users\/[^/]+/, '~');
// ── 项目 ───────────────────────────────────────────────
export function adaptProject(p) {
const s = p.summary || {};
let state = 'idle';
if (p.status === 'paused') state = 'paused';
else if (s.executing > 0) state = 'running';
else if (s.blocked > 0) state = 'blocked';
else if (s.alive > 0) state = 'running';
return {
id: p.id, name: p.name, path: shortPath(p.repoPath), branch: p.defaultBranch,
autonomy: p.autonomy, concurrency: p.concurrency, hue: hueFor(p.name),
state, pending: s.pending || 0, agents: s.executing || 0,
// 透传给 ConfigPanel 用的原始字段
model: p.model, verifyCmd: p.verifyCmd, maxRetries: p.maxRetries, repoPath: p.repoPath,
logo: p.logo, budgetUsd: p.budgetUsd ?? null, budgetPeriod: p.budgetPeriod ?? 'month',
};
}
// ── 任务树(扁平 → 嵌套 children)──────────────────────
function mapTask(tk) {
return {
id: tk.id, title: tk.title, cplx: tk.complexity, status: tk.status, prio: prio(tk.priority),
deps: tk.deps || [],
doc: tk.spec || tk.plan || undefined,
ops: tk.operations || undefined,
children: [],
_raw: tk,
};
}
// 返回 { active: 顶层活跃任务树, archived: 终态任务卡片[] }
export function buildTaskTree(flat) {
const byId = new Map();
flat.forEach((tk) => byId.set(tk.id, mapTask(tk)));
const roots = [];
byId.forEach((node) => {
const pid = node._raw.parentId;
if (pid && byId.has(pid)) byId.get(pid).children.push(node);
else roots.push(node);
});
// 子节点按优先级(P0 在前)再按创建时间排序
const sortKids = (n) => {
n.children.sort((a, b) => a.prio.localeCompare(b.prio) || a._raw.createdAt.localeCompare(b._raw.createdAt));
n.children.forEach(sortKids);
};
roots.forEach(sortKids);
const active = [];
const archived = [];
for (const r of roots) {
const hasLiveDescendant = (n) => !ARCHIVE_STATUSES.has(n.status) || n.children.some(hasLiveDescendant);
if (ARCHIVE_STATUSES.has(r.status) && !r.children.some(hasLiveDescendant)) {
archived.push(adaptArchive(r));
} else {
active.push(r);
}
}
active.sort((a, b) => a.prio.localeCompare(b.prio) || a._raw.createdAt.localeCompare(b._raw.createdAt));
archived.sort((a, b) => b.timeFull.localeCompare(a.timeFull));
return { active, archived };
}
// ── 审批闸卡片 ─────────────────────────────────────────
export function adaptApproval(tk, projName) {
const gate = tk.status.replace('_review', ''); // plan | spec | exec
let doc;
if (gate === 'plan') doc = tk.plan;
else if (gate === 'spec') doc = tk.spec;
else doc = (tk.result && (tk.result.diffSummary || JSON.stringify(tk.result, null, 2))) || tk.operations;
return {
id: tk.id, gate, taskId: tk.id, title: tk.title,
meta: `${projName || ''} · ${(tk.complexity || '').toUpperCase()}`,
doc: doc || '(无可预览内容)',
};
}
// ── 事件流 ─────────────────────────────────────────────
const TIME = (iso) => { try { return new Date(iso).toTimeString().slice(0, 8); } catch { return ''; } };
export function adaptEvent(e) {
const p = e.payload || {};
let detail = '';
switch (e.type) {
case 'status.changed': detail = `${p.from}${p.to}` + (p.by ? ` · ${p.by}` : ''); break;
case 'run.started': detail = `${p.kind || 'run'} · ${p.runId || ''}`; break;
case 'run.finished': detail = `${p.runId || ''} · ${p.status || ''}`; break;
case 'approval.granted': case 'approval.rejected':
detail = `${p.gate || ''}` + (p.reason ? `${p.reason}` : ''); break;
case 'task.created': detail = p.title || e.taskId || ''; break;
case 'project.synced': detail = `导入 ${p.created ?? 0} · 完成 ${p.done ?? 0}`; break;
default: detail = JSON.stringify(p).slice(0, 80);
}
return { type: e.type, time: TIME(e.at), detail };
}
// ── 用量 / 配额 ────────────────────────────────────────
const resetIn = (iso) => {
if (!iso) return '—';
const ms = new Date(iso).getTime() - Date.now();
if (isNaN(ms) || ms <= 0) return '0m';
const h = Math.floor(ms / 3.6e6), m = Math.floor((ms % 3.6e6) / 6e4);
return h > 0 ? `${h}h ${m}m` : `${m}m`;
};
export function adaptQuota(u) {
return {
five: { pct: u?.session?.percent ?? 0, reset: resetIn(u?.session?.resetsAt) },
week: { pct: u?.weekly?.percent ?? 0, reset: resetIn(u?.weekly?.resetsAt) },
};
}
// ── 全局概览(从 projects + agents 派生)───────────────
export function deriveGlobal(projects, agentsResp) {
const running = projects.filter((p) => p.state === 'running').length;
const maxAgents = projects.reduce((s, p) => s + (p.concurrency || 0), 0);
return {
projectCount: projects.length, runningProjects: running,
runningAgents: agentsResp?.totalActive ?? 0, maxAgents,
autonomy: projects[0]?.autonomy || 'manual', daemon: 'live', port: 4517, uptime: '—',
};
}
// 跨项目 agent 概览。token/成本来自 /api/usage 的 cost 明细(按当期窗口聚合)。
export function deriveAgentSummary(projects, agentsResp, cost) {
const byPid = new Map((cost?.byProject || []).map((c) => [c.projectId, c]));
const tokensTotal = (cost?.byProject || []).reduce((sum, c) => sum + (c.tokens || 0), 0);
return {
tokensWeek: tokensTotal, runsWeek: 0, costWeek: cost?.total || 0, activeNow: agentsResp?.totalActive ?? 0,
byProject: projects.map((p) => {
const c = byPid.get(p.id);
return {
id: p.id, name: p.name, hue: p.hue,
active: p.agents || 0, runs: 0,
tokens: c?.tokens || 0, cost: c?.costUsd || 0,
};
}),
};
}
// 当前项目正在跑的 agent runAgentSection 用)
export function adaptActiveAgents(agentsResp, projectId) {
const out = [];
for (const a of agentsResp?.agents || []) {
if (projectId && a.projectId !== projectId) continue;
for (const r of a.active || []) {
out.push({
id: r.runId || r.id, taskId: r.taskId, project: a.projectName,
title: r.title || r.taskTitle || r.taskId || '(运行中)',
kind: (r.kind || 'run').toUpperCase(),
time: TIME(r.startedAt || r.at), meta: `${r.worktree || ''} · ${r.model || ''}`,
});
}
}
return out;
}
// ── 归档任务详情 ───────────────────────────────────────
function adaptArchive(node) {
const tk = node._raw;
const cplxU = (tk.complexity || '').toUpperCase();
const statusZh = tk.status === 'done' ? '完成' : '取消';
const attrs = [
['id', tk.id], ['复杂度', cplxU], ['优先级', node.prio], ['状态', statusZh],
['深度', String(tk.depth ?? 1)], ['创建时间', (tk.createdAt || '').replace('T', ' ').slice(0, 19)],
['最后更新', (tk.updatedAt || '').replace('T', ' ').slice(0, 19)],
];
const detail = { attrs };
if (tk.spec) detail.spec = tk.spec;
if (tk.operations) detail.ops = tk.operations;
if (tk.result) {
detail.result = {
branch: tk.result.branch,
commits: tk.result.commits || [],
diff: tk.result.diffSummary ? tk.result.diffSummary.split('\n').slice(-1)[0] : undefined,
};
}
detail.approvals = (tk.approvals || []).map((a) => ({
gate: a.gate, action: a.action, actor: a.actor, at: (a.at || '').replace('T', ' ').slice(0, 19), reason: a.reason,
}));
detail.runs = [];
detail.timeline = [{ time: (tk.createdAt || '').slice(5, 16).replace('T', ' '), text: '任务创建', who: 'you' }];
return {
id: tk.id, title: tk.title, cplx: tk.complexity, status: tk.status,
subs: node.children.length, time: relTime(tk.updatedAt), timeFull: (tk.updatedAt || '').replace('T', ' ').slice(0, 19),
detail,
};
}
function relTime(iso) {
if (!iso) return '';
const ms = Date.now() - new Date(iso).getTime();
const h = Math.floor(ms / 3.6e6);
if (h < 1) return '刚刚';
if (h < 24) return `${h} 小时前`;
return `${Math.floor(h / 24)} 天前`;
}