Commit 958af56bceefdeeb2afde878e29ff0b1c41da84d
1 parent
c75bfc64
feat: add task-level cost breakdown to quota page
Parses convId strings (e.g. task-T-xxx, callback-T-xxx) to group billing records by task ID. Provides a consolidated view showing total cost, token consumption, duration, and the agent pipeline sequence for each task, along with an expandable detailed breakdown per agent call. This allows easier identification of wasteful agent patterns.
Showing
1 changed file
with
140 additions
and
0 deletions
web/src/views/QuotaMonitor.vue
| ... | ... | @@ -97,6 +97,67 @@ |
| 97 | 97 | </section> |
| 98 | 98 | </div> |
| 99 | 99 | |
| 100 | + <!-- 🆕 按任务链聚合的成本分析 --> | |
| 101 | + <section class="chart-card"> | |
| 102 | + <div class="card-header"> | |
| 103 | + <h3>🎯 任务级成本分析</h3> | |
| 104 | + <div class="time-filters"> | |
| 105 | + <button :class="{active: taskSort === 'cost'}" @click="taskSort = 'cost'">按花费</button> | |
| 106 | + <button :class="{active: taskSort === 'tokens'}" @click="taskSort = 'tokens'">按 Tokens</button> | |
| 107 | + <button :class="{active: taskSort === 'time'}" @click="taskSort = 'time'">按时间</button> | |
| 108 | + </div> | |
| 109 | + </div> | |
| 110 | + <div class="task-table-wrapper"> | |
| 111 | + <table class="log-table task-table"> | |
| 112 | + <thead> | |
| 113 | + <tr> | |
| 114 | + <th>任务 ID</th> | |
| 115 | + <th>Agent 链路</th> | |
| 116 | + <th>调用次数</th> | |
| 117 | + <th>Input Tokens</th> | |
| 118 | + <th>Output Tokens</th> | |
| 119 | + <th>总耗时</th> | |
| 120 | + <th>总花费</th> | |
| 121 | + </tr> | |
| 122 | + </thead> | |
| 123 | + <tbody> | |
| 124 | + <tr v-for="task in taskStats" :key="task.taskId" | |
| 125 | + :class="{'task-row-expensive': task.cost > 2}" | |
| 126 | + @click="task.expanded = !task.expanded"> | |
| 127 | + <td class="log-agent task-id-cell"> | |
| 128 | + <span class="expand-icon">{{ task.expanded ? '▼' : '▶' }}</span> | |
| 129 | + {{ task.taskId }} | |
| 130 | + </td> | |
| 131 | + <td class="task-agents"> | |
| 132 | + <span v-for="(a, i) in task.agents" :key="a" class="agent-badge"> | |
| 133 | + {{ a }}<span v-if="i < task.agents.length - 1"> → </span> | |
| 134 | + </span> | |
| 135 | + </td> | |
| 136 | + <td>{{ task.calls }}</td> | |
| 137 | + <td class="log-tokens in">{{ (task.input / 1000).toFixed(0) }}k</td> | |
| 138 | + <td class="log-tokens out">{{ (task.output / 1000).toFixed(0) }}k</td> | |
| 139 | + <td class="log-duration">{{ (task.duration / 1000).toFixed(0) }}s</td> | |
| 140 | + <td class="log-cost" :class="{'cost-warn': task.cost > 2, 'cost-danger': task.cost > 5}"> | |
| 141 | + ${{ task.cost.toFixed(2) }} | |
| 142 | + </td> | |
| 143 | + </tr> | |
| 144 | + <template v-for="task in taskStats" :key="'detail-'+task.taskId"> | |
| 145 | + <tr v-if="task.expanded" v-for="(call, ci) in task.details" :key="ci" class="task-detail-row"> | |
| 146 | + <td></td> | |
| 147 | + <td class="log-agent">{{ call.agentId }}</td> | |
| 148 | + <td class="log-model">{{ call.convId }}</td> | |
| 149 | + <td class="log-tokens in">{{ (call.usage?.input || 0).toLocaleString() }}</td> | |
| 150 | + <td class="log-tokens out">{{ (call.usage?.output || 0).toLocaleString() }}</td> | |
| 151 | + <td class="log-duration">{{ (call.durationMs / 1000).toFixed(1) }}s</td> | |
| 152 | + <td class="log-cost">${{ call.costUsd.toFixed(4) }}</td> | |
| 153 | + </tr> | |
| 154 | + </template> | |
| 155 | + </tbody> | |
| 156 | + </table> | |
| 157 | + <div v-if="taskStats.length === 0" class="empty-state">暂无任务数据</div> | |
| 158 | + </div> | |
| 159 | + </section> | |
| 160 | + | |
| 100 | 161 | <!-- 详细调用日志 --> |
| 101 | 162 | <section class="chart-card"> |
| 102 | 163 | <h3>🕒 最近的大模型调用日志</h3> |
| ... | ... | @@ -145,6 +206,7 @@ const loading = ref(true) |
| 145 | 206 | const rawData = ref([]) |
| 146 | 207 | |
| 147 | 208 | const timeScale = ref('day') // hour, day, week, month |
| 209 | +const taskSort = ref('cost') // cost, tokens, time | |
| 148 | 210 | |
| 149 | 211 | onMounted(async () => { |
| 150 | 212 | try { |
| ... | ... | @@ -223,6 +285,36 @@ const maxAgentTokens = computed(() => { |
| 223 | 285 | return Math.max(...agentStats.value.map(a => a.tokens), 1) |
| 224 | 286 | }) |
| 225 | 287 | |
| 288 | +// Task-level Breakdown | |
| 289 | +const taskStats = computed(() => { | |
| 290 | + const map = {} | |
| 291 | + rawData.value.forEach(d => { | |
| 292 | + // 从 convId 中提取 taskId | |
| 293 | + const convId = d.convId || '' | |
| 294 | + let taskId = null | |
| 295 | + const taskMatch = convId.match(/(?:task|callback)-?(T-[a-z0-9]+)/i) | |
| 296 | + if (taskMatch) taskId = taskMatch[1] | |
| 297 | + else if (convId.startsWith('tpl-train-')) taskId = convId | |
| 298 | + else return // 跳过非任务类型的记录(如 conv-xxx 用户对话) | |
| 299 | + | |
| 300 | + if (!map[taskId]) map[taskId] = { taskId, cost: 0, input: 0, output: 0, calls: 0, duration: 0, agentSet: new Set(), details: [], expanded: false, firstTime: d.timestamp } | |
| 301 | + map[taskId].cost += (d.costUsd || 0) | |
| 302 | + map[taskId].input += (d.usage?.input || 0) | |
| 303 | + map[taskId].output += (d.usage?.output || 0) | |
| 304 | + map[taskId].calls += 1 | |
| 305 | + map[taskId].duration += (d.durationMs || 0) | |
| 306 | + map[taskId].agentSet.add(d.agentId) | |
| 307 | + map[taskId].details.push(d) | |
| 308 | + if (d.timestamp < map[taskId].firstTime) map[taskId].firstTime = d.timestamp | |
| 309 | + }) | |
| 310 | + | |
| 311 | + const arr = Object.values(map).map(t => ({ ...t, agents: [...t.agentSet] })) | |
| 312 | + if (taskSort.value === 'cost') arr.sort((a, b) => b.cost - a.cost) | |
| 313 | + else if (taskSort.value === 'tokens') arr.sort((a, b) => (b.input + b.output) - (a.input + a.output)) | |
| 314 | + else arr.sort((a, b) => new Date(b.firstTime) - new Date(a.firstTime)) | |
| 315 | + return arr.slice(0, 50) | |
| 316 | +}) | |
| 317 | + | |
| 226 | 318 | // Recent Logs table |
| 227 | 319 | const recentLogs = computed(() => rawData.value.slice(0, 50)) |
| 228 | 320 | |
| ... | ... | @@ -473,6 +565,54 @@ function formatTime(iso) { |
| 473 | 565 | color: var(--text-faint); |
| 474 | 566 | } |
| 475 | 567 | |
| 568 | +/* Task breakdown */ | |
| 569 | +.task-table-wrapper { | |
| 570 | + overflow-x: auto; | |
| 571 | + max-height: 600px; | |
| 572 | + overflow-y: auto; | |
| 573 | +} | |
| 574 | +.task-table tr { | |
| 575 | + cursor: pointer; | |
| 576 | + transition: background 0.15s; | |
| 577 | +} | |
| 578 | +.task-table tr:hover { | |
| 579 | + background: rgba(82, 168, 255, 0.05); | |
| 580 | +} | |
| 581 | +.task-id-cell { | |
| 582 | + font-family: monospace; | |
| 583 | + font-size: 12px; | |
| 584 | + white-space: nowrap; | |
| 585 | +} | |
| 586 | +.expand-icon { | |
| 587 | + font-size: 10px; | |
| 588 | + margin-right: 6px; | |
| 589 | + color: var(--text-secondary); | |
| 590 | +} | |
| 591 | +.agent-badge { | |
| 592 | + font-size: 12px; | |
| 593 | + color: var(--text-secondary); | |
| 594 | +} | |
| 595 | +.task-row-expensive { | |
| 596 | + background: rgba(255, 100, 50, 0.04); | |
| 597 | +} | |
| 598 | +.cost-warn { | |
| 599 | + color: #f59e0b !important; | |
| 600 | + font-weight: 700; | |
| 601 | +} | |
| 602 | +.cost-danger { | |
| 603 | + color: #ef4444 !important; | |
| 604 | + font-weight: 700; | |
| 605 | +} | |
| 606 | +.task-detail-row { | |
| 607 | + background: var(--bg-secondary) !important; | |
| 608 | + cursor: default !important; | |
| 609 | + font-size: 12px; | |
| 610 | +} | |
| 611 | +.task-detail-row td { | |
| 612 | + padding: 8px 12px; | |
| 613 | + color: var(--text-secondary); | |
| 614 | +} | |
| 615 | + | |
| 476 | 616 | .log-table-wrapper { |
| 477 | 617 | overflow-x: auto; |
| 478 | 618 | } | ... | ... |