evolclaw-web 1.2.0 → 1.2.2
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 +182 -23
- package/dist/sources/gateway.js +44 -0
- package/dist/sources/session.js +1 -1
- package/dist/sources/stats.js +269 -136
- package/dist/static/app.js +1940 -270
- package/dist/static/index.html +122 -57
- package/dist/static/style.css +844 -19
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -20,12 +20,13 @@ import { ipcQuery } from './ipc-client.js';
|
|
|
20
20
|
import { setDebugLog, dlog } from './debug-log.js';
|
|
21
21
|
import { aidSource } from './sources/aid.js';
|
|
22
22
|
import { msgSource } from './sources/msg.js';
|
|
23
|
-
import { sessionSource } from './sources/session.js';
|
|
23
|
+
import { sessionSource, buildBindMap } from './sources/session.js';
|
|
24
24
|
import { cacheSource } from './sources/cache.js';
|
|
25
25
|
import { systemSource } from './sources/system.js';
|
|
26
26
|
import { triggersSource } from './sources/triggers.js';
|
|
27
27
|
import { monitorSource } from './sources/monitor.js';
|
|
28
|
-
import {
|
|
28
|
+
import { gatewaySource } from './sources/gateway.js';
|
|
29
|
+
import { queryStatsForDashboard, queryStatsExplorer, queryStatsByPeer, queryStatsOverview, queryUsageDetail, queryUsedModels } from './sources/stats.js';
|
|
29
30
|
import { getSessionsAunDir, listLocalAids, listPeers, readMessages } from './fs-utils.js';
|
|
30
31
|
import { ccProjectsDir } from './paths.js';
|
|
31
32
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -34,7 +35,7 @@ const TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30天(滑动窗口:每次
|
|
|
34
35
|
const PAIRING_TTL_MS = 5 * 60 * 1000; // 5min
|
|
35
36
|
const DEFAULT_PORT = 42705;
|
|
36
37
|
const PROTOCOL_VERSION = 1; // 与 evolclaw ping response 对齐的软校验版本
|
|
37
|
-
const SOURCES = { agents: aidSource, msg: msgSource, session: sessionSource, cache: cacheSource, system: systemSource, triggers: triggersSource, monitor: monitorSource };
|
|
38
|
+
const SOURCES = { agents: aidSource, msg: msgSource, session: sessionSource, cache: cacheSource, system: systemSource, triggers: triggersSource, monitor: monitorSource, gateway: gatewaySource };
|
|
38
39
|
// ECWeb 自身版本:渲染 System 页时随快照下发(不走 daemon IPC,ECWeb 就是这个进程)。
|
|
39
40
|
function readEcwebVersion() {
|
|
40
41
|
try {
|
|
@@ -104,6 +105,26 @@ function validateAndRenew(token, now) {
|
|
|
104
105
|
return false;
|
|
105
106
|
}
|
|
106
107
|
// ── Static ──
|
|
108
|
+
// 经 AUN Service Proxy 转发时,proxy-server 注入 x-forwarded-prefix 头,
|
|
109
|
+
// 值为外部前缀(如 /evolai/ecweb)。把它作为 <base href> 注入 index.html,
|
|
110
|
+
// 使相对路径资源(style.css / app.js / api / ws)在带不带尾斜杠时都正确解析。
|
|
111
|
+
// 本地直连无此头,<base> 注入为 "/",行为不变。
|
|
112
|
+
function forwardedPrefix(req) {
|
|
113
|
+
const raw = req.headers['x-forwarded-prefix'];
|
|
114
|
+
const value = (Array.isArray(raw) ? raw[0] : raw || '').trim();
|
|
115
|
+
// 安全:只接受形如 /a/b 的简单路径,拒绝含协议、双斜杠、引号、尖括号等的值
|
|
116
|
+
if (!value || !/^\/[A-Za-z0-9._~%/-]*$/.test(value))
|
|
117
|
+
return '';
|
|
118
|
+
return value.replace(/\/+$/, ''); // 去尾斜杠,注入时统一补
|
|
119
|
+
}
|
|
120
|
+
function injectBaseHref(html, prefix) {
|
|
121
|
+
const base = prefix ? `${prefix}/` : '/';
|
|
122
|
+
const tag = `<base href="${base}">`;
|
|
123
|
+
// 若已有 <base> 则替换,否则插在 <head> 之后
|
|
124
|
+
if (/<base\b[^>]*>/i.test(html))
|
|
125
|
+
return html.replace(/<base\b[^>]*>/i, tag);
|
|
126
|
+
return html.replace(/<head[^>]*>/i, (m) => `${m}\n ${tag}`);
|
|
127
|
+
}
|
|
107
128
|
function serveStatic(req, res) {
|
|
108
129
|
let urlPath = (req.url || '/').split('?')[0];
|
|
109
130
|
if (urlPath === '/')
|
|
@@ -119,7 +140,16 @@ function serveStatic(req, res) {
|
|
|
119
140
|
res.writeHead(404).end('Not Found');
|
|
120
141
|
return;
|
|
121
142
|
}
|
|
122
|
-
|
|
143
|
+
const ext = path.extname(file);
|
|
144
|
+
// index.html 注入 <base href>,适配 AUN Service Proxy 前缀
|
|
145
|
+
if (ext === '.html') {
|
|
146
|
+
const prefix = forwardedPrefix(req);
|
|
147
|
+
const html = injectBaseHref(data.toString('utf-8'), prefix);
|
|
148
|
+
res.writeHead(200, { 'Content-Type': MIME[ext] });
|
|
149
|
+
res.end(html);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
|
|
123
153
|
res.end(data);
|
|
124
154
|
});
|
|
125
155
|
}
|
|
@@ -183,40 +213,97 @@ function handleStatsApi(req, res) {
|
|
|
183
213
|
res.end(JSON.stringify(data));
|
|
184
214
|
}
|
|
185
215
|
else if (urlPath === '/api/stats/agents') {
|
|
216
|
+
// 返回所有agent列表(与智能体管理页面一致,从aidSource获取)
|
|
217
|
+
aidSource.snapshot().then(snapshot => {
|
|
218
|
+
const agents = (snapshot?.agents || []).map((ag) => ({
|
|
219
|
+
agent_aid: ag.aid,
|
|
220
|
+
agent_name: ag.displayName || null
|
|
221
|
+
}));
|
|
222
|
+
res.writeHead(200);
|
|
223
|
+
res.end(JSON.stringify(agents));
|
|
224
|
+
}).catch(err => {
|
|
225
|
+
res.writeHead(500);
|
|
226
|
+
res.end(JSON.stringify({ error: 'Failed to fetch agents' }));
|
|
227
|
+
});
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
else if (urlPath === '/api/stats/overview') {
|
|
231
|
+
// 从查询参数中获取时间范围和筛选条件
|
|
186
232
|
const params = {};
|
|
187
233
|
if (query.from)
|
|
188
234
|
params.from_ts = Number(query.from);
|
|
189
235
|
if (query.to)
|
|
190
236
|
params.to_ts = Number(query.to);
|
|
191
|
-
if (query.
|
|
192
|
-
params.
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
else if (urlPath === '/api/stats/overview') {
|
|
198
|
-
const tokenStats = queryStatsOverview();
|
|
199
|
-
// session count: scan all CC project dirs
|
|
237
|
+
if (query.agent)
|
|
238
|
+
params.agent_aid = String(query.agent);
|
|
239
|
+
if (query.peer)
|
|
240
|
+
params.peer_key = String(query.peer);
|
|
241
|
+
const tokenStats = queryStatsOverview(params);
|
|
242
|
+
// session count: 使用 bindMap 按 agent 和 peer 筛选
|
|
200
243
|
let sessionCount = 0;
|
|
201
244
|
try {
|
|
245
|
+
// 构建 agentSessionId → agent_aid 的映射
|
|
246
|
+
const bindMap = buildBindMap();
|
|
247
|
+
// 如果指定了 peer_key,提取并解码 channelId
|
|
248
|
+
let targetChannelId;
|
|
249
|
+
if (params.peer_key) {
|
|
250
|
+
const parts = params.peer_key.split('#');
|
|
251
|
+
if (parts.length >= 4) {
|
|
252
|
+
// aun#{agent}#main#{channelId},需要解码 URL 编码
|
|
253
|
+
targetChannelId = decodeURIComponent(parts[3]);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
202
256
|
const base = ccProjectsDir();
|
|
203
257
|
for (const d of fs.readdirSync(base, { withFileTypes: true })) {
|
|
204
258
|
if (!d.isDirectory())
|
|
205
259
|
continue;
|
|
260
|
+
const projectDir = path.join(base, d.name);
|
|
206
261
|
try {
|
|
207
|
-
|
|
262
|
+
const sessionFiles = fs.readdirSync(projectDir).filter(f => f.endsWith('.jsonl'));
|
|
263
|
+
for (const sessionFile of sessionFiles) {
|
|
264
|
+
const sessionId = sessionFile.replace('.jsonl', '');
|
|
265
|
+
// 如果指定了 agent 或 peer,检查该会话是否匹配
|
|
266
|
+
if (params.agent_aid || targetChannelId) {
|
|
267
|
+
const bindInfo = bindMap.get(sessionId);
|
|
268
|
+
if (!bindInfo)
|
|
269
|
+
continue;
|
|
270
|
+
// 检查 agent
|
|
271
|
+
if (params.agent_aid && bindInfo.selfAID !== params.agent_aid)
|
|
272
|
+
continue;
|
|
273
|
+
// 检查 peer
|
|
274
|
+
if (targetChannelId && bindInfo.channelId !== targetChannelId)
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
// 如果有时间范围限制,检查会话文件的修改时间
|
|
278
|
+
if (params.from_ts || params.to_ts) {
|
|
279
|
+
const stat = fs.statSync(path.join(projectDir, sessionFile));
|
|
280
|
+
const mtime = stat.mtimeMs;
|
|
281
|
+
if (params.from_ts && mtime < params.from_ts)
|
|
282
|
+
continue;
|
|
283
|
+
if (params.to_ts && mtime > params.to_ts)
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
sessionCount++;
|
|
287
|
+
}
|
|
208
288
|
}
|
|
209
289
|
catch { }
|
|
210
290
|
}
|
|
211
291
|
}
|
|
212
292
|
catch { }
|
|
213
|
-
// message counts: scan aun dir
|
|
293
|
+
// message counts: scan aun dir, 根据时间范围和 agent/peer 过滤
|
|
214
294
|
let msgIn = 0, msgOut = 0;
|
|
215
295
|
try {
|
|
216
296
|
const aunDir = getSessionsAunDir();
|
|
217
|
-
|
|
218
|
-
|
|
297
|
+
const aids = params.agent_aid ? [params.agent_aid] : listLocalAids(aunDir);
|
|
298
|
+
for (const aid of aids) {
|
|
299
|
+
const peers = params.peer_key ? [params.peer_key] : listPeers(aunDir, aid);
|
|
300
|
+
for (const peer of peers) {
|
|
219
301
|
for (const m of readMessages(aunDir, aid, peer)) {
|
|
302
|
+
// 根据消息时间戳过滤
|
|
303
|
+
if (params.from_ts && m.ts < params.from_ts)
|
|
304
|
+
continue;
|
|
305
|
+
if (params.to_ts && m.ts > params.to_ts)
|
|
306
|
+
continue;
|
|
220
307
|
if (m.dir === 'in')
|
|
221
308
|
msgIn++;
|
|
222
309
|
else
|
|
@@ -229,6 +316,34 @@ function handleStatsApi(req, res) {
|
|
|
229
316
|
res.writeHead(200);
|
|
230
317
|
res.end(JSON.stringify({ token_stats: tokenStats, session_count: sessionCount, msg_in: msgIn, msg_out: msgOut }));
|
|
231
318
|
}
|
|
319
|
+
else if (urlPath === '/api/stats/detail') {
|
|
320
|
+
// 模型访问明细查询
|
|
321
|
+
const params = {};
|
|
322
|
+
if (query.from)
|
|
323
|
+
params.from_ts = Number(query.from);
|
|
324
|
+
if (query.to)
|
|
325
|
+
params.to_ts = Number(query.to);
|
|
326
|
+
if (query.agent)
|
|
327
|
+
params.agent_aid = query.agent;
|
|
328
|
+
if (query.model)
|
|
329
|
+
params.model = String(query.model);
|
|
330
|
+
params.limit = Number(query.limit) || 50; // 默认限制50条
|
|
331
|
+
params.offset = Number(query.offset) || 0; // 默认从0开始
|
|
332
|
+
const result = queryUsageDetail(params);
|
|
333
|
+
res.writeHead(200);
|
|
334
|
+
res.end(JSON.stringify(result));
|
|
335
|
+
}
|
|
336
|
+
else if (urlPath === '/api/stats/models') {
|
|
337
|
+
// 获取指定时间范围内使用过的模型列表
|
|
338
|
+
const params = {};
|
|
339
|
+
if (query.from)
|
|
340
|
+
params.from_ts = Number(query.from);
|
|
341
|
+
if (query.to)
|
|
342
|
+
params.to_ts = Number(query.to);
|
|
343
|
+
const models = queryUsedModels(params);
|
|
344
|
+
res.writeHead(200);
|
|
345
|
+
res.end(JSON.stringify(models));
|
|
346
|
+
}
|
|
232
347
|
else {
|
|
233
348
|
res.writeHead(404);
|
|
234
349
|
res.end(JSON.stringify({ error: 'not found' }));
|
|
@@ -246,6 +361,18 @@ function isLocalhost(req) {
|
|
|
246
361
|
const addr = req.socket.remoteAddress || '';
|
|
247
362
|
return addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1';
|
|
248
363
|
}
|
|
364
|
+
/**
|
|
365
|
+
* 判定「真本地直连」:socket 地址是回环 AND 无 x-aun-provider-aid 头。
|
|
366
|
+
* proxy-server 回连本地时 remoteAddress 也是 127.0.0.1,但会注入 x-aun-provider-aid
|
|
367
|
+
* 可信头(访客伪造的 x-aun-* 会被 proxy-server 剥掉)。只有真本地直连时两条件同时满足。
|
|
368
|
+
* 真本地直连免配对(自动发 token),隧道/远程需配对码。
|
|
369
|
+
*/
|
|
370
|
+
function isLocalDirect(req) {
|
|
371
|
+
if (!isLocalhost(req))
|
|
372
|
+
return false;
|
|
373
|
+
const providerAid = req.headers['x-aun-provider-aid'];
|
|
374
|
+
return !providerAid || (Array.isArray(providerAid) ? providerAid.length === 0 : !providerAid.trim());
|
|
375
|
+
}
|
|
249
376
|
function genPairingCode() {
|
|
250
377
|
return String(crypto.randomInt(0, 1000000)).padStart(6, '0');
|
|
251
378
|
}
|
|
@@ -281,6 +408,23 @@ function handlePair(req, res, pairingCode, pairingExpiry, log) {
|
|
|
281
408
|
log(`✓ 配对成功 from ${ip}(token 缓存 30 天,有访问自动续期)`);
|
|
282
409
|
});
|
|
283
410
|
}
|
|
411
|
+
/**
|
|
412
|
+
* 本地直连免配对:自动发 token。远程(隧道或真远程)需配对码。
|
|
413
|
+
* 本地直连判定:socket 来源是回环 AND 无 x-aun-provider-aid 头(非隧道)。
|
|
414
|
+
*/
|
|
415
|
+
function issueLocalDirectToken(req, log) {
|
|
416
|
+
if (!isLocalDirect(req))
|
|
417
|
+
return null;
|
|
418
|
+
const now = Date.now();
|
|
419
|
+
const token = crypto.randomBytes(32).toString('hex');
|
|
420
|
+
const store = loadTokens();
|
|
421
|
+
pruneExpired(store, now);
|
|
422
|
+
const ip = clientIp(req);
|
|
423
|
+
store.tokens.push({ token, createdAt: now, lastActive: now, label: `local-direct:${ip}` });
|
|
424
|
+
saveTokens(store);
|
|
425
|
+
log(`✓ 本地直连自动授权 from ${ip}`);
|
|
426
|
+
return token;
|
|
427
|
+
}
|
|
284
428
|
// ── WebSocket connection ──
|
|
285
429
|
function handleConnection(ws, req, log) {
|
|
286
430
|
const ip = clientIp(req);
|
|
@@ -471,8 +615,10 @@ export async function startWatchWebServer(opts = {}) {
|
|
|
471
615
|
}
|
|
472
616
|
const server = http.createServer((req, res) => {
|
|
473
617
|
if (req.method === 'GET' && (req.url || '') === '/api/pair-code') {
|
|
474
|
-
//
|
|
475
|
-
|
|
618
|
+
// 取码 API:仅真本地直连可用(socket 回环 + 无 x-aun-provider-aid)。
|
|
619
|
+
// 隧道回连虽然 socket 也是 127.0.0.1,但有 x-aun-provider-aid 头 → 拒绝,
|
|
620
|
+
// 防止远程访客通过隧道拿到配对码(安全漏洞)。
|
|
621
|
+
if (!isLocalDirect(req)) {
|
|
476
622
|
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
477
623
|
res.end(JSON.stringify({ error: 'forbidden' }));
|
|
478
624
|
return;
|
|
@@ -482,15 +628,27 @@ export async function startWatchWebServer(opts = {}) {
|
|
|
482
628
|
res.end(JSON.stringify({ code, expiresAt }));
|
|
483
629
|
}
|
|
484
630
|
else if (req.method === 'POST' && (req.url || '').startsWith('/api/pair')) {
|
|
631
|
+
// 配对 API:远程(隧道/真远程)需要配对码;本地直连自动发 token 跳过配对。
|
|
632
|
+
const autoToken = issueLocalDirectToken(req, log);
|
|
633
|
+
if (autoToken) {
|
|
634
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
635
|
+
res.end(JSON.stringify({ ok: true, token: autoToken }));
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
485
638
|
handlePair(req, res, pairingCode, pairingExpiry, log);
|
|
486
639
|
}
|
|
487
640
|
else if (req.method === 'GET' && (req.url || '').startsWith('/api/stats/')) {
|
|
488
|
-
// Stats API —
|
|
641
|
+
// Stats API — 本地直连自动发 token(免鉴权),远程需鉴权
|
|
489
642
|
const authHeader = req.headers.authorization || '';
|
|
490
643
|
const bearerToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
|
|
491
644
|
const { query } = parseUrl(req.url || '');
|
|
492
|
-
|
|
493
|
-
if (!
|
|
645
|
+
let token = bearerToken || query.token || '';
|
|
646
|
+
if (!token) {
|
|
647
|
+
const autoToken = issueLocalDirectToken(req, log);
|
|
648
|
+
if (autoToken)
|
|
649
|
+
token = autoToken;
|
|
650
|
+
}
|
|
651
|
+
if (!token || !validateAndRenew(token, Date.now())) {
|
|
494
652
|
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
495
653
|
res.end(JSON.stringify({ error: 'unauthorized' }));
|
|
496
654
|
return;
|
|
@@ -504,7 +662,8 @@ export async function startWatchWebServer(opts = {}) {
|
|
|
504
662
|
const wss = new WebSocketServer({ noServer: true });
|
|
505
663
|
server.on('upgrade', (req, socket, head) => {
|
|
506
664
|
const { query } = parseUrl(req.url || '');
|
|
507
|
-
|
|
665
|
+
// 本地直连免 token;远程需 token。
|
|
666
|
+
const authed = isLocalDirect(req) || validateAndRenew(query.token || '', Date.now());
|
|
508
667
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
509
668
|
if (!authed) {
|
|
510
669
|
log(`✗ WS 拒绝(无效 token) from ${clientIp(req)}`);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gateway.ts — 网关(baseagent 后端)配置数据源(只读代理)。
|
|
3
|
+
*
|
|
4
|
+
* ECWeb 不直接读写配置文件。snapshot 通过 daemon 的 menu.exec IPC 调
|
|
5
|
+
* menu.query name=gateway,由 daemon 端的 command-handler-gateway-control 统一
|
|
6
|
+
* 出数据(apiKey 已掩码)。写操作(update/test/delete)走前端 menuSend → menu IPC,
|
|
7
|
+
* 不经过本 source。
|
|
8
|
+
*
|
|
9
|
+
* 连不上 daemon 时返回 { gateways: [], error } 让前端提示。
|
|
10
|
+
*/
|
|
11
|
+
import { resolvePaths } from '../paths.js';
|
|
12
|
+
import { ipcQuery } from '../ipc-client.js';
|
|
13
|
+
async function getSnapshot() {
|
|
14
|
+
const sock = resolvePaths().socket;
|
|
15
|
+
const resp = await ipcQuery(sock, { type: 'menu.exec', payload: { type: 'menu.query', name: 'gateway', id: 'ecw-gateway-snap' } }, 5000);
|
|
16
|
+
if (!resp) {
|
|
17
|
+
return { gateways: [], scopes: [], types: [], error: 'evolclaw 未运行或 socket 不可达' };
|
|
18
|
+
}
|
|
19
|
+
if (!resp.ok) {
|
|
20
|
+
return { gateways: [], scopes: [], types: [], error: resp.error || 'daemon 返回失败' };
|
|
21
|
+
}
|
|
22
|
+
const inner = resp.response;
|
|
23
|
+
if (inner?.error) {
|
|
24
|
+
return { gateways: [], scopes: [], types: [], error: inner.error.message };
|
|
25
|
+
}
|
|
26
|
+
const data = inner?.data ?? {};
|
|
27
|
+
return {
|
|
28
|
+
gateways: Array.isArray(data.gateways) ? data.gateways : [],
|
|
29
|
+
scopes: Array.isArray(data.scopes) ? data.scopes : [],
|
|
30
|
+
types: Array.isArray(data.types) ? data.types : [],
|
|
31
|
+
effective: Array.isArray(data.effective) ? data.effective : [],
|
|
32
|
+
envMismatch: data.envMismatch || { hasMismatch: false, mismatches: [] },
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export const gatewaySource = {
|
|
36
|
+
kind: 'gateway',
|
|
37
|
+
async snapshot() {
|
|
38
|
+
return getSnapshot();
|
|
39
|
+
},
|
|
40
|
+
subscribe(_params, _push) {
|
|
41
|
+
// 网关配置变更不频繁,无文件监听;前端通过操作后主动 re-subscribe 刷新。
|
|
42
|
+
return () => { };
|
|
43
|
+
},
|
|
44
|
+
};
|
package/dist/sources/session.js
CHANGED
|
@@ -221,7 +221,7 @@ function resolveProject(params, projects) {
|
|
|
221
221
|
dlog(`[session] resolveProject: cwd project=${curEncoded.slice(-24)} not found → using first=${projects[0]?.encoded.slice(-24)}`);
|
|
222
222
|
return projects[0] || null;
|
|
223
223
|
}
|
|
224
|
-
function buildBindMap() {
|
|
224
|
+
export function buildBindMap() {
|
|
225
225
|
const map = new Map();
|
|
226
226
|
try {
|
|
227
227
|
const p = resolvePaths();
|