Commit 9f5229d5f40ded1d30637de91eeb89b1fdb8d6fd

Authored by yibo
1 parent 1d4b7c2c

feat: add Quota Monitor dashboard traversing all FDE LLM usage and API calls tok…

…en costs across agents
data/system/agents/agent-creator/memory/hippocampus-index.json
... ... @@ -13,8 +13,8 @@
13 13 ],
14 14 "planFile": "task-from-master.md",
15 15 "confidence": 0.9,
16   - "useCount": 0,
17   - "lastUsed": null,
  16 + "useCount": 1,
  17 + "lastUsed": "2026-03-27",
18 18 "createdAt": "2026-03-27"
19 19 }
20 20 ]
21 21 \ No newline at end of file
... ...
data/system/agents/agent-creator/memory/memory.md
... ... @@ -2,3 +2,4 @@
2 2  
3 3 ## 关键事实
4 4 - [2026-03-27] [TASK from master] → ... |
  5 +- [2026-03-27] [TASK_CALLBACK] → 收到了 innovator 的任务完成回调。weibo-scraper 技能训练已完成:
... ...
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 12 ],
13 13 "planFile": "task-callback.md",
14 14 "confidence": 0.76,
15   - "useCount": 23,
  15 + "useCount": 24,
16 16 "lastUsed": "2026-03-27",
17 17 "createdAt": "2026-03-27"
18 18 },
... ...
data/system/agents/master/memory/memory.md
... ... @@ -32,3 +32,4 @@
32 32 - [2026-03-27] [TASK_CALLBACK] → 收到 innovator-001 的任务完成回调。让我先检查 plan.md 了解当前整体进度。
33 33 - [2026-03-27] hi → 你好,我是 Master Agent,LemonBoss 组织的管理者。告诉我你想做什么,我会把任务分配给合适的 Agent 来执行。
34 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 43 - 2026-03-27 15:55: 定时巡检 - cto 15:37/coo 15:39 活跃,qa-judge 15:33,hr 15:18,系统正常
44 44 - 2026-03-27 16:00: 定时巡检 - cto 15:43/coo 15:43/qa-judge 15:43 活跃,hr 15:18,系统正常
45 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 331 cost_usd: event.total_cost_usd,
332 332 session_id: event.session_id,
333 333 stop_reason: event.stop_reason,
  334 + usage: this._lastUsage || { input: 0, output: 0 },
334 335 },
335 336 timestamp: Date.now(),
336 337 });
337 338  
  339 + this._lastUsage = null;
  340 +
338 341 if (this._promptResolve) {
339 342 if (this._idleTimer) { clearTimeout(this._idleTimer); this._idleTimer = null; }
340 343 this._promptResolve();
... ... @@ -391,6 +394,17 @@ class ClaudeCliProtocol extends EventEmitter {
391 394 _handleInnerStreamEvent(innerEvent, sessionId) {
392 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 408 switch (innerEvent.type) {
395 409 case 'content_block_delta':
396 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 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 680 router.get('/list', (req, res) => {
635 681 try {
636 682 const allAgents = []
... ... @@ -1862,11 +1908,10 @@ router.post('/:agentId/chat', async (req, res) => {
1862 1908 currentToolInput = '';
1863 1909 }
1864 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 1914 try {
1869   - // billing.log 写到公司目录下 (company agent) 或 master 目录下 (system agent)
1870 1915 let billingDir;
1871 1916 if (agentId && agentId.includes(':')) {
1872 1917 const compId = agentId.split(':')[0];
... ... @@ -1875,8 +1920,15 @@ router.post('/:agentId/chat', async (req, res) => {
1875 1920 billingDir = path.resolve(__dirname, '../../data/system/agents/master/logs');
1876 1921 }
1877 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 1932 } catch (err) {}
1881 1933 }
1882 1934 if (!fullOutput && event.params?.result) {
... ...
web/src/components/AppNav.vue
... ... @@ -13,6 +13,10 @@
13 13 <span>📦</span> Templates
14 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 20 <div class="nav-divider"></div>
17 21  
18 22 <!-- Company Switcher -->
... ...
web/src/router/index.js
... ... @@ -59,6 +59,11 @@ const routes = [
59 59 name: 'PipelineEditor',
60 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 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>
... ...