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,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 '../components/AppNav.vue' | @@ -100,12 +144,13 @@ import AppNav from '../components/AppNav.vue' | ||
| 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 () => { | @@ -119,10 +164,43 @@ onMounted(async () => { | ||
| 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(() => { | @@ -130,21 +208,23 @@ const agentStats = computed(() => { | ||
| 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; |