evolclaw-web 1.2.0 → 1.2.3

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,21 +20,23 @@ 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 { queryStatsForDashboard, queryStatsExplorer, queryStatsByPeer, queryStatsByAgent, queryStatsOverview } from './sources/stats.js';
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';
32
+ import { detectBaseAgents } from './sources/baseagent-detector.js';
31
33
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
32
34
  const STATIC_DIR = path.join(__dirname, 'static');
33
35
  const TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30天(滑动窗口:每次有效访问刷新 lastActive 自动续期)
34
36
  const PAIRING_TTL_MS = 5 * 60 * 1000; // 5min
35
37
  const DEFAULT_PORT = 42705;
36
38
  const PROTOCOL_VERSION = 1; // 与 evolclaw ping response 对齐的软校验版本
37
- const SOURCES = { agents: aidSource, msg: msgSource, session: sessionSource, cache: cacheSource, system: systemSource, triggers: triggersSource, monitor: monitorSource };
39
+ const SOURCES = { agents: aidSource, msg: msgSource, session: sessionSource, cache: cacheSource, system: systemSource, triggers: triggersSource, monitor: monitorSource, gateway: gatewaySource };
38
40
  // ECWeb 自身版本:渲染 System 页时随快照下发(不走 daemon IPC,ECWeb 就是这个进程)。
39
41
  function readEcwebVersion() {
40
42
  try {
@@ -104,6 +106,26 @@ function validateAndRenew(token, now) {
104
106
  return false;
105
107
  }
106
108
  // ── Static ──
109
+ // 经 AUN Service Proxy 转发时,proxy-server 注入 x-forwarded-prefix 头,
110
+ // 值为外部前缀(如 /evolai/ecweb)。把它作为 <base href> 注入 index.html,
111
+ // 使相对路径资源(style.css / app.js / api / ws)在带不带尾斜杠时都正确解析。
112
+ // 本地直连无此头,<base> 注入为 "/",行为不变。
113
+ function forwardedPrefix(req) {
114
+ const raw = req.headers['x-forwarded-prefix'];
115
+ const value = (Array.isArray(raw) ? raw[0] : raw || '').trim();
116
+ // 安全:只接受形如 /a/b 的简单路径,拒绝含协议、双斜杠、引号、尖括号等的值
117
+ if (!value || !/^\/[A-Za-z0-9._~%/-]*$/.test(value))
118
+ return '';
119
+ return value.replace(/\/+$/, ''); // 去尾斜杠,注入时统一补
120
+ }
121
+ function injectBaseHref(html, prefix) {
122
+ const base = prefix ? `${prefix}/` : '/';
123
+ const tag = `<base href="${base}">`;
124
+ // 若已有 <base> 则替换,否则插在 <head> 之后
125
+ if (/<base\b[^>]*>/i.test(html))
126
+ return html.replace(/<base\b[^>]*>/i, tag);
127
+ return html.replace(/<head[^>]*>/i, (m) => `${m}\n ${tag}`);
128
+ }
107
129
  function serveStatic(req, res) {
108
130
  let urlPath = (req.url || '/').split('?')[0];
109
131
  if (urlPath === '/')
@@ -119,7 +141,16 @@ function serveStatic(req, res) {
119
141
  res.writeHead(404).end('Not Found');
120
142
  return;
121
143
  }
122
- res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream' });
144
+ const ext = path.extname(file);
145
+ // index.html 注入 <base href>,适配 AUN Service Proxy 前缀
146
+ if (ext === '.html') {
147
+ const prefix = forwardedPrefix(req);
148
+ const html = injectBaseHref(data.toString('utf-8'), prefix);
149
+ res.writeHead(200, { 'Content-Type': MIME[ext] });
150
+ res.end(html);
151
+ return;
152
+ }
153
+ res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
123
154
  res.end(data);
124
155
  });
125
156
  }
@@ -183,40 +214,97 @@ function handleStatsApi(req, res) {
183
214
  res.end(JSON.stringify(data));
184
215
  }
