Commit 958af56bceefdeeb2afde878e29ff0b1c41da84d

Authored by yibo
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(() =&gt; {
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 }
... ...