Commit 9f5229d5f40ded1d30637de91eeb89b1fdb8d6fd
1 parent
1d4b7c2c
feat: add Quota Monitor dashboard traversing all FDE LLM usage and API calls tok…
…en costs across agents
Showing
12 changed files
with
502 additions
and
9 deletions
data/system/agents/agent-creator/memory/hippocampus-index.json
| @@ -13,8 +13,8 @@ | @@ -13,8 +13,8 @@ | ||
| 13 | ], | 13 | ], |
| 14 | "planFile": "task-from-master.md", | 14 | "planFile": "task-from-master.md", |
| 15 | "confidence": 0.9, | 15 | "confidence": 0.9, |
| 16 | - "useCount": 0, | ||
| 17 | - "lastUsed": null, | 16 | + "useCount": 1, |
| 17 | + "lastUsed": "2026-03-27", | ||
| 18 | "createdAt": "2026-03-27" | 18 | "createdAt": "2026-03-27" |
| 19 | } | 19 | } |
| 20 | ] | 20 | ] |
| 21 | \ No newline at end of file | 21 | \ No newline at end of file |
data/system/agents/agent-creator/memory/memory.md
data/system/agents/agent-creator/memory/short-term/session-1774635384262.md
0 → 100644
| 1 | +# Session 摘要 | ||
| 2 | + | ||
| 3 | +- **时间**: 2026-03-27 18:16:24 | ||
| 4 | +- **对话ID**: callback-T-WEIBO-TRAINING | ||
| 5 | +- **状态**: completed | ||
| 6 | +- **工具调用**: 1 次 | ||
| 7 | +- **耗时**: 14秒 | ||
| 8 | + | ||
| 9 | +## 任务 | ||
| 10 | +[TASK_CALLBACK] | ||
| 11 | +-TRAINING | ||
| 12 | +执行者: innova | ||
| 13 | + | ||
| 14 | +## 结果 | ||
| 15 | +收到了 innovator 的任务完成回调。weibo-scraper 技能训练已完成: | ||
| 16 | +- 最终分数: **93.50** ✓ | ||
| 17 | +- 版本: v1.6.0 | ||
| 18 | +- 训练迭代: 3轮 | ||
| 19 | +- 状态: 已部署 | ||
| 20 | + | ||
| 21 | +根据工作流程,我需要向任务分发者汇报完成。 | ||
| 22 | + | ||
| 23 | + | ||
| 24 | +收到!weibo-scraper 技能训练已成功完成: | ||
| 25 | + | ||
| 26 | +| 指标 | 值 | | ||
| 27 | +|------|-----| | ||
| 28 | +| 最终分数 | 93.50 | | ||
| 29 | +| 版本 | v1.6.0 | | ||
| 30 | +| 训练迭代 | 3轮 | | ||
| 31 | +| 状态 | ✅ 已部署 | | ||
| 32 | + | ||
| 33 | +已向 master 汇报任务完成。weibo-scraper Agent 现在已经具备从微博平台爬取热点话题、热门微博、热搜榜等内容的能耐,可以正式上岗了! |
data/system/agents/master/memory/hippocampus-index.json
| @@ -12,7 +12,7 @@ | @@ -12,7 +12,7 @@ | ||
| 12 | ], | 12 | ], |
| 13 | "planFile": "task-callback.md", | 13 | "planFile": "task-callback.md", |
| 14 | "confidence": 0.76, | 14 | "confidence": 0.76, |
| 15 | - "useCount": 23, | 15 | + "useCount": 24, |
| 16 | "lastUsed": "2026-03-27", | 16 | "lastUsed": "2026-03-27", |
| 17 | "createdAt": "2026-03-27" | 17 | "createdAt": "2026-03-27" |
| 18 | }, | 18 | }, |
data/system/agents/master/memory/memory.md
| @@ -32,3 +32,4 @@ | @@ -32,3 +32,4 @@ | ||
| 32 | - [2026-03-27] [TASK_CALLBACK] → 收到 innovator-001 的任务完成回调。让我先检查 plan.md 了解当前整体进度。 | 32 | - [2026-03-27] [TASK_CALLBACK] → 收到 innovator-001 的任务完成回调。让我先检查 plan.md 了解当前整体进度。 |
| 33 | - [2026-03-27] hi → 你好,我是 Master Agent,LemonBoss 组织的管理者。告诉我你想做什么,我会把任务分配给合适的 Agent 来执行。 | 33 | - [2026-03-27] hi → 你好,我是 Master Agent,LemonBoss 组织的管理者。告诉我你想做什么,我会把任务分配给合适的 Agent 来执行。 |
| 34 | - [2026-03-27] [TASK_CALLBACK] → 收到 Innovator 任务完成回调。让我先检查 plan.md 了解当前整体进度。 | 34 | - [2026-03-27] [TASK_CALLBACK] → 收到 Innovator 任务完成回调。让我先检查 plan.md 了解当前整体进度。 |
| 35 | +- [2026-03-27] [TASK_CALLBACK] → 收到 weibo-scraper 训练完成回调。让我更新记录并汇报。 |
data/system/agents/master/memory/short-term/session-1774635398379.md
0 → 100644
| 1 | +# Session 摘要 | ||
| 2 | + | ||
| 3 | +- **时间**: 2026-03-27 18:16:38 | ||
| 4 | +- **对话ID**: callback-T-WEIBO-TRAINING | ||
| 5 | +- **状态**: completed | ||
| 6 | +- **工具调用**: 2 次 | ||
| 7 | +- **耗时**: 18秒 | ||
| 8 | + | ||
| 9 | +## 任务 | ||
| 10 | +[TASK_CALLBACK] | ||
| 11 | +-TRAINING | ||
| 12 | +执行者: agent- | ||
| 13 | + | ||
| 14 | +## 结果 | ||
| 15 | +收到 weibo-scraper 训练完成回调。让我更新记录并汇报。 | ||
| 16 | + | ||
| 17 | + | ||
| 18 | +收到任务完成回调。weibo-scraper 技能训练已完成! | ||
| 19 | + | ||
| 20 | +**训练结果汇总:** | ||
| 21 | + | ||
| 22 | +| 项目 | 结果 | | ||
| 23 | +|------|------| | ||
| 24 | +| **最终 Benchmark 得分** | 93.50 / 100 ✅ | | ||
| 25 | +| **最终版本** | v1.6.0 | | ||
| 26 | +| **部署状态** | 已部署至生产环境 ✅ | | ||
| 27 | + | ||
| 28 | +--- | ||
| 29 | + | ||
| 30 | +**当前系统状态:** | ||
| 31 | + | ||
| 32 | +| 公司/服务 | 状态 | | ||
| 33 | +|-----------|------| | ||
| 34 | +| ABC Inc. | ✅ 运营中 (CEO/CTO/COO/HR + 执行层) | | ||
| 35 | +| weibo-scraper | ✅ 训练完成,已部署 | | ||
| 36 | +| 系统 Agent | 全部正常 | | ||
| 37 | + | ||
| 38 | +--- | ||
| 39 | + | ||
| 40 | +告诉我下一步你想做什么?我可以: | ||
| 41 | +1. 为 ABC Inc. 部署新的业务能力 | ||
| 42 | +2. 创建新的公司或技能模板 | ||
| 43 | +3. 查看系统整体健康度 |
data/system/agents/master/plan.md
| @@ -43,6 +43,7 @@ | @@ -43,6 +43,7 @@ | ||
| 43 | - 2026-03-27 15:55: 定时巡检 - cto 15:37/coo 15:39 活跃,qa-judge 15:33,hr 15:18,系统正常 | 43 | - 2026-03-27 15:55: 定时巡检 - cto 15:37/coo 15:39 活跃,qa-judge 15:33,hr 15:18,系统正常 |
| 44 | - 2026-03-27 16:00: 定时巡检 - cto 15:43/coo 15:43/qa-judge 15:43 活跃,hr 15:18,系统正常 | 44 | - 2026-03-27 16:00: 定时巡检 - cto 15:43/coo 15:43/qa-judge 15:43 活跃,hr 15:18,系统正常 |
| 45 | - 2026-03-27 17:55: [TASK_CALLBACK] Innovator weibo-scraper FDE 训练完成 → Benchmark 得分 92.65/100 (≥90 达标), SKILL.md 已升级到 v1.3.0 | 45 | - 2026-03-27 17:55: [TASK_CALLBACK] Innovator weibo-scraper FDE 训练完成 → Benchmark 得分 92.65/100 (≥90 达标), SKILL.md 已升级到 v1.3.0 |
| 46 | +- 2026-03-27 18:04: [TASK_CALLBACK] weibo-scraper 训练最终完成 → 最终分数 93.50, 版本 v1.6.0, 已部署至生产环境 | ||
| 46 | 47 | ||
| 47 | --- | 48 | --- |
| 48 | 49 |
packages/acp-core/protocol/ClaudeCliProtocol.js
| @@ -331,10 +331,13 @@ class ClaudeCliProtocol extends EventEmitter { | @@ -331,10 +331,13 @@ class ClaudeCliProtocol extends EventEmitter { | ||
| 331 | cost_usd: event.total_cost_usd, | 331 | cost_usd: event.total_cost_usd, |
| 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 | }, | 335 | }, |
| 335 | timestamp: Date.now(), | 336 | timestamp: Date.now(), |
| 336 | }); | 337 | }); |
| 337 | 338 | ||
| 339 | + this._lastUsage = null; | ||
| 340 | + | ||
| 338 | if (this._promptResolve) { | 341 | if (this._promptResolve) { |
| 339 | if (this._idleTimer) { clearTimeout(this._idleTimer); this._idleTimer = null; } | 342 | if (this._idleTimer) { clearTimeout(this._idleTimer); this._idleTimer = null; } |
| 340 | this._promptResolve(); | 343 | this._promptResolve(); |
| @@ -391,6 +394,17 @@ class ClaudeCliProtocol extends EventEmitter { | @@ -391,6 +394,17 @@ class ClaudeCliProtocol extends EventEmitter { | ||
| 391 | _handleInnerStreamEvent(innerEvent, sessionId) { | 394 | _handleInnerStreamEvent(innerEvent, sessionId) { |
| 392 | if (!innerEvent) return; | 395 | if (!innerEvent) return; |
| 393 | 396 | ||
| 397 | + if (innerEvent.usage) { | ||
| 398 | + this._lastUsage = this._lastUsage || { input: 0, output: 0 }; | ||
| 399 | + this._lastUsage.input += (innerEvent.usage.input_tokens || 0); | ||
| 400 | + this._lastUsage.output += (innerEvent.usage.output_tokens || 0); | ||
| 401 | + } | ||
| 402 | + if (innerEvent.message?.usage) { | ||
| 403 | + this._lastUsage = this._lastUsage || { input: 0, output: 0 }; | ||
| 404 | + this._lastUsage.input += (innerEvent.message.usage.input_tokens || 0); | ||
| 405 | + this._lastUsage.output += (innerEvent.message.usage.output_tokens || 0); | ||
| 406 | + } | ||
| 407 | + | ||
| 394 | switch (innerEvent.type) { | 408 | switch (innerEvent.type) { |
| 395 | case 'content_block_delta': | 409 | case 'content_block_delta': |
| 396 | if (innerEvent.delta?.type === 'text_delta' && innerEvent.delta.text) { | 410 | if (innerEvent.delta?.type === 'text_delta' && innerEvent.delta.text) { |
server/routes/agent-chat.js
| @@ -631,6 +631,52 @@ function buildPrompt(agentDir, agentId, convId, userMessage, isNewSession) { | @@ -631,6 +631,52 @@ function buildPrompt(agentDir, agentId, convId, userMessage, isNewSession) { | ||
| 631 | // 路由 | 631 | // 路由 |
| 632 | // ═══════════════════════════════════════ | 632 | // ═══════════════════════════════════════ |
| 633 | 633 | ||
| 634 | +router.get('/quota', (req, res) => { | ||
| 635 | + try { | ||
| 636 | + const DATA_ROOT = path.resolve(__dirname, '../../data'); | ||
| 637 | + const records = []; | ||
| 638 | + | ||
| 639 | + // 读取所有 billing.log | ||
| 640 | + const readBilling = (dir) => { | ||
| 641 | + const p = path.join(dir, 'billing.log'); | ||
| 642 | + if (fs.existsSync(p)) { | ||
| 643 | + fs.readFileSync(p, 'utf-8').split('\n').forEach(line => { | ||
| 644 | + line = line.trim(); | ||
| 645 | + if (line.startsWith('{')) { | ||
| 646 | + try { records.push(JSON.parse(line)); } catch(e) {} | ||
| 647 | + } else if (line) { | ||
| 648 | + // 旧的纯文本兼容: 2026-03-28T18:00:00.000Z | Agent: master | Conv: 123 | Cost: $0.123 | Duration: 123ms | ||
| 649 | + try { | ||
| 650 | + const parts = line.split('|').map(p => p.trim()); | ||
| 651 | + const ts = parts[0]; | ||
| 652 | + const ai = parts[1].replace('Agent:', '').trim(); | ||
| 653 | + const ci = parts[2].replace('Conv:', '').trim(); | ||
| 654 | + const cost = parseFloat(parts[3].replace('Cost: $', '').trim()); | ||
| 655 | + const dur = parseInt(parts[4].replace('Duration:', '').replace('ms', '').trim()); | ||
| 656 | + records.push({ timestamp: ts, agentId: ai, convId: ci, costUsd: cost || 0, durationMs: dur || 0, usage: { input: 0, output: 0 } }); | ||
| 657 | + } catch(e) {} | ||
| 658 | + } | ||
| 659 | + }); | ||
| 660 | + } | ||
| 661 | + }; | ||
| 662 | + | ||
| 663 | + readBilling(path.join(DATA_ROOT, 'system/agents/master/logs')); | ||
| 664 | + const compDir = path.join(DATA_ROOT, 'companies'); | ||
| 665 | + if (fs.existsSync(compDir)) { | ||
| 666 | + const comps = fs.readdirSync(compDir, { withFileTypes: true }); | ||
| 667 | + for (const c of comps) { | ||
| 668 | + if (c.isDirectory() && !c.name.startsWith('.')) { | ||
| 669 | + readBilling(path.join(compDir, c.name, 'logs')); | ||
| 670 | + } | ||
| 671 | + } | ||
| 672 | + } | ||
| 673 | + | ||
| 674 | + res.json(records); | ||
| 675 | + } catch (err) { | ||
| 676 | + res.status(500).json({ error: err.message }); | ||
| 677 | + } | ||
| 678 | +}); | ||
| 679 | + | ||
| 634 | router.get('/list', (req, res) => { | 680 | router.get('/list', (req, res) => { |
| 635 | try { | 681 | try { |
| 636 | const allAgents = [] | 682 | const allAgents = [] |
| @@ -1862,11 +1908,10 @@ router.post('/:agentId/chat', async (req, res) => { | @@ -1862,11 +1908,10 @@ router.post('/:agentId/chat', async (req, res) => { | ||
| 1862 | currentToolInput = ''; | 1908 | currentToolInput = ''; |
| 1863 | } | 1909 | } |
| 1864 | } else if (event.method === 'session/result') { | 1910 | } else if (event.method === 'session/result') { |
| 1865 | - if (event.params?.cost_usd) { | ||
| 1866 | - sendSSE('session_cost', { cost: event.params.cost_usd, duration: event.params.duration_ms }); | ||
| 1867 | - // Phase 5: Economic Audit - 记录账单 (Token Profiling) | 1911 | + if (event.params?.duration_ms) { |
| 1912 | + sendSSE('session_cost', { cost: event.params.cost_usd, duration: event.params.duration_ms, usage: event.params.usage }); | ||
| 1913 | + // Phase 5: Economic Audit - 记录账单 (Token Profiling) JSON lines | ||
| 1868 | try { | 1914 | try { |
| 1869 | - // billing.log 写到公司目录下 (company agent) 或 master 目录下 (system agent) | ||
| 1870 | let billingDir; | 1915 | let billingDir; |
| 1871 | if (agentId && agentId.includes(':')) { | 1916 | if (agentId && agentId.includes(':')) { |
| 1872 | const compId = agentId.split(':')[0]; | 1917 | const compId = agentId.split(':')[0]; |
| @@ -1875,8 +1920,15 @@ router.post('/:agentId/chat', async (req, res) => { | @@ -1875,8 +1920,15 @@ router.post('/:agentId/chat', async (req, res) => { | ||
| 1875 | billingDir = path.resolve(__dirname, '../../data/system/agents/master/logs'); | 1920 | billingDir = path.resolve(__dirname, '../../data/system/agents/master/logs'); |
| 1876 | } | 1921 | } |
| 1877 | if (!fs.existsSync(billingDir)) fs.mkdirSync(billingDir, { recursive: true }); | 1922 | if (!fs.existsSync(billingDir)) fs.mkdirSync(billingDir, { recursive: true }); |
| 1878 | - const billingEntry = `${new Date().toISOString()} | Agent: ${agentId} | Conv: ${convId} | Cost: $${event.params.cost_usd.toFixed(6)} | Duration: ${event.params.duration_ms}ms\n`; | ||
| 1879 | - fs.appendFileSync(path.join(billingDir, 'billing.log'), billingEntry, 'utf-8'); | 1923 | + const logEntry = { |
| 1924 | + timestamp: new Date().toISOString(), | ||
| 1925 | + agentId, | ||
| 1926 | + convId, | ||
| 1927 | + costUsd: event.params.cost_usd || 0, | ||
| 1928 | + durationMs: event.params.duration_ms, | ||
| 1929 | + usage: event.params.usage || { input: 0, output: 0 } | ||
| 1930 | + }; | ||
| 1931 | + fs.appendFileSync(path.join(billingDir, 'billing.log'), JSON.stringify(logEntry) + '\n', 'utf-8'); | ||
| 1880 | } catch (err) {} | 1932 | } catch (err) {} |
| 1881 | } | 1933 | } |
| 1882 | if (!fullOutput && event.params?.result) { | 1934 | if (!fullOutput && event.params?.result) { |
web/src/components/AppNav.vue
| @@ -13,6 +13,10 @@ | @@ -13,6 +13,10 @@ | ||
| 13 | <span>📦</span> Templates | 13 | <span>📦</span> Templates |
| 14 | </router-link> | 14 | </router-link> |
| 15 | 15 | ||
| 16 | + <router-link to="/quota" class="nav-link" :class="{ active: $route.path === '/quota' }"> | ||
| 17 | + <span>📊</span> Quota | ||
| 18 | + </router-link> | ||
| 19 | + | ||
| 16 | <div class="nav-divider"></div> | 20 | <div class="nav-divider"></div> |
| 17 | 21 | ||
| 18 | <!-- Company Switcher --> | 22 | <!-- Company Switcher --> |
web/src/router/index.js
| @@ -59,6 +59,11 @@ const routes = [ | @@ -59,6 +59,11 @@ const routes = [ | ||
| 59 | name: 'PipelineEditor', | 59 | name: 'PipelineEditor', |
| 60 | component: () => import('@/views/PipelineEditor.vue'), | 60 | component: () => import('@/views/PipelineEditor.vue'), |
| 61 | }, | 61 | }, |
| 62 | + { | ||
| 63 | + path: '/quota', | ||
| 64 | + name: 'QuotaMonitor', | ||
| 65 | + component: () => import('@/views/QuotaMonitor.vue'), | ||
| 66 | + }, | ||
| 62 | ] | 67 | ] |
| 63 | 68 | ||
| 64 | const router = createRouter({ | 69 | const router = createRouter({ |
web/src/views/QuotaMonitor.vue
0 → 100644
| 1 | +<template> | ||
| 2 | + <div class="quota-page"> | ||
| 3 | + <AppNav /> | ||
| 4 | + <div class="quota-layout"> | ||
| 5 | + <header class="page-header"> | ||
| 6 | + <h1>📊 算力消耗与 Token 监控 (Quota Monitor)</h1> | ||
| 7 | + <p class="subtitle">实时监控企业级 Multi-Agent 系统的大模型 API 成本与耗时</p> | ||
| 8 | + </header> | ||
| 9 | + | ||
| 10 | + <main class="dashboard-content" v-if="!loading"> | ||
| 11 | + <!-- 核心指标 --> | ||
| 12 | + <section class="overview-grid"> | ||
| 13 | + <div class="stat-card sum-card"> | ||
| 14 | + <h3>总消耗金额 (USD)</h3> | ||
| 15 | + <div class="stat-value highlight">${{ totalCost.toFixed(4) }}</div> | ||
| 16 | + </div> | ||
| 17 | + <div class="stat-card"> | ||
| 18 | + <h3>总输入 Tokens</h3> | ||
| 19 | + <div class="stat-value">{{ totalInputTokens.toLocaleString() }}</div> | ||
| 20 | + </div> | ||
| 21 | + <div class="stat-card"> | ||
| 22 | + <h3>总输出 Tokens</h3> | ||
| 23 | + <div class="stat-value">{{ totalOutputTokens.toLocaleString() }}</div> | ||
| 24 | + </div> | ||
| 25 | + <div class="stat-card"> | ||
| 26 | + <h3>平均单次推理耗时</h3> | ||
| 27 | + <div class="stat-value">{{ avgDuration.toFixed(1) }}s</div> | ||
| 28 | + </div> | ||
| 29 | + </section> | ||
| 30 | + | ||
| 31 | + <div class="charts-row"> | ||
| 32 | + <!-- 按照 Agent 的消耗排行榜 --> | ||
| 33 | + <section class="chart-card flex-double"> | ||
| 34 | + <h3>🤖 各 Agent 计算力消耗排行榜</h3> | ||
| 35 | + <div class="bar-chart"> | ||
| 36 | + <div v-for="agent in agentStats" :key="agent.id" class="bar-row"> | ||
| 37 | + <div class="bar-label"> | ||
| 38 | + <span class="agent-id">{{ agent.id }}</span> | ||
| 39 | + <span class="agent-calls">{{ agent.calls }} 次调用</span> | ||
| 40 | + </div> | ||
| 41 | + <div class="bar-track-wrapper"> | ||
| 42 | + <div class="bar-track"> | ||
| 43 | + <div class="bar-fill" :style="{ width: ((agent.cost / maxAgentCost) * 100) + '%' }"></div> | ||
| 44 | + </div> | ||
| 45 | + <div class="bar-value">${{ agent.cost.toFixed(4) }}</div> | ||
| 46 | + </div> | ||
| 47 | + <div class="bar-subtext"> | ||
| 48 | + (In: {{ agent.input.toLocaleString() }} / Out: {{ agent.output.toLocaleString() }}) | ||
| 49 | + </div> | ||
| 50 | + </div> | ||
| 51 | + <div v-if="agentStats.length === 0" class="empty-state">暂无数据</div> | ||
| 52 | + </div> | ||
| 53 | + </section> | ||
| 54 | + </div> | ||
| 55 | + | ||
| 56 | + <!-- 详细调用日志 --> | ||
| 57 | + <section class="chart-card"> | ||
| 58 | + <h3>🕒 最近的大模型调用日志</h3> | ||
| 59 | + <div class="log-table-wrapper"> | ||
| 60 | + <table class="log-table"> | ||
| 61 | + <thead> | ||
| 62 | + <tr> | ||
| 63 | + <th>时间</th> | ||
| 64 | + <th>Agent</th> | ||
| 65 | + <th>对话 ID</th> | ||
| 66 | + <th>耗时</th> | ||
| 67 | + <th>输入 Tokens</th> | ||
| 68 | + <th>输出 Tokens</th> | ||
| 69 | + <th>消耗金额</th> | ||
| 70 | + </tr> | ||
| 71 | + </thead> | ||
| 72 | + <tbody> | ||
| 73 | + <tr v-for="(log, i) in recentLogs" :key="i"> | ||
| 74 | + <td class="log-time">{{ formatTime(log.timestamp) }}</td> | ||
| 75 | + <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> | ||
| 78 | + <td class="log-tokens in">+{{ (log.usage?.input || 0).toLocaleString() }}</td> | ||
| 79 | + <td class="log-tokens out">+{{ (log.usage?.output || 0).toLocaleString() }}</td> | ||
| 80 | + <td class="log-cost">${{ log.costUsd.toFixed(6) }}</td> | ||
| 81 | + </tr> | ||
| 82 | + </tbody> | ||
| 83 | + </table> | ||
| 84 | + <div v-if="recentLogs.length === 0" class="empty-state">暂无日志</div> | ||
| 85 | + </div> | ||
| 86 | + </section> | ||
| 87 | + </main> | ||
| 88 | + | ||
| 89 | + <div v-else class="loading-state"> | ||
| 90 | + <div class="spinner"></div> 加载监控数据中... | ||
| 91 | + </div> | ||
| 92 | + </div> | ||
| 93 | + </div> | ||
| 94 | +</template> | ||
| 95 | + | ||
| 96 | +<script setup> | ||
| 97 | +import { ref, computed, onMounted } from 'vue' | ||
| 98 | +import AppNav from '../components/AppNav.vue' | ||
| 99 | + | ||
| 100 | +const loading = ref(true) | ||
| 101 | +const rawData = ref([]) | ||
| 102 | + | ||
| 103 | +onMounted(async () => { | ||
| 104 | + try { | ||
| 105 | + const res = await fetch('/api/agent/quota') | ||
| 106 | + if (res.ok) { | ||
| 107 | + 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)) | ||
| 110 | + } | ||
| 111 | + } catch(e) { | ||
| 112 | + console.error('Failed to load quota', e) | ||
| 113 | + } finally { | ||
| 114 | + loading.value = false | ||
| 115 | + } | ||
| 116 | +}) | ||
| 117 | + | ||
| 118 | +// Overview stats | ||
| 119 | +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)) | ||
| 121 | +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 | ||
| 126 | +}) | ||
| 127 | + | ||
| 128 | +// Agent Breakdown | ||
| 129 | +const agentStats = computed(() => { | ||
| 130 | + const map = {} | ||
| 131 | + rawData.value.forEach(d => { | ||
| 132 | + const id = d.agentId || 'unknown' | ||
| 133 | + if (!map[id]) map[id] = { id, cost: 0, input: 0, output: 0, calls: 0 } | ||
| 134 | + map[id].cost += (d.costUsd || 0) | ||
| 135 | + map[id].input += (d.usage?.input || 0) | ||
| 136 | + map[id].output += (d.usage?.output || 0) | ||
| 137 | + map[id].calls += 1 | ||
| 138 | + }) | ||
| 139 | + return Object.values(map).sort((a,b) => b.cost - a.cost) | ||
| 140 | +}) | ||
| 141 | +const maxAgentCost = computed(() => { | ||
| 142 | + if (agentStats.value.length === 0) return 1 | ||
| 143 | + return Math.max(...agentStats.value.map(a => a.cost), 0.0001) | ||
| 144 | +}) | ||
| 145 | + | ||
| 146 | +// Recent Logs table | ||
| 147 | +const recentLogs = computed(() => rawData.value.slice(0, 100)) | ||
| 148 | + | ||
| 149 | +function formatTime(iso) { | ||
| 150 | + const d = new Date(iso) | ||
| 151 | + return d.toLocaleDateString() + ' ' + d.toLocaleTimeString() | ||
| 152 | +} | ||
| 153 | +</script> | ||
| 154 | + | ||
| 155 | +<style scoped> | ||
| 156 | +.quota-page { | ||
| 157 | + height: 100vh; | ||
| 158 | + display: flex; | ||
| 159 | + flex-direction: column; | ||
| 160 | + background: var(--bg-primary); | ||
| 161 | + color: var(--text-primary); | ||
| 162 | + overflow-y: auto; | ||
| 163 | +} | ||
| 164 | + | ||
| 165 | +.quota-layout { | ||
| 166 | + max-width: 1200px; | ||
| 167 | + margin: 0 auto; | ||
| 168 | + padding: 32px 40px; | ||
| 169 | + width: 100%; | ||
| 170 | +} | ||
| 171 | + | ||
| 172 | +.page-header { | ||
| 173 | + margin-bottom: 32px; | ||
| 174 | +} | ||
| 175 | +.page-header h1 { | ||
| 176 | + font-size: 28px; | ||
| 177 | + font-weight: 700; | ||
| 178 | + margin-bottom: 8px; | ||
| 179 | + color: var(--text-primary); | ||
| 180 | +} | ||
| 181 | +.subtitle { | ||
| 182 | + color: var(--text-secondary); | ||
| 183 | + font-size: 14px; | ||
| 184 | +} | ||
| 185 | + | ||
| 186 | +.overview-grid { | ||
| 187 | + display: grid; | ||
| 188 | + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); | ||
| 189 | + gap: 20px; | ||
| 190 | + margin-bottom: 24px; | ||
| 191 | +} | ||
| 192 | + | ||
| 193 | +.stat-card { | ||
| 194 | + background: var(--bg-surface); | ||
| 195 | + border: 1px solid var(--border-color); | ||
| 196 | + border-radius: 12px; | ||
| 197 | + padding: 24px; | ||
| 198 | + display: flex; | ||
| 199 | + flex-direction: column; | ||
| 200 | + box-shadow: 0 4px 6px rgba(0,0,0,0.05); | ||
| 201 | +} | ||
| 202 | +.stat-card h3 { | ||
| 203 | + font-size: 13px; | ||
| 204 | + text-transform: uppercase; | ||
| 205 | + letter-spacing: 0.5px; | ||
| 206 | + color: var(--text-secondary); | ||
| 207 | + margin-bottom: 12px; | ||
| 208 | +} | ||
| 209 | +.stat-value { | ||
| 210 | + font-size: 32px; | ||
| 211 | + font-weight: 700; | ||
| 212 | + color: var(--text-primary); | ||
| 213 | +} | ||
| 214 | +.stat-value.highlight { | ||
| 215 | + color: var(--accent-primary); | ||
| 216 | +} | ||
| 217 | +.sum-card { | ||
| 218 | + 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); | ||
| 220 | +} | ||
| 221 | + | ||
| 222 | +.charts-row { | ||
| 223 | + display: flex; | ||
| 224 | + gap: 24px; | ||
| 225 | + margin-bottom: 24px; | ||
| 226 | +} | ||
| 227 | +.chart-card { | ||
| 228 | + background: var(--bg-surface); | ||
| 229 | + border: 1px solid var(--border-color); | ||
| 230 | + border-radius: 12px; | ||
| 231 | + padding: 24px; | ||
| 232 | + flex: 1; | ||
| 233 | +} | ||
| 234 | +.chart-card h3 { | ||
| 235 | + font-size: 16px; | ||
| 236 | + margin-bottom: 20px; | ||
| 237 | + color: var(--text-primary); | ||
| 238 | + display: flex; | ||
| 239 | + align-items: center; | ||
| 240 | + gap: 8px; | ||
| 241 | +} | ||
| 242 | + | ||
| 243 | +.bar-chart { | ||
| 244 | + display: flex; | ||
| 245 | + flex-direction: column; | ||
| 246 | + gap: 16px; | ||
| 247 | +} | ||
| 248 | +.bar-row { | ||
| 249 | + display: flex; | ||
| 250 | + flex-direction: column; | ||
| 251 | + gap: 6px; | ||
| 252 | +} | ||
| 253 | +.bar-label { | ||
| 254 | + display: flex; | ||
| 255 | + justify-content: space-between; | ||
| 256 | + font-size: 13px; | ||
| 257 | +} | ||
| 258 | +.agent-id { font-weight: 600; color: var(--text-primary); } | ||
| 259 | +.agent-calls { color: var(--text-secondary); font-size: 12px; } | ||
| 260 | +.bar-track-wrapper { | ||
| 261 | + display: flex; | ||
| 262 | + align-items: center; | ||
| 263 | + gap: 12px; | ||
| 264 | +} | ||
| 265 | +.bar-track { | ||
| 266 | + flex: 1; | ||
| 267 | + height: 8px; | ||
| 268 | + background: var(--bg-secondary); | ||
| 269 | + border-radius: 4px; | ||
| 270 | + overflow: hidden; | ||
| 271 | +} | ||
| 272 | +.bar-fill { | ||
| 273 | + height: 100%; | ||
| 274 | + background: var(--accent-primary); | ||
| 275 | + border-radius: 4px; | ||
| 276 | + transition: width 0.5s ease-out; | ||
| 277 | +} | ||
| 278 | +.bar-value { | ||
| 279 | + font-size: 13px; | ||
| 280 | + font-weight: 600; | ||
| 281 | + width: 70px; | ||
| 282 | + text-align: right; | ||
| 283 | + color: var(--text-primary); | ||
| 284 | +} | ||
| 285 | +.bar-subtext { | ||
| 286 | + font-size: 11px; | ||
| 287 | + color: var(--text-faint); | ||
| 288 | +} | ||
| 289 | + | ||
| 290 | +.log-table-wrapper { | ||
| 291 | + overflow-x: auto; | ||
| 292 | +} | ||
| 293 | +.log-table { | ||
| 294 | + width: 100%; | ||
| 295 | + border-collapse: collapse; | ||
| 296 | + font-size: 13px; | ||
| 297 | +} | ||
| 298 | +.log-table th { | ||
| 299 | + text-align: left; | ||
| 300 | + padding: 12px; | ||
| 301 | + border-bottom: 1px solid var(--border-color); | ||
| 302 | + color: var(--text-secondary); | ||
| 303 | + font-weight: 500; | ||
| 304 | +} | ||
| 305 | +.log-table td { | ||
| 306 | + padding: 12px; | ||
| 307 | + border-bottom: 1px solid var(--border-color); | ||
| 308 | + color: var(--text-primary); | ||
| 309 | +} | ||
| 310 | +.log-time { color: var(--text-secondary) !important; font-variant-numeric: tabular-nums; } | ||
| 311 | +.log-agent { font-weight: 500; } | ||
| 312 | +.log-conv { color: var(--text-faint) !important; font-family: monospace; } | ||
| 313 | +.log-tokens.in { color: #10b981 !important; } | ||
| 314 | +.log-tokens.out { color: #8b5cf6 !important; } | ||
| 315 | +.log-cost { font-weight: 600; color: var(--accent-primary) !important; } | ||
| 316 | + | ||
| 317 | +.empty-state { | ||
| 318 | + padding: 40px; | ||
| 319 | + text-align: center; | ||
| 320 | + color: var(--text-faint); | ||
| 321 | + font-style: italic; | ||
| 322 | +} | ||
| 323 | +.loading-state { | ||
| 324 | + display: flex; | ||
| 325 | + align-items: center; | ||
| 326 | + justify-content: center; | ||
| 327 | + padding: 100px; | ||
| 328 | + color: var(--text-secondary); | ||
| 329 | + gap: 12px; | ||
| 330 | +} | ||
| 331 | +.spinner { | ||
| 332 | + width: 20px; height: 20px; | ||
| 333 | + border: 2px solid var(--border-color); | ||
| 334 | + border-top-color: var(--accent-primary); | ||
| 335 | + border-radius: 50%; | ||
| 336 | + animation: spin 1s linear infinite; | ||
| 337 | +} | ||
| 338 | +@keyframes spin { to { transform: rotate(360deg); } } | ||
| 339 | +</style> |