From 0f6bcf21612b1d5db50261a1eb80d146d6bef258 Mon Sep 17 00:00:00 2001 From: yibo Date: Sat, 28 Mar 2026 02:27:38 +0800 Subject: [PATCH] feat: track genuine LLM model names in billing logs, and revamp Quota Monitor with time-based multi-dimension token tracking charts --- packages/acp-core/protocol/ClaudeCliProtocol.js | 5 +++++ server/routes/agent-chat.js | 1 + web/src/views/QuotaMonitor.vue | 243 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------- 3 files changed, 221 insertions(+), 28 deletions(-) diff --git a/packages/acp-core/protocol/ClaudeCliProtocol.js b/packages/acp-core/protocol/ClaudeCliProtocol.js index 9c0dc6c..1b987ab 100644 --- a/packages/acp-core/protocol/ClaudeCliProtocol.js +++ b/packages/acp-core/protocol/ClaudeCliProtocol.js @@ -332,11 +332,13 @@ class ClaudeCliProtocol extends EventEmitter { session_id: event.session_id, stop_reason: event.stop_reason, usage: this._lastUsage || { input: 0, output: 0 }, + model: this._lastModel || 'unknown', }, timestamp: Date.now(), }); this._lastUsage = null; + this._lastModel = null; if (this._promptResolve) { if (this._idleTimer) { clearTimeout(this._idleTimer); this._idleTimer = null; } @@ -404,6 +406,9 @@ class ClaudeCliProtocol extends EventEmitter { this._lastUsage.input += (innerEvent.message.usage.input_tokens || 0); this._lastUsage.output += (innerEvent.message.usage.output_tokens || 0); } + if (innerEvent.message?.model) { + this._lastModel = innerEvent.message.model; + } switch (innerEvent.type) { case 'content_block_delta': diff --git a/server/routes/agent-chat.js b/server/routes/agent-chat.js index baa2e74..b5ce237 100644 --- a/server/routes/agent-chat.js +++ b/server/routes/agent-chat.js @@ -1924,6 +1924,7 @@ router.post('/:agentId/chat', async (req, res) => { timestamp: new Date().toISOString(), agentId, convId, + model: event.params.model || 'unknown', costUsd: event.params.cost_usd || 0, durationMs: event.params.duration_ms, usage: event.params.usage || { input: 0, output: 0 } diff --git a/web/src/views/QuotaMonitor.vue b/web/src/views/QuotaMonitor.vue index 4e1e81d..7e581f3 100644 --- a/web/src/views/QuotaMonitor.vue +++ b/web/src/views/QuotaMonitor.vue @@ -10,11 +10,15 @@
-
-

总消耗金额 (USD)

+
+

总预估金额 (USD)*

${{ totalCost.toFixed(4) }}
+

总调用次数

+
{{ totalCalls.toLocaleString() }} 次
+
+

总输入 Tokens

{{ totalInputTokens.toLocaleString() }}
@@ -22,9 +26,49 @@

总输出 Tokens

{{ totalOutputTokens.toLocaleString() }}
-
-

平均单次推理耗时

-
{{ avgDuration.toFixed(1) }}s
+
+ + +
+
+

📈 使用趋势统计

+
+ + + + +
+
+ +
+ +
+

Token 消耗量 ■输入 ■输出

+
+
+
+
+
+
+
{{ t.label }}
+
+
无数据
+
+
+ + +
+

API 调用频次

+
+
+
+
+
+
{{ t.label }}
+
+
无数据
+
+
@@ -40,12 +84,12 @@
-
+
-
${{ agent.cost.toFixed(4) }}
+
{{ (agent.tokens / 1000).toFixed(1) }}k Tkns
- (In: {{ agent.input.toLocaleString() }} / Out: {{ agent.output.toLocaleString() }}) + (模型: {{ agent.model }} | 预估: ${{ agent.cost.toFixed(4) }})
暂无数据
@@ -61,19 +105,19 @@ 时间 + 模型 (Model) Agent - 对话 ID 耗时 输入 Tokens 输出 Tokens - 消耗金额 + 计算预估* {{ formatTime(log.timestamp) }} + {{ log.model || 'unknown' }} {{ log.agentId }} - {{ log.convId.substring(0, 12) }}... {{ (log.durationMs / 1000).toFixed(1) }}s +{{ (log.usage?.input || 0).toLocaleString() }} +{{ (log.usage?.output || 0).toLocaleString() }} @@ -100,12 +144,13 @@ import AppNav from '../components/AppNav.vue' const loading = ref(true) const rawData = ref([]) +const timeScale = ref('day') // hour, day, week, month + onMounted(async () => { try { const res = await fetch('/api/agent/quota') if (res.ok) { const data = await res.json() - // Filter out invalid records if any rawData.value = data.filter(d => d && d.timestamp).sort((a,b) => new Date(b.timestamp) - new Date(a.timestamp)) } } catch(e) { @@ -119,10 +164,43 @@ onMounted(async () => { const totalCost = computed(() => rawData.value.reduce((s, d) => s + (d.costUsd || 0), 0)) const totalInputTokens = computed(() => rawData.value.reduce((s, d) => s + (d.usage?.input || 0), 0)) const totalOutputTokens = computed(() => rawData.value.reduce((s, d) => s + (d.usage?.output || 0), 0)) -const avgDuration = computed(() => { - if (rawData.value.length === 0) return 0 - const sumMs = rawData.value.reduce((s, d) => s + (d.durationMs || 0), 0) - return (sumMs / rawData.value.length) / 1000 +const totalCalls = computed(() => rawData.value.length) + +// Time Breakdown +const timeStats = computed(() => { + const map = {} + rawData.value.forEach(d => { + const date = new Date(d.timestamp) + let label = '' + if (timeScale.value === 'hour') { + label = `${date.getMonth()+1}/${date.getDate()} ${date.getHours()}:00` + } else if (timeScale.value === 'day') { + label = `${date.getMonth()+1}/${date.getDate()}` + } else if (timeScale.value === 'week') { + const year = date.getFullYear() + const week = Math.ceil(date.getDate() / 7) + label = `W${week} (${date.getMonth()+1}月)` + } else if (timeScale.value === 'month') { + label = `${date.getFullYear()}-${date.getMonth()+1}` + } + + if (!map[label]) map[label] = { label, input: 0, output: 0, calls: 0, time: date.getTime() } + map[label].input += (d.usage?.input || 0) + map[label].output += (d.usage?.output || 0) + map[label].calls += 1 + }) + + // Sort chronically + return Object.values(map).sort((a,b) => a.time - b.time).slice(-15) // Show last 15 periods +}) + +const maxTimeTokens = computed(() => { + if (timeStats.value.length === 0) return 1 + return Math.max(...timeStats.value.map(t => t.input + t.output), 1) +}) +const maxTimeCalls = computed(() => { + if (timeStats.value.length === 0) return 1 + return Math.max(...timeStats.value.map(t => t.calls), 1) }) // Agent Breakdown @@ -130,21 +208,23 @@ const agentStats = computed(() => { const map = {} rawData.value.forEach(d => { const id = d.agentId || 'unknown' - if (!map[id]) map[id] = { id, cost: 0, input: 0, output: 0, calls: 0 } + if (!map[id]) map[id] = { id, cost: 0, input: 0, output: 0, tokens: 0, calls: 0, model: d.model || 'claude-3-5-*' } map[id].cost += (d.costUsd || 0) map[id].input += (d.usage?.input || 0) map[id].output += (d.usage?.output || 0) + map[id].tokens += (d.usage?.input || 0) + (d.usage?.output || 0) map[id].calls += 1 + if (d.model && d.model !== 'unknown') map[id].model = d.model }) - return Object.values(map).sort((a,b) => b.cost - a.cost) + return Object.values(map).sort((a,b) => b.tokens - a.tokens) }) -const maxAgentCost = computed(() => { +const maxAgentTokens = computed(() => { if (agentStats.value.length === 0) return 1 - return Math.max(...agentStats.value.map(a => a.cost), 0.0001) + return Math.max(...agentStats.value.map(a => a.tokens), 1) }) // Recent Logs table -const recentLogs = computed(() => rawData.value.slice(0, 100)) +const recentLogs = computed(() => rawData.value.slice(0, 50)) function formatTime(iso) { const d = new Date(iso) @@ -185,7 +265,7 @@ function formatTime(iso) { .overview-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 20px; margin-bottom: 24px; } @@ -197,7 +277,7 @@ function formatTime(iso) { padding: 24px; display: flex; flex-direction: column; - box-shadow: 0 4px 6px rgba(0,0,0,0.05); + box-shadow: 0 4px 6px rgba(0,0,0,0.02); } .stat-card h3 { font-size: 13px; @@ -207,7 +287,7 @@ function formatTime(iso) { margin-bottom: 12px; } .stat-value { - font-size: 32px; + font-size: 30px; font-weight: 700; color: var(--text-primary); } @@ -217,6 +297,7 @@ function formatTime(iso) { .sum-card { background: linear-gradient(145deg, rgba(82, 168, 255, 0.1) 0%, rgba(35, 131, 226, 0.02) 100%); border-color: rgba(82, 168, 255, 0.2); + cursor: help; } .charts-row { @@ -230,16 +311,121 @@ function formatTime(iso) { border-radius: 12px; padding: 24px; flex: 1; + margin-bottom: 24px; +} +.card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; } .chart-card h3 { font-size: 16px; - margin-bottom: 20px; color: var(--text-primary); display: flex; align-items: center; gap: 8px; } +/* 按时间过滤 */ +.time-filters { + display: flex; + background: var(--bg-secondary); + border-radius: 8px; + padding: 4px; +} +.time-filters button { + background: transparent; + border: none; + font-size: 12px; + padding: 6px 12px; + border-radius: 6px; + color: var(--text-secondary); + cursor: pointer; +} +.time-filters button.active { + background: var(--bg-surface); + color: var(--text-primary); + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +/* 双柱状图布局 */ +.trend-charts-wrapper { + display: flex; + gap: 32px; +} +.chart-box { + flex: 1; + display: flex; + flex-direction: column; +} +.chart-title { + font-size: 13px; + color: var(--text-secondary); + margin-bottom: 16px; + font-weight: 500; + display: flex; + justify-content: space-between; +} +.legend { font-size: 11px; } +.legend .c-in { color: #10b981; margin-right: 4px; } +.legend .c-out { color: #8b5cf6; margin-right: 4px; margin-left:8px; } + +.trend-bars { + display: flex; + align-items: flex-end; + gap: 8px; + height: 180px; + padding-bottom: 24px; + position: relative; + border-bottom: 1px solid var(--border-color); +} +.trend-bar-col { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + height: 100%; + justify-content: flex-end; + position: relative; +} +.bar-stack { + width: 100%; + max-width: 40px; + height: 100%; + display: flex; + flex-direction: column; + justify-content: flex-end; + cursor: crosshair; +} +.bar-portion-in { + background: #10b981; + width: 100%; + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; +} +.bar-portion-out { + background: #8b5cf6; + width: 100%; + border-top-left-radius: 4px; + border-top-right-radius: 4px; +} +.bar-portion-calls { + background: var(--accent-primary); + width: 100%; + border-radius: 4px; +} +.trend-label { + position: absolute; + bottom: -22px; + font-size: 10px; + color: var(--text-secondary); + white-space: nowrap; + transform: rotate(-30deg); + transform-origin: top center; +} + +/* Agent 排行榜 */ .bar-chart { display: flex; flex-direction: column; @@ -278,7 +464,7 @@ function formatTime(iso) { .bar-value { font-size: 13px; font-weight: 600; - width: 70px; + width: 80px; text-align: right; color: var(--text-primary); } @@ -308,17 +494,18 @@ function formatTime(iso) { color: var(--text-primary); } .log-time { color: var(--text-secondary) !important; font-variant-numeric: tabular-nums; } +.log-model { color: #aa862e; font-family: monospace; font-size: 12px; } .log-agent { font-weight: 500; } -.log-conv { color: var(--text-faint) !important; font-family: monospace; } .log-tokens.in { color: #10b981 !important; } .log-tokens.out { color: #8b5cf6 !important; } -.log-cost { font-weight: 600; color: var(--accent-primary) !important; } +.log-cost { font-weight: 600; color: var(--text-faint) !important; } .empty-state { padding: 40px; text-align: center; color: var(--text-faint); font-style: italic; + width: 100%; } .loading-state { display: flex; -- libgit2 0.21.4