evolclaw-web 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -20,13 +20,30 @@ import { setDebugLog, dlog } from './debug-log.js';
20
20
  import { aidSource } from './sources/aid.js';
21
21
  import { msgSource } from './sources/msg.js';
22
22
  import { sessionSource } from './sources/session.js';
23
+ import { cacheSource } from './sources/cache.js';
24
+ import { systemSource } from './sources/system.js';
25
+ import { triggersSource } from './sources/triggers.js';
26
+ import { queryStatsForDashboard, queryStatsExplorer, queryStatsByPeer, queryStatsByAgent, queryStatsOverview } from './sources/stats.js';
27
+ import { getSessionsAunDir, listLocalAids, listPeers, readMessages } from './fs-utils.js';
28
+ import { ccProjectsDir } from './paths.js';
23
29
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
24
30
  const STATIC_DIR = path.join(__dirname, 'static');
25
31
  const TOKEN_TTL_MS = 24 * 60 * 60 * 1000; // 24h
26
32
  const PAIRING_TTL_MS = 5 * 60 * 1000; // 5min
27
33
  const DEFAULT_PORT = 42705;
28
34
  const PROTOCOL_VERSION = 1; // 与 evolclaw ping response 对齐的软校验版本
29
- const SOURCES = { aid: aidSource, msg: msgSource, session: sessionSource };
35
+ const SOURCES = { agents: aidSource, msg: msgSource, session: sessionSource, cache: cacheSource, system: systemSource, triggers: triggersSource };
36
+ // ECWeb 自身版本:渲染 System 页时随快照下发(不走 daemon IPC,ECWeb 就是这个进程)。
37
+ function readEcwebVersion() {
38
+ try {
39
+ const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8'));
40
+ return pkg?.version ?? '0.0.0';
41
+ }
42
+ catch {
43
+ return '0.0.0';
44
+ }
45
+ }
46
+ const ECWEB_VERSION = readEcwebVersion();
30
47
  const MIME = {
31
48
  '.html': 'text/html; charset=utf-8',
32
49
  '.js': 'text/javascript; charset=utf-8',
@@ -108,12 +125,116 @@ function parseUrl(rawUrl) {
108
125
  }
109
126
  return { path: rawUrl.slice(0, qIdx), query };
110
127
  }
