From 6c9b05e575640077ba7b6fbe241c66f09ad1e631 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Wed, 24 Jun 2026 15:29:44 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=A1=B9=E7=9B=AE=E7=BA=A7=E6=88=90?= =?UTF-8?q?=E6=9C=AC=E9=A2=84=E7=AE=97=E7=B3=BB=E7=BB=9F=EF=BC=88=E6=8C=89?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=C3=97token=20=E7=B2=BE=E7=A1=AE=E8=AE=A1?= =?UTF-8?q?=E8=B4=B9=20+=20=E8=B6=85=E9=A2=9D=E8=87=AA=E5=8A=A8=E6=9A=82?= =?UTF-8?q?=E5=81=9C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 精确到项目级的成本/预算护栏,成本按「模型 × 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 --- app/src/adapt.js | 21 ++++-- app/src/app.jsx | 8 ++- design/ui_kits/console/Topbar.jsx | 14 +++- src/api/server.ts | 21 +++++- src/daemon/ingest.ts | 12 ++++ src/executor/cc.ts | 24 ++++++- src/executor/pipeline.ts | 22 +++++-- src/executor/protocol.ts | 5 +- src/executor/reviewer.ts | 6 +- src/executor/runner.ts | 20 +++--- src/model/pricing.ts | 50 ++++++++++++++ src/model/types.ts | 9 +++ src/store/db.ts | 4 ++ src/store/mappers.ts | 6 ++ src/store/schema.sql | 8 ++- src/store/store.ts | 104 +++++++++++++++++++++++++++++- test/budget.test.ts | 64 ++++++++++++++++++ 17 files changed, 366 insertions(+), 32 deletions(-) create mode 100644 src/model/pricing.ts create mode 100644 test/budget.test.ts diff --git a/app/src/adapt.js b/app/src/adapt.js index 3e16b6f..91dc877 100644 --- a/app/src/adapt.js +++ b/app/src/adapt.js @@ -28,6 +28,7 @@ export function adaptProject(p) { 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', }; } @@ -135,14 +136,20 @@ export function deriveGlobal(projects, agentsResp) { }; } -// 跨项目 agent 概览。token/成本属 ⑧ 标 ★ 的新 /api/usage(预算特性),暂留 0。 -export function deriveAgentSummary(projects, agentsResp) { +// 跨项目 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: 0, runsWeek: 0, costWeek: 0, activeNow: agentsResp?.totalActive ?? 0, - byProject: projects.map((p) => ({ - id: p.id, name: p.name, hue: p.hue, - active: p.agents || 0, runs: 0, tokens: 0, - })), + 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, + }; + }), }; } diff --git a/app/src/app.jsx b/app/src/app.jsx index 16f736e..eaeb96c 100644 --- a/app/src/app.jsx +++ b/app/src/app.jsx @@ -154,6 +154,10 @@ export function App() { if (!evt.projectId || evt.projectId === currentId) { setEventsRaw((list) => [evt, ...list].slice(0, 60)); } + if (evt.type === 'budget.exceeded') { + const pay = evt.payload || {}; + toast('warn', `⚠ 项目超预算已暂停 · 已用 $${(pay.spend ?? 0).toFixed?.(2) ?? pay.spend} / $${pay.budget}`); + } clearTimeout(refreshTimer.current); refreshTimer.current = setTimeout(() => { loadProject(currentId); @@ -178,7 +182,7 @@ export function App() { const events = eventsRaw.map(adaptEvent); const quota = adaptQuota(usage); const global = deriveGlobal(projects, agentsResp); - const agentSummary = deriveAgentSummary(projects, agentsResp); + const agentSummary = deriveAgentSummary(projects, agentsResp, usage?.cost); const NONTERMINAL = (s) => !['done', 'cancelled', 'decomposed'].includes(s); const counts = { gate: gates.length, @@ -240,7 +244,7 @@ export function App() { counts={counts} onSync={onSync} onToggleConfig={() => setShowConfig(!showConfig)} /> : null} - {showConfig && project ? setShowConfig(false)} /> : null} diff --git a/design/ui_kits/console/Topbar.jsx b/design/ui_kits/console/Topbar.jsx index 3db99ee..c8922ff 100644 --- a/design/ui_kits/console/Topbar.jsx +++ b/design/ui_kits/console/Topbar.jsx @@ -107,16 +107,23 @@ function Topbar({ project, counts, onSync, onToggleConfig, t, theme, onToggleThe ); } -function ConfigPanel({ project, onSave, onClose, onSync, t }) { +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 (
@@ -134,6 +141,11 @@ function ConfigPanel({ project, onSave, onClose, onSync, t }) { +
+ +