feat(web): 控制台 UI 批次——用量卡片精简+详情弹框、项目列表封顶 6、评审弹框增强、live 输出流、标题栏置顶

- 全局 AGENT 卡片精简为 2×2 概览(近 7 天窗口 + 真实运行数),删按项目列表,加「详情」弹框(按天/周/月柱状图 + 分项目下钻表);- 项目列表展开态封顶 6,多余折叠;- plan/exec 评审弹框:markdown 折叠卡片 + 点文件看 diff;- live 输出流提取 tool_use/tool_result;- Agent/审批闸/任务树/已归档标题栏滚动置顶。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-29 17:42:39 +08:00
parent 5198435ba0
commit 1c62059424
8 changed files with 414 additions and 77 deletions
+16 -13
View File
@@ -79,15 +79,25 @@ export function buildTaskTree(flat) {
// ── 审批闸卡片 ─────────────────────────────────────────
export function adaptApproval(tk, projName) {
const gate = tk.status.replace('_review', ''); // plan | spec | exec
const r = tk.result || {};
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 {
else doc = r.summary || r.diffSummary || tk.operations; // exec:优先 code review 报告(做了什么/怎么做/测试/逐条 review)
const out = {
id: tk.id, gate, taskId: tk.id, title: tk.title,
meta: `${projName || ''} · ${(tk.complexity || '').toUpperCase()}`,
doc: doc || '(无可预览内容)',
};
// 结果评审:挂上 双复审结论 + 安全报告 + 提交 + diff 体量,供评审弹层结构化展示
if (gate === 'exec' && tk.result) {
out.review = {
verdict: r.verdict || null, securityVerdict: r.securityVerdict || null,
securitySummary: r.securitySummary || null,
commits: r.commits || [], diffSummary: r.diffSummary || null, branch: r.branch || null,
};
}
return out;
}
// ── 事件流 ─────────────────────────────────────────────
@@ -144,20 +154,13 @@ export function deriveGlobal(projects, agentsResp) {
};
}
// 跨项目 agent 概览。token/成本来自 /api/usage 的 cost 明细(按当期窗口聚合)。
// 跨项目 agent 概览(常驻卡片 2×2 概览)。token/成本/运行来自 /api/usage 的 cost 明细(近 7 天窗口聚合)。
// 按项目下钻已移至「详情」弹框(独立走 /api/usage/detail),此处不再构建 byProject。
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,
};
}),
tokensWeek: tokensTotal, runsWeek: cost?.runs || 0, costWeek: cost?.total || 0,
activeNow: agentsResp?.totalActive ?? 0,
};
}
+2 -1
View File
@@ -24,7 +24,8 @@ export const api = {
projectEvents: (pid) => http('GET', `/api/projects/${pid}/events`),
agents: () => http('GET', '/api/agents'),
approvals: () => http('GET', '/api/approvals'),
usage: () => http('GET', '/api/usage'),
usage: () => http('GET', '/api/usage?period=week'),
usageDetail: (g) => http('GET', `/api/usage/detail?granularity=${g}`),
syncProject: (pid) => http('POST', `/api/projects/${pid}/sync`),
decide: (taskId, action, reason, merge) =>
http('POST', `/api/tasks/${taskId}/decide`, { action, reason, merge }),
+52 -15
View File
@@ -48,19 +48,47 @@ function NewProjectModal({ t, lang, onCreate, onClose, defaultAutonomy }) {
);
}
// SDK 流式消息 → 一行可读文本(助手文本 / 工具调用 / 终态 / maestro 元事件
function summarizeMessage(m) {
if (!m || !m.type) return '';
// 工具参数 → 可读摘要(命令 / 文件 / 模式…
function toolArg(input) {
if (!input || typeof input !== 'object') return '';
return input.command || input.file_path || input.path || input.pattern || input.url || input.prompt
|| (Object.keys(input).length ? JSON.stringify(input) : '');
}
const trunc = (s, n) => { s = String(s == null ? '' : s); return s.length > n ? s.slice(0, n) + '…' : s; };
// SDK 流式消息 → 结构化块(助手文本 / 工具调用含参数 / 工具结果预览 / 终态 / 元事件)
function messageBlocks(m) {
if (!m || !m.type) return [];
const content = m.message && m.message.content;
const out = [];
if (m.type === 'assistant' && Array.isArray(content)) {
return content.map((c) => (c.type === 'text' ? c.text : c.type === 'tool_use' ? `${c.name}` : '')).filter(Boolean).join(' ');
for (const c of content) {
if (c.type === 'text' && c.text && c.text.trim()) out.push({ kind: 'text', text: c.text.trim() });
else if (c.type === 'tool_use') out.push({ kind: 'tool', name: c.name, arg: trunc(toolArg(c.input), 220) });
}
} else if (m.type === 'user' && Array.isArray(content)) {
for (const c of content) {
if (c.type === 'tool_result') {
const cnt = typeof c.content === 'string' ? c.content
: Array.isArray(c.content) ? c.content.map((x) => (x && x.text) || '').join('') : '';
out.push({ kind: 'result', text: trunc((cnt || '').trim(), 400), isError: c.is_error });
}
}
} else if (m.type === 'result') out.push({ kind: 'meta', text: '■ ' + (m.subtype || 'done') });
else if (typeof m.type === 'string' && m.type.startsWith('maestro.')) out.push({ kind: 'meta', text: '· ' + m.type });
return out;
}
// 单块渲染:文本 / 工具调用(青) / 结果预览(暗、缩进) / 元事件
function StreamLine({ b }) {
if (b.kind === 'tool') {
return <div style={{ padding: '3px 0' }}><span style={{ color: 'var(--cyan)', fontWeight: 600 }}> {b.name}</span>{b.arg ? <span style={{ color: 'var(--muted)' }}> {b.arg}</span> : null}</div>;
}
if (m.type === 'user' && Array.isArray(content)) {
return content.some((c) => c.type === 'tool_result') ? '↩ tool result' : '';
if (b.kind === 'result') {
return <div style={{ padding: '1px 0 5px 16px', color: b.isError ? 'var(--red)' : 'var(--faint)', fontSize: 11.5, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}> {b.text || '(空)'}</div>;
}
if (m.type === 'result') return `${m.subtype || 'done'}`;
if (typeof m.type === 'string' && m.type.startsWith('maestro.')) return `· ${m.type}`;
return '';
if (b.kind === 'meta') return <div style={{ padding: '3px 0', color: 'var(--green)' }}>{b.text}</div>;
return <div style={{ padding: '3px 0', color: 'var(--ink)' }}>{b.text}</div>;
}
// 实时流模态:EventSource 跟随某 run 的 transcript,逐条渲染 agent 输出
@@ -72,9 +100,9 @@ function StreamModal({ agent, onClose }) {
const es = new EventSource(`/api/tasks/${agent.taskId}/stream`);
es.addEventListener('open', () => setStatus('streaming'));
es.onmessage = (e) => {
let text = '';
try { text = summarizeMessage(JSON.parse(e.data)); } catch { text = ''; }
if (text) setLines((ls) => [...ls.slice(-400), text]);
let bs = [];
try { bs = messageBlocks(JSON.parse(e.data)); } catch { bs = []; }
if (bs.length) setLines((ls) => [...ls, ...bs].slice(-500));
};
es.addEventListener('end', () => { setStatus('done'); es.close(); });
es.onerror = () => { setStatus('disconnected'); es.close(); };
@@ -95,7 +123,7 @@ function StreamModal({ agent, onClose }) {
<button onClick={onClose} style={{ fontFamily: 'var(--mono)', fontSize: 13, background: 'transparent', color: 'var(--muted)', border: 'none', cursor: 'pointer' }}></button>
</div>
<div ref={boxRef} style={{ flex: 1, overflowY: 'auto', padding: '12px 16px', fontFamily: 'var(--mono)', fontSize: 12.5, lineHeight: 1.6, whiteSpace: 'pre-wrap', wordBreak: 'break-word', color: 'var(--ink)' }}>
{lines.length ? lines.map((l, i) => <div key={i} style={{ paddingBottom: 2 }}>{l}</div>)
{lines.length ? lines.map((b, i) => <StreamLine key={i} b={b} />)
: <div style={{ color: 'var(--faint)' }}>等待输出</div>}
</div>
</div>
@@ -275,7 +303,16 @@ export function App() {
const REVIEW = new Set(['plan_review', 'spec_review', 'exec_review']);
const gates = approvalsRaw
.filter((a) => a.projectId === currentId && REVIEW.has(a.status))
.map((a) => adaptApproval(a, projNameOf(a.projectId)));
.map((a) => {
const g = adaptApproval(a, projNameOf(a.projectId));
// 拆解评审:挂上实际拆出的子任务(标题/复杂度/优先级/依赖/状态),供评审弹层结构化展示
if (g.gate === 'plan') {
g.subtasks = tasksRaw
.filter((tk) => tk.parentId === a.id)
.map((tk) => ({ id: tk.id, title: tk.title, complexity: tk.complexity, priority: tk.priority, deps: tk.deps || [], status: tk.status }));
}
return g;
});
const agents = adaptActiveAgents(agentsResp, currentId);
const events = eventsRaw.map(adaptEvent);
const quota = adaptQuota(usage);
@@ -366,7 +403,7 @@ export function App() {
collapsed={collapsed} onToggleCollapse={toggleCollapse}
onSelect={setCurrentId} onNewProject={() => setShowNewProject(true)} onReorder={reorderProjects}
settings={settings} onSaveSettings={saveSettings} />
<main style={{ overflowY: 'auto', padding: '0 22px 60px' }}>
<main style={{ overflowY: 'auto', minHeight: 0, padding: '0 22px 60px' }}>
{project ? <window.MaestroKitTopbar project={project} t={t} theme={theme}
onToggleTheme={() => setTheme(theme === 'light' ? 'dark' : 'light')}
lang={lang} onSelectLang={setLang}