128
+ function handleStatsApi(req, res) {
129
+ const { path: urlPath, query } = parseUrl(req.url || '');
130
+ res.setHeader('Content-Type', 'application/json');
131
+ if (urlPath === '/api/stats/dashboard') {
132
+ const data = queryStatsForDashboard();
133
+ if (!data) {
134
+ res.writeHead(503);
135
+ res.end(JSON.stringify({ error: 'stats unavailable (node:sqlite missing or no data)' }));
136
+ }
137
+ else {
138
+ res.writeHead(200);
139
+ res.end(JSON.stringify(data));
140
+ }
141
+ }
142
+ else if (urlPath === '/api/stats/explorer') {
143
+ const params = {};
144
+ if (query.from)
145
+ params.from_ts = Number(query.from);
146
+ if (query.to)
147
+ params.to_ts = Number(query.to);
148
+ if (query.agent)
149
+ params.agent_aid = query.agent;
150
+ if (query.peer)
151
+ params.peer_key = query.peer;
152
+ if (query.model)
153
+ params.model = query.model;
154
+ if (query.granularity)
155
+ params.granularity = query.granularity;
156
+ const data = queryStatsExplorer(params);
157
+ res.writeHead(200);
158
+ res.end(JSON.stringify(data));
159
+ }
160
+ else if (urlPath === '/api/stats/peers') {
161
+ const params = {};
162
+ if (query.from)
163
+ params.from_ts = Number(query.from);
164
+ if (query.to)
165
+ params.to_ts = Number(query.to);
166
+ if (query.agent)
167
+ params.agent_aid = query.agent;
168
+ if (query.limit)
169
+ params.limit = Number(query.limit);
170
+ const data = queryStatsByPeer(params);
171
+ res.writeHead(200);
172
+ res.end(JSON.stringify(data));
173
+ }
174
+ else if (urlPath === '/api/stats/agents') {
175
+ const params = {};
176
+ if (query.from)
177
+ params.from_ts = Number(query.from);
178
+ if (query.to)
179
+ params.to_ts = Number(query.to);
180
+ if (query.limit)
181
+ params.limit = Number(query.limit);
182
+ const data = queryStatsByAgent(params);
183
+ res.writeHead(200);
184
+ res.end(JSON.stringify(data));
185
+ }
186
+ else if (urlPath === '/api/stats/overview') {
187
+ const tokenStats = queryStatsOverview();
188
+ // session count: scan all CC project dirs
189
+ let sessionCount = 0;
190
+ try {
191
+ const base = ccProjectsDir();
192
+ for (const d of fs.readdirSync(base, { withFileTypes: true })) {
193
+ if (!d.isDirectory())
194
+ continue;
195
+ try {
196
+ sessionCount += fs.readdirSync(path.join(base, d.name)).filter(f => f.endsWith('.jsonl')).length;
197
+ }
198
+ catch { }
199
+ }
200
+ }
201
+ catch { }
202
+ // message counts: scan aun dir
203
+ let msgIn = 0, msgOut = 0;
204
+ try {
205
+ const aunDir = getSessionsAunDir();
206
+ for (const aid of listLocalAids(aunDir)) {
207
+ for (const peer of listPeers(aunDir, aid)) {
208
+ for (const m of readMessages(aunDir, aid, peer)) {
209
+ if (m.dir === 'in')
210
+ msgIn++;
211
+ else
212
+ msgOut++;
213
+ }
214
+ }
215
+ }
216
+ }
217
+ catch { }
218
+ res.writeHead(200);
219
+ res.end(JSON.stringify({ token_stats: tokenStats, session_count: sessionCount, msg_in: msgIn, msg_out: msgOut }));
220
+ }
221
+ else {
222
+ res.writeHead(404);
223
+ res.end(JSON.stringify({ error: 'not found' }));
224
+ }
225
+ }
111
226
  function clientIp(req) {
112
227
  const fwd = req.headers['x-forwarded-for'];
113
228
  if (typeof fwd === 'string' && fwd)
114
229
  return fwd.split(',')[0].trim();
115
230
  return req.socket.remoteAddress || '?';
116
231
  }
232
+ // 仅 localhost 可访问(取配对码):直连 socket 地址必须是回环。
233
+ // 不信任 x-forwarded-for(代理头可伪造),只看真实 TCP 来源。
234
+ function isLocalhost(req) {
235
+ const addr = req.socket.remoteAddress || '';
236
+ return addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1';
237
+ }
117
238
  function genPairingCode() {
118
239
  return String(crypto.randomInt(0, 1000000)).padStart(6, '0');
119
240
  }
@@ -173,15 +294,17 @@ function handleConnection(ws, req, log) {
173
294
  send({ type: 'error', message: `unknown view: ${view}` });
174
295
  return;
175
296
  }
297
+ // System 视图:把 ECWeb 自身版本合并进快照(前端版本卡用)
298
+ const decorate = (data) => (view === 'system' && data ? { ...data, ecwebVersion: ECWEB_VERSION } : data);
176
299
  try {
177
- send({ type: 'snapshot', view, data: await source.snapshot(params) });
300
+ send({ type: 'snapshot', view, data: decorate(await source.snapshot(params)) });
178
301
  }
179
302
  catch (e) {
180
303
  send({ type: 'error', message: `snapshot failed: ${e?.message || e}` });
181
304
  }
182
305
  unsubscribe = source.subscribe(params, (data) => {
183
306
  if (currentView === view)
184
- send({ type: 'delta', view, data });
307
+ send({ type: 'delta', view, data: decorate(data) });
185
308
  });
186
309
  };
