cc-viewer 1.7.4 → 1.7.6

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.
Files changed (88) hide show
  1. package/cli.js +19 -4
  2. package/dist/assets/App-3VM471BO.js +2 -0
  3. package/dist/assets/{App-Dj37fluO.css → App-CCDFxj11.css} +1 -1
  4. package/dist/assets/{MdxEditorPanel-DeSJp_ct.js → MdxEditorPanel-BQ5MgI4D.js} +1 -1
  5. package/dist/assets/Mobile-wwcXmnrU.js +1 -0
  6. package/dist/assets/UnifiedProxyRetryPage-BpcKIn9N.js +1 -0
  7. package/dist/assets/UnifiedProxyRetryPage-CFz4vOH5.css +1 -0
  8. package/dist/assets/{_baseUniq-BK7Nhp_x.js → _baseUniq-yiNE5Ckw.js} +1 -1
  9. package/dist/assets/{arc-Cm4HMXz6.js → arc-M-eBYYbN.js} +1 -1
  10. package/dist/assets/{architectureDiagram-Q4EWVU46-Co8QCowN.js → architectureDiagram-Q4EWVU46-CtweeLKi.js} +1 -1
  11. package/dist/assets/{blockDiagram-DXYQGD6D-Cudecf7x.js → blockDiagram-DXYQGD6D-DCVQ-go_.js} +1 -1
  12. package/dist/assets/{c4Diagram-AHTNJAMY-4JeFoCHX.js → c4Diagram-AHTNJAMY-C7JB9PqP.js} +1 -1
  13. package/dist/assets/{channel-K7eT7Hjr.js → channel-BnzX9zBS.js} +1 -1
  14. package/dist/assets/{chunk-4BX2VUAB-BBjl6EVW.js → chunk-4BX2VUAB-dVKiUtqe.js} +1 -1
  15. package/dist/assets/{chunk-4TB4RGXK-DBP9YnwO.js → chunk-4TB4RGXK-CQjD9-fD.js} +1 -1
  16. package/dist/assets/{chunk-55IACEB6--A_i9gwR.js → chunk-55IACEB6-BZuKI6JQ.js} +1 -1
  17. package/dist/assets/{chunk-EDXVE4YY-UBsL-3qr.js → chunk-EDXVE4YY-sk9g7szp.js} +1 -1
  18. package/dist/assets/{chunk-FMBD7UC4-DBO0asmI.js → chunk-FMBD7UC4-CGL9Lo51.js} +1 -1
  19. package/dist/assets/{chunk-OYMX7WX6-DOCcEaE6.js → chunk-OYMX7WX6-DaN0AdQD.js} +1 -1
  20. package/dist/assets/{chunk-QZHKN3VN-B-j8QbQh.js → chunk-QZHKN3VN-CNEAHnZV.js} +1 -1
  21. package/dist/assets/{chunk-YZCP3GAM-B0WO1U-U.js → chunk-YZCP3GAM-BOlts_Dm.js} +1 -1
  22. package/dist/assets/classDiagram-6PBFFD2Q-CumOMdO7.js +1 -0
  23. package/dist/assets/classDiagram-v2-HSJHXN6E-CumOMdO7.js +1 -0
  24. package/dist/assets/clone-C3SZDzs1.js +1 -0
  25. package/dist/assets/{cose-bilkent-S5V4N54A-DQgpss_a.js → cose-bilkent-S5V4N54A-DH4_YiGi.js} +1 -1
  26. package/dist/assets/{dagre-KV5264BT-CCJBKmKv.js → dagre-KV5264BT-RqusStYI.js} +1 -1
  27. package/dist/assets/{diagram-5BDNPKRD-DgUcnfkU.js → diagram-5BDNPKRD-8haVbBnt.js} +1 -1
  28. package/dist/assets/{diagram-G4DWMVQ6-CRpVvexw.js → diagram-G4DWMVQ6-CsrJNUhT.js} +1 -1
  29. package/dist/assets/{diagram-MMDJMWI5-C3IhpsM-.js → diagram-MMDJMWI5-1luPOM38.js} +1 -1
  30. package/dist/assets/{diagram-TYMM5635-BaVbsrwt.js → diagram-TYMM5635-Dad1XR3W.js} +1 -1
  31. package/dist/assets/{erDiagram-SMLLAGMA-BxRoBUR8.js → erDiagram-SMLLAGMA-Br6-kbp9.js} +1 -1
  32. package/dist/assets/{flowDiagram-DWJPFMVM-Du2lIzoM.js → flowDiagram-DWJPFMVM-DuX1mbA7.js} +1 -1
  33. package/dist/assets/{ganttDiagram-T4ZO3ILL-DR1pDqLK.js → ganttDiagram-T4ZO3ILL-BL_XQ958.js} +1 -1
  34. package/dist/assets/{gitGraphDiagram-UUTBAWPF-DHAmE20O.js → gitGraphDiagram-UUTBAWPF-DzvW5FEs.js} +1 -1
  35. package/dist/assets/{graph-D8Yv0IU1.js → graph-CbUmu0Oe.js} +1 -1
  36. package/dist/assets/{index-B6ZWIxY0.js → index-B3-JUU7J.js} +1 -1
  37. package/dist/assets/{index-D0pvxpcl.js → index-BgW_nP_0.js} +1 -1
  38. package/dist/assets/{index-Dpu92rw_.js → index-BjNLFJ0z.js} +2 -2
  39. package/dist/assets/{index-cCB-lhEO.js → index-CzPHj3iF.js} +1 -1
  40. package/dist/assets/{index-D2c77__8.js → index-DDlAMOiV.js} +1 -1
  41. package/dist/assets/{index-Bq95HjYM.js → index-Do5xNTOT.js} +1 -1
  42. package/dist/assets/{index-AOmFG_I_.js → index-LjVy-Pzg.js} +1 -1
  43. package/dist/assets/{index-DgtUyP57.js → index-vImWXiMM.js} +1 -1
  44. package/dist/assets/{infoDiagram-42DDH7IO-BAqRIm2G.js → infoDiagram-42DDH7IO-DGsgRji7.js} +1 -1
  45. package/dist/assets/{ishikawaDiagram-UXIWVN3A-CzH4tezb.js → ishikawaDiagram-UXIWVN3A-D3odpv9_.js} +1 -1
  46. package/dist/assets/{journeyDiagram-VCZTEJTY-Qs4eiyuI.js → journeyDiagram-VCZTEJTY-Dj3NaOWF.js} +1 -1
  47. package/dist/assets/{jszip.min-C0OccDsk.js → jszip.min-GmVScWAV.js} +1 -1
  48. package/dist/assets/{kanban-definition-6JOO6SKY-BAjl0gpV.js → kanban-definition-6JOO6SKY-GKjJaLwD.js} +1 -1
  49. package/dist/assets/{layout-Cf6cd9xE.js → layout-Bhk66H0F.js} +1 -1
  50. package/dist/assets/{linear-PzdCkji6.js → linear-CLk9nvqn.js} +1 -1
  51. package/dist/assets/{mermaid.core-DV9745Lq.js → mermaid.core-DH-GfeNS.js} +2 -2
  52. package/dist/assets/{min-CHgWxbzS.js → min-DQftOGfo.js} +1 -1
  53. package/dist/assets/{mindmap-definition-QFDTVHPH-CHUjMdpi.js → mindmap-definition-QFDTVHPH-DgDD6Jbu.js} +1 -1
  54. package/dist/assets/{pieDiagram-DEJITSTG-D_r2QC0f.js → pieDiagram-DEJITSTG-jy1yFaxA.js} +1 -1
  55. package/dist/assets/{quadrantDiagram-34T5L4WZ-DPzGygdN.js → quadrantDiagram-34T5L4WZ-BdvMwrSD.js} +1 -1
  56. package/dist/assets/{requirementDiagram-MS252O5E-BA2ynnGb.js → requirementDiagram-MS252O5E-CtBOCtbk.js} +1 -1
  57. package/dist/assets/{sankeyDiagram-XADWPNL6-XHVvXuqU.js → sankeyDiagram-XADWPNL6-DEFlQp-h.js} +1 -1
  58. package/dist/assets/{seqResourceLoaders-kBEzcKLh.js → seqResourceLoaders-CDRtUSxI.js} +2 -2
  59. package/dist/assets/{sequenceDiagram-FGHM5R23-C47sA8YH.js → sequenceDiagram-FGHM5R23-0F2R8Ky0.js} +1 -1
  60. package/dist/assets/{stateDiagram-FHFEXIEX-BOmer839.js → stateDiagram-FHFEXIEX-D4xOwcSW.js} +1 -1
  61. package/dist/assets/{stateDiagram-v2-QKLJ7IA2-D-kVvTHA.js → stateDiagram-v2-QKLJ7IA2-DSf_BSF8.js} +1 -1
  62. package/dist/assets/{timeline-definition-GMOUNBTQ-CTAKVZgs.js → timeline-definition-GMOUNBTQ-B5cPasy_.js} +1 -1
  63. package/dist/assets/{vendor-antd-BuJ6oz45.js → vendor-antd-DeqwrDxf.js} +2 -2
  64. package/dist/assets/{vendor-codemirror-D2KAW4ty.js → vendor-codemirror-_NbrtdQc.js} +1 -1
  65. package/dist/assets/{vendor-mdxeditor-BsFVVUcj.js → vendor-mdxeditor-DG0Nyxw5.js} +2 -2
  66. package/dist/assets/{vendor-qrcode-BpxSq04x.js → vendor-qrcode-B3HN--HO.js} +1 -1
  67. package/dist/assets/{vendor-virtuoso-an4WvAvS.js → vendor-virtuoso-BvrezPQx.js} +1 -1
  68. package/dist/assets/{vennDiagram-DHZGUBPP-CuXzoroh.js → vennDiagram-DHZGUBPP-a_ybZDST.js} +1 -1
  69. package/dist/assets/{wardley-RL74JXVD-CFCmxnck.js → wardley-RL74JXVD-CKla3KF8.js} +1 -1
  70. package/dist/assets/{wardleyDiagram-NUSXRM2D-gfhDB9SR.js → wardleyDiagram-NUSXRM2D-BKke2aih.js} +1 -1
  71. package/dist/assets/{xychartDiagram-5P7HB3ND-X-pj66qI.js → xychartDiagram-5P7HB3ND-CozWtcdf.js} +1 -1
  72. package/dist/index.html +4 -4
  73. package/findcc.js +58 -0
  74. package/package.json +1 -1
  75. package/server/lib/interceptor-core.js +26 -15
  76. package/server/lib/proxy-stats.js +436 -0
  77. package/server/lib/stats-worker.js +119 -5
  78. package/server/proxy.js +50 -7
  79. package/server/pty-manager.js +8 -0
  80. package/server/routes/preferences.js +1 -1
  81. package/server/routes/proxy-stats.js +95 -0
  82. package/server/server.js +31 -0
  83. package/src/utils/isProxyMode.js +11 -0
  84. package/dist/assets/App-CKvCavmF.js +0 -2
  85. package/dist/assets/Mobile-CWou-jtF.js +0 -1
  86. package/dist/assets/classDiagram-6PBFFD2Q-Ba3tOmih.js +0 -1
  87. package/dist/assets/classDiagram-v2-HSJHXN6E-Ba3tOmih.js +0 -1
  88. package/dist/assets/clone-DjURvTeN.js +0 -1
