Files
maestro/design/ui_kits/console/Topbar.jsx
T
wangjia 6c9b05e575 feat: 项目级成本预算系统(按模型×token 精确计费 + 超额自动暂停)
精确到项目级的成本/预算护栏,成本按「模型 × token 用量」折算(⑧ ★ 新特性)。

定价(单源):
- src/model/pricing.ts:MODEL_PRICES(opus/sonnet/haiku,USD/1M token,分
  input/output/cacheRead/cacheWrite)+ computeCost/addUsage,前缀容错、未知模型记 0

token 捕获链(worker → daemon):
- cc.ts:从 SDK result 消息抓 usage,跨 resume/回退累计;CCResult.usage
- runner/reviewer:结果对象带 usage + modelUsed
- protocol.ts:新增 'usage' outbox 记录;pipeline 每次 CC(executor/复审/planner/
  conflict)后 emit usage(worker 侧写文件,不碰 DB)
- ingest.ts:'usage' → store.setRunUsage(累加、按模型折算)→ enforceBudget

存储 + 护栏:
- schema/db.ts:runs.usage/cost_usd、projects.budget_usd/budget_period(幂等迁移)
- store.ts:setRunUsage(累加)、projectSpend、costSummary(按项目/模型/总计)、
  enforceBudget(超额 → 置 paused + 广播 budget.exceeded)
- 编排器天然停领:orchestrator 既有「跳过 paused 项目」即生效,无需改

API:
- GET /api/usage:并入成本明细 {session,weekly,cost:{period,total,byProject,byModel}},?period/?project
- GET /api/health:{ok,db,inflight,at}
- PATCH /api/projects:支持 budgetUsd/budgetPeriod

前端:
- adapt.js:agentSummary 用真实成本/token;adaptProject 透传预算字段
- app.jsx:budget.exceeded → 告警 toast
- ConfigPanel:预算 $ + 周期(day|month)受控输入

验证:typecheck 干净;206 测试通过(含新增 budget.test.ts 4 例:定价折算/累加/
costSummary/enforceBudget);前端 build + 截图确认预算输入渲染、0 错误。
注:生效需合并后重启 daemon(迁移幂等加列、向后兼容旧库)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 15:29:44 +08:00