187
310
  ws.on('message', async (raw) => {
@@ -206,8 +329,25 @@ function handleConnection(ws, req, log) {
206
329
  params.sessionId = msg.sessionId;
207
330
  if (msg.project)
208
331
  params.project = msg.project;
332
+ if (msg.agent)
333
+ params.agent = msg.agent;
209
334
  dlog(`▸ 订阅 ${msg.view}${msg.aid ? ` aid=${String(msg.aid).split('.')[0]}` : ''}${msg.peer ? ` peer=${String(msg.peer).split('.')[0]}` : ''}${msg.sessionId ? ` session=${String(msg.sessionId).slice(0, 8)}` : ''} from ${ip}`);
210
335
  await switchSubscription(msg.view, params);
336
+ return;
337
+ }
338
+ if (msg.type === 'menu' && msg.payload) {
339
+ const p = resolvePaths();
340
+ const resp = await ipcQuery(p.socket, { type: 'menu.exec', payload: msg.payload }, 5000);
341
+ if (resp?.ok) {
342
+ send({ type: 'menu.response', requestId: msg.requestId, data: resp.response });
343
+ }
344
+ else {
345
+ send({ type: 'menu.response', requestId: msg.requestId, data: {
346
+ type: 'menu.response', id: msg.payload?.id ?? '',
347
+ error: { code: 'INTERNAL', message: resp?.error ?? 'daemon unreachable' },
348
+ } });
349
+ }
350
+ return;
211
351
  }
212
352
  });
213
353
  // NAT keepalive: ping every 25s to prevent middlebox from cutting the connection
@@ -270,6 +410,12 @@ export async function startWatchWebServer(opts = {}) {
270
410
  }
271
411
  const server = http.createServer((req, res) => {
272
412
  if (req.method === 'GET' && (req.url || '') === '/api/pair-code') {
413
+ // 仅 localhost 可取码:远程浏览器拿不到,必须由同机的 `ec watch web` 显示给用户
414
+ if (!isLocalhost(req)) {
415
+ res.writeHead(403, { 'Content-Type': 'application/json' });
416
+ res.end(JSON.stringify({ error: 'forbidden' }));
417
+ return;
418
+ }
273
419
  const { code, expiresAt } = freshPairing();
274
420
  res.writeHead(200, { 'Content-Type': 'application/json' });
275
421
  res.end(JSON.stringify({ code, expiresAt }));
@@ -277,6 +423,19 @@ export async function startWatchWebServer(opts = {}) {
277
423
  else if (req.method === 'POST' && (req.url || '').startsWith('/api/pair')) {
278
424
  handlePair(req, res, pairingCode, pairingExpiry, log);
279
425
  }
426
+ else if (req.method === 'GET' && (req.url || '').startsWith('/api/stats/')) {
427
+ // Stats API — requires auth
428
+ const authHeader = req.headers.authorization || '';
429
+ const bearerToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
430
+ const { query } = parseUrl(req.url || '');
431
+ const token = bearerToken || query.token || '';
432
+ if (!validateAndRenew(token, Date.now())) {
433
+ res.writeHead(401, { 'Content-Type': 'application/json' });
434
+ res.end(JSON.stringify({ error: 'unauthorized' }));
435
+ return;
436
+ }
437
+ handleStatsApi(req, res);
438
+ }
280
439
  else {
281
440
  serveStatic(req, res);
282
441
  }
@@ -92,7 +92,7 @@ async function buildSnapshot() {
92
92
  };
93
93
  }
94
94
  export const aidSource = {
95
- kind: 'aid',
95
+ kind: 'agents',
96
96
  async snapshot() {
97
97
  return buildSnapshot();
98
98
  },
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Cache 数据源 — daemon 统一 FileCache 的运行统计(命中率/读盘/驱逐/失效)。
3
+ *
4
+ * 复用 daemon 的 IPC socket:拉 `cache-stats`(只读)。IPC 无推送,
5
+ * 故 1s 轮询 + JSON diff,仅在变化时 push(与 aidSource 同款)。
6
+ * daemon 未运行(ipcQuery 返回 null)→ { daemonRunning:false, stats:null }。
7
+ */
8
+ import { resolvePaths } from '../paths.js';
9
+ import { ipcQuery } from '../ipc-client.js';
10
+ async function buildSnapshot() {
11
+ const p = resolvePaths();
12
+ const resp = await ipcQuery(p.socket, { type: 'cache-stats' });
13
+ if (resp === null || !resp.ok) {
14
+ // daemon 离线,或旧 daemon 不认识 cache-stats(回 {error:...},ok 为 undefined)
15
+ return { daemonRunning: resp !== null, supported: !!resp?.ok, stats: null };
16
+ }
17
+ return { daemonRunning: true, supported: true, stats: resp.stats };
18
+ }
19
+ export const cacheSource = {
20
+ kind: 'cache',
21
+ async snapshot() {
22
+ return buildSnapshot();
23
+ },
24
+ subscribe(_params, push) {
25
+ let lastJson = '';
26
+ let stopped = false;
27
+ const tick = async () => {
28
+ if (stopped)
29
+ return;
30
+ try {
31
+ const snap = await buildSnapshot();
32
+ const json = JSON.stringify(snap);
33
+ if (json !== lastJson) {
34
+ lastJson = json;
35
+ push(snap);
36
+ }
37
+ }
38
+ catch { /* ignore transient IPC errors */ }
39
+ };
40
+ const timer = setInterval(tick, 1000);
41
+ return () => { stopped = true; clearInterval(timer); };
42
+ },
43
+ };
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Control 数据源 — 通过 daemon IPC 的 menu.exec 代理拉取 menu.* 当前状态。
3
+ *
4
+ * snapshot: 一批 menu.list + menu.query(各 name 当前值)+ menu.options(列表类)。
5
+ * subscribe: 1s 轮询 + JSON diff,仅变化时 push(IPC 无推送,与 aid.ts 同款)。
6
+ *
7
+ * 写操作(update/action)不走这里——浏览器经 WS `menu` 消息直发,requestId 配对响应。
8
+ */
9
+ import { resolvePaths } from '../paths.js';
10
+ import { ipcQuery } from '../ipc-client.js';
11
+ // 支持 query 当前值的 name
12
+ const QUERY_NAMES = ['system', 'pwd', 'baseagent', 'model', 'effort',
13
+ 'chatmode', 'permission', 'activity', 'dispatch', 'session'];
14
+ // 列表类(options)
15
+ const OPTIONS_NAMES = ['session', 'agent', 'trigger'];
16
+ async function menuExec(payload) {
17
+ const p = resolvePaths();
18
+ const r = await ipcQuery(p.socket, { type: 'menu.exec', payload }, 5000);
19
+ return r?.ok ? r.response : null;
20
+ }
21
+ async function buildSnapshot() {
22
+ const [listResp, ...queryResps] = await Promise.all([
23
+ menuExec({ type: 'menu.list', id: 'ctrl-list' }),
24
+ ...QUERY_NAMES.map((name, i) => menuExec({ type: 'menu.query', id: `ctrl-q-${i}`, name })),
25
+ ]);
26
+ const optResps = await Promise.all(OPTIONS_NAMES.map((name, i) => menuExec({ type: 'menu.options', id: `ctrl-o-${i}`, name })));
27
+ const daemonRunning = listResp !== null;
28
+ const queries = {};
29
+ QUERY_NAMES.forEach((name, i) => { queries[name] = queryResps[i]; });
30
+ const options = {};
31
+ OPTIONS_NAMES.forEach((name, i) => { options[name] = optResps[i]; });
32
+ return { daemonRunning, list: listResp, queries, options };
33
+ }
34
+ export const controlSource = {
35
+ kind: 'control',
36
+ async snapshot() {
37
+ return buildSnapshot();
38
+ },
39
+ subscribe(_params, push) {
40
+ let lastJson = '';
41
+ let stopped = false;
42
+ const tick = async () => {
43
+ if (stopped)
44
+ return;
45
+ try {
46
+ const snap = await buildSnapshot();
47
+ const json = JSON.stringify(snap);
48
+ if (json !== lastJson) {
49
+ lastJson = json;
50
+ push(snap);
51
+ }
52
+ }
53
+ catch { /* ignore transient IPC errors */ }
54
+ };
55
+ const timer = setInterval(tick, 1000);
56
+ return () => { stopped = true; clearInterval(timer); };
57
+ },
58
+ };
@@ -0,0 +1,348 @@
1
+ /**
2
+ * ecweb/src/sources/stats.ts — Stats 数据源,直接只读查 usage.db。
3
+ */
4
+ import path from 'path';
5
+ import fs from 'fs';
6
+ import { createRequire } from 'module';
7
+ import { resolvePaths } from '../paths.js';
8
+ const requireFromHere = createRequire(import.meta.url);
9
+ let sqliteModule;
10
+ function loadSqlite() {
11
+ if (sqliteModule !== undefined)
12
+ return sqliteModule;
13
+ try {
14
+ sqliteModule = requireFromHere('node:sqlite');
15
+ }
16
+ catch {
17
+ sqliteModule = null;
18
+ }
19
+ return sqliteModule;
20
+ }
21
+ function getDbPath() {
22
+ const { root } = resolvePaths();
23
+ return path.join(root, 'data', 'stats', 'usage.db');
24
+ }
25
+ function openDb() {
26
+ const sqlite = loadSqlite();
27
+ if (!sqlite)
28
+ return null;
29
+ const dbPath = getDbPath();
30
+ if (!fs.existsSync(dbPath))
31
+ return null;
32
+ try {
33
+ return new sqlite.DatabaseSync(dbPath, { readOnly: true });
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ }
39
+ let _priceCache = null;
40
+ let _aliasCache = null;
41
+ let _priceCacheTs = 0;
42
+ const PRICE_CACHE_TTL = 5 * 60 * 1000;
43
+ function _loadPrices() {
44
+ const now = Date.now();
45
+ if (_priceCache && now - _priceCacheTs < PRICE_CACHE_TTL)
46
+ return _priceCache;
47
+ const { root } = resolvePaths();
48
+ const file = path.join(root, 'data', 'stats', 'model-prices.jsonl');
49
+ if (!fs.existsSync(file)) {
50
+ _priceCache = [];
51
+ _priceCacheTs = now;
52
+ return [];
53
+ }
54
+ try {
55
+ _priceCache = fs.readFileSync(file, 'utf-8').split('\n').filter(Boolean).map(l => JSON.parse(l));
56
+ _priceCacheTs = now;
57
+ return _priceCache;
58
+ }
59
+ catch {
60
+ return [];
61
+ }
62
+ }
63
+ function _loadAliases() {
64
+ const now = Date.now();
65
+ if (_aliasCache && now - _priceCacheTs < PRICE_CACHE_TTL)
66
+ return _aliasCache;
67
+ const { root } = resolvePaths();
68
+ const file = path.join(root, 'data', 'stats', 'model-aliases.jsonl');
69
+ if (!fs.existsSync(file)) {
70
+ _aliasCache = [];
71
+ return [];
72
+ }
73
+ try {
74
+ _aliasCache = fs.readFileSync(file, 'utf-8').split('\n').filter(Boolean).map(l => JSON.parse(l));
75
+ return _aliasCache;
76
+ }
77
+ catch {
78
+ return [];
79
+ }
80
+ }
81
+ function _resolvePrice(model, ts) {
82
+ const prices = _loadPrices();
83
+ let candidates = prices.filter(p => p.model === model && p.effective_from <= ts);
84
+ if (!candidates.length) {
85
+ const aliases = _loadAliases();
86
+ const entry = aliases.find(a => a.alias === model);
87
+ if (entry)
88
+ candidates = prices.filter(p => p.model === entry.canonical && p.effective_from <= ts);
89
+ }
90
+ if (!candidates.length)
91
+ return null;
92
+ return candidates.reduce((a, b) => a.effective_from >= b.effective_from ? a : b);
93
+ }
94
+ function _calcRowCost(row) {
95
+ const p = _resolvePrice(row.model, row.ts);
96
+ if (!p)
97
+ return { usd: 0, cny: 0 };
98
+ let cost = 0;
99
+ switch (p.billing_fn) {
100
+ case 'per_token_v1':
101
+ cost = ((p.price_input ?? 0) * (row.input_tokens ?? 0)
102
+ + (p.price_output ?? 0) * (row.output_tokens ?? 0)
103
+ + (p.price_cache_creation ?? 0) * (row.cache_creation_tokens ?? 0)
104
+ + (p.price_cache_read ?? 0) * (row.cache_read_tokens ?? 0)) / 1e6;
105
+ break;
106
+ case 'per_token_deepseek_v1':
107
+ cost = ((p.price_cache_hit ?? 0) * (row.cache_hit_tokens ?? 0)
108
+ + (p.price_cache_miss ?? 0) * (row.cache_miss_tokens ?? 0)
109
+ + (p.price_output ?? 0) * (row.output_tokens ?? 0)) / 1e6;
110
+ break;
111
+ case 'per_token_tiered_v1': {
112
+ const tiers = p.tiers;
113
+ if (!Array.isArray(tiers))
114
+ break;
115
+ const ctx = row.total_context_tokens ?? row.input_tokens ?? 0;
116
+ const tier = tiers.find(t => t.up_to_tokens == null || ctx <= t.up_to_tokens) ?? tiers[tiers.length - 1];
117
+ cost = ((tier.price_input ?? 0) * (row.input_tokens ?? 0)
118
+ + (tier.price_output ?? 0) * (row.output_tokens ?? 0)
119
+ + (tier.price_cache_read ?? 0) * (row.cache_read_tokens ?? 0)) / 1e6;
120
+ break;
121
+ }
122
+ case 'per_token_image_v1':
123
+ cost = ((p.price_input ?? 0) * (row.input_tokens ?? 0)
124
+ + (p.price_output ?? 0) * (row.output_tokens ?? 0)
125
+ + (p.price_image ?? 0) * (row.image_tokens ?? 0)) / 1e6;
126
+ break;
127
+ }
128
+ return p.currency === 'CNY' ? { usd: 0, cny: cost } : { usd: cost, cny: 0 };
129
+ }
130
+ export function queryStatsForDashboard() {
131
+ const db = openDb();
132
+ if (!db)
133
+ return null;
134
+ const now = new Date();
135
+ const todayStart = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
136
+ const h24ago = Date.now() - 24 * 60 * 60 * 1000;
137
+ try {
138
+ // Today summary
139
+ const todayRow = db.prepare(`
140
+ SELECT
141
+ COALESCE(SUM(input_tokens),0) AS input_tokens,
142
+ COALESCE(SUM(output_tokens),0) AS output_tokens,
143
+ COALESCE(SUM(cache_creation_tokens),0) AS cache_creation_tokens,
144
+ COALESCE(SUM(cache_read_tokens),0) AS cache_read_tokens,
145
+ COUNT(*) AS call_count
146
+ FROM usage_events WHERE ts >= ?
147
+ `).get(todayStart);
148
+ const totalIn = (todayRow.input_tokens ?? 0) + (todayRow.cache_read_tokens ?? 0);
149
+ const hitRate = totalIn > 0 ? (todayRow.cache_read_tokens ?? 0) / totalIn : 0;
150
+ // Today cost (逐行计算)
151
+ let costUsd = 0, costCny = 0;
152
+ const costRows = db.prepare(`SELECT * FROM usage_events WHERE ts >= ?`).all(todayStart);
153
+ for (const r of costRows) {
154
+ const c = _calcRowCost(r);
155
+ costUsd += c.usd;
156
+ costCny += c.cny;
157
+ }
158
+ // Hourly (last 24h)
159
+ const hourly = db.prepare(`
160
+ SELECT
161
+ strftime('%Y-%m-%d %H:00', ts/1000, 'unixepoch', 'localtime') AS hour,
162
+ COALESCE(SUM(input_tokens),0) AS input_tokens,
163
+ COALESCE(SUM(output_tokens),0) AS output_tokens,
164
+ COALESCE(SUM(cache_read_tokens),0) AS cache_read_tokens,
165
+ COUNT(*) AS call_count
166
+ FROM usage_events WHERE ts >= ?
167
+ GROUP BY hour ORDER BY hour
168
+ `).all(h24ago);
169
+ // Top models (today)
170
+ const top_models = db.prepare(`
171
+ SELECT model, SUM(input_tokens+output_tokens) AS total_tokens, COUNT(*) AS call_count
172
+ FROM usage_events WHERE ts >= ?
173
+ GROUP BY model ORDER BY total_tokens DESC LIMIT 10
174
+ `).all(todayStart);
175
+ // Top peers (today)
176
+ const top_peers = db.prepare(`
177
+ SELECT peer_key, SUM(input_tokens+output_tokens) AS total_tokens, COUNT(*) AS call_count
178
+ FROM usage_events WHERE ts >= ?
179
+ GROUP BY peer_key ORDER BY total_tokens DESC LIMIT 5
180
+ `).all(todayStart);
181
+ return {
182
+ today: { ...todayRow, cache_hit_rate: hitRate, cost_usd: costUsd, cost_cny: costCny },
183
+ hourly,
184
+ top_models,
185
+ top_peers,
186
+ };
187
+ }
188
+ finally {
189
+ db.close();
190
+ }
191
+ }
192
+ export function queryStatsExplorer(params) {
193
+ const db = openDb();
194
+ if (!db)
195
+ return [];
196
+ const gran = params.granularity || 'day';
197
+ const fmt = { hour: '%Y-%m-%d %H:00', day: '%Y-%m-%d', week: '%Y-W%W', month: '%Y-%m' };
198
+ const strfmt = fmt[gran] || fmt.day;
199
+ const conds = [];
200
+ const p = [];
201
+ if (params.from_ts) {
202
+ conds.push('ts >= ?');
203
+ p.push(params.from_ts);
204
+ }
205
+ if (params.to_ts) {
206
+ conds.push('ts < ?');
207
+ p.push(params.to_ts);
208
+ }
209
+ if (params.agent_aid) {
210
+ conds.push('agent_aid = ?');
211
+ p.push(params.agent_aid);
212
+ }
213
+ if (params.peer_key) {
214
+ conds.push('peer_key = ?');
215
+ p.push(params.peer_key);
216
+ }
217
+ if (params.model) {
218
+ conds.push('model = ?');
219
+ p.push(params.model);
220
+ }
221
+ const where = conds.length ? 'WHERE ' + conds.join(' AND ') : '';
222
+ try {
223
+ return db.prepare(`
224
+ SELECT
225
+ strftime('${strfmt}', ts/1000, 'unixepoch', 'localtime') AS period,
226
+ COALESCE(SUM(input_tokens),0) AS input_tokens,
227
+ COALESCE(SUM(output_tokens),0) AS output_tokens,
228
+ COALESCE(SUM(cache_creation_tokens),0) AS cache_creation_tokens,
229
+ COALESCE(SUM(cache_read_tokens),0) AS cache_read_tokens,
230
+ COUNT(*) AS call_count
231
+ FROM usage_events ${where}
232
+ GROUP BY period ORDER BY period
233
+ `).all(...p);
234
+ }
235
+ finally {
236
+ db.close();
237
+ }
238
+ }
239
+ /** 按 peer 分组聚合(支持时间范围过滤)。 */
240
+ export function queryStatsByPeer(params) {
241
+ const db = openDb();
242
+ if (!db)
243
+ return [];
244
+ const conds = [];
245
+ const p = [];
246
+ if (params.from_ts) {
247
+ conds.push('ts >= ?');
248
+ p.push(params.from_ts);
249
+ }
250
+ if (params.to_ts) {
251
+ conds.push('ts < ?');
252
+ p.push(params.to_ts);
253
+ }
254
+ if (params.agent_aid) {
255
+ conds.push('agent_aid = ?');
256
+ p.push(params.agent_aid);
257
+ }
258
+ const where = conds.length ? 'WHERE ' + conds.join(' AND ') : '';
259
+ const limit = params.limit ?? 50;
260
+ try {
261
+ return db.prepare(`
262
+ SELECT peer_key, peer_type,
263
+ COALESCE(SUM(input_tokens),0) AS input_tokens,
264
+ COALESCE(SUM(output_tokens),0) AS output_tokens,
265
+ COALESCE(SUM(cache_read_tokens),0) AS cache_read_tokens,
266
+ COUNT(*) AS call_count
267
+ FROM usage_events ${where}
268
+ GROUP BY peer_key ORDER BY (input_tokens+output_tokens) DESC LIMIT ${limit}
269
+ `).all(...p);
270
+ }
271
+ finally {
272
+ db.close();
273
+ }
274
+ }
275
+ export function queryStatsOverview() {
276
+ const db = openDb();
277
+ if (!db)
278
+ return null;
279
+ try {
280
+ const allRows = db.prepare('SELECT * FROM usage_events').all();
281
+ let totIn = 0, totOut = 0, totCc = 0, totCr = 0, totCalls = 0, totUsd = 0, totCny = 0;
282
+ const byAgent = new Map();
283
+ for (const r of allRows) {
284
+ totIn += r.input_tokens ?? 0;
285
+ totOut += r.output_tokens ?? 0;
286
+ totCc += r.cache_creation_tokens ?? 0;
287
+ totCr += r.cache_read_tokens ?? 0;
288
+ totCalls++;
289
+ const { usd, cny } = _calcRowCost(r);
290
+ totUsd += usd;
291
+ totCny += cny;
292
+ const aid = r.agent_aid || '';
293
+ let a = byAgent.get(aid);
294
+ if (!a) {
295
+ a = { input_tokens: 0, output_tokens: 0, cache_creation_tokens: 0, cache_read_tokens: 0, call_count: 0, cost_usd: 0, cost_cny: 0 };
296
+ byAgent.set(aid, a);
297
+ }
298
+ a.input_tokens += r.input_tokens ?? 0;
299
+ a.output_tokens += r.output_tokens ?? 0;
300
+ a.cache_creation_tokens += r.cache_creation_tokens ?? 0;
301
+ a.cache_read_tokens += r.cache_read_tokens ?? 0;
302
+ a.call_count++;
303
+ a.cost_usd += usd;
304
+ a.cost_cny += cny;
305
+ }
306
+ return {
307
+ all_time: { input_tokens: totIn, output_tokens: totOut, cache_creation_tokens: totCc, cache_read_tokens: totCr, call_count: totCalls, cost_usd: totUsd, cost_cny: totCny },
308
+ by_agent: Array.from(byAgent.entries())
309
+ .map(([agent_aid, v]) => ({ agent_aid, ...v }))
310
+ .sort((a, b) => (b.input_tokens + b.output_tokens) - (a.input_tokens + a.output_tokens)),
311
+ };
312
+ }
313
+ finally {
314
+ db.close();
315
+ }
316
+ }
317
+ /** 按 agent 分组聚合(支持时间范围过滤)。 */
318
+ export function queryStatsByAgent(params) {
319
+ const db = openDb();
320
+ if (!db)
321
+ return [];
322
+ const conds = [];
323
+ const p = [];
324
+ if (params.from_ts) {
325
+ conds.push('ts >= ?');
326
+ p.push(params.from_ts);
327
+ }
328
+ if (params.to_ts) {
329
+ conds.push('ts < ?');
330
+ p.push(params.to_ts);
331
+ }
332
+ const where = conds.length ? 'WHERE ' + conds.join(' AND ') : '';
333
+ const limit = params.limit ?? 50;
334
+ try {
335
+ return db.prepare(`
336
+ SELECT agent_aid,
337
+ COALESCE(SUM(input_tokens),0) AS input_tokens,
338
+ COALESCE(SUM(output_tokens),0) AS output_tokens,
339
+ COALESCE(SUM(cache_read_tokens),0) AS cache_read_tokens,
340
+ COUNT(*) AS call_count
341
+ FROM usage_events ${where}
342
+ GROUP BY agent_aid ORDER BY (input_tokens+output_tokens) DESC LIMIT ${limit}
343
+ `).all(...p);
344
+ }
345
+ finally {
346
+ db.close();
347
+ }
348
+ }