aicodeswitch 5.2.12 → 6.0.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.
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -25,12 +25,13 @@ const index_1 = require("./access-keys/index");
25
25
  const manager_1 = require("./access-keys/manager");
26
26
  const policy_manager_1 = require("./access-keys/policy-manager");
27
27
  const performance_tracker_1 = require("./performance-tracker");
28
+ const log_store_1 = require("./log-store");
29
+ const agent_map_1 = require("./agent-map");
30
+ const notifier_1 = require("./notifier");
28
31
  const os_1 = __importDefault(require("os"));
29
32
  const auth_1 = require("./auth");
30
33
  const version_check_1 = require("./version-check");
31
34
  const utils_1 = require("./utils");
32
- const tools_service_1 = require("./tools-service");
33
- const websocket_service_1 = require("./websocket-service");
34
35
  const rules_status_service_1 = require("./rules-status-service");
35
36
  const type_migration_1 = require("./type-migration");
36
37
  const config_metadata_1 = require("./config-metadata");
@@ -47,8 +48,15 @@ const upgradeHashFilePath = path_1.default.join(appDir, 'upgrade-hash');
47
48
  if (fs_1.default.existsSync(dotenvPath)) {
48
49
  dotenv_1.default.config({ path: dotenvPath });
49
50
  }
50
- const host = process.env.HOST || '0.0.0.0';
51
+ // 服务监听地址由 AUTH 模式强制决定(忽略 process.env.HOST):
52
+ // - AUTH 开启:监听 0.0.0.0,允许远端 AccessKey 客户端连接
53
+ // - AUTH 关闭:监听 127.0.0.1,仅本机访问(默认最安全)
54
+ const host = (0, auth_1.isAuthEnabled)() ? '0.0.0.0' : '127.0.0.1';
51
55
  const port = process.env.PORT ? parseInt(process.env.PORT, 10) : 4567;
56
+ // 写入本地编程工具配置(Codex config.toml / Claude settings.json)+ UI/CLI 展示用的地址恒为回环地址。
57
+ // 即便 AUTH 开启监听 0.0.0.0,本机工具与 dashboard 仍走 127.0.0.1,
58
+ // 避免 0.0.0.0(监听语义)被当成连接目标,导致 Windows 客户端 stream disconnected。
59
+ const clientHost = '127.0.0.1';
52
60
  let globalProxyConfig = null;
