evolclaw-web 1.0.0 → 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/index.js +11 -4
- package/dist/server.js +187 -6
- package/dist/sources/aid.js +1 -1
- package/dist/sources/cache.js +43 -0
- package/dist/sources/control.js +58 -0
- package/dist/sources/session.js +38 -4
- package/dist/sources/stats.js +348 -0
- package/dist/sources/system.js +51 -0
- package/dist/sources/triggers.js +54 -0
- package/dist/sources/types.js +1 -1
- package/dist/static/app.js +921 -17
- package/dist/static/index.html +95 -3
- package/dist/static/style.css +253 -1
- package/package.json +8 -3
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* ecweb — EvolClaw 监控面板独立程序。
|
|
4
4
|
*
|
|
5
5
|
* 用法:
|
|
6
|
-
* ecweb [--port
|
|
6
|
+
* ecweb [--port 42705] [--home <EVOLCLAW_HOME>]
|
|
7
7
|
*
|
|
8
8
|
* 通过 EVOLCLAW_HOME 定位 evolclaw 数据目录,启动 HTTP+WS 服务,浏览器配对码登录。
|
|
9
9
|
* 与 evolclaw daemon 通过 IPC socket(live 状态)+ 文件系统(历史数据)旁路通信。
|
|
@@ -22,7 +22,7 @@ for (let i = 0; i < argv.length; i++) {
|
|
|
22
22
|
else if (a === '--home' || a === '-h')
|
|
23
23
|
home = argv[++i];
|
|
24
24
|
else if (a === '--help') {
|
|
25
|
-
process.stdout.write(`ecweb — EvolClaw 监控面板\n\n用法:\n ecweb [--port
|
|
25
|
+
process.stdout.write(`ecweb — EvolClaw 监控面板\n\n用法:\n ecweb [--port 42705] [--home <EVOLCLAW_HOME>]\n\n选项:\n --port, -p 监听端口(默认 42705,占用则自动 +1)\n --home EVOLCLAW_HOME 数据目录(默认读环境变量或 ~/.evolclaw)\n`);
|
|
26
26
|
process.exit(0);
|
|
27
27
|
}
|
|
28
28
|
}
|
|
@@ -73,7 +73,7 @@ const killedWebs = cleanupWatchWebs();
|
|
|
73
73
|
for (const r of killedWebs)
|
|
74
74
|
logLine(`${YELLOW}↺ 已清理旧 watch 进程 PID ${r.pid}(端口 ${r.port})${RST}`);
|
|
75
75
|
// 2) 兜底:按端口杀掉 instance 文件已丢失的孤儿进程(杀不掉的僵尸)
|
|
76
|
-
const WATCH_WEB_PORT = port ??
|
|
76
|
+
const WATCH_WEB_PORT = port ?? 42705;
|
|
77
77
|
const killedByPort = cleanupWatchWebByPort(WATCH_WEB_PORT);
|
|
78
78
|
for (const pid of killedByPort)
|
|
79
79
|
logLine(`${YELLOW}↺ 已强占端口 ${WATCH_WEB_PORT}:杀掉占用进程 PID ${pid}${RST}`);
|
|
@@ -110,8 +110,16 @@ if (handle.displaced) {
|
|
|
110
110
|
}
|
|
111
111
|
process.stdout.write(`\n ${DIM}绑定 0.0.0.0,远程可访问。Ctrl-C 退出。${RST}\n`);
|
|
112
112
|
process.stdout.write(` ${DIM}调试日志: ${logFile}${RST}\n\n`);
|
|
113
|
+
let cleaningUp = false;
|
|
113
114
|
const cleanup = () => {
|
|
115
|
+
if (cleaningUp)
|
|
116
|
+
return; // 幂等:raw 模式下连按 Ctrl-C/q 不应重复触发
|
|
117
|
+
cleaningUp = true;
|
|
118
|
+
logLine(`${YELLOW}退出中…${RST}`);
|
|
114
119
|
removeWatchWeb();
|
|
120
|
+
// 兜底:close() 万一卡住也强制退出,避免进程挂死
|
|
121
|
+
const force = setTimeout(() => process.exit(0), 2000);
|
|
122
|
+
force.unref();
|
|
115
123
|
handle.close().finally(() => process.exit(0));
|
|
116
124
|
};
|
|
117
125
|
process.on('exit', () => removeWatchWeb());
|
|
@@ -123,7 +131,6 @@ if (process.stdin.isTTY) {
|
|
|
123
131
|
process.stdin.on('data', (key) => {
|
|
124
132
|
// Ctrl-C (0x03) 或 q 退出
|
|
125
133
|
if (key[0] === 0x03 || key.toString() === 'q') {
|
|
126
|
-
logLine(`${YELLOW}退出中…${RST}`);
|
|
127
134
|
cleanup();
|
|
128
135
|
}
|
|
129
136
|
});
|
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
|
-
const DEFAULT_PORT =
|
|
33
|
+
const DEFAULT_PORT = 42705;
|
|
28
34
|
const PROTOCOL_VERSION = 1; // 与 evolclaw ping response 对齐的软校验版本
|
|
29
|
-
const SOURCES = {
|
|
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,11 +329,43 @@ 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
|
|
354
|
+
let alive = true;
|
|
355
|
+
const heartbeat = setInterval(() => {
|
|
356
|
+
if (ws.readyState !== ws.OPEN) {
|
|
357
|
+
clearInterval(heartbeat);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if (!alive) {
|
|
361
|
+
ws.terminate();
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
alive = false;
|
|
365
|
+
ws.ping();
|
|
366
|
+
}, 25000);
|
|
367
|
+
ws.on('pong', () => { alive = true; });
|
|
368
|
+
ws.on('close', () => { clearInterval(heartbeat); if (unsubscribe) {
|
|
214
369
|
unsubscribe();
|
|
215
370
|
unsubscribe = null;
|
|
216
371
|
} log(`◇ WS 断开 from ${ip}`); });
|
|
@@ -255,6 +410,12 @@ export async function startWatchWebServer(opts = {}) {
|
|
|
255
410
|
}
|
|
256
411
|
const server = http.createServer((req, res) => {
|
|
257
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
|
+
}
|
|
258
419
|
const { code, expiresAt } = freshPairing();
|
|
259
420
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
260
421
|
res.end(JSON.stringify({ code, expiresAt }));
|
|
@@ -262,6 +423,19 @@ export async function startWatchWebServer(opts = {}) {
|
|
|
262
423
|
else if (req.method === 'POST' && (req.url || '').startsWith('/api/pair')) {
|
|
263
424
|
handlePair(req, res, pairingCode, pairingExpiry, log);
|
|
264
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
|
+
}
|
|
265
439
|
else {
|
|
266
440
|
serveStatic(req, res);
|
|
267
441
|
}
|
|
@@ -287,13 +461,20 @@ export async function startWatchWebServer(opts = {}) {
|
|
|
287
461
|
pairingCode,
|
|
288
462
|
close() {
|
|
289
463
|
return new Promise((resolve) => {
|
|
464
|
+
// 强制断开所有 WS 客户端(graceful close 握手可能永不完成)
|
|
290
465
|
for (const client of wss.clients)
|
|
291
466
|
try {
|
|
292
|
-
client.
|
|
467
|
+
client.terminate();
|
|
293
468
|
}
|
|
294
469
|
catch { }
|
|
295
470
|
wss.close();
|
|
471
|
+
// server.close() 仅停止接受新连接,会等待存量连接(含 HTTP keep-alive、已升级的 WS)排空,
|
|
472
|
+
// 否则回调永不触发 → 进程挂起。Node 18.2+ 用 closeAllConnections() 强制关闭。
|
|
296
473
|
server.close(() => resolve());
|
|
474
|
+
try {
|
|
475
|
+
server.closeAllConnections();
|
|
476
|
+
}
|
|
477
|
+
catch { }
|
|
297
478
|
});
|
|
298
479
|
},
|
|
299
480
|
};
|
package/dist/sources/aid.js
CHANGED
|
@@ -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
|
+
};
|
package/dist/sources/session.js
CHANGED
|
@@ -259,6 +259,7 @@ function readTranscriptFile(file) {
|
|
|
259
259
|
return empty;
|
|
260
260
|
}
|
|
261
261
|
const turns = [];
|
|
262
|
+
const counts = { userInput: 0, modelOutput: 0, toolCall: 0, toolResult: 0, msgSend: 0 };
|
|
262
263
|
let inTok = 0, outTok = 0, model = '', branch = '', version = '', title = '', cwd = '', userMsgs = 0, totalMsgs = 0, contextTokens = 0, costUsd = 0, lastUsageKey = '';
|
|
263
264
|
for (const line of raw.split('\n')) {
|
|
264
265
|
if (!line.trim())
|
|
@@ -304,12 +305,45 @@ function readTranscriptFile(file) {
|
|
|
304
305
|
if (o.message.model)
|
|
305
306
|
model = o.message.model;
|
|
306
307
|
}
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
308
|
+
if (o.type === 'user' || o.type === 'assistant') {
|
|
309
|
+
const content = o.message?.content;
|
|
310
|
+
const arr = typeof content === 'string' ? [{ type: 'text', text: content }] : (Array.isArray(content) ? content : []);
|
|
311
|
+
const blocks = [];
|
|
312
|
+
let hasToolUse = false, hasToolResult = false;
|
|
313
|
+
for (const item of arr) {
|
|
314
|
+
if (!item || typeof item !== 'object')
|
|
315
|
+
continue;
|
|
316
|
+
if (item.type === 'text' && item.text) {
|
|
317
|
+
blocks.push({ kind: 'text', text: item.text });
|
|
318
|
+
}
|
|
319
|
+
else if (item.type === 'thinking' && item.thinking) {
|
|
320
|
+
blocks.push({ kind: 'thinking', text: item.thinking });
|
|
321
|
+
}
|
|
322
|
+
else if (item.type === 'tool_use') {
|
|
323
|
+
const inputStr = item.input ? JSON.stringify(item.input, null, 2) : '';
|
|
324
|
+
blocks.push({ kind: 'tool_use', name: item.name || '', input: item.input || {}, inputStr });
|
|
325
|
+
hasToolUse = true;
|
|
326
|
+
}
|
|
327
|
+
else if (item.type === 'tool_result') {
|
|
328
|
+
const c = item.content;
|
|
329
|
+
const text = typeof c === 'string' ? c : (Array.isArray(c) ? c.filter((x) => x?.type === 'text').map((x) => x.text).join('\n') : '');
|
|
330
|
+
blocks.push({ kind: 'tool_result', text, isError: !!item.is_error });
|
|
331
|
+
hasToolResult = true;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
let category;
|
|
335
|
+
if (o.type === 'user') {
|
|
336
|
+
category = hasToolResult ? 'tool_result' : 'user_input';
|
|
337
|
+
}
|
|
338
|
+
else {
|
|
339
|
+
category = hasToolUse ? 'tool_call' : 'model_output';
|
|
340
|
+
}
|
|
341
|
+
counts[category === 'user_input' ? 'userInput' : category === 'model_output' ? 'modelOutput' : category === 'tool_call' ? 'toolCall' : 'toolResult']++;
|
|
342
|
+
turns.push({ role: o.type, ts: o.timestamp ? Date.parse(o.timestamp) : 0, uuid: o.uuid, category, blocks });
|
|
343
|
+
}
|
|
310
344
|
}
|
|
311
345
|
const shown = turns.length > 500 ? turns.slice(-500) : turns;
|
|
312
|
-
return { turns: shown, totalTurns: turns.length, userMsgs, totalMsgs, counts
|
|
346
|
+
return { turns: shown, totalTurns: turns.length, userMsgs, totalMsgs, counts, inputTokens: inTok, outputTokens: outTok, contextTokens, costUsd, model, gitBranch: branch, version, title, cwd };
|
|
313
347
|
}
|
|
314
348
|
function buildSnapshot(params) {
|
|
315
349
|
const projects = listProjects();
|