Commit 0f6bcf21612b1d5db50261a1eb80d146d6bef258

Authored by yibo
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
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 &#39;../components/AppNav.vue&#39;
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 () =&gt; {
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(() =&gt; {
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;
... ...