feat(web): Markdown 渲染器 + 拆解轨迹 + 需你处理区 + 复审报告折叠卡片
- RichDoc(marked+DOMPurify+highlight.js CDN)统一渲染 gate spec/复审报告/任务树 SPEC;- Transcript 组件:拆解评审「拆解轨迹」懒加载 planner run transcript;- 「审批闸」改名「需你处理」并并入 needs_attention(requeue/接管/取消 + 完整复审报告,按代码/安全分段折叠卡片、标裁决徽章);- 子任务显 scopeFiles 文件范围 + frozen「待拆解确认」标。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,32 +1,4 @@
|
||||
// ── 报告渲染:把 markdown(## 章节 + - 列表 + **粗体** + `代码`)渲染成可折叠卡片 ──
|
||||
function mdInline(text) {
|
||||
const nodes = []; const re = /(\*\*[^*]+\*\*|`[^`]+`)/g;
|
||||
let last = 0; let m; let key = 0;
|
||||
while ((m = re.exec(text))) {
|
||||
if (m.index > last) nodes.push(text.slice(last, m.index));
|
||||
const tok = m[0];
|
||||
if (tok.startsWith('**')) nodes.push(<b key={key++} style={{ color: 'var(--ink)' }}>{tok.slice(2, -2)}</b>);
|
||||
else nodes.push(<code key={key++} style={{ fontFamily: 'var(--mono)', fontSize: '.92em', color: 'var(--cyan)', background: 'var(--panel-2)', borderRadius: 3, padding: '1px 5px' }}>{tok.slice(1, -1)}</code>);
|
||||
last = m.index + tok.length;
|
||||
}
|
||||
if (last < text.length) nodes.push(text.slice(last));
|
||||
return nodes;
|
||||
}
|
||||
function mdBody(body) {
|
||||
const out = []; let key = 0;
|
||||
for (const raw of body.split('\n')) {
|
||||
const t = raw.trim();
|
||||
if (!t) { out.push(<div key={key++} style={{ height: 6 }} />); continue; }
|
||||
if (/^[-*]\s/.test(t)) {
|
||||
out.push(<div key={key++} style={{ display: 'flex', gap: 8, padding: '2px 0 2px 2px' }}><span style={{ flex: 'none', color: 'var(--green)' }}>·</span><span>{mdInline(t.replace(/^[-*]\s/, ''))}</span></div>);
|
||||
} else if (/^#{1,4}\s/.test(t)) {
|
||||
out.push(<div key={key++} style={{ fontWeight: 700, color: 'var(--muted)', margin: '6px 0 2px' }}>{mdInline(t.replace(/^#{1,4}\s/, ''))}</div>);
|
||||
} else {
|
||||
out.push(<div key={key++} style={{ padding: '1px 0' }}>{mdInline(t)}</div>);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// ── 报告渲染:把 markdown 按「## 章节」切成可折叠卡片,章节内容交 window.MaestroRichDoc 渲染 ──
|
||||
function mdSections(md) {
|
||||
if (!md) return [];
|
||||
const out = []; const lines = md.split('\n');
|
||||
@@ -48,7 +20,7 @@ function ReportSectionCard({ title, body, t, defaultOpen }) {
|
||||
<span style={{ flex: 'none', color: 'var(--green)', fontSize: 9, transition: 'transform .12s', transform: open ? 'none' : 'rotate(-90deg)' }}>▼</span>
|
||||
<span style={{ flex: 1, fontSize: 12.5, fontWeight: 700, color: 'var(--ink)', letterSpacing: '.02em' }}>{title || '说明'}</span>
|
||||
</div>
|
||||
{open ? <div style={{ padding: '11px 14px', fontSize: 12.5, lineHeight: 1.7, color: 'var(--ink)', wordBreak: 'break-word' }}>{mdBody(body)}</div> : null}
|
||||
{open ? <div style={{ padding: '11px 14px' }}><window.MaestroRichDoc md={body} /></div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -100,6 +72,85 @@ function FileDiffs({ taskId, t }) {
|
||||
);
|
||||
}
|
||||
|
||||
// 单段复审报告卡片:默认折叠,标题显示复审类型 + 裁决徽章,点击展开看完整报告(markdown)。
|
||||
function ReviewReportCard({ label, verdict, report, t, first }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const vb = verdict === 'reject' ? { c: 'var(--red)', bd: 'var(--red-dim)', txt: '✗ ' + t.vReject }
|
||||
: verdict === 'approve' ? { c: 'var(--green)', bd: 'var(--green-dim)', txt: '✓ ' + t.vApprove } : null;
|
||||
return (
|
||||
<div style={{ marginTop: first ? 0 : 8, background: 'var(--bg-deep)', border: '1px solid var(--line-soft)', borderRadius: 6, overflow: 'hidden' }}>
|
||||
<div onClick={() => setOpen((o) => !o)} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', cursor: 'pointer', userSelect: 'none', background: 'var(--panel-2)' }}>
|
||||
<span style={{ color: 'var(--green)', fontSize: 9, transition: 'transform .12s', transform: open ? 'none' : 'rotate(-90deg)' }}>▼</span>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, letterSpacing: '.14em', color: 'var(--muted)' }}>{label}</span>
|
||||
{vb ? <span style={{ fontSize: 10, fontWeight: 700, color: vb.c, border: '1px solid ' + vb.bd, borderRadius: 3, padding: '0 6px' }}>{vb.txt}</span> : null}
|
||||
<span style={{ marginLeft: 'auto', fontSize: 10, color: 'var(--faint)' }}>{open ? t.gateCollapse : t.gateExpand}</span>
|
||||
</div>
|
||||
{open ? <div style={{ padding: '11px 13px' }}><window.MaestroRichDoc md={report} /></div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 需人工卡片:拉最近的 reviewer / security run 的「完整复审报告」(脱敏 transcript 末块=最终报告),markdown 渲染——
|
||||
// 替代被截断到 200 字的 lastRunError,把驳回原因罗列清楚。无复审报告(执行崩溃/冲突/重评估等)则回退显示 lastRunError。
|
||||
function ReviewReports({ taskId, fallback, t }) {
|
||||
const [st, setSt] = React.useState({ loading: true, reports: null });
|
||||
React.useEffect(() => {
|
||||
let alive = true;
|
||||
(async () => {
|
||||
try {
|
||||
const runs = await fetch('/api/tasks/' + taskId + '/runs').then((r) => r.ok ? r.json() : []);
|
||||
const list = Array.isArray(runs) ? runs : [];
|
||||
const latest = (kind) => list.filter((r) => r.kind === kind)
|
||||
.slice().sort((a, b) => String(b.startedAt || '').localeCompare(String(a.startedAt || '')))[0];
|
||||
const out = [];
|
||||
for (const [kind, label] of [['reviewer', t.lblCodeReview], ['security', t.lblSecurity]]) {
|
||||
const run = latest(kind);
|
||||
if (!run) continue;
|
||||
const d = await fetch('/api/runs/' + run.id + '/transcript').then((r) => r.ok ? r.json() : null);
|
||||
const texts = ((d && d.messages) || []).filter((m) => m.type === 'text' && m.text && m.text.trim());
|
||||
if (texts.length) {
|
||||
const report = texts[texts.length - 1].text;
|
||||
const vm = /VERDICT:\s*(approve|reject)/i.exec(report);
|
||||
out.push({ label, report, verdict: vm ? vm[1].toLowerCase() : null });
|
||||
}
|
||||
}
|
||||
if (alive) setSt({ loading: false, reports: out });
|
||||
} catch (e) { if (alive) setSt({ loading: false, reports: [] }); }
|
||||
})();
|
||||
return () => { alive = false; };
|
||||
}, [taskId]);
|
||||
|
||||
if (st.loading) return <div style={{ fontSize: 11.5, color: 'var(--faint)', padding: '4px 2px' }}>…</div>;
|
||||
if (st.reports && st.reports.length) {
|
||||
return (
|
||||
<div>
|
||||
{st.reports.map((r, i) => (
|
||||
<ReviewReportCard key={i} label={r.label} verdict={r.verdict} report={r.report} t={t} first={i === 0} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return fallback ? <window.MaestroRichDoc md={fallback} /> : null;
|
||||
}
|
||||
|
||||
// 拆解评审「拆解轨迹」:默认折叠,点击展开后懒加载该任务 planner run 的完整 transcript(表格/分析+工具调用)。
|
||||
function GateTranscript({ taskId, t }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
return (
|
||||
<div style={{ marginTop: 18 }}>
|
||||
<div onClick={() => setOpen((o) => !o)} style={{ display: 'inline-flex', alignItems: 'center', gap: 8, cursor: 'pointer', userSelect: 'none', padding: '4px 0' }}>
|
||||
<span style={{ color: 'var(--green)', fontSize: 9, transition: 'transform .12s', transform: open ? 'none' : 'rotate(-90deg)' }}>▼</span>
|
||||
<span style={{ fontSize: 10.5, fontWeight: 700, color: 'var(--muted)', letterSpacing: '.18em' }}>{t.planTranscript}</span>
|
||||
</div>
|
||||
{open ? (
|
||||
<div style={{ marginTop: 8, background: 'var(--bg-deep)', border: '1px solid var(--line-soft)', borderRadius: 6, padding: '8px 12px' }}>
|
||||
<window.MaestroTranscript taskId={taskId} kind="planner" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 全屏预览:读完整方案 + 就地裁决
|
||||
function GatePreview({ item, t, onDecide, onClose }) {
|
||||
const { ComplexityBadge, StatusChip, Button, Textarea } = window.MaestroDesignSystem_a6a290;
|
||||
@@ -150,9 +201,7 @@ function GatePreview({ item, t, onDecide, onClose }) {
|
||||
</div>
|
||||
) : null}
|
||||
{item.docLabel ? <div style={{ fontSize: 10.5, color: 'var(--muted)', letterSpacing: '.18em', marginBottom: 6 }}>{item.docLabel}</div> : null}
|
||||
{/^##\s/m.test(item.doc || '')
|
||||
? <ReportCards md={item.doc} t={t} />
|
||||
: <div style={{ background: 'var(--bg-deep)', border: '1px solid var(--line-soft)', borderLeft: '2px solid var(--green-dim)', borderRadius: 4, padding: '12px 16px', fontSize: 13.5, lineHeight: 1.7, whiteSpace: 'pre-wrap', wordBreak: 'break-word', color: 'var(--ink)' }}>{item.doc}</div>}
|
||||
{item.doc ? <ReportCards md={item.doc} t={t} /> : null}
|
||||
{item.gate === 'exec' && rv && rv.securitySummary ? (
|
||||
<React.Fragment>{secLabel(t.lblSecurity)}<ReportCards md={rv.securitySummary} t={t} /></React.Fragment>
|
||||
) : null}
|
||||
@@ -179,17 +228,26 @@ function GatePreview({ item, t, onDecide, onClose }) {
|
||||
<ComplexityBadge complexity={s.complexity} />
|
||||
<span style={{ flex: 'none', fontSize: 10, fontWeight: 700, color: 'var(--muted)', border: '1px solid var(--line)', borderRadius: 3, padding: '1px 6px' }}>P{s.priority}</span>
|
||||
<span style={{ flex: 1, fontSize: 13.5, fontWeight: 600, color: 'var(--ink)', minWidth: 120 }}>{s.title}</span>
|
||||
{StatusChip ? <StatusChip status={s.status} label={t.status[s.status]} /> : null}
|
||||
{StatusChip ? <StatusChip status="paused" label={t.frozenPending} /> : null}
|
||||
</div>
|
||||
<div style={{ marginTop: 7, paddingLeft: 31, fontSize: 11.5, color: 'var(--faint)' }}>
|
||||
{t.dependsOn}: {deps.length ? <span style={{ color: 'var(--amber)', fontWeight: 600 }}>{deps.join('、')}</span> : t.noDeps}
|
||||
</div>
|
||||
{s.scopeFiles && s.scopeFiles.length ? (
|
||||
<div style={{ marginTop: 5, paddingLeft: 31, display: 'flex', flexWrap: 'wrap', gap: 5, alignItems: 'center' }}>
|
||||
<span style={{ flex: 'none', fontSize: 11.5, color: 'var(--faint)' }}>{t.scopeFiles}:</span>
|
||||
{s.scopeFiles.map((f) => (
|
||||
<span key={f} style={{ fontSize: 10.5, fontFamily: 'var(--mono)', color: 'var(--cyan)', background: 'var(--panel-2)', border: '1px solid var(--line-soft)', borderRadius: 3, padding: '1px 6px' }}>{f}</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{item.gate === 'plan' ? <GateTranscript taskId={item.taskId} t={t} /> : null}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', padding: '12px 16px', borderTop: '1px solid var(--line)', background: 'var(--panel-2)' }}>
|
||||
{rejecting ? (
|
||||
@@ -212,8 +270,8 @@ function GatePreview({ item, t, onDecide, onClose }) {
|
||||
);
|
||||
}
|
||||
|
||||
// 审批闸区:斜纹条 + GateCard 列表(accept/reject + 驳回必填理由)
|
||||
function GateSection({ approvals, onDecide, t }) {
|
||||
// 「需你处理」区:审批闸(accept/reject + 驳回必填理由)+ 需人工(requeue/接管/取消)
|
||||
function GateSection({ approvals, onDecide, taskOps, onTakeover, t }) {
|
||||
const { GateCard, GateStripe, Button, Textarea } = window.MaestroDesignSystem_a6a290;
|
||||
const [rejecting, setRejecting] = React.useState(null);
|
||||
const [reason, setReason] = React.useState('');
|
||||
@@ -233,24 +291,36 @@ function GateSection({ approvals, onDecide, t }) {
|
||||
const isFolded = folded.has(a.id);
|
||||
return (
|
||||
<div key={a.id} style={{ marginBottom: 12 }}>
|
||||
<GateCard gate={a.gate} kindLabel={t.gates[a.gate]} title={a.title} meta={a.meta} docLabel={a.docLabel} doc={a.doc}
|
||||
<GateCard gate={a.gate} kindLabel={a.gate === 'attention' ? t.gateNeedsHuman : t.gates[a.gate]} title={a.title} meta={a.meta} docLabel={a.docLabel} doc={a.gate === 'attention' ? undefined : a.doc}
|
||||
collapsed={isFolded} foldable onToggleFold={() => toggleFold(a.id)}
|
||||
foldTitle={isFolded ? t.gateExpand : t.gateCollapse}
|
||||
onHeaderDoubleClick={() => setPreview(a)} headerTitle={t.gateDblTip}
|
||||
headerExtra={<button type="button" title={t.fullRead} onClick={() => setPreview(a)}
|
||||
onHeaderDoubleClick={a.gate === 'attention' ? undefined : () => setPreview(a)} headerTitle={a.gate === 'attention' ? null : t.gateDblTip}
|
||||
headerExtra={a.gate === 'attention' ? null : (<button type="button" title={t.fullRead} onClick={() => setPreview(a)}
|
||||
style={{ fontFamily: 'var(--mono)', fontSize: 12, lineHeight: 1, cursor: 'pointer', background: 'transparent', color: 'var(--muted)', border: '1px solid var(--line)', borderRadius: 'var(--radius-sm,4px)', padding: '3px 7px' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--violet)'; e.currentTarget.style.borderColor = 'var(--violet-dim)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--muted)'; e.currentTarget.style.borderColor = 'var(--line)'; }}>⛶</button>}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--muted)'; e.currentTarget.style.borderColor = 'var(--line)'; }}>⛶</button>)}
|
||||
actions={
|
||||
<React.Fragment>
|
||||
<Button variant="accept" onClick={() => onDecide(a, 'accept')}>{t.accept}</Button>
|
||||
<Button variant="reject" onClick={() => setRejecting(rejecting === a.id ? null : a.id)}>{t.reject}</Button>
|
||||
<span style={{ marginLeft: 'auto' }}>
|
||||
<Button variant="ghost" size="xs" title={t.fullRead} onClick={() => setPreview(a)}>⛶ {t.fullRead}</Button>
|
||||
</span>
|
||||
</React.Fragment>
|
||||
a.gate === 'attention' ? (
|
||||
<React.Fragment>
|
||||
<Button variant="accept" onClick={() => taskOps && taskOps.requeue(a.taskId)}>{t.gRequeue}</Button>
|
||||
{onTakeover ? <Button variant="ghost" onClick={() => onTakeover({ id: a.taskId, title: a.title })}>{t.gTakeover}</Button> : null}
|
||||
<span style={{ marginLeft: 'auto' }}>
|
||||
<Button variant="reject" size="xs" onClick={() => taskOps && taskOps.cancel(a.taskId)}>{t.gCancelTask}</Button>
|
||||
</span>
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<React.Fragment>
|
||||
<Button variant="accept" onClick={() => onDecide(a, 'accept')}>{t.accept}</Button>
|
||||
<Button variant="reject" onClick={() => setRejecting(rejecting === a.id ? null : a.id)}>{t.reject}</Button>
|
||||
<span style={{ marginLeft: 'auto' }}>
|
||||
<Button variant="ghost" size="xs" title={t.fullRead} onClick={() => setPreview(a)}>⛶ {t.fullRead}</Button>
|
||||
</span>
|
||||
</React.Fragment>
|
||||
)
|
||||
}>
|
||||
{rejecting === a.id ? (
|
||||
{a.gate === 'attention' ? (
|
||||
<ReviewReports taskId={a.taskId} fallback={a.doc} t={t} />
|
||||
) : rejecting === a.id ? (
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-start', marginTop: 10 }}>
|
||||
<Textarea danger placeholder={t.rejectPlaceholder} minHeight={56} value={reason} onChange={setReason} />
|
||||
<Button variant="reject" disabled={!reason.trim()}
|
||||
|
||||
Reference in New Issue
Block a user