From 5198435ba0f4d4ceabe648074932def0a99b23ab Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Mon, 29 Jun 2026 17:42:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(usage):=20=E7=94=A8=E9=87=8F=E6=98=8E?= =?UTF-8?q?=E7=BB=86=E2=80=94=E2=80=94/api/usage=20=E5=8A=A0=20week=20?= =?UTF-8?q?=E7=AA=97=E5=8F=A3+runs=20=E8=AE=A1=E6=95=B0=EF=BC=8C=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20/api/usage/detail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit costSummary 顶层补 runs 计数、period 扩 day|week|month;新增 usageDetail(day|week|month) 返回时间序列分桶 + 分项目下钻(token/花费/任务数/模型,model 维度沿用 usage JSON 近似口径)。 Co-Authored-By: Claude Opus 4.8 --- src/api/server.ts | 9 ++++- src/store/store.ts | 82 +++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 86 insertions(+), 5 deletions(-) diff --git a/src/api/server.ts b/src/api/server.ts index 269dd20..7aff59a 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -196,12 +196,19 @@ export function buildServer(opts: ApiOptions): FastifyInstance { // ?period=day|month(默认 month);?project= 限定单项目成本。 app.get('/api/usage', async (req) => { const q = req.query as { period?: string; project?: string }; - const period = q.period === 'day' ? 'day' : 'month'; + const period = q.period === 'day' ? 'day' : q.period === 'week' ? 'week' : 'month'; const quota = await getUsage(); const cost = store.costSummary(period, q.project && q.project.trim() ? q.project.trim() : undefined); return { ...(quota ?? {}), cost }; }); + // 用量详情:时间序列(按天/按月)+ 分项目下钻,供侧栏「详情」弹框。 + app.get('/api/usage/detail', (req) => { + const q = req.query as { granularity?: string }; + const granularity = q.granularity === 'month' ? 'month' : q.granularity === 'week' ? 'week' : 'day'; + return store.usageDetail(granularity); + }); + // 健康检查:daemon 存活 + DB 可读 + 在途 run 数。 app.get('/api/health', () => { let db = true; diff --git a/src/store/store.ts b/src/store/store.ts index aa1b676..1300f96 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -39,14 +39,22 @@ const SETTINGS_DEFAULT: UserSettings = { const id = (prefix: string): string => `${prefix}_${nanoid(12)}`; /** 预算周期起点(UTC):day=今日零点 / month=当月 1 号零点。与存储的 ISO 时间戳同基准比较。 */ -function periodStartISO(period: 'day' | 'month'): string { +function periodStartISO(period: 'day' | 'week' | 'month'): string { const d = new Date(); + if (period === 'week') return new Date(Date.now() - 7 * 86400_000).toISOString(); const start = period === 'day' ? Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()) : Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1); return new Date(start).toISOString(); } +/** 把 ISO 时间戳归一到所属周的周一(UTC),返回 YYYY-MM-DD,作为「按周」时间桶标签。 */ +function weekStartLabel(iso: string): string { + const d = new Date(iso); + const dow = (d.getUTCDay() + 6) % 7; // 周一=0 … 周日=6 + return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() - dow)).toISOString().slice(0, 10); +} + export class StoreError extends Error {} const MODEL_ROLES = ['executor', 'planner', 'reviewer', 'conflict'] as const; @@ -101,12 +109,24 @@ export interface PatchTaskInput { } /** 成本明细(按项目 / 按模型 / 总计),供 GET /api/usage 与预算判定 */ export interface CostSummary { - period: 'day' | 'month'; + period: 'day' | 'week' | 'month'; total: number; + runs: number; byProject: Array<{ projectId: string; costUsd: number; tokens: number }>; byModel: Array<{ model: string; costUsd: number; tokens: number }>; } +/** 用量详情:时间序列 + 分项目下钻,供 GET /api/usage/detail(详情弹框) */ +export interface UsageDetail { + granularity: 'day' | 'week' | 'month'; + total: { costUsd: number; tokens: number; runs: number; tasks: number }; + series: Array<{ bucket: string; costUsd: number; tokens: number; runs: number }>; + byProject: Array<{ + projectId: string; costUsd: number; tokens: number; runs: number; tasks: number; + models: Array<{ model: string; costUsd: number; tokens: number }>; + }>; +} + /** 执行中的 run(联 tasks 取标题/项目),供 GET /api/agents 汇总 */ export interface ActiveRun { runId: string; taskId: string; taskTitle: string; kind: string; @@ -1157,7 +1177,7 @@ export class Store { } /** 成本明细:按项目 / 按模型 / 总计(period 窗口内,可选限定项目)。 */ - costSummary(period: 'day' | 'month', projectId?: string): CostSummary { + costSummary(period: 'day' | 'week' | 'month', projectId?: string): CostSummary { const since = periodStartISO(period); const rows = this.db.prepare( `SELECT t.project_id AS pid, r.usage AS usage, r.cost_usd AS cost @@ -1176,12 +1196,66 @@ export class Store { const bm = byModel.get(model) ?? { cost: 0, tokens: 0 }; bm.cost += row.cost; bm.tokens += tokens; byModel.set(model, bm); } return { - period, total, + period, total, runs: rows.length, byProject: [...byProject].map(([pid, v]) => ({ projectId: pid, costUsd: v.cost, tokens: v.tokens })), byModel: [...byModel].map(([model, v]) => ({ model, costUsd: v.cost, tokens: v.tokens })), }; } + /** + * 用量详情:时间序列(按天/按周/按月分桶)+ 分项目下钻(token/花费/任务数/模型)。 + * day → 近 30 天、桶=YYYY-MM-DD;week → 近 12 周、桶=周一 YYYY-MM-DD;month → 近 12 个月、桶=YYYY-MM。 + * token/model 解析与 costSummary 同款(usage JSON 存累计 token + 最近 model,model 维度为近似)。 + */ + usageDetail(granularity: 'day' | 'week' | 'month'): UsageDetail { + const now = new Date(); + const since = granularity === 'day' + ? new Date(Date.now() - 30 * 86400_000).toISOString() + : granularity === 'week' + ? new Date(Date.now() - 12 * 7 * 86400_000).toISOString() + : new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 11, 1)).toISOString(); + const rows = this.db.prepare( + `SELECT t.project_id AS pid, r.task_id AS tid, r.started_at AS at, r.usage AS usage, r.cost_usd AS cost + FROM runs r JOIN tasks t ON t.id = r.task_id + WHERE r.started_at >= ? AND r.cost_usd IS NOT NULL`, + ).all(since) as Array<{ pid: string; tid: string; at: string; usage: string | null; cost: number }>; + + const series = new Map(); + const proj = new Map; models: Map }>(); + const total = { costUsd: 0, tokens: 0, runs: 0, tasks: new Set() }; + + for (const row of rows) { + const u = row.usage ? JSON.parse(row.usage) as UsageTokens & { model?: string } : null; + const tokens = u ? u.input + u.output + u.cacheRead + u.cacheWrite : 0; + const model = u?.model ?? 'unknown'; + const bucket = granularity === 'day' ? row.at.slice(0, 10) + : granularity === 'week' ? weekStartLabel(row.at) + : row.at.slice(0, 7); + + const s = series.get(bucket) ?? { cost: 0, tokens: 0, runs: 0 }; + s.cost += row.cost; s.tokens += tokens; s.runs += 1; series.set(bucket, s); + + const p = proj.get(row.pid) ?? { cost: 0, tokens: 0, runs: 0, tasks: new Set(), models: new Map() }; + p.cost += row.cost; p.tokens += tokens; p.runs += 1; p.tasks.add(row.tid); + const m = p.models.get(model) ?? { cost: 0, tokens: 0 }; m.cost += row.cost; m.tokens += tokens; p.models.set(model, m); + proj.set(row.pid, p); + + total.costUsd += row.cost; total.tokens += tokens; total.runs += 1; total.tasks.add(row.tid); + } + + return { + granularity, + total: { costUsd: total.costUsd, tokens: total.tokens, runs: total.runs, tasks: total.tasks.size }, + series: [...series].sort((a, b) => a[0] < b[0] ? -1 : 1) + .map(([bucket, v]) => ({ bucket, costUsd: v.cost, tokens: v.tokens, runs: v.runs })), + byProject: [...proj].map(([projectId, v]) => ({ + projectId, costUsd: v.cost, tokens: v.tokens, runs: v.runs, tasks: v.tasks.size, + models: [...v.models].map(([model, mv]) => ({ model, costUsd: mv.cost, tokens: mv.tokens })) + .sort((a, b) => b.costUsd - a.costUsd), + })).sort((a, b) => b.costUsd - a.costUsd), + }; + } + /** * 预算护栏:项目当期成本达到预算上限时,置 paused 并广播 budget.exceeded(仅在 active→paused 时广播一次)。 * 无预算(budgetUsd 为 null/≤0)则不干预。返回是否已超额(供编排器跳过领取)。