package/server/proxy.js CHANGED
@@ -1,7 +1,7 @@
1
1
 
2
2
  import { createServer } from 'node:http';
3
3
  import { readFileSync, existsSync } from 'node:fs';
4
- import { join } from 'node:path';
4
+ import { join, basename } from 'node:path';
5
5
  import { homedir } from 'node:os';
6
6
  import * as interceptor from './interceptor.js';
7
7
  import { setupInterceptor } from './interceptor.js';
@@ -9,11 +9,19 @@ import { extractApiErrorMessage, formatProxyRequestError } from './lib/proxy-err
9
9
  import { getProxyDispatcher } from './lib/proxy-env.js';
10
10
  import { getClaudeConfigDir } from '../findcc.js';
11
11
  import { isAnthropicApiPath } from './lib/interceptor-core.js';
12
- import { executeRequest } from './lib/proxy-retry.js';
12
+ import { executeRequest, extractModel } from './lib/proxy-retry.js';
13
+ import { buildRecord, appendRecord, dailyFilePath, todayStr, emitProxyStatsUpdate } from './lib/proxy-stats.js';
14
+ import { LOG_DIR } from '../findcc.js';
13
15
 
14
16
  // Setup interceptor to patch fetch
15
17
  setupInterceptor();
16
18
 
