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,6 +97,67 @@
97 </section> 97 </section>
98 </div> 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 <section class="chart-card"> 162 <section class="chart-card">
102 <h3>🕒 最近的大模型调用日志</h3> 163 <h3>🕒 最近的大模型调用日志</h3>
@@ -145,6 +206,7 @@ const loading = ref(true) @@ -145,6 +206,7 @@ const loading = ref(true)
145 const rawData = ref([]) 206 const rawData = ref([])
146 207
147 const timeScale = ref('day') // hour, day, week, month 208 const timeScale = ref('day') // hour, day, week, month
  209 +const taskSort = ref('cost') // cost, tokens, time
148 210
149 onMounted(async () => { 211 onMounted(async () => {
150 try { 212 try {
@@ -223,6 +285,36 @@ const maxAgentTokens = computed(() =&gt; { @@ -223,6 +285,36 @@ const maxAgentTokens = computed(() =&gt; {
223 return Math.max(...agentStats.value.map(a => a.tokens), 1) 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 // Recent Logs table 318 // Recent Logs table
227 const recentLogs = computed(() => rawData.value.slice(0, 50)) 319 const recentLogs = computed(() => rawData.value.slice(0, 50))
228 320
@@ -473,6 +565,54 @@ function formatTime(iso) { @@ -473,6 +565,54 @@ function formatTime(iso) {
473 color: var(--text-faint); 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 .log-table-wrapper { 616 .log-table-wrapper {
477 overflow-x: auto; 617 overflow-x: auto;
478 } 618 }