a688b6977e
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>
213 lines
8.9 KiB
JavaScript
213 lines
8.9 KiB
JavaScript
// 适配层:把真实 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 run(AgentSection 用)
|
||
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)} 天前`;
|
||
}
|