185
216
  else if (urlPath === '/api/stats/agents') {
217
+ // 返回所有agent列表(与智能体管理页面一致,从aidSource获取)
218
+ aidSource.snapshot().then(snapshot => {
219
+ const agents = (snapshot?.agents || []).map((ag) => ({
220
+ agent_aid: ag.aid,
221
+ agent_name: ag.displayName || null
222
+ }));
223
+ res.writeHead(200);
224
+ res.end(JSON.stringify(agents));
225
+ }).catch(err => {
226
+ res.writeHead(500);
227
+ res.end(JSON.stringify({ error: 'Failed to fetch agents' }));
228
+ });
229
+ return;
230
+ }
231
+ else if (urlPath === '/api/stats/overview') {
232
+ // 从查询参数中获取时间范围和筛选条件
186
233
  const params = {};
187
234
  if (query.from)
188
235
  params.from_ts = Number(query.from);
189
236
  if (query.to)
190
237
  params.to_ts = Number(query.to);
191
- if (query.limit)
192
- params.limit = Number(query.limit);
193
- const data = queryStatsByAgent(params);
194
- res.writeHead(200);
195
- res.end(JSON.stringify(data));
196
- }
197
- else if (urlPath === '/api/stats/overview') {
198
- const tokenStats = queryStatsOverview();
199
- // session count: scan all CC project dirs
238
+ if (query.agent)
239
+ params.agent_aid = String(query.agent);
240
+ if (query.peer)
241
+ params.peer_key = String(query.peer);
242
+ const tokenStats = queryStatsOverview(params);
243
+ // session count: 使用 bindMap 按 agent 和 peer 筛选
200
244
  let sessionCount = 0;
201
245
  try {
246
+ // 构建 agentSessionId → agent_aid 的映射
247
+ const bindMap = buildBindMap();
248
+ // 如果指定了 peer_key,提取并解码 channelId
249
+ let targetChannelId;
250
+ if (params.peer_key) {
251
+ const parts = params.peer_key.split('#');
252
+ if (parts.length >= 4) {
253
+ // aun#{agent}#main#{channelId},需要解码 URL 编码
254
+ targetChannelId = decodeURIComponent(parts[3]);
255
+ }
256
+ }
202
257
  const base = ccProjectsDir();
203
258
  for (const d of fs.readdirSync(base, { withFileTypes: true })) {
204
259
  if (!d.isDirectory())
205
260
  continue;
261
+ const projectDir = path.join(base, d.name);
206
262
  try {
207
- sessionCount += fs.readdirSync(path.join(base, d.name)).filter(f => f.endsWith('.jsonl')).length;
263
+ const sessionFiles = fs.readdirSync(projectDir).filter(f => f.endsWith('.jsonl'));
264
+ for (const sessionFile of sessionFiles) {
265
+ const sessionId = sessionFile.replace('.jsonl', '');
266
+ // 如果指定了 agent 或 peer,检查该会话是否匹配
267
+ if (params.agent_aid || targetChannelId) {
268
+ const bindInfo = bindMap.get(sessionId);
269
+ if (!bindInfo)
270
+ continue;
271
+ // 检查 agent
272
+ if (params.agent_aid && bindInfo.selfAID !== params.agent_aid)
273
+ continue;
274
+ // 检查 peer
275
+ if (targetChannelId && bindInfo.channelId !== targetChannelId)
276
+ continue;
277
+ }
278
+ // 如果有时间范围限制,检查会话文件的修改时间
279
+ if (params.from_ts || params.to_ts) {
280
+ const stat = fs.statSync(path.join(projectDir, sessionFile));
281
+ const mtime = stat.mtimeMs;
282
+ if (params.from_ts && mtime < params.from_ts)
283
+ continue;
284
+ if (params.to_ts && mtime > params.to_ts)
285
+ continue;
286
+ }
287
+ sessionCount++;
288
+ }
208
289
  }
209
290
  catch { }
210
291
  }
211
292
  }
212
293
  catch { }
213
- // message counts: scan aun dir
294
+ // message counts: scan aun dir, 根据时间范围和 agent/peer 过滤
214
295
  let msgIn = 0, msgOut = 0;
215
296
  try {
216
297
  const aunDir = getSessionsAunDir();
217
- for (const aid of listLocalAids(aunDir)) {
218
- for (const peer of listPeers(aunDir, aid)) {
298
+ const aids = params.agent_aid ? [params.agent_aid] : listLocalAids(aunDir);
299
+ for (const aid of aids) {
300
+ const peers = params.peer_key ? [params.peer_key] : listPeers(aunDir, aid);
301
+ for (const peer of peers) {
219
302
  for (const m of readMessages(aunDir, aid, peer)) {
303
+ // 根据消息时间戳过滤
304
+ if (params.from_ts && m.ts < params.from_ts)
305
+ continue;
306
+ if (params.to_ts && m.ts > params.to_ts)
307
+ continue;
220
308
  if (m.dir === 'in')
221
309
  msgIn++;
222
310
  else
@@ -229,6 +317,34 @@ function handleStatsApi(req, res) {
229
317
  res.writeHead(200);
230
318
  res.end(JSON.stringify({ token_stats: tokenStats, session_count: sessionCount, msg_in: msgIn, msg_out: msgOut }));
231
319
  }
320
+ else if (urlPath === '/api/stats/detail') {
321
+ // 模型访问明细查询
322
+ const params = {};
323
+ if (query.from)
324
+ params.from_ts = Number(query.from);
325
+ if (query.to)
326
+ params.to_ts = Number(query.to);
327
+ if (query.agent)
328
+ params.agent_aid = query.agent;
329
+ if (query.model)
330
+ params.model = String(query.model);
331
+ params.limit = Number(query.limit) || 50; // 默认限制50条
332
+ params.offset = Number(query.offset) || 0; // 默认从0开始
333
+ const result = queryUsageDetail(params);
334
+ res.writeHead(200);
335
+ res.end(JSON.stringify(result));
336
+ }
337
+ else if (urlPath === '/api/stats/models') {
338
+ // 获取指定时间范围内使用过的模型列表
339
+ const params = {};
340
+ if (query.from)
341
+ params.from_ts = Number(query.from);
342
+ if (query.to)
343
+ params.to_ts = Number(query.to);
344
+ const models = queryUsedModels(params);
345
+ res.writeHead(200);
346
+ res.end(JSON.stringify(models));
347
+ }
232
348
  else {
233
349
  res.writeHead(404);
234
350
  res.end(JSON.stringify({ error: 'not found' }));
@@ -246,6 +362,18 @@ function isLocalhost(req) {
246
362
  const addr = req.socket.remoteAddress || '';
247
363
  return addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1';
248
364
  }
365
+ /**
366
+ * 判定「真本地直连」:socket 地址是回环 AND 无 x-aun-provider-aid 头。
367
+ * proxy-server 回连本地时 remoteAddress 也是 127.0.0.1,但会注入 x-aun-provider-aid
368
+ * 可信头(访客伪造的 x-aun-* 会被 proxy-server 剥掉)。只有真本地直连时两条件同时满足。
369
+ * 真本地直连免配对(自动发 token),隧道/远程需配对码。
370
+ */
371
+ function isLocalDirect(req) {
372
+ if (!isLocalhost(req))
373
+ return false;
374
+ const providerAid = req.headers['x-aun-provider-aid'];
375
+ return !providerAid || (Array.isArray(providerAid) ? providerAid.length === 0 : !providerAid.trim());
376
+ }
249
377
  function genPairingCode() {
250
378
  return String(crypto.randomInt(0, 1000000)).padStart(6, '0');
251
379
  }
@@ -281,6 +409,23 @@ function handlePair(req, res, pairingCode, pairingExpiry, log) {
281
409
  log(`✓ 配对成功 from ${ip}(token 缓存 30 天,有访问自动续期)`);
282
410
  });
283
411
  }
412
+ /**
413
+ * 本地直连免配对:自动发 token。远程(隧道或真远程)需配对码。
414
+ * 本地直连判定:socket 来源是回环 AND 无 x-aun-provider-aid 头(非隧道)。
415
+ */
416
+ function issueLocalDirectToken(req, log) {
417
+ if (!isLocalDirect(req))
418
+ return null;
419
+ const now = Date.now();
420
+ const token = crypto.randomBytes(32).toString('hex');
421
+ const store = loadTokens();
422
+ pruneExpired(store, now);
423
+ const ip = clientIp(req);
424
+ store.tokens.push({ token, createdAt: now, lastActive: now, label: `local-direct:${ip}` });
425
+ saveTokens(store);
426
+ log(`✓ 本地直连自动授权 from ${ip}`);
427
+ return token;
428
+ }
284
429
  // ── WebSocket connection ──
285
430
  function handleConnection(ws, req, log) {
286
431
  const ip = clientIp(req);
@@ -342,7 +487,9 @@ function handleConnection(ws, req, log) {
342
487
  params.project = msg.project;
343
488
  if (msg.agent)
344
489
  params.agent = msg.agent;
345
- 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}`);
490
+ if (msg.baseagent)
491
+ params.baseagent = msg.baseagent;
492
+ 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)}` : ''}${msg.baseagent ? ` baseagent=${msg.baseagent}` : ''} from ${ip}`);
346
493
  await switchSubscription(msg.view, params);
347
494
  return;
348
495
  }
@@ -470,9 +617,17 @@ export async function startWatchWebServer(opts = {}) {
470
617
  return { code: pairingCode, expiresAt: pairingExpiry };
471
618
  }
472
619
  const server = http.createServer((req, res) => {
473
- if (req.method === 'GET' && (req.url || '') === '/api/pair-code') {
474
- // localhost 可取码:远程浏览器拿不到,必须由同机的 `ec watch web` 显示给用户
475
- if (!isLocalhost(req)) {
620
+ if (req.method === 'GET' && (req.url || '') === '/api/available-baseagents') {
621
+ // Base agent 可用性检测 API
622
+ const available = detectBaseAgents();
623
+ res.writeHead(200, { 'Content-Type': 'application/json' });
624
+ res.end(JSON.stringify(available));
625
+ }
626
+ else if (req.method === 'GET' && (req.url || '') === '/api/pair-code') {
627
+ // 取码 API:仅真本地直连可用(socket 回环 + 无 x-aun-provider-aid)。
628
+ // 隧道回连虽然 socket 也是 127.0.0.1,但有 x-aun-provider-aid 头 → 拒绝,
629
+ // 防止远程访客通过隧道拿到配对码(安全漏洞)。
630
+ if (!isLocalDirect(req)) {
476
631
  res.writeHead(403, { 'Content-Type': 'application/json' });
477
632
  res.end(JSON.stringify({ error: 'forbidden' }));
478
633
  return;
@@ -482,15 +637,27 @@ export async function startWatchWebServer(opts = {}) {
482
637
  res.end(JSON.stringify({ code, expiresAt }));
483
638
  }
484
639
  else if (req.method === 'POST' && (req.url || '').startsWith('/api/pair')) {
640
+ // 配对 API:远程(隧道/真远程)需要配对码;本地直连自动发 token 跳过配对。
641
+ const autoToken = issueLocalDirectToken(req, log);
642
+ if (autoToken) {
643
+ res.writeHead(200, { 'Content-Type': 'application/json' });
644
+ res.end(JSON.stringify({ ok: true, token: autoToken }));
645
+ return;
646
+ }
485
647
  handlePair(req, res, pairingCode, pairingExpiry, log);
486
648
  }
487
649
  else if (req.method === 'GET' && (req.url || '').startsWith('/api/stats/')) {
488
- // Stats API — requires auth
650
+ // Stats API — 本地直连自动发 token(免鉴权),远程需鉴权
489
651
  const authHeader = req.headers.authorization || '';
490
652
  const bearerToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
491
653
  const { query } = parseUrl(req.url || '');
492
- const token = bearerToken || query.token || '';
493
- if (!validateAndRenew(token, Date.now())) {
654
+ let token = bearerToken || query.token || '';
655
+ if (!token) {
656
+ const autoToken = issueLocalDirectToken(req, log);
657
+ if (autoToken)
658
+ token = autoToken;
659
+ }
660
+ if (!token || !validateAndRenew(token, Date.now())) {
494
661
  res.writeHead(401, { 'Content-Type': 'application/json' });
495
662
  res.end(JSON.stringify({ error: 'unauthorized' }));
496
663
  return;
@@ -504,7 +671,8 @@ export async function startWatchWebServer(opts = {}) {
504
671
  const wss = new WebSocketServer({ noServer: true });
505
672
  server.on('upgrade', (req, socket, head) => {
506
673
  const { query } = parseUrl(req.url || '');
507
- const authed = validateAndRenew(query.token || '', Date.now());
674
+ // 本地直连免 token;远程需 token
675
+ const authed = isLocalDirect(req) || validateAndRenew(query.token || '', Date.now());
508
676
  wss.handleUpgrade(req, socket, head, (ws) => {
509
677
  if (!authed) {
510
678
  log(`✗ WS 拒绝(无效 token) from ${clientIp(req)}`);
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * AID 数据源 — 复用 daemon 的 IPC socket(与 evolclaw `watch aid` 同源)。
3
3
  *
4
- * daemon 运行时:拉 aun-aids / aun-aid-stats / status / evolagent.list。
4
+ * daemon 运行时:拉 aun-aids / aun-aid-stats / agent-stats / status / evolagent.list。
5
5
  * daemon 未运行时:降级到读 instance/ 目录的 aid-*.jsonl(精简版 scanInstances)。
6
6
  * IPC 无推送能力,故 1s 轮询 + JSON diff,仅在变化时 push。
7
7
  */
@@ -83,9 +83,10 @@ async function fetchVersion(socket) {
83
83
  }
84
84
  async function buildSnapshot() {
85
85
  const p = resolvePaths();
86
- const [aidsResp, statsResp, statusResp, agentsResp, version] = await Promise.all([
86
+ const [aidsResp, statsResp, agentStatsResp, statusResp, agentsResp, version] = await Promise.all([
87
87
  ipcQuery(p.socket, { type: 'aun-aids' }),
88
88
  ipcQuery(p.socket, { type: 'aun-aid-stats' }),
89
+ ipcQuery(p.socket, { type: 'agent-stats' }),
89
90
  ipcQuery(p.socket, { type: 'status' }),
90
91
  ipcQuery(p.socket, { type: 'evolagent.list' }),
91
92
  fetchVersion(p.socket),
@@ -105,6 +106,7 @@ async function buildSnapshot() {
105
106
  daemonRunning: true,
106
107
  aids: aidsResp?.aids ?? [],
107
108
  stats: statsResp?.stats ?? [],
109
+ agentStats: agentStatsResp?.stats ?? [],
108
110
  status: statusResp ?? null,
109
111
  agents: agentsResp?.agents ?? [],
110
112
  version: version ?? null,
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Base agent 环境检测 — 检测 Claude 和 Codex 的可用性。
3
+ *
4
+ * 检测条件:
5
+ * - Claude: ~/.claude/projects/ 目录存在
6
+ * - Codex: ~/.codex/sessions/ 和 ~/.codex/state_*.sqlite 存在,且 Node 版本 >= 22.5
7
+ */
8
+ import fs from 'fs';
9
+ import path from 'path';
10
+ import os from 'os';
11
+ import { createRequire } from 'module';
12
+ const requireFromHere = createRequire(import.meta.url);
13
+ let _cached = null;
14
+ /**
15
+ * 检测 node:sqlite 模块是否可用(Node 22.5+)
16
+ */
17
+ function checkSqliteAvailable() {
18
+ try {
19
+ requireFromHere('node:sqlite');
20
+ return true;
21
+ }
22
+ catch {
23
+ return false;
24
+ }
25
+ }
26
+ /**
27
+ * 检测 Codex state_*.sqlite 文件是否存在
28
+ */
29
+ function checkCodexStateDb() {
30
+ const codexHome = path.join(os.homedir(), '.codex');
31
+ if (!fs.existsSync(codexHome))
32
+ return false;
33
+ try {
34
+ const files = fs.readdirSync(codexHome).filter(f => /^state_\d+\.sqlite$/.test(f));
35
+ return files.length > 0;
36
+ }
37
+ catch {
38
+ return false;
39
+ }
40
+ }
41
+ /**
42
+ * 检测 Claude projects 目录是否存在
43
+ */
44
+ function checkClaudeProjects() {
45
+ const claudeProjects = path.join(os.homedir(), '.claude', 'projects');
46
+ return fs.existsSync(claudeProjects);
47
+ }
48
+ /**
49
+ * 检测 Codex sessions 目录是否存在
50
+ */
51
+ function checkCodexSessions() {
52
+ const codexSessions = path.join(os.homedir(), '.codex', 'sessions');
53
+ return fs.existsSync(codexSessions);
54
+ }
55
+ /**
56
+ * 检测所有 base agent 的可用性
57
+ * 结果会缓存,避免重复检测
58
+ */
59
+ export function detectBaseAgents() {
60
+ if (_cached)
61
+ return _cached;
62
+ const claude = checkClaudeProjects();
63
+ const codex = checkCodexSessions() && checkCodexStateDb() && checkSqliteAvailable();
64
+ _cached = { claude, codex };
65
+ return _cached;
66
+ }
67
+ /**
68
+ * 重置缓存(测试用)
69
+ */
70
+ export function resetDetectionCache() {
71
+ _cached = null;
72
+ }
@@ -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
+ };