194 lines
13 KiB
React
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.
// 顶栏动作:默认仅图标,hover 横向展开文字(与 CountBadge 同交互)
const maestroIconActionCss = `
.m-iact{display:inline-flex;align-items:center;height:28px;padding:0 8px;cursor:pointer;font-family:var(--mono);font-size:11.5px;font-weight:600;letter-spacing:.08em;line-height:1;background:transparent;color:var(--muted);border:1px solid var(--line);border-radius:var(--radius-sm,4px);transition:color .12s,border-color .12s;white-space:nowrap}
.m-iact svg{flex:none}
.m-iact .m-iact-label{max-width:0;opacity:0;overflow:hidden;transition:max-width .28s ease,opacity .22s ease,margin-left .28s ease}
.m-iact:hover{color:var(--green);border-color:var(--green-dim)}
.m-iact:hover .m-iact-label{max-width:9em;opacity:1;margin-left:6px}
`;
function ensureIconActionCss() {
if (document.getElementById('m-iact-css')) return;
const s = document.createElement('style'); s.id = 'm-iact-css'; s.textContent = maestroIconActionCss;
document.head.appendChild(s);
}
function IconAction({ icon, label, title, onClick }) {
ensureIconActionCss();
return (
<button type="button" className="m-iact" title={title || label} onClick={onClick}>
{icon}
<span className="m-iact-label">{label}</span>
</button>
);
}
const iactSvg = { fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round', width: 14, height: 14, viewBox: '0 0 24 24' };
function SyncIcon() {
return <svg {...iactSvg}><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" /><path d="M21 3v5h-5" /></svg>;
}
function ConfigIcon() {
return <svg {...iactSvg}><line x1="3" y1="6" x2="12" y2="6" /><circle cx="15" cy="6" r="2.5" /><line x1="18" y1="6" x2="21" y2="6" /><line x1="3" y1="12" x2="5.5" y2="12" /><circle cx="9" cy="12" r="2.5" /><line x1="12.5" y1="12" x2="21" y2="12" /><line x1="3" y1="18" x2="12.5" y2="18" /><circle cx="16" cy="18" r="2.5" /><line x1="19.5" y1="18" x2="21" y2="18" /></svg>;
}
function LangIcon() {
return <svg {...iactSvg}><circle cx="12" cy="12" r="9" /><path d="M3 12h18" /><path d="M12 3a14 14 0 0 1 0 18a14 14 0 0 1 0-18" /></svg>;
}
function SunIcon() {
return <svg {...iactSvg}><circle cx="12" cy="12" r="4" /><line x1="12" y1="2" x2="12" y2="5" /><line x1="12" y1="19" x2="12" y2="22" /><line x1="2" y1="12" x2="5" y2="12" /><line x1="19" y1="12" x2="22" y2="12" /><line x1="4.9" y1="4.9" x2="7" y2="7" /><line x1="17" y1="17" x2="19.1" y2="19.1" /><line x1="4.9" y1="19.1" x2="7" y2="17" /><line x1="17" y1="7" x2="19.1" y2="4.9" /></svg>;
}
function MoonIcon() {
return <svg {...iactSvg}><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" /></svg>;
}
function LangMenu({ t, lang, onSelectLang }) {
const [open, setOpen] = React.useState(false);
const ref = React.useRef(null);
React.useEffect(() => {
if (!open) return;
const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', onDoc);
return () => document.removeEventListener('mousedown', onDoc);
}, [open]);
return (
<span ref={ref} style={{ position: 'relative', display: 'inline-flex' }}>
<span onClick={() => setOpen(!open)}>
<IconAction icon={<LangIcon />} label={t.lang} title={t.langTip} onClick={() => {}} />
</span>
{open ? (
<div style={{
position: 'absolute', top: 'calc(100% + 5px)', right: 0, zIndex: 60,
display: 'grid', minWidth: 124,
background: 'var(--bg-deep)', border: '1px solid var(--line)', borderRadius: 'var(--radius-md, 6px)',
padding: 4, boxShadow: '0 10px 30px rgba(0,0,0,.65)', animation: 'maestro-rise .12s ease both',
}}>
{window.MAESTRO_LANGS.map(([code, name]) => {
const active = code === lang;
return (
<button key={code} onClick={() => { onSelectLang(code); setOpen(false); }} style={{
fontFamily: 'var(--mono)', fontSize: 12, textAlign: 'left',
background: active ? 'var(--panel-2)' : 'transparent',
color: active ? 'var(--green)' : 'var(--ink)',
border: 'none', borderRadius: 4, padding: '7px 10px', cursor: 'pointer',
display: 'flex', alignItems: 'center', gap: 8,
}}
onMouseEnter={(e) => { if (!active) e.currentTarget.style.background = 'var(--panel)'; }}
onMouseLeave={(e) => { if (!active) e.currentTarget.style.background = 'transparent'; }}>
<span style={{ width: 12, color: 'var(--green)' }}>{active ? '▍' : ''}</span>{name}
</button>
);
})}
</div>
) : null}
</span>
);
}
// 顶栏:项目标题 + 徽章组 + 动作;agent 执行面板
function Topbar({ project, counts, onSync, onToggleConfig, t, theme, onToggleTheme, lang, onSelectLang }) {
const { Button, CountBadge } = window.MaestroDesignSystem_a6a290;
return (
<header style={{ display: 'flex', alignItems: 'flex-end', gap: 14, padding: '22px 0 14px', borderBottom: '1px solid var(--line)' }}>
<div style={{ minWidth: 0, flex: '0 1 auto' }}>
<div style={{ fontSize: 20, fontWeight: 700, letterSpacing: '.04em', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{project.name}</div>
<div style={{ fontSize: 11, color: 'var(--muted)', marginTop: 2, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }} title={project.path + ' · ' + project.branch + ' · ' + project.autonomy}>{project.path} · {project.branch} · {project.autonomy}</div>
</div>
<div style={{ flex: 1 }}></div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<CountBadge kind="gate" count={counts.gate} label={t.badgeGate} title={t.badgeGateTip} />
<CountBadge kind="ready" count={counts.ready} label={t.badgeReady} title={t.badgeReadyTip} />
<CountBadge kind="run" count={counts.run} label={t.badgeRun} title={t.badgeRunTip} />
<CountBadge kind="blocked" count={counts.blocked} label={t.badgeBlocked} title={t.badgeBlockedTip} />
<CountBadge kind="total" count={counts.total} label={t.badgeTotal} title={t.badgeTotalTip} />
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<IconAction icon={<ConfigIcon />} label={t.config} title={t.configTip} onClick={onToggleConfig} />
<LangMenu t={t} lang={lang} onSelectLang={onSelectLang} />
<IconAction icon={theme === 'light' ? <MoonIcon /> : <SunIcon />} label={theme === 'light' ? t.themeDark : t.themeLight} title={t.themeTip} onClick={onToggleTheme} />
</div>
</header>
);
}
function ConfigPanel({ project, onSave, onClose, onSync, t, lang }) {
const { Button, Input, Select } = window.MaestroDesignSystem_a6a290;
const [concurrency, setConcurrency] = React.useState(String(project.concurrency));
const [autonomy, setAutonomy] = React.useState(project.autonomy);
const [logo, setLogo] = React.useState(project.logo || '');
const [verifyCmd, setVerifyCmd] = React.useState(project.verifyCmd || '');
const [model, setModel] = React.useState(project.model || '');
const [budgetUsd, setBudgetUsd] = React.useState(project.budgetUsd != null ? String(project.budgetUsd) : '');
const [budgetPeriod, setBudgetPeriod] = React.useState(project.budgetPeriod || 'month');
const BL = lang === 'en'
? { budget: 'Budget $ (empty=∞)', period: 'Period', day: 'Daily', month: 'Monthly', ph: 'e.g. 20' }
: { budget: '预算 $(空=不限)', period: '周期', day: '按天', month: '按月', ph: '如 20' };
const save = () => onSave({
concurrency: Number(concurrency), autonomy,
logo: logo || null, verifyCmd: verifyCmd || null, model: model || null,
budgetUsd: budgetUsd.trim() === '' ? null : Number(budgetUsd),
budgetPeriod,
});
return (
<section style={{ position: 'relative', zIndex: 30, background: 'var(--panel)', border: '1px solid var(--line)', borderRadius: 6, padding: '12px 14px', marginTop: 12, animation: 'maestro-rise .18s ease both' }}>
<div style={{ display: 'flex', gap: 14, alignItems: 'flex-end', flexWrap: 'wrap' }}>
<Input label={t.maxConcurrency} type="number" value={concurrency} onChange={setConcurrency} width={76} />
<Select label={t.workMode} value={autonomy} onChange={setAutonomy} options={Object.entries(t.autonomy).map(([value, label]) => ({ value, label }))} />
<span style={{ fontSize: 11, color: 'var(--muted)', letterSpacing: '.06em', marginLeft: 'auto', paddingBottom: 7 }}>
{t.current}{project.concurrency} · {project.autonomy}
</span>
<Button variant="solid" onClick={save}>{t.save}</Button>
<Button onClick={onClose}>{t.collapse}</Button>
</div>
<div style={{ display: 'flex', gap: 14, alignItems: 'flex-end', flexWrap: 'wrap', marginTop: 12 }}>
<span style={{ flex: 2, minWidth: 220, display: 'flex' }}><Input label={t.projLogo} placeholder={t.logoPh} value={logo} onChange={setLogo} style={{ width: '100%' }} /></span>
<span style={{ flex: 1, minWidth: 170, display: 'flex' }}><Input label={t.verifyCmd} placeholder={t.verifyPh} value={verifyCmd} onChange={setVerifyCmd} style={{ width: '100%' }} /></span>
<span style={{ flex: 1, minWidth: 170, display: 'flex' }}><Input label={t.model} placeholder={t.modelPh} value={model} onChange={setModel} style={{ width: '100%' }} /></span>
</div>
<div style={{ display: 'flex', gap: 14, alignItems: 'flex-end', flexWrap: 'wrap', marginTop: 12 }}>
<Input label={BL.budget} type="number" placeholder={BL.ph} value={budgetUsd} onChange={setBudgetUsd} width={150} />
<Select label={BL.period} value={budgetPeriod} onChange={setBudgetPeriod}
options={[{ value: 'day', label: BL.day }, { value: 'month', label: BL.month }]} />
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 10, paddingTop: 10, borderTop: '1px dashed var(--line-soft)' }}>
<span style={{ fontSize: 10.5, color: 'var(--faint)', letterSpacing: '.06em' }}>{t.lastSync}</span>
<Button size="xs" onClick={onSync}> {t.sync}</Button>
</div>
</section>
);
}
function AgentSection({ agents, quota, t }) {
const { SectionHead, QuotaMeter } = window.MaestroDesignSystem_a6a290;
return (
<section>
<SectionHead mark="cyan" title={<span>{t.agentSection}<span style={{ color: 'var(--cyan)', letterSpacing: '.08em', marginLeft: 8 }}>{agents.length > 0 ? agents.length : ''}</span></span>} sticky />
{quota ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 22, flexWrap: 'wrap', fontFamily: 'var(--mono)', margin: '0 0 12px' }}>
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--muted)', letterSpacing: '.08em' }}>{t.quota}</span>
<QuotaMeter label="5h" pct={quota.five.pct} detail={t.quotaReset.replace('{t}', quota.five.reset)} />
<QuotaMeter label={t.quotaWeek} pct={quota.week.pct} detail={t.quotaReset.replace('{t}', quota.week.reset)} />
</div>
) : null}
{agents.length === 0 ? (
<div style={{ padding: '12px 14px', color: 'var(--faint)', fontSize: 12, border: '1px dashed var(--line)', borderRadius: 6 }}>{t.agentEmpty}</div>
) : (
<div style={{ display: 'flex', gap: 20, alignItems: 'stretch', background: 'var(--panel)', border: '1px solid var(--cyan-dim)', borderRadius: 6, padding: '12px 16px', animation: 'maestro-rise .2s ease both' }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minWidth: 64, padding: '4px 8px', borderRight: '1px solid var(--line-soft)' }}>
<span style={{ fontSize: 40, fontWeight: 700, lineHeight: 1, color: 'var(--cyan)', textShadow: '0 0 18px rgba(89,200,216,.5)' }}>{agents.length}</span>
<span style={{ fontSize: 9, fontWeight: 600, letterSpacing: '.3em', color: 'var(--muted)', marginTop: 6 }}>RUNNING</span>
</div>
<div style={{ flex: 1, display: 'grid', gap: 10, minWidth: 0, alignContent: 'center' }}>
{agents.map((a) => (
<div key={a.id} style={{ display: 'flex', gap: 8, alignItems: 'center', fontSize: 12, padding: '3px 0' }}>
<span style={{ flex: 'none', width: 6, height: 6, borderRadius: '50%', background: 'var(--cyan)', boxShadow: '0 0 8px var(--cyan)', animation: 'maestro-pulse .9s infinite' }}></span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{a.title}</span>
<span style={{ flex: 'none', fontSize: 10, letterSpacing: '.1em', color: 'var(--cyan)', border: '1px solid var(--cyan-dim)', padding: '0 6px', borderRadius: 3 }}>{a.kind}</span>
<span style={{ flex: 'none', marginLeft: 'auto', fontSize: 10.5, color: 'var(--faint)' }}>{a.meta} · {a.time} {t.since}</span>
</div>
))}
</div>
</div>
)}
</section>
);
}
Object.assign(window, { MaestroKitTopbar: Topbar, MaestroKitConfigPanel: ConfigPanel, MaestroKitAgentSection: AgentSection });