53
61
  function updateProxyConfig(config) {
54
62
  if (config.proxyEnabled && config.proxyUrl) {
@@ -283,7 +291,7 @@ const writeClaudeConfig = (_dbManager_1, enableAgentTeams_1, enableBypassPermiss
283
291
  // 构建代理配置
284
292
  const claudeSettingsEnv = {
285
293
  ANTHROPIC_AUTH_TOKEN: "api_key",
286
- ANTHROPIC_BASE_URL: `http://${host}:${port}/claude-code`,
294
+ ANTHROPIC_BASE_URL: `http://${clientHost}:${port}/claude-code`,
287
295
  API_TIMEOUT_MS: "3000000",
288
296
  CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: 1,
289
297
  CLAUDE_CODE_MAX_RETRIES: 3
@@ -444,7 +452,7 @@ const writeCodexConfig = (_dbManager_1, ...args_1) => __awaiter(void 0, [_dbMana
444
452
  model_providers: {
445
453
  aicodeswitch: {
446
454
  name: "aicodeswitch",
447
- base_url: `http://${host}:${process.env.PORT ? parseInt(process.env.PORT, 10) : 4567}/codex`,
455
+ base_url: `http://${clientHost}:${port}/codex`,
448
456
  wire_api: "responses",
449
457
  stream_max_retries: 3,
450
458
  stream_retry_backoff: "fixed"
@@ -1341,6 +1349,8 @@ const registerRoutes = (dbManager, proxyServer) => __awaiter(void 0, void 0, voi
1341
1349
  clearInterval(heartbeat);
1342
1350
  });
1343
1351
  })));
1352
+ // Agent Map(任务可视化节点地图)路由:SSE 实时流 + REST 快照/事件
1353
+ (0, agent_map_1.registerAgentMapRoutes)(app, agent_map_1.agentMapService);
1344
1354
  // 清除规则的错误状态(广播 idle 状态给所有客户端)
1345
1355
  app.post('/api/rules/:id/clear-status', asyncHandler((req, res) => __awaiter(void 0, void 0, void 0, function* () {
1346
1356
  const { id } = req.params;
@@ -1388,8 +1398,28 @@ const registerRoutes = (dbManager, proxyServer) => __awaiter(void 0, void 0, voi
1388
1398
  const rawOffset = typeof req.query.offset === 'string' ? parseInt(req.query.offset, 10) : NaN;
1389
1399
  const limit = Number.isFinite(rawLimit) ? rawLimit : 100;
1390
1400
  const offset = Number.isFinite(rawOffset) ? rawOffset : 0;
1391
- const logs = yield dbManager.getLogs(limit, offset);
1392
- res.json(logs);
1401
+ const str = (v) => (typeof v === 'string' ? v.trim() : '');
1402
+ const keyword = str(req.query.keyword) || str(req.query.query);
1403
+ const filters = {
1404
+ targetType: str(req.query.targetType) || undefined,
1405
+ vendorId: str(req.query.vendorId) || undefined,
1406
+ targetServiceId: str(req.query.serviceId) || str(req.query.targetServiceId) || undefined,
1407
+ targetModel: str(req.query.model) || str(req.query.targetModel) || undefined,
1408
+ routeId: str(req.query.routeId) || undefined,
1409
+ };
1410
+ const hasAnyFilter = keyword || filters.targetType || filters.vendorId || filters.targetServiceId || filters.targetModel || filters.routeId;
1411
+ if (hasAnyFilter) {
1412
+ const result = yield dbManager.queryLogs({ filters, keyword, limit, offset });
1413
+ res.json({ logs: result.data, total: result.total });
1414
+ }
1415
+ else {
1416
+ // 无筛选:仍返回 total,避免前端额外请求 count
1417
+ const [logs, total] = yield Promise.all([
1418
+ dbManager.getLogs(limit, offset),
1419
+ dbManager.getLogsCount(),
1420
+ ]);
1421
+ res.json({ logs, total });
1422
+ }
1393
1423
  })));
1394
1424
  app.delete('/api/logs', asyncHandler((_req, res) => __awaiter(void 0, void 0, void 0, function* () {
1395
1425
  yield dbManager.clearLogs();
@@ -1400,8 +1430,27 @@ const registerRoutes = (dbManager, proxyServer) => __awaiter(void 0, void 0, voi
1400
1430
  const rawOffset = typeof req.query.offset === 'string' ? parseInt(req.query.offset, 10) : NaN;
1401
1431
  const limit = Number.isFinite(rawLimit) ? rawLimit : 100;
1402
1432
  const offset = Number.isFinite(rawOffset) ? rawOffset : 0;
1403
- const logs = yield dbManager.getErrorLogs(limit, offset);
1404
- res.json(logs);
1433
+ const str = (v) => (typeof v === 'string' ? v.trim() : '');
1434
+ const filters = {
1435
+ targetType: str(req.query.targetType) || undefined,
1436
+ vendorId: str(req.query.vendorId) || undefined,
1437
+ serviceId: str(req.query.serviceId) || str(req.query.targetServiceId) || undefined,
1438
+ model: str(req.query.model) || str(req.query.targetModel) || undefined,
1439
+ routeId: str(req.query.routeId) || undefined,
1440
+ };
1441
+ const keyword = str(req.query.keyword) || str(req.query.query);
1442
+ const hasAnyFilter = keyword || filters.targetType || filters.vendorId || filters.serviceId || filters.model || filters.routeId;
1443
+ if (hasAnyFilter) {
1444
+ const result = yield dbManager.queryErrorLogs({ filters, keyword, limit, offset });
1445
+ res.json({ logs: result.data, total: result.total });
1446
+ }
1447
+ else {
1448
+ const [logs, total] = yield Promise.all([
1449
+ dbManager.getErrorLogs(limit, offset),
1450
+ dbManager.getErrorLogsCount(),
1451
+ ]);
1452
+ res.json({ logs, total });
1453
+ }
1405
1454
  })));
1406
1455
  app.delete('/api/error-logs', asyncHandler((_req, res) => __awaiter(void 0, void 0, void 0, function* () {
1407
1456
  yield dbManager.clearErrorLogs();
@@ -2189,8 +2238,21 @@ ${instruction}
2189
2238
  const rawOffset = typeof req.query.offset === 'string' ? parseInt(req.query.offset, 10) : NaN;
2190
2239
  const limit = Number.isFinite(rawLimit) ? rawLimit : 100;
2191
2240
  const offset = Number.isFinite(rawOffset) ? rawOffset : 0;
2192
- const sessions = yield dbManager.getSessions(undefined, limit, offset);
2193
- res.json(sessions);
2241
+ const str = (v) => (typeof v === 'string' ? v.trim() : '');
2242
+ const opts = {
2243
+ targetType: str(req.query.targetType) || undefined,
2244
+ keyword: str(req.query.keyword) || str(req.query.query) || undefined,
2245
+ vendorId: str(req.query.vendorId) || undefined,
2246
+ serviceId: str(req.query.serviceId) || undefined,
2247
+ model: str(req.query.model) || undefined,
2248
+ routeId: str(req.query.routeId) || undefined,
2249
+ };
2250
+ const hasFilter = opts.targetType || opts.keyword || opts.vendorId || opts.serviceId || opts.model || opts.routeId;
2251
+ const [sessions, total] = yield Promise.all([
2252
+ dbManager.getSessions(hasFilter ? opts : undefined, limit, offset),
2253
+ dbManager.getSessionsCount(hasFilter ? opts : undefined),
2254
+ ]);
2255
+ res.json({ sessions, total });
2194
2256
  })));
2195
2257
  app.get('/api/sessions/count', asyncHandler((_req, res) => __awaiter(void 0, void 0, void 0, function* () {
2196
2258
  const count = yield dbManager.getSessionsCount();
@@ -2450,19 +2512,6 @@ ${instruction}
2450
2512
  res.json({ success: false });
2451
2513
  }
2452
2514
  })));
2453
- // 工具安装检测相关路由
2454
- app.get('/api/tools/status', asyncHandler((_req, res) => __awaiter(void 0, void 0, void 0, function* () {
2455
- console.log('[API] GET /api/tools/status - 获取工具安装状态');
2456
- try {
2457
- const status = yield (0, tools_service_1.getToolsInstallationStatus)();
2458
- console.log('[API] 工具安装状态:', status);
2459
- res.json(status);
2460
- }
2461
- catch (error) {
2462
- console.error('[API] 获取工具状态失败:', error);
2463
- res.status(500).json({ error: '获取工具状态失败' });
2464
- }
2465
- })));
2466
2515
  // MCP 工具管理相关路由
2467
2516
  app.get('/api/mcps', (_req, res) => {
2468
2517
  res.json(dbManager.getMCPs());
@@ -3369,6 +3418,20 @@ const start = () => __awaiter(void 0, void 0, void 0, function* () {
3369
3418
  // 自动检测数据库类型并执行迁移(如果需要)
3370
3419
  console.time('[Server] step "database-init"');
3371
3420
  const dbManager = yield database_factory_1.DatabaseFactory.createAuto(dataDir, legacyDataDir);
3421
+ // 创建并初始化共享 LogStore(追加写 NDJSON),注入 dbManager
3422
+ const logStore = (0, log_store_1.createLogStore)(dataDir);
3423
+ yield logStore.init();
3424
+ // 在服务对外提供流量前完成旧 JSON → NDJSON 迁移(含自愈:标记缺失时清空重迁,避免重复)。
3425
+ // 必须在 listen 之前,防止迁移的「清空重迁」与实时写入竞争。
3426
+ try {
3427
+ yield logStore.migrateLegacy(dataDir);
3428
+ }
3429
+ catch (err) {
3430
+ console.error('[Server] LogStore legacy migration failed:', err);
3431
+ }
3432
+ dbManager.setLogStore(logStore);
3433
+ // Agent Map 服务接入 dbManager(种子化已有 Session + 启动状态清扫定时器)
3434
+ agent_map_1.agentMapService.attach(dbManager);
3372
3435
  console.timeEnd('[Server] step "database-init"');
3373
3436
  // 服务启动时自动同步配置文件(适用于 CLI 和 dev:server)
3374
3437
  console.time('[Server] step "sync-configs"');
@@ -3386,7 +3449,7 @@ const start = () => __awaiter(void 0, void 0, void 0, function* () {
3386
3449
  catch ( /* ignore */_a) { /* ignore */ }
3387
3450
  const proxyServer = new proxy_server_1.ProxyServer(dbManager, app);
3388
3451
  // Initialize AccessKey module
3389
- const accessKeyModule = new index_1.AccessKeyModule(dataDir);
3452
+ const accessKeyModule = new index_1.AccessKeyModule(dataDir, logStore);
3390
3453
  try {
3391
3454
  yield accessKeyModule.initialize();
3392
3455
  proxyServer.setAccessKeyModule(accessKeyModule);
@@ -3394,6 +3457,15 @@ const start = () => __awaiter(void 0, void 0, void 0, function* () {
3394
3457
  catch (error) {
3395
3458
  console.error('[Server] AccessKey module initialization failed:', error);
3396
3459
  }
3460
+ // 日志保留期定时清理(主库 global + 所有 AccessKey key:*),每 6h 一次
3461
+ const logRetentionTimer = setInterval(() => {
3462
+ Promise.all([
3463
+ logStore.retain('global', 30).catch(() => { }),
3464
+ accessKeyModule.keyLogger.cleanupOldLogs().catch(() => { }),
3465
+ ]).catch(() => { });
3466
+ }, 6 * 60 * 60 * 1000);
3467
+ if (typeof logRetentionTimer.unref === 'function')
3468
+ logRetentionTimer.unref();
3397
3469
  // Initialize Service Performance Tracker (全局统计,与 AUTH 无关)
3398
3470
  const performanceTracker = new performance_tracker_1.ServicePerformanceTracker(dataDir);
3399
3471
  try {
@@ -3430,15 +3502,34 @@ const start = () => __awaiter(void 0, void 0, void 0, function* () {
3430
3502
  res.setHeader('Content-Type', 'application/json');
3431
3503
  res.status(500).json({ error: err.message || 'Internal server error' });
3432
3504
  });
3433
- const isPortUsable = yield (0, utils_1.checkPortUsable)(port);
3505
+ // 端口检测:若被占用,可能是上一个进程仍在退出过程中(Ctrl+C shutdown 尚未完全释放端口),
3506
+ // 此处轮询等待其释放后再继续启动,避免与正在退出的旧进程发生端口冲突。
3507
+ // 超时仍未释放才判定为真正的占用并报错退出。
3508
+ const PORT_POLL_INTERVAL = 300;
3509
+ const PORT_WAIT_TIMEOUT = 10000;
3510
+ let isPortUsable = yield (0, utils_1.checkPortUsable)(port);
3434
3511
  if (!isPortUsable) {
3435
- console.error(`端口 ${port} 已被占用,无法启动服务。请执行 aicos stop 后重启。`);
3436
- process.exit(1);
3512
+ console.warn(`端口 ${port} 当前被占用,可能是上一个服务进程仍在退出中,等待其释放...`);
3513
+ const portDeadline = Date.now() + PORT_WAIT_TIMEOUT;
3514
+ while (!isPortUsable && Date.now() < portDeadline) {
3515
+ yield new Promise(resolve => setTimeout(resolve, PORT_POLL_INTERVAL));
3516
+ isPortUsable = yield (0, utils_1.checkPortUsable)(port);
3517
+ }
3518
+ if (!isPortUsable) {
3519
+ console.error(`端口 ${port} 在 ${PORT_WAIT_TIMEOUT / 1000}s 后仍被占用,无法启动服务。请执行 aicos stop 后重启。`);
3520
+ process.exit(1);
3521
+ }
3522
+ console.log(`端口 ${port} 已释放,继续启动...`);
3437
3523
  }
3438
3524
  console.time('[Server] step "listen"');
3439
3525
  const server = app.listen(port, host, () => {
3440
3526
  listenReady = true;
3441
- console.log(`Admin server running on http://${host}:${port}`);
3527
+ const listenInfo = host === '0.0.0.0'
3528
+ ? ` (listening on all interfaces, port ${port})`
3529
+ : '';
3530
+ console.log(`Admin server running on http://${clientHost}:${port}${listenInfo}`);
3531
+ // 点击 OS 通知时打开任务地图页(仅 terminal-notifier 路径生效;osascript 无法控制点击)
3532
+ (0, notifier_1.setNotifierAppUrl)(`http://${clientHost}:${port}/#/agent-map`);
3442
3533
  console.timeEnd('[Server] step "listen"');
3443
3534
  // 启动后异步执行延迟维护任务(分片校验/修复、日志清理、会话索引构建)
3444
3535
  // 不阻塞服务启动,后台静默执行
@@ -3457,26 +3548,12 @@ const start = () => __awaiter(void 0, void 0, void 0, function* () {
3457
3548
  }
3458
3549
  setImmediate(() => process.exit(1));
3459
3550
  });
3460
- // 创建 WebSocket 服务器用于工具安装
3461
- const toolInstallWss = (0, websocket_service_1.createToolInstallationWSServer)();
3462
3551
  // 设置黑名单检查函数,用于在规则状态同步时检查黑名单是否已过期
3463
3552
  rules_status_service_1.rulesStatusBroadcaster.setBlacklistChecker((serviceId, routeId, contentType) => __awaiter(void 0, void 0, void 0, function* () {
3464
3553
  // 检查服务��否在黑名单中
3465
3554
  const isBlacklisted = yield dbManager.isServiceBlacklisted(serviceId, routeId, contentType);
3466
3555
  return isBlacklisted;
3467
3556
  }));
3468
- // 将 WebSocket 服务器附加到 HTTP 服务器(仅用于工具安装)
3469
- server.on('upgrade', (request, socket, head) => {
3470
- if (request.url === '/api/tools/install') {
3471
- toolInstallWss.handleUpgrade(request, socket, head, (ws) => {
3472
- toolInstallWss.emit('connection', ws, request);
3473
- });
3474
- }
3475
- else {
3476
- socket.destroy();
3477
- }
3478
- });
3479
- console.log(`WebSocket server for tool installation attached to ws://${host}:${port}/api/tools/install`);
3480
3557
  let isShuttingDown = false;
3481
3558
  let shutdownPromise = null;
3482
3559
  const shutdown = (signal) => __awaiter(void 0, void 0, void 0, function* () {
@@ -3486,6 +3563,18 @@ const start = () => __awaiter(void 0, void 0, void 0, function* () {
3486
3563
  isShuttingDown = true;
3487
3564
  shutdownPromise = (() => __awaiter(void 0, void 0, void 0, function* () {
3488
3565
  console.log(`[Server] Received ${signal}, shutting down...`);
3566
+ // 立即停止监听以释放端口(同步关闭监听句柄,端口马上可用),
3567
+ // 避免在漫长的清理流程期间端口仍被占用,导致此时重启产生 EADDRINUSE 冲突。
3568
+ const serverClosedPromise = new Promise((resolve) => {
3569
+ server.close(() => resolve());
3570
+ });
3571
+ // 强制断开所有现存连接(含浏览器长连的 SSE:agent-map stream、rules-status
3572
+ // 广播等)。否则这些连接永不自行关闭,server.close 的回调要等满下面 5s 超时
3573
+ // 才触发,表现为 Ctrl+C 后「卡很久才打印 Server stopped.」。closeAllConnections
3574
+ // 立即销毁所有 socket,让 close 回调几乎瞬时完成。
3575
+ if (typeof server.closeAllConnections === 'function') {
3576
+ server.closeAllConnections();
3577
+ }
3489
3578
  // 服务终止前恢复配置文件(适用于 aicos stop 与 Ctrl+C)
3490
3579
  try {
3491
3580
  const claudeRestored = yield restoreClaudeConfig();
@@ -3517,12 +3606,18 @@ const start = () => __awaiter(void 0, void 0, void 0, function* () {
3517
3606
  console.error('[Shutdown ...] Performance tracker flush failed:', error);
3518
3607
  }
3519
3608
  dbManager.close();
3609
+ // 落盘 LogStore 所有 namespace 的索引
3610
+ try {
3611
+ yield logStore.close();
3612
+ }
3613
+ catch (error) {
3614
+ console.error('[Shutdown ...] LogStore close failed:', error);
3615
+ }
3520
3616
  // 清理规则状态广播器(关闭 SSE 连接)
3521
3617
  rules_status_service_1.rulesStatusBroadcaster.destroy();
3618
+ // 等待监听句柄与现有连接关闭完成(最多 5s),确保端口彻底释放后再退出。
3522
3619
  yield Promise.race([
3523
- new Promise((resolve) => {
3524
- server.close(() => resolve());
3525
- }),
3620
+ serverClosedPromise,
3526
3621
  new Promise((resolve) => {
3527
3622
  setTimeout(resolve, 5000);
3528
3623
  })
@@ -0,0 +1,100 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.setNotifierAppUrl = setNotifierAppUrl;
4
+ exports.notify = notify;
5
+ /**
6
+ * 跨平台 OS 原生通知(零 npm 依赖,shell-out)。
7
+ *
8
+ * macOS:直接用 `osascript display notification`。它以 Terminal/Script Editor 的身份发起,
9
+ * 这些身份在本机已有通知权限,因此**始终能可靠弹出**。代价:通知图标位是系统默认占位
10
+ * (无自定义 Logo)——尝试用「带 Logo 的 AppleScript applet」方案在较新 macOS 上会被系统
11
+ * 静默拦截(ad-hoc applet 无通知权限),故回退到这条最稳的路径。
12
+ *
13
+ * - linux:notify-send(libnotify;未安装则静默)
14
+ * - win32:PowerShell + WinForms NotifyIcon balloon(best-effort)
15
+ *
16
+ * 任何环节失败均静默,绝不影响代理主流程。
17
+ */
18
+ const child_process_1 = require("child_process");
19
+ /** 转义 AppleScript 字符串里的反斜杠与双引号 */
20
+ function escApple(s) {
21
+ return String(s !== null && s !== void 0 ? s : '').replace(/\\/g, '\\\\').replace(/"/g, '\\"');
22
+ }
23
+ // terminal-notifier 存在性(带缓存)。它是 macOS 上唯一能「点击通知可控」的可靠手段:
24
+ // osascript 的 display notification 点击必激活脚本宿主(Script Editor/文本阅读器),无法关闭;
25
+ // terminal-notifier 点击可经 -open 打开我们指定的 URL(默认无 URL 则点击=仅消失、无动作)。
26
+ let hasTN;
27
+ function hasTerminalNotifier() {
28
+ if (hasTN !== undefined)
29
+ return hasTN;
30
+ try {
31
+ (0, child_process_1.execFileSync)('which', ['terminal-notifier'], { stdio: 'ignore', windowsHide: true });
32
+ hasTN = true;
33
+ }
34
+ catch (_a) {
35
+ hasTN = false;
36
+ }
37
+ return hasTN;
38
+ }
39
+ // 点击通知要打开的 URL(由 main.ts 在服务启动后设置,指向 AICodeSwitch 任务地图页)。
40
+ // 仅 terminal-notifier 路径生效;osascript 无法控制点击行为。
41
+ let appOpenUrl = null;
42
+ function setNotifierAppUrl(url) { appOpenUrl = url; }
43
+ function notifyDarwin(opts) {
44
+ // 优先 terminal-notifier:可控制点击行为。设了 appOpenUrl 则点击打开该 URL(我们的页面),
45
+ // 否则点击仅消失、无动作。
46
+ if (hasTerminalNotifier()) {
47
+ const args = ['-title', opts.title, '-message', opts.body, '-ignoreDn'];
48
+ if (opts.subtitle)
49
+ args.push('-subtitle', opts.subtitle);
50
+ if (appOpenUrl)
51
+ args.push('-open', appOpenUrl);
52
+ (0, child_process_1.execFile)('terminal-notifier', args, { windowsHide: true }, () => { });
53
+ return;
54
+ }
55
+ // 回退:osascript(稳定弹通知,但点击会打开脚本宿主,无法避免)
56
+ let script = `display notification "${escApple(opts.body)}" with title "${escApple(opts.title)}"`;
57
+ if (opts.subtitle)
58
+ script += ` subtitle "${escApple(opts.subtitle)}"`;
59
+ (0, child_process_1.execFile)('osascript', ['-e', script], { windowsHide: true }, () => { });
60
+ }
61
+ function notifyLinux(opts) {
62
+ (0, child_process_1.execFile)('notify-send', ['-a', 'AICodeSwitch', opts.title, opts.body], { windowsHide: true }, () => { });
63
+ }
64
+ function notifyWindows(opts) {
65
+ var _a, _b;
66
+ // WinForms NotifyIcon balloon(Win10/11 上显示为 toast)
67
+ const title = String((_a = opts.title) !== null && _a !== void 0 ? _a : '').replace(/'/g, "''");
68
+ const body = String((_b = opts.body) !== null && _b !== void 0 ? _b : '').replace(/'/g, "''");
69
+ const script = [
70
+ 'Add-Type -AssemblyName System.Windows.Forms',
71
+ '$n = New-Object System.Windows.Forms.NotifyIcon',
72
+ '$n.Icon = [System.Drawing.SystemIcons]::Information',
73
+ '$n.Visible = $true',
74
+ `$n.ShowBalloonTip(5000, '${title}', '${body}', [System.Windows.Forms.ToolTipIcon]::Info)`,
75
+ 'Start-Sleep -Seconds 6',
76
+ '$n.Dispose()',
77
+ ].join('; ');
78
+ const child = (0, child_process_1.spawn)('powershell', ['-NoProfile', '-Command', script], {
79
+ stdio: 'ignore',
80
+ windowsHide: true,
81
+ detached: true,
82
+ });
83
+ child.on('error', () => { });
84
+ child.unref();
85
+ }
86
+ /** 发送一条 OS 原生通知。失败静默。title 请自带「AICodeSwitch」标识来源。 */
87
+ function notify(opts) {
88
+ try {
89
+ if (process.platform === 'darwin')
90
+ return notifyDarwin(opts);
91
+ if (process.platform === 'linux')
92
+ return notifyLinux(opts);
93
+ if (process.platform === 'win32')
94
+ return notifyWindows(opts);
95
+ // 其它平台:no-op
96
+ }
97
+ catch (_a) {
98
+ /* ignore */
99
+ }
100
+ }
@@ -68,8 +68,12 @@ class ServicePerformanceTracker {
68
68
  try {
69
69
  const raw = yield promises_1.default.readFile(this.filePath, 'utf-8');
70
70
  const parsed = JSON.parse(raw);
71
- if (parsed && parsed.vendors)
71
+ if (parsed && parsed.vendors) {
72
72
  this.file = parsed;
73
+ // 旧数据文件可能缺新增的 token 字段(sumInputTokens/sumTotalTokens),
74
+ // 直接累加会得到 undefined+N=NaN,污染派生值。这里补齐为 0。
75
+ this.migrateBuckets(this.file);
76
+ }
73
77
  }
74
78
  catch (_a) {
75
79
  // 首次启动或文件损坏,使用空桶
@@ -77,6 +81,42 @@ class ServicePerformanceTracker {
77
81
  }
78
82
  });
79
83
  }
84
+ /** 加载后补齐旧数据缺失的桶字段,避免 undefined 累加成 NaN */
85
+ migrateBuckets(file) {
86
+ for (const v of Object.values(file.vendors)) {
87
+ if (v.vendorRollup)
88
+ this.normalizeAggregate(v.vendorRollup);
89
+ for (const s of Object.values(v.services || {})) {
90
+ if (s.serviceRollup)
91
+ this.normalizeAggregate(s.serviceRollup);
92
+ for (const agg of Object.values(s.models || {})) {
93
+ this.normalizeAggregate(agg);
94
+ }
95
+ }
96
+ }
97
+ }
98
+ normalizeAggregate(agg) {
99
+ this.normalizeBucket(agg.precise);
100
+ this.normalizeBucket(agg.estimated);
101
+ for (const b of Object.values(agg.hourly || {}))
102
+ this.normalizeBucket(b);
103
+ }
104
+ normalizeBucket(b) {
105
+ if (!b)
106
+ return;
107
+ if (typeof b.count !== 'number')
108
+ b.count = 0;
109
+ if (typeof b.sumTtftMs !== 'number')
110
+ b.sumTtftMs = 0;
111
+ if (typeof b.sumTps !== 'number')
112
+ b.sumTps = 0;
113
+ if (typeof b.totalOutputTokens !== 'number')
114
+ b.totalOutputTokens = 0;
115
+ if (typeof b.sumInputTokens !== 'number')
116
+ b.sumInputTokens = 0;
117
+ if (typeof b.sumTotalTokens !== 'number')
118
+ b.sumTotalTokens = 0;
119
+ }
80
120
  /**
81
121
  * 记录一次请求的性能数据点(三级同步聚合)。
82
122
  * 纯内存操作,不阻塞调用方。
@@ -257,7 +297,7 @@ class ServicePerformanceTracker {
257
297
  };
258
298
  }
259
299
  emptyBucket() {
260
- return { count: 0, sumTtftMs: 0, sumTps: 0, totalOutputTokens: 0 };
300
+ return { count: 0, sumTtftMs: 0, sumTps: 0, totalOutputTokens: 0, sumInputTokens: 0, sumTotalTokens: 0 };
261
301
  }
262
302
  accumulate(agg, m, hour, withExtremes) {
263
303
  var _a;
@@ -268,6 +308,7 @@ class ServicePerformanceTracker {
268
308
  const bucket = m.timingAccuracy === 'precise' ? agg.precise : agg.estimated;
269
309
  const hasTtft = m.timingAccuracy === 'precise' && typeof m.ttftMs === 'number';
270
310
  const hasTps = typeof m.tokensPerSecond === 'number';
311
+ // token 量与计时精度无关,precise/estimated 桶都累加
271
312
  bucket.count += 1;
272
313
  if (hasTtft)
273
314
  bucket.sumTtftMs += m.ttftMs;
@@ -275,18 +316,37 @@ class ServicePerformanceTracker {
275
316
  bucket.sumTps += m.tokensPerSecond;
276
317
  if (m.outputTokens)
277
318
  bucket.totalOutputTokens += m.outputTokens;
278
- // 小时桶(仅精确样本计入走势,避免估算样本污染)
319
+ if (m.inputTokens)
320
+ bucket.sumInputTokens += m.inputTokens;
321
+ if (m.totalTokens)
322
+ bucket.sumTotalTokens += m.totalTokens;
323
+ // 小时走势桶:
324
+ // - count/sumTtftMs/sumTps 仅精确样本计入(避免估算样本污染 TTFT/TPM 均值)
325
+ // - token 三项与精度无关,所有样本都计入走势(含非流式)
326
+ const hb = (_a = agg.hourly[hour]) !== null && _a !== void 0 ? _a : (agg.hourly[hour] = this.emptyBucket());
327
+ let touchedHourly = false;
279
328
  if (m.timingAccuracy === 'precise') {
280
- const hb = (_a = agg.hourly[hour]) !== null && _a !== void 0 ? _a : (agg.hourly[hour] = this.emptyBucket());
281
329
  hb.count += 1;
282
330
  if (hasTtft)
283
331
  hb.sumTtftMs += m.ttftMs;
284
332
  if (hasTps)
285
333
  hb.sumTps += m.tokensPerSecond;
286
- if (m.outputTokens)
287
- hb.totalOutputTokens += m.outputTokens;
288
- this.trimHourly(agg.hourly);
334
+ touchedHourly = true;
335
+ }
336
+ if (m.outputTokens) {
337
+ hb.totalOutputTokens += m.outputTokens;
338
+ touchedHourly = true;
339
+ }
340
+ if (m.inputTokens) {
341
+ hb.sumInputTokens += m.inputTokens;
342
+ touchedHourly = true;
289
343
  }
344
+ if (m.totalTokens) {
345
+ hb.sumTotalTokens += m.totalTokens;
346
+ touchedHourly = true;
347
+ }
348
+ if (touchedHourly)
349
+ this.trimHourly(agg.hourly);
290
350
  // 极值(仅模型级、仅精确样本)
291
351
  if (withExtremes && m.timingAccuracy === 'precise') {
292
352
  if (hasTtft) {
@@ -315,9 +375,14 @@ class ServicePerformanceTracker {
315
375
  // ---------------- 内部:派生 ----------------
316
376
  derive(agg) {
317
377
  const p = agg.precise;
378
+ const e = agg.estimated;
318
379
  const count = p.count;
319
380
  const avgTtftMs = count > 0 ? p.sumTtftMs / count : 0;
320
381
  const avgTps = count > 0 ? p.sumTps / count : 0;
382
+ // token 量跨 precise+estimated 求和(含非流式样本)
383
+ const totalOutputTokens = p.totalOutputTokens + e.totalOutputTokens;
384
+ const totalInputTokens = p.sumInputTokens + e.sumInputTokens;
385
+ const totalTokens = p.sumTotalTokens + e.sumTotalTokens;
321
386
  return {
322
387
  count,
323
388
  avgTtftMs,
@@ -327,7 +392,9 @@ class ServicePerformanceTracker {
327
392
  minTps: agg.minTps,
328
393
  maxTps: agg.maxTps,
329
394
  errorCount: agg.errorCount,
330
- totalOutputTokens: p.totalOutputTokens,
395
+ totalOutputTokens,
396
+ totalInputTokens,
397
+ totalTokens,
331
398
  successRate: count + agg.errorCount > 0 ? count / (count + agg.errorCount) : 0,
332
399
  };
333
400
  }
@@ -355,6 +422,9 @@ class ServicePerformanceTracker {
355
422
  count: b.count,
356
423
  avgTtftMs: b.count > 0 ? b.sumTtftMs / b.count : 0,
357
424
  avgTpm: b.count > 0 ? (b.sumTps / b.count) * 60 : 0,
425
+ inputTokens: b.sumInputTokens,
426
+ outputTokens: b.totalOutputTokens,
427
+ totalTokens: b.sumTotalTokens,
358
428
  }));
359
429
  }
360
430
  locateService(serviceId) {