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,11 +332,13 @@ class ClaudeCliProtocol extends EventEmitter {
332 session_id: event.session_id, 332 session_id: event.session_id,
333 stop_reason: event.stop_reason, 333 stop_reason: event.stop_reason,
334 usage: this._lastUsage || { input: 0, output: 0 }, 334 usage: this._lastUsage || { input: 0, output: 0 },
  335 + model: this._lastModel || 'unknown',
335 }, 336 },
336 timestamp: Date.now(), 337 timestamp: Date.now(),
337 }); 338 });
338 339
339 this._lastUsage = null; 340 this._lastUsage = null;
  341 + this._lastModel = null;
340 342
341 if (this._promptResolve) { 343 if (this._promptResolve) {
342 if (this._idleTimer) { clearTimeout(this._idleTimer); this._idleTimer = null; } 344 if (this._idleTimer) { clearTimeout(this._idleTimer); this._idleTimer = null; }
@@ -404,6 +406,9 @@ class ClaudeCliProtocol extends EventEmitter { @@ -404,6 +406,9 @@ class ClaudeCliProtocol extends EventEmitter {
404 this._lastUsage.input += (innerEvent.message.usage.input_tokens || 0); 406 this._lastUsage.input += (innerEvent.message.usage.input_tokens || 0);
405 this._lastUsage.output += (innerEvent.message.usage.output_tokens || 0); 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 switch (innerEvent.type) { 413 switch (innerEvent.type) {
409 case 'content_block_delta': 414 case 'content_block_delta':
server/routes/agent-chat.js
@@ -1924,6 +1924,7 @@ router.post('/:agentId/chat', async (req, res) => { @@ -1924,6 +1924,7 @@ router.post('/:agentId/chat', async (req, res) => {
1924 timestamp: new Date().toISOString(), 1924 timestamp: new Date().toISOString(),
1925 agentId, 1925 agentId,
1926 convId, 1926 convId,
  1927 + model: event.params.model || 'unknown',
1927 costUsd: event.params.cost_usd || 0, 1928 costUsd: event.params.cost_usd || 0,
1928 durationMs: event.params.duration_ms, 1929 durationMs: event.params.duration_ms,
1929 usage: event.params.usage || { input: 0, output: 0 } 1930 usage: event.params.usage || { input: 0, output: 0 }
web/src/views/QuotaMonitor.vue
@@ -10,11 +10,15 @@ @@ -10,11 +10,15 @@
10 <main class="dashboard-content" v-if="!loading"> 10 <main class="dashboard-content" v-if="!loading">
11 <!-- 核心指标 --> 11 <!-- 核心指标 -->
12 <section class="overview-grid"> 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 <div class="stat-value highlight">${{ totalCost.toFixed(4) }}</div> 15 <div class="stat-value highlight">${{ totalCost.toFixed(4) }}</div>
16 </div> 16 </div>
17 <div class="stat-card"> 17 <div class="stat-card">
  18 + <h3>总调用次数</h3>
  19 + <div class="stat-value">{{ totalCalls.toLocaleString() }} 次</div>
  20 + </div>
  21 + <div class="stat-card">
18 <h3>总输入 Tokens</h3> 22 <h3>总输入 Tokens</h3>
19 <div class="stat-value">{{ totalInputTokens.toLocaleString() }}</div> 23 <div class="stat-value">{{ totalInputTokens.toLocaleString() }}</div>
20 </div> 24 </div>
@@ -22,9 +26,49 @@ @@ -22,9 +26,49 @@
22 <h3>总输出 Tokens</h3> 26 <h3>总输出 Tokens</h3>
23 <div class="stat-value">{{ totalOutputTokens.toLocaleString() }}</div> 27 <div class="stat-value">{{ totalOutputTokens.toLocaleString() }}</div>
24 </div> 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 </div> 72 </div>
29 </section> 73 </section>
30 74
@@ -40,12 +84,12 @@ @@ -40,12 +84,12 @@
40 </div> 84 </div>
41 <div class="bar-track-wrapper"> 85 <div class="bar-track-wrapper">
42 <div class="bar-track"> 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 </div> 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 </div> 90 </div>
47 <div class="bar-subtext"> 91 <div class="bar-subtext">
48 - (In: {{ agent.input.toLocaleString() }} / Out: {{ agent.output.toLocaleString() }}) 92 + (模型: {{ agent.model }} | 预估: ${{ agent.cost.toFixed(4) }})
49 </div> 93 </div>
50 </div> 94 </div>
51 <div v-if="agentStats.length === 0" class="empty-state">暂无数据</div> 95 <div v-if="agentStats.length === 0" class="empty-state">暂无数据</div>
@@ -61,19 +105,19 @@ @@ -61,19 +105,19 @@
61 <thead> 105 <thead>
62 <tr> 106 <tr>
63 <th>时间</th> 107 <th>时间</th>
  108 + <th>模型 (Model)</th>
64 <th>Agent</th> 109 <th>Agent</th>
65 - <th>对话 ID</th>  
66 <th>耗时</th> 110 <th>耗时</th>
67 <th>输入 Tokens</th> 111 <th>输入 Tokens</th>
68 <th>输出 Tokens</th> 112 <th>输出 Tokens</th>
69 - <th>消耗金额</th> 113 + <th>计算预估*</th>
70 </tr> 114 </tr>
71 </thead> 115 </thead>
72 <tbody> 116 <tbody>
73 <tr v-for="(log, i) in recentLogs" :key="i"> 117 <tr v-for="(log, i) in recentLogs" :key="i">
74 <td class="log-time">{{ formatTime(log.timestamp) }}</td> 118 <td class="log-time">{{ formatTime(log.timestamp) }}</td>
  119 + <td class="log-model">{{ log.model || 'unknown' }}</td>
75 <td class="log-agent">{{ log.agentId }}</td> 120 <td class="log-agent">{{ log.agentId }}</td>
76 - <td class="log-conv" :title="log.convId">{{ log.convId.substring(0, 12) }}...</td>  
77 <td class="log-duration">{{ (log.durationMs / 1000).toFixed(1) }}s</td> 121 <td class="log-duration">{{ (log.durationMs / 1000).toFixed(1) }}s</td>
78 <td class="log-tokens in">+{{ (log.usage?.input || 0).toLocaleString() }}</td> 122 <td class="log-tokens in">+{{ (log.usage?.input || 0).toLocaleString() }}</td>
79 <td class="log-tokens out">+{{ (log.usage?.output || 0).toLocaleString() }}</td> 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,12 +144,13 @@ import AppNav from &#39;../components/AppNav.vue&#39;
100 const loading = ref(true) 144 const loading = ref(true)
101 const rawData = ref([]) 145 const rawData = ref([])
102 146
  147 +const timeScale = ref('day') // hour, day, week, month
  148 +
103 onMounted(async () => { 149 onMounted(async () => {
104 try { 150 try {
105 const res = await fetch('/api/agent/quota') 151 const res = await fetch('/api/agent/quota')
106 if (res.ok) { 152 if (res.ok) {
107 const data = await res.json() 153 const data = await res.json()
108 - // Filter out invalid records if any  
109 rawData.value = data.filter(d => d && d.timestamp).sort((a,b) => new Date(b.timestamp) - new Date(a.timestamp)) 154 rawData.value = data.filter(d => d && d.timestamp).sort((a,b) => new Date(b.timestamp) - new Date(a.timestamp))
110 } 155 }
111 } catch(e) { 156 } catch(e) {
@@ -119,10 +164,43 @@ onMounted(async () =&gt; { @@ -119,10 +164,43 @@ onMounted(async () =&gt; {
119 const totalCost = computed(() => rawData.value.reduce((s, d) => s + (d.costUsd || 0), 0)) 164 const totalCost = computed(() => rawData.value.reduce((s, d) => s + (d.costUsd || 0), 0))
120 const totalInputTokens = computed(() => rawData.value.reduce((s, d) => s + (d.usage?.input || 0), 0)) 165 const totalInputTokens = computed(() => rawData.value.reduce((s, d) => s + (d.usage?.input || 0), 0))
121 const totalOutputTokens = computed(() => rawData.value.reduce((s, d) => s + (d.usage?.output || 0), 0)) 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 // Agent Breakdown 206 // Agent Breakdown
@@ -130,21 +208,23 @@ const agentStats = computed(() =&gt; { @@ -130,21 +208,23 @@ const agentStats = computed(() =&gt; {
130 const map = {} 208 const map = {}
131 rawData.value.forEach(d => { 209 rawData.value.forEach(d => {
132 const id = d.agentId || 'unknown' 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 map[id].cost += (d.costUsd || 0) 212 map[id].cost += (d.costUsd || 0)
135 map[id].input += (d.usage?.input || 0) 213 map[id].input += (d.usage?.input || 0)
136 map[id].output += (d.usage?.output || 0) 214 map[id].output += (d.usage?.output || 0)
  215 + map[id].tokens += (d.usage?.input || 0) + (d.usage?.output || 0)
137 map[id].calls += 1 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 if (agentStats.value.length === 0) return 1 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 // Recent Logs table 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 function formatTime(iso) { 229 function formatTime(iso) {
150 const d = new Date(iso) 230 const d = new Date(iso)
@@ -185,7 +265,7 @@ function formatTime(iso) { @@ -185,7 +265,7 @@ function formatTime(iso) {
185 265
186 .overview-grid { 266 .overview-grid {
187 display: grid; 267 display: grid;
188 - grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); 268 + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
189 gap: 20px; 269 gap: 20px;
190 margin-bottom: 24px; 270 margin-bottom: 24px;
191 } 271 }
@@ -197,7 +277,7 @@ function formatTime(iso) { @@ -197,7 +277,7 @@ function formatTime(iso) {
197 padding: 24px; 277 padding: 24px;
198 display: flex; 278 display: flex;
199 flex-direction: column; 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 .stat-card h3 { 282 .stat-card h3 {
203 font-size: 13px; 283 font-size: 13px;
@@ -207,7 +287,7 @@ function formatTime(iso) { @@ -207,7 +287,7 @@ function formatTime(iso) {
207 margin-bottom: 12px; 287 margin-bottom: 12px;
208 } 288 }
209 .stat-value { 289 .stat-value {
210 - font-size: 32px; 290 + font-size: 30px;
211 font-weight: 700; 291 font-weight: 700;
212 color: var(--text-primary); 292 color: var(--text-primary);
213 } 293 }
@@ -217,6 +297,7 @@ function formatTime(iso) { @@ -217,6 +297,7 @@ function formatTime(iso) {
217 .sum-card { 297 .sum-card {
218 background: linear-gradient(145deg, rgba(82, 168, 255, 0.1) 0%, rgba(35, 131, 226, 0.02) 100%); 298 background: linear-gradient(145deg, rgba(82, 168, 255, 0.1) 0%, rgba(35, 131, 226, 0.02) 100%);
219 border-color: rgba(82, 168, 255, 0.2); 299 border-color: rgba(82, 168, 255, 0.2);
  300 + cursor: help;
220 } 301 }
221 302
222 .charts-row { 303 .charts-row {
@@ -230,16 +311,121 @@ function formatTime(iso) { @@ -230,16 +311,121 @@ function formatTime(iso) {
230 border-radius: 12px; 311 border-radius: 12px;
231 padding: 24px; 312 padding: 24px;
232 flex: 1; 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 .chart-card h3 { 322 .chart-card h3 {
235 font-size: 16px; 323 font-size: 16px;
236 - margin-bottom: 20px;  
237 color: var(--text-primary); 324 color: var(--text-primary);
238 display: flex; 325 display: flex;
239 align-items: center; 326 align-items: center;
240 gap: 8px; 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 .bar-chart { 429 .bar-chart {
244 display: flex; 430 display: flex;
245 flex-direction: column; 431 flex-direction: column;
@@ -278,7 +464,7 @@ function formatTime(iso) { @@ -278,7 +464,7 @@ function formatTime(iso) {
278 .bar-value { 464 .bar-value {
279 font-size: 13px; 465 font-size: 13px;
280 font-weight: 600; 466 font-weight: 600;
281 - width: 70px; 467 + width: 80px;
282 text-align: right; 468 text-align: right;
283 color: var(--text-primary); 469 color: var(--text-primary);
284 } 470 }
@@ -308,17 +494,18 @@ function formatTime(iso) { @@ -308,17 +494,18 @@ function formatTime(iso) {
308 color: var(--text-primary); 494 color: var(--text-primary);
309 } 495 }
310 .log-time { color: var(--text-secondary) !important; font-variant-numeric: tabular-nums; } 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 .log-agent { font-weight: 500; } 498 .log-agent { font-weight: 500; }
312 -.log-conv { color: var(--text-faint) !important; font-family: monospace; }  
313 .log-tokens.in { color: #10b981 !important; } 499 .log-tokens.in { color: #10b981 !important; }
314 .log-tokens.out { color: #8b5cf6 !important; } 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 .empty-state { 503 .empty-state {
318 padding: 40px; 504 padding: 40px;
319 text-align: center; 505 text-align: center;
320 color: var(--text-faint); 506 color: var(--text-faint);
321 font-style: italic; 507 font-style: italic;
  508 + width: 100%;
322 } 509 }
323 .loading-state { 510 .loading-state {
324 display: flex; 511 display: flex;