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
+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}