19
+ // 通知 stats-worker 重扫 proxy 明细(review P2:原实现动态 import('./server.js') 反向触达
20
+ // statsWorker 单例——若未来出现 proxy-only 进程,首条请求会因模块加载副作用意外拉起第二个
21
+ // viewer。现在方向摆正:server.js(statsWorker 属主)加载时经 proxy-stats.js 的
22
+ // setProxyStatsListener 注册回调,本模块只 emit;server 未加载则 emit 为 no-op)。
23
+ const _notifyProxyStats = emitProxyStatsUpdate;
24
+
17
25
  // 强制上游返回未压缩响应,取代仅剥 zstd 的旧策略。
18
26
  // 原因:链路中的网关/代理(典型是本地 MITM 网络代理)可能把上游的压缩 body 原样透传,
19
27
  // 却把 content-encoding 响应头剥掉。undici 看不到 content-encoding 就不会解压,于是把一坨
@@ -123,7 +131,7 @@ export function startProxy() {
123
131
  const cleanReq = req.url.startsWith('/') ? req.url.slice(1) : req.url;
124
132
  const fullUrl = `${cleanBase}/${cleanReq}`;
125
133
 
126
- // 大模型 API 请求(/v1/messages 等)走重试引擎:serial/race/stagger 重试。
134
+ // 大模型 API 请求(/v1/messages 等)走重试引擎:serial/race/stagger 重试 + 明细统计。
127
135
  // 重试引擎内部用 x-cc-viewer-trace 头让 interceptor 记录每次重试尝试到会话日志(网络视图数据源),
128
136
  // 模型替换由重试引擎用 resolveProfileModel 纯函数完成(interceptor 对 trace 请求会再跑一次,幂等 no-op)。
129
137
  // 非大模型 API 请求走原逻辑(同样用 x-cc-viewer-trace 让 interceptor 记录,但不经重试引擎)。
@@ -212,17 +220,18 @@ export function startProxy() {
212
220
  });
213
221
  }
214
222
 
215
- // ── 大模型 API 请求处理:重试引擎 ──────────────────────
216
- // mode=off 时退化为单次 fetch(与原透传一致)。
223
+ // ── 大模型 API 请求处理:重试引擎 + 明细统计 ──────────────────────
224
+ // mode=off 时仍写 1 条明细(attempts=1)以支撑可用率统计;完全禁用统计用 CCV_PROXY_STATS=off。
217
225
  // 重试配置由 interceptor._retryConfigState 提供(live binding,watchFile 热刷新),
218
226
  // 每请求取最新值,UI 改 retry-config.json 后下一个请求即生效,无需重启。
219
227
  const _retryConfigGetter = () => interceptor._retryConfigState;
220
228
 
221
229
  async function handleLlmApiRequest(req, res, fullUrl, fetchOptions, body, proxyDispatcher) {
230
+ const statsEnabled = process.env.CCV_PROXY_STATS !== 'off';
222
231
  const retryConfig = _retryConfigGetter(); // live binding:每请求取最新重试配置
223
232
  const profile = interceptor._activeProfile || null;
224
233
 
225
- // 清理可能的 trace 头(executeRequest 会重新加,避免旧值干扰);interceptor 会正常记录请求
234
+ // 清理可能的 trace 头(singleFetch 会重新加,避免旧值干扰);interceptor 会正常记录请求
226
235
  // 同时清理 hop-by-hop / 传输编码头:body 已被 buffer 成完整 Buffer,
227
236
  // 残留的 transfer-encoding:chunked / content-length 会让 undici 拒绝(invalid transfer-encoding header)
228
237
  const retryFetchOptions = {
@@ -240,6 +249,9 @@ async function handleLlmApiRequest(req, res, fullUrl, fetchOptions, body, proxyD
240
249
  retryFetchOptions.body = body;
241
250
  }
242
251
 
252
+ // Model name for the stats detail record (extracted from the request body)
253
+ const model = extractModel(body);
254
+
243
255
  // Client-disconnect propagation: without this, a client that gave up leaves
244
256
  // the retry loop hammering (and billing) the upstream for up to
245
257
  // maxRetryDurationMs. res 'close' before the response is finished means the
@@ -259,7 +271,7 @@ async function handleLlmApiRequest(req, res, fullUrl, fetchOptions, body, proxyD
259
271
  ctx: { dispatcher: proxyDispatcher, profile, signal: clientAbort.signal },
260
272
  });
261
273
 
262
- const { response, attempts, finalStatus } = result;
274
+ const { response, attempts, retryCodes, durationMs, finalStatus, succeeded, upstreamStatus } = result;
263
275
 
264
276
  // Client already gone: nothing to write; the engine has aborted upstream work.
265
277
  if (clientAbort.signal.aborted || res.writableEnded) {
@@ -277,6 +289,37 @@ async function handleLlmApiRequest(req, res, fullUrl, fetchOptions, body, proxyD
277
289
  }
278
290
  responseHeaders['x-forward-attempts'] = String(attempts);
279
291
 
292
+ // 写代理重试明细(主线程写,之后通知 stats-worker 重扫聚合)
293
+ if (statsEnabled && interceptor._projectName) {
294
+ try {
295
+ const projectDir = join(LOG_DIR, interceptor._projectName);
296
+ const filePath = dailyFilePath(projectDir, todayStr());
297
+ const record = buildRecord({
298
+ method: req.method,
299
+ path: req.url,
300
+ model,
301
+ profileId: profile?.id || 'default',
302
+ profileName: profile?.name || 'Default',
303
+ // Review P2 fix: upstreamStatus was hard-wired to finalStatus, killing
304
+ // the dual-status distinction (race/stagger fallbacks can differ).
305
+ upstreamStatus: upstreamStatus ?? finalStatus,
306
+ finalStatus,
307
+ attempts,
308
+ durationMs,
309
+ succeeded,
310
+ retryCodes,
311
+ });
312
+ appendRecord(filePath, record);
313
+ // 通知 stats-worker 重扫该 proxy 明细文件(缓存 dynamic import,非每请求新建)
314
+ const fileName = basename(filePath);
315
+ _notifyProxyStats(fileName);
316
+ } catch (err) {
317
+ if (process.env.CCV_DEBUG) {
318
+ console.error('[CC-Viewer Proxy] Failed to write proxy stats:', err.message);
319
+ }
320
+ }
321
+ }
322
+
280
323
  // 网络异常(finalStatus=0)或竞速全失败:返回 502 + Proxy Error,与原 catch 分支一致
281
324
  if (finalStatus === 0) {
282
325
  res.writeHead(502, { 'x-forward-attempts': String(attempts) });
@@ -231,6 +231,14 @@ async function _spawnClaudeImpl(proxyPort, cwd, extraArgs = [], claudePath = nul
231
231
  }
232
232
 
233
233
  const env = { ...process.env };
234
+ // CodeFuse owns and versions the Claude binary used by `cfuse --ccv`. Keep
235
+ // that managed 2.1.x binary from invoking Claude Code's independent updater;
236
+ // upgrades must come from CodeFuse so the enterprise-approved version cannot
237
+ // be silently replaced during a CCV session.
238
+ const normalizedClaudePath = String(claudePath || '').replace(/\\/g, '/');
239
+ if (normalizedClaudePath.includes('/.codefuse/fuse/engine/bin/claude/')) {
240
+ env.DISABLE_AUTOUPDATER = '1';
241
+ }
234
242
  env.ANTHROPIC_BASE_URL = `http://127.0.0.1:${proxyPort}`;
235
243
  env.CCV_PROXY_MODE = '1'; // 告诉 interceptor.js 不要再启动 server
236
244
  env.CCV_LOG_DIR = LOG_DIR; // 让 fork 出的 Claude Code 进程找到同一份 profile.json 等资源
@@ -302,7 +302,7 @@ function proxyProfilesPost(req, res, parsedUrl, isLocal, deps) {
302
302
  });
303
303
  }
304
304
 
305
- // ── 代理重试配置(GET/POST,仿 proxyProfilesGet/Post,但无密钥脱敏、无 workspace 隔离)──
305
+ // ── 代理重试配置(GET/POST + SSE,仿 proxyProfilesGet/Post,但无密钥脱敏、无 workspace 隔离)──
306
306
  // 返回 { config, defaults }:config = 当前生效(env 基础 + retry-config.json 覆盖后的 live binding);
307
307
  // defaults = DEFAULT_RETRY_CONFIG,供 UI「恢复默认」按钮参照。
308
308
 
@@ -0,0 +1,95 @@
1
+ // Proxy retry stats routes — HTTP endpoints for proxy retry statistics.
2
+ //
3
+ // The stats-worker scans proxy_*.jsonl detail logs and aggregates them into the
4
+ // proxyStats field of <project>/<project>.json (same file as token stats). These
5
+ // routes only read that data and trigger refreshes.
6
+ //
7
+ // Route shape follows the _dispatch.js descriptor convention:
8
+ // { method, match: 'exact'|'prefix', path, handler(req, res, parsedUrl, isLocal, deps) }
9
+ import { readFileSync, existsSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { LOG_DIR } from '../../findcc.js';
12
+ import { _projectName } from '../interceptor.js';
13
+
14
+ // GET /api/proxy-stats — returns the current project's proxy retry stats (proxyStats field).
15
+ // Response body: { proxyStats: {...} }; proxyStats is null when there is no data.
16
+ function getProxyStats(req, res) {
17
+ try {
18
+ if (!_projectName) {
19
+ res.writeHead(404, { 'Content-Type': 'application/json' });
20
+ res.end(JSON.stringify({ error: 'No project name', proxyStats: null }));
21
+ return;
22
+ }
23
+ const statsFile = join(LOG_DIR, _projectName, `${_projectName}.json`);
24
+ if (!existsSync(statsFile)) {
25
+ res.writeHead(200, { 'Content-Type': 'application/json' });
26
+ res.end(JSON.stringify({ proxyStats: null }));
27
+ return;
28
+ }
29
+ const stats = JSON.parse(readFileSync(statsFile, 'utf-8'));
30
+ res.writeHead(200, { 'Content-Type': 'application/json' });
31
+ res.end(JSON.stringify({ proxyStats: stats.proxyStats || null }));
32
+ } catch (err) {
33
+ res.writeHead(500, { 'Content-Type': 'application/json' });
34
+ res.end(JSON.stringify({ error: err.message, proxyStats: null }));
35
+ }
36
+ }
37
+
38
+ // POST /api/refresh-proxy-stats — triggers a full re-scan by the stats-worker (including proxy detail aggregation).
39
+ // Reuses the existing worker's scan-all message mechanism; shares the same worker as /api/refresh-stats.
40
+ // scan-all refreshes both token stats and proxy stats (both are produced in the same generateProjectStats run).
41
+ //
42
+ // Review P2 fix — in-flight coalescing: the old handler attached one worker
43
+ // listener per request (30s lifetime → listener pile-up under repeated
44
+ // clicks/floods) and resolved on ANY scan-all-done (two concurrent refreshes
45
+ // cross-resolved each other). Now a module-level latch: the first request
46
+ // starts ONE scan with ONE listener; requests arriving while it runs just
47
+ // join the waiter list and share its completion. A flood of POSTs therefore
48
+ // costs a single full scan instead of N.
49
+ let _refreshWaiters = null; // Array<res> while a scan is in flight
50
+
51
+ function refreshProxyStats(req, res, parsedUrl, isLocal, deps) {
52
+ try {
53
+ if (!deps.statsWorker) deps.startStatsWorker();
54
+ if (!deps.statsWorker) {
55
+ res.writeHead(500, { 'Content-Type': 'application/json' });
56
+ res.end(JSON.stringify({ error: 'Stats worker not available' }));
57
+ return;
58
+ }
59
+ if (_refreshWaiters) {
60
+ _refreshWaiters.push(res); // join the in-flight scan
61
+ return;
62
+ }
63
+ _refreshWaiters = [res];
64
+ const worker = deps.statsWorker;
65
+ const finish = (status, body) => {
66
+ const waiters = _refreshWaiters || [];
67
+ _refreshWaiters = null;
68
+ clearTimeout(timeout);
69
+ worker.removeListener('message', onDone);
70
+ const payload = JSON.stringify(body);
71
+ for (const r of waiters) {
72
+ try {
73
+ r.writeHead(status, { 'Content-Type': 'application/json' });
74
+ r.end(payload);
75
+ } catch { /* client may have disconnected while waiting */ }
76
+ }
77
+ };
78
+ const timeout = setTimeout(() => finish(504, { error: 'Proxy stats refresh timed out' }), 30000);
79
+ timeout.unref?.();
80
+ const onDone = (m) => {
81
+ if (m.type === 'scan-all-done') finish(200, { ok: true });
82
+ };
83
+ worker.on('message', onDone);
84
+ worker.postMessage({ type: 'scan-all', logDir: LOG_DIR });
85
+ } catch (err) {
86
+ _refreshWaiters = null;
87
+ res.writeHead(500, { 'Content-Type': 'application/json' });
88
+ res.end(JSON.stringify({ error: err.message }));
89
+ }
90
+ }
91
+
92
+ export const proxyStatsRoutes = [
93
+ { method: 'GET', match: 'exact', path: '/api/proxy-stats', handler: getProxyStats },
94
+ { method: 'POST', match: 'exact', path: '/api/refresh-proxy-stats', handler: refreshProxyStats },
95
+ ];
package/server/server.js CHANGED
@@ -40,6 +40,8 @@ import { teamRoutes } from './routes/team.js';
40
40
  import { authRoutes } from './routes/auth.js';
41
41
  import { dingtalkRoutes } from './routes/dingtalk.js';
42
42
  import { imRoutes } from './routes/im.js';
43
+ import { proxyStatsRoutes } from './routes/proxy-stats.js';
44
+ import { setProxyStatsListener } from './lib/proxy-stats.js';
43
45
  import * as imCore from './lib/im-bridge-core.js';
44
46
  import * as imProcMgr from './lib/im-process-manager.js';
45
47
  import './lib/adapters/dingtalk-adapter.js'; // side-effect: registers the DingTalk adapter
@@ -431,6 +433,34 @@ function notifyStatsWorker(logFile) {
431
433
  }
432
434
  }
433
435
 
436
+ // 供 proxy.js 在写入代理重试明细后通知 stats-worker 重扫聚合。
437
+ // 单独 export 避免循环依赖(proxy.js 不能直接 import server.js 的 deps)。
438
+ // Review P1 fix: one worker message per proxied LLM request made the worker
439
+ // re-aggregate the whole project on EVERY request (O(N²) over a busy day —
440
+ // the session-log path has the log-watcher debounce, the proxy path had
441
+ // none). Trailing coalesce: the first notify schedules a flush, further
442
+ // notifies within the window only update the pending file. unref() so a
443
+ // pending flush never holds the process open on shutdown.
444
+ const PROXY_STATS_DEBOUNCE_MS = 2000;
445
+ let _proxyStatsFlushTimer = null;
446
+ let _proxyStatsPendingFile = null;
447
+ export function notifyProxyStats(logFile) {
448
+ _proxyStatsPendingFile = logFile;
449
+ if (_proxyStatsFlushTimer) return;
450
+ _proxyStatsFlushTimer = setTimeout(() => {
451
+ _proxyStatsFlushTimer = null;
452
+ const pending = _proxyStatsPendingFile;
453
+ _proxyStatsPendingFile = null;
454
+ if (!statsWorker) startStatsWorker();
455
+ notifyStatsWorker(pending);
456
+ }, PROXY_STATS_DEBOUNCE_MS);
457
+ _proxyStatsFlushTimer.unref?.();
458
+ }
459
+ // Dependency inversion (review P2): server.js owns the statsWorker, so IT
460
+ // registers the notify callback with the proxy-stats lib; proxy.js only emits
461
+ // through the lib and never reaches back into this module.
462
+ setProxyStatsListener(notifyProxyStats);
463
+
434
464
  const MIME_TYPES = {
435
465
  '.html': 'text/html; charset=utf-8',
436
466
  '.js': 'application/javascript; charset=utf-8',
@@ -662,6 +692,7 @@ const _routes = [
662
692
  ...teamRoutes,
663
693
  ...dingtalkRoutes,
664
694
  ...imRoutes,
695
+ ...proxyStatsRoutes,
665
696
  ];
666
697
  const dispatch = createDispatcher(_routes);
667
698
 
@@ -0,0 +1,11 @@
1
+ // Shared proxy-mode detection logic.
2
+ // Used by App.jsx (showProxyStatsNav gate) and AppHeader.jsx (menu visibility, Electron tab bar).
3
+ //
4
+ // "Confirmed proxy" = a non-built-in profile is active, or the built-in Default
5
+ // points at a non-official endpoint (same api.anthropic.com test as ProxyModal's
6
+ // Max warning). Official subscription → returns false.
7
+ export function isProxyMode(activeProxyId, defaultConfig) {
8
+ if (activeProxyId && activeProxyId !== 'max') return true;
9
+ const origin = defaultConfig?.origin || '';
10
+ return !!origin && !/api\.anthropic\.com/i.test(origin);
11
+ }