Commit 0f6bcf21612b1d5db50261a1eb80d146d6bef258
1 parent
9f5229d5
feat: track genuine LLM model names in billing logs, and revamp Quota Monitor wi…
…th time-based multi-dimension token tracking charts
Showing
3 changed files
with
221 additions
and
28 deletions
packages/acp-core/protocol/ClaudeCliProtocol.js
| ... | ... | @@ -332,11 +332,13 @@ class ClaudeCliProtocol extends EventEmitter { |
| 332 | 332 | session_id: event.session_id, |
| 333 | 333 | stop_reason: event.stop_reason, |
| 334 | 334 | usage: this._lastUsage || { input: 0, output: 0 }, |
| 335 | + model: this._lastModel || 'unknown', | |
| 335 | 336 | }, |
| 336 | 337 | timestamp: Date.now(), |
| 337 | 338 | }); |
| 338 | 339 | |
| 339 | 340 | this._lastUsage = null; |
| 341 | + this._lastModel = null; | |
| 340 | 342 | |
| 341 | 343 | if (this._promptResolve) { |
| 342 | 344 | if (this._idleTimer) { clearTimeout(this._idleTimer); this._idleTimer = null; } |
| ... | ... | @@ -404,6 +406,9 @@ class ClaudeCliProtocol extends EventEmitter { |
| 404 | 406 | this._lastUsage.input += (innerEvent.message.usage.input_tokens || 0); |
| 405 | 407 | this._lastUsage.output += (innerEvent.message.usage.output_tokens || 0); |
| 406 | 408 | } |
| 409 | + if (innerEvent.message?.model) { | |
| 410 | + this._lastModel = innerEvent.message.model; | |
| 411 | + } | |
| 407 | 412 | |
| 408 | 413 | switch (innerEvent.type) { |
| 409 | 414 | case 'content_block_delta': | ... | ... |
server/routes/agent-chat.js
| ... | ... | @@ -1924,6 +1924,7 @@ router.post('/:agentId/chat', async (req, res) => { |
| 1924 | 1924 | timestamp: new Date().toISOString(), |
| 1925 | 1925 | agentId, |
| 1926 | 1926 | convId, |
| 1927 | + model: event.params.model || 'unknown', | |
| 1927 | 1928 | costUsd: event.params.cost_usd || 0, |
| 1928 | 1929 | durationMs: event.params.duration_ms, |
| 1929 | 1930 | usage: event.params.usage || { input: 0, output: 0 } | ... | ... |
web/src/views/QuotaMonitor.vue
| ... | ... | @@ -10,11 +10,15 @@ |
| 10 | 10 | <main class="dashboard-content" v-if="!loading"> |
| 11 | 11 | <!-- 核心指标 --> |
| 12 | 12 | <section class="overview-grid"> |
| 13 | - <div class="stat-card sum-card"> | |
| 14 | - <h3>总消耗金额 (USD)</h3> | |
| 13 | + <div class="stat-card sum-card" title="由于底层使用 Claude CLI 引擎,这里的 USD 计费默认根据官方 Claude 3.5 Sonnet 单价($3输入/$15输出)进行估值。"> | |
| 14 | + <h3>总预估金额 (USD)*</h3> | |
| 15 | 15 | <div class="stat-value highlight">${{ totalCost.toFixed(4) }}</div> |
| 16 | 16 | </div> |
| 17 | 17 | <div class="stat-card"> |
| 18 | + <h3>总调用次数</h3> | |
| 19 | + <div class="stat-value">{{ totalCalls.toLocaleString() }} 次</div> | |
| 20 | + </div> | |
| 21 | + <div class="stat-card"> | |
| 18 | 22 | <h3>总输入 Tokens</h3> |
| 19 | 23 | <div class="stat-value">{{ totalInputTokens.toLocaleString() }}</div> |
| 20 | 24 | </div> |
| ... | ... | @@ -22,9 +26,49 @@ |
| 22 | 26 | <h3>总输出 Tokens</h3> |
| 23 | 27 | <div class="stat-value">{{ totalOutputTokens.toLocaleString() }}</div> |
| 24 | 28 | </div> |
| 25 | - <div class="stat-card"> | |
| 26 | - <h3>平均单次推理耗时</h3> | |
| 27 | - <div class="stat-value">{{ avgDuration.toFixed(1) }}s</div> | |
| 29 | + </section> | |
| 30 | + | |
| 31 | + <!-- 按照时间维度的调用频次柱状图 --> | |
| 32 | + <section class="chart-card flex-double time-chart-section"> | |
| 33 | + <div class="card-header"> | |
| 34 | + <h3>📈 使用趋势统计</h3> | |
| 35 | + <div class="time-filters"> | |
| 36 | + <button :class="{active: timeScale === 'hour'}" @click="timeScale = 'hour'">按小时</button> | |
| 37 | + <button :class="{active: timeScale === 'day'}" @click="timeScale = 'day'">按天</button> | |
| 38 | + <button :class="{active: timeScale === 'week'}" @click="timeScale = 'week'">按周</button> | |
| 39 | + <button :class="{active: timeScale === 'month'}" @click="timeScale = 'month'">按月</button> | |
| 40 | + </div> | |
| 41 | + </div> | |
| 42 | + | |
| 43 | + <div class="trend-charts-wrapper"> | |
| 44 | + <!-- Tokens 柱状图 --> | |
| 45 | + <div class="chart-box"> | |
| 46 | + <h4 class="chart-title">Token 消耗量 <span class="legend"><span class="c-in">■</span>输入 <span class="c-out">■</span>输出</span></h4> | |
| 47 | + <div class="trend-bars"> | |
| 48 | + <div v-for="t in timeStats" :key="t.label" class="trend-bar-col"> | |
| 49 | + <div class="bar-stack" title="Input: {{ t.input }} | Output: {{ t.output }}"> | |
| 50 | + <div class="bar-portion-out" :style="{ height: ((t.output / maxTimeTokens) * 100) + '%' }"></div> | |
| 51 | + <div class="bar-portion-in" :style="{ height: ((t.input / maxTimeTokens) * 100) + '%' }"></div> | |
| 52 | + </div> | |
| 53 | + <div class="trend-label">{{ t.label }}</div> | |
| 54 | + </div> | |
| 55 | + <div v-if="timeStats.length === 0" class="empty-state">无数据</div> | |
| 56 | + </div> | |
| 57 | + </div> | |
| 58 | + | |
| 59 | + <!-- 调用次数柱状图 --> | |
| 60 | + <div class="chart-box"> | |
| 61 | + <h4 class="chart-title">API 调用频次</h4> | |
| 62 | + <div class="trend-bars"> | |
| 63 | + <div v-for="t in timeStats" :key="t.label" class="trend-bar-col"> | |
| 64 | + <div class="bar-stack" title="{{ t.calls }} 次"> | |
| 65 | + <div class="bar-portion-calls" :style="{ height: ((t.calls / maxTimeCalls) * 100) + '%' }"></div> | |
| 66 | + </div> | |
| 67 | + <div class="trend-label">{{ t.label }}</div> | |
| 68 | + </div> | |
| 69 | + <div v-if="timeStats.length === 0" class="empty-state">无数据</div> | |
| 70 | + </div> | |
| 71 | + </div> | |
| 28 | 72 | </div> |
| 29 | 73 | </section> |
| 30 | 74 | |
| ... | ... | @@ -40,12 +84,12 @@ |
| 40 | 84 | </div> |
| 41 | 85 | <div class="bar-track-wrapper"> |
| 42 | 86 | <div class="bar-track"> |
| 43 | - <div class="bar-fill" :style="{ width: ((agent.cost / maxAgentCost) * 100) + '%' }"></div> | |
| 87 | + <div class="bar-fill" :style="{ width: ((agent.tokens / maxAgentTokens) * 100) + '%' }"></div> | |
| 44 | 88 | </div> |
| 45 | - <div class="bar-value">${{ agent.cost.toFixed(4) }}</div> | |
| 89 | + <div class="bar-value">{{ (agent.tokens / 1000).toFixed(1) }}k Tkns</div> | |
| 46 | 90 | </div> |
| 47 | 91 | <div class="bar-subtext"> |
| 48 | - (In: {{ agent.input.toLocaleString() }} / Out: {{ agent.output.toLocaleString() }}) | |
| 92 | + (模型: {{ agent.model }} | 预估: ${{ agent.cost.toFixed(4) }}) | |
| 49 | 93 | </div> |
| 50 | 94 | </div> |
| 51 | 95 | <div v-if="agentStats.length === 0" class="empty-state">暂无数据</div> |
| ... | ... | @@ -61,19 +105,19 @@ |
| 61 | 105 | <thead> |
| 62 | 106 | <tr> |
| 63 | 107 | <th>时间</th> |
| 108 | + <th>模型 (Model)</th> | |
| 64 | 109 | <th>Agent</th> |
| 65 | - <th>对话 ID</th> | |
| 66 | 110 | <th>耗时</th> |
| 67 | 111 | <th>输入 Tokens</th> |
| 68 | 112 | <th>输出 Tokens</th> |
| 69 | - <th>消耗金额</th> | |
| 113 | + <th>计算预估*</th> | |
| 70 | 114 | </tr> |
| 71 | 115 | </thead> |
| 72 | 116 | <tbody> |
| 73 | 117 | <tr v-for="(log, i) in recentLogs" :key="i"> |
| 74 | 118 | <td class="log-time">{{ formatTime(log.timestamp) }}</td> |
| 119 | + <td class="log-model">{{ log.model || 'unknown' }}</td> | |
| 75 | 120 | <td class="log-agent">{{ log.agentId }}</td> |
| 76 | - <td class="log-conv" :title="log.convId">{{ log.convId.substring(0, 12) }}...</td> | |
| 77 | 121 | <td class="log-duration">{{ (log.durationMs / 1000).toFixed(1) }}s</td> |
| 78 | 122 | <td class="log-tokens in">+{{ (log.usage?.input || 0).toLocaleString() }}</td> |
| 79 | 123 | <td class="log-tokens out">+{{ (log.usage?.output || 0).toLocaleString() }}</td> |
| ... | ... | @@ -100,12 +144,13 @@ import AppNav from '../components/AppNav.vue' |
| 100 | 144 | const loading = ref(true) |
| 101 | 145 | const rawData = ref([]) |
| 102 | 146 | |
| 147 | +const timeScale = ref('day') // hour, day, week, month | |
| 148 | + | |
| 103 | 149 | onMounted(async () => { |
| 104 | 150 | try { |
| 105 | 151 | const res = await fetch('/api/agent/quota') |
| 106 | 152 | if (res.ok) { |
| 107 | 153 | const data = await res.json() |
| 108 | - // Filter out invalid records if any | |
| 109 | 154 | rawData.value = data.filter(d => d && d.timestamp).sort((a,b) => new Date(b.timestamp) - new Date(a.timestamp)) |
| 110 | 155 | } |
| 111 | 156 | } catch(e) { |
| ... | ... | @@ -119,10 +164,43 @@ onMounted(async () => { |
| 119 | 164 | const totalCost = computed(() => rawData.value.reduce((s, d) => s + (d.costUsd || 0), 0)) |
| 120 | 165 | const totalInputTokens = computed(() => rawData.value.reduce((s, d) => s + (d.usage?.input || 0), 0)) |
| 121 | 166 | const totalOutputTokens = computed(() => rawData.value.reduce((s, d) => s + (d.usage?.output || 0), 0)) |
| 122 | -const avgDuration = computed(() => { | |
| 123 | - if (rawData.value.length === 0) return 0 | |
| 124 | - const sumMs = rawData.value.reduce((s, d) => s + (d.durationMs || 0), 0) | |
| 125 | - return (sumMs / rawData.value.length) / 1000 | |
| 167 | +const totalCalls = computed(() => rawData.value.length) | |
| 168 | + | |
| 169 | +// Time Breakdown | |
| 170 | +const timeStats = computed(() => { | |
| 171 | + const map = {} | |
| 172 | + rawData.value.forEach(d => { | |
| 173 | + const date = new Date(d.timestamp) | |
| 174 | + let label = '' | |
| 175 | + if (timeScale.value === 'hour') { | |
| 176 | + label = `${date.getMonth()+1}/${date.getDate()} ${date.getHours()}:00` | |
| 177 | + } else if (timeScale.value === 'day') { | |
| 178 | + label = `${date.getMonth()+1}/${date.getDate()}` | |
| 179 | + } else if (timeScale.value === 'week') { | |
| 180 | + const year = date.getFullYear() | |
| 181 | + const week = Math.ceil(date.getDate() / 7) | |
| 182 | + label = `W${week} (${date.getMonth()+1}月)` | |
| 183 | + } else if (timeScale.value === 'month') { | |
| 184 | + label = `${date.getFullYear()}-${date.getMonth()+1}` | |
| 185 | + } | |
| 186 | + | |
| 187 | + if (!map[label]) map[label] = { label, input: 0, output: 0, calls: 0, time: date.getTime() } | |
| 188 | + map[label].input += (d.usage?.input || 0) | |
| 189 | + map[label].output += (d.usage?.output || 0) | |
| 190 | + map[label].calls += 1 | |
| 191 | + }) | |
| 192 | + | |
| 193 | + // Sort chronically | |
| 194 | + return Object.values(map).sort((a,b) => a.time - b.time).slice(-15) // Show last 15 periods | |
| 195 | +}) | |
| 196 | + | |
| 197 | +const maxTimeTokens = computed(() => { | |
| 198 | + if (timeStats.value.length === 0) return 1 | |
| 199 | + return Math.max(...timeStats.value.map(t => t.input + t.output), 1) | |
| 200 | +}) | |
| 201 | +const maxTimeCalls = computed(() => { | |
| 202 | + if (timeStats.value.length === 0) return 1 | |
| 203 | + return Math.max(...timeStats.value.map(t => t.calls), 1) | |
| 126 | 204 | }) |
| 127 | 205 | |
| 128 | 206 | // Agent Breakdown |
| ... | ... | @@ -130,21 +208,23 @@ const agentStats = computed(() => { |
| 130 | 208 | const map = {} |
| 131 | 209 | rawData.value.forEach(d => { |
| 132 | 210 | const id = d.agentId || 'unknown' |
| 133 | - if (!map[id]) map[id] = { id, cost: 0, input: 0, output: 0, calls: 0 } | |
| 211 | + if (!map[id]) map[id] = { id, cost: 0, input: 0, output: 0, tokens: 0, calls: 0, model: d.model || 'claude-3-5-*' } | |
| 134 | 212 | map[id].cost += (d.costUsd || 0) |
| 135 | 213 | map[id].input += (d.usage?.input || 0) |
| 136 | 214 | map[id].output += (d.usage?.output || 0) |
| 215 | + map[id].tokens += (d.usage?.input || 0) + (d.usage?.output || 0) | |
| 137 | 216 | map[id].calls += 1 |
| 217 | + if (d.model && d.model !== 'unknown') map[id].model = d.model | |
| 138 | 218 | }) |
| 139 | - return Object.values(map).sort((a,b) => b.cost - a.cost) | |
| 219 | + return Object.values(map).sort((a,b) => b.tokens - a.tokens) | |
| 140 | 220 | }) |
| 141 | -const maxAgentCost = computed(() => { | |
| 221 | +const maxAgentTokens = computed(() => { | |
| 142 | 222 | if (agentStats.value.length === 0) return 1 |
| 143 | - return Math.max(...agentStats.value.map(a => a.cost), 0.0001) | |
| 223 | + return Math.max(...agentStats.value.map(a => a.tokens), 1) | |
| 144 | 224 | }) |
| 145 | 225 | |
| 146 | 226 | // Recent Logs table |
| 147 | -const recentLogs = computed(() => rawData.value.slice(0, 100)) | |
| 227 | +const recentLogs = computed(() => rawData.value.slice(0, 50)) | |
| 148 | 228 | |
| 149 | 229 | function formatTime(iso) { |
| 150 | 230 | const d = new Date(iso) |
| ... | ... | @@ -185,7 +265,7 @@ function formatTime(iso) { |
| 185 | 265 | |
| 186 | 266 | .overview-grid { |
| 187 | 267 | display: grid; |
| 188 | - grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); | |
| 268 | + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); | |
| 189 | 269 | gap: 20px; |
| 190 | 270 | margin-bottom: 24px; |
| 191 | 271 | } |
| ... | ... | @@ -197,7 +277,7 @@ function formatTime(iso) { |
| 197 | 277 | padding: 24px; |
| 198 | 278 | display: flex; |
| 199 | 279 | flex-direction: column; |
| 200 | - box-shadow: 0 4px 6px rgba(0,0,0,0.05); | |
| 280 | + box-shadow: 0 4px 6px rgba(0,0,0,0.02); | |
| 201 | 281 | } |
| 202 | 282 | .stat-card h3 { |
| 203 | 283 | font-size: 13px; |
| ... | ... | @@ -207,7 +287,7 @@ function formatTime(iso) { |
| 207 | 287 | margin-bottom: 12px; |
| 208 | 288 | } |
| 209 | 289 | .stat-value { |
| 210 | - font-size: 32px; | |
| 290 | + font-size: 30px; | |
| 211 | 291 | font-weight: 700; |
| 212 | 292 | color: var(--text-primary); |
| 213 | 293 | } |
| ... | ... | @@ -217,6 +297,7 @@ function formatTime(iso) { |
| 217 | 297 | .sum-card { |
| 218 | 298 | background: linear-gradient(145deg, rgba(82, 168, 255, 0.1) 0%, rgba(35, 131, 226, 0.02) 100%); |
| 219 | 299 | border-color: rgba(82, 168, 255, 0.2); |
| 300 | + cursor: help; | |
| 220 | 301 | } |
| 221 | 302 | |
| 222 | 303 | .charts-row { |
| ... | ... | @@ -230,16 +311,121 @@ function formatTime(iso) { |
| 230 | 311 | border-radius: 12px; |
| 231 | 312 | padding: 24px; |
| 232 | 313 | flex: 1; |
| 314 | + margin-bottom: 24px; | |
| 315 | +} | |
| 316 | +.card-header { | |
| 317 | + display: flex; | |
| 318 | + justify-content: space-between; | |
| 319 | + align-items: center; | |
| 320 | + margin-bottom: 20px; | |
| 233 | 321 | } |
| 234 | 322 | .chart-card h3 { |
| 235 | 323 | font-size: 16px; |
| 236 | - margin-bottom: 20px; | |
| 237 | 324 | color: var(--text-primary); |
| 238 | 325 | display: flex; |
| 239 | 326 | align-items: center; |
| 240 | 327 | gap: 8px; |
| 241 | 328 | } |
| 242 | 329 | |
| 330 | +/* 按时间过滤 */ | |
| 331 | +.time-filters { | |
| 332 | + display: flex; | |
| 333 | + background: var(--bg-secondary); | |
| 334 | + border-radius: 8px; | |
| 335 | + padding: 4px; | |
| 336 | +} | |
| 337 | +.time-filters button { | |
| 338 | + background: transparent; | |
| 339 | + border: none; | |
| 340 | + font-size: 12px; | |
| 341 | + padding: 6px 12px; | |
| 342 | + border-radius: 6px; | |
| 343 | + color: var(--text-secondary); | |
| 344 | + cursor: pointer; | |
| 345 | +} | |
| 346 | +.time-filters button.active { | |
| 347 | + background: var(--bg-surface); | |
| 348 | + color: var(--text-primary); | |
| 349 | + box-shadow: 0 2px 4px rgba(0,0,0,0.1); | |
| 350 | +} | |
| 351 | + | |
| 352 | +/* 双柱状图布局 */ | |
| 353 | +.trend-charts-wrapper { | |
| 354 | + display: flex; | |
| 355 | + gap: 32px; | |
| 356 | +} | |
| 357 | +.chart-box { | |
| 358 | + flex: 1; | |
| 359 | + display: flex; | |
| 360 | + flex-direction: column; | |
| 361 | +} | |
| 362 | +.chart-title { | |
| 363 | + font-size: 13px; | |
| 364 | + color: var(--text-secondary); | |
| 365 | + margin-bottom: 16px; | |
| 366 | + font-weight: 500; | |
| 367 | + display: flex; | |
| 368 | + justify-content: space-between; | |
| 369 | +} | |
| 370 | +.legend { font-size: 11px; } | |
| 371 | +.legend .c-in { color: #10b981; margin-right: 4px; } | |
| 372 | +.legend .c-out { color: #8b5cf6; margin-right: 4px; margin-left:8px; } | |
| 373 | + | |
| 374 | +.trend-bars { | |
| 375 | + display: flex; | |
| 376 | + align-items: flex-end; | |
| 377 | + gap: 8px; | |
| 378 | + height: 180px; | |
| 379 | + padding-bottom: 24px; | |
| 380 | + position: relative; | |
| 381 | + border-bottom: 1px solid var(--border-color); | |
| 382 | +} | |
| 383 | +.trend-bar-col { | |
| 384 | + flex: 1; | |
| 385 | + display: flex; | |
| 386 | + flex-direction: column; | |
| 387 | + align-items: center; | |
| 388 | + height: 100%; | |
| 389 | + justify-content: flex-end; | |
| 390 | + position: relative; | |
| 391 | +} | |
| 392 | +.bar-stack { | |
| 393 | + width: 100%; | |
| 394 | + max-width: 40px; | |
| 395 | + height: 100%; | |
| 396 | + display: flex; | |
| 397 | + flex-direction: column; | |
| 398 | + justify-content: flex-end; | |
| 399 | + cursor: crosshair; | |
| 400 | +} | |
| 401 | +.bar-portion-in { | |
| 402 | + background: #10b981; | |
| 403 | + width: 100%; | |
| 404 | + border-bottom-left-radius: 4px; | |
| 405 | + border-bottom-right-radius: 4px; | |
| 406 | +} | |
| 407 | +.bar-portion-out { | |
| 408 | + background: #8b5cf6; | |
| 409 | + width: 100%; | |
| 410 | + border-top-left-radius: 4px; | |
| 411 | + border-top-right-radius: 4px; | |
| 412 | +} | |
| 413 | +.bar-portion-calls { | |
| 414 | + background: var(--accent-primary); | |
| 415 | + width: 100%; | |
| 416 | + border-radius: 4px; | |
| 417 | +} | |
| 418 | +.trend-label { | |
| 419 | + position: absolute; | |
| 420 | + bottom: -22px; | |
| 421 | + font-size: 10px; | |
| 422 | + color: var(--text-secondary); | |
| 423 | + white-space: nowrap; | |
| 424 | + transform: rotate(-30deg); | |
| 425 | + transform-origin: top center; | |
| 426 | +} | |
| 427 | + | |
| 428 | +/* Agent 排行榜 */ | |
| 243 | 429 | .bar-chart { |
| 244 | 430 | display: flex; |
| 245 | 431 | flex-direction: column; |
| ... | ... | @@ -278,7 +464,7 @@ function formatTime(iso) { |
| 278 | 464 | .bar-value { |
| 279 | 465 | font-size: 13px; |
| 280 | 466 | font-weight: 600; |
| 281 | - width: 70px; | |
| 467 | + width: 80px; | |
| 282 | 468 | text-align: right; |
| 283 | 469 | color: var(--text-primary); |
| 284 | 470 | } |
| ... | ... | @@ -308,17 +494,18 @@ function formatTime(iso) { |
| 308 | 494 | color: var(--text-primary); |
| 309 | 495 | } |
| 310 | 496 | .log-time { color: var(--text-secondary) !important; font-variant-numeric: tabular-nums; } |
| 497 | +.log-model { color: #aa862e; font-family: monospace; font-size: 12px; } | |
| 311 | 498 | .log-agent { font-weight: 500; } |
| 312 | -.log-conv { color: var(--text-faint) !important; font-family: monospace; } | |
| 313 | 499 | .log-tokens.in { color: #10b981 !important; } |
| 314 | 500 | .log-tokens.out { color: #8b5cf6 !important; } |
| 315 | -.log-cost { font-weight: 600; color: var(--accent-primary) !important; } | |
| 501 | +.log-cost { font-weight: 600; color: var(--text-faint) !important; } | |
| 316 | 502 | |
| 317 | 503 | .empty-state { |
| 318 | 504 | padding: 40px; |
| 319 | 505 | text-align: center; |
| 320 | 506 | color: var(--text-faint); |
| 321 | 507 | font-style: italic; |
| 508 | + width: 100%; | |
| 322 | 509 | } |
| 323 | 510 | .loading-state { |
| 324 | 511 | display: flex; | ... | ... |