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.
- package/cli.js +19 -4
- package/dist/assets/App-3VM471BO.js +2 -0
- package/dist/assets/{App-Dj37fluO.css → App-CCDFxj11.css} +1 -1
- package/dist/assets/{MdxEditorPanel-DeSJp_ct.js → MdxEditorPanel-BQ5MgI4D.js} +1 -1
- package/dist/assets/Mobile-wwcXmnrU.js +1 -0
- package/dist/assets/UnifiedProxyRetryPage-BpcKIn9N.js +1 -0
- package/dist/assets/UnifiedProxyRetryPage-CFz4vOH5.css +1 -0
- package/dist/assets/{_baseUniq-BK7Nhp_x.js → _baseUniq-yiNE5Ckw.js} +1 -1
- package/dist/assets/{arc-Cm4HMXz6.js → arc-M-eBYYbN.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-Co8QCowN.js → architectureDiagram-Q4EWVU46-CtweeLKi.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-Cudecf7x.js → blockDiagram-DXYQGD6D-DCVQ-go_.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-4JeFoCHX.js → c4Diagram-AHTNJAMY-C7JB9PqP.js} +1 -1
- package/dist/assets/{channel-K7eT7Hjr.js → channel-BnzX9zBS.js} +1 -1
- package/dist/assets/{chunk-4BX2VUAB-BBjl6EVW.js → chunk-4BX2VUAB-dVKiUtqe.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-DBP9YnwO.js → chunk-4TB4RGXK-CQjD9-fD.js} +1 -1
- package/dist/assets/{chunk-55IACEB6--A_i9gwR.js → chunk-55IACEB6-BZuKI6JQ.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-UBsL-3qr.js → chunk-EDXVE4YY-sk9g7szp.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-DBO0asmI.js → chunk-FMBD7UC4-CGL9Lo51.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-DOCcEaE6.js → chunk-OYMX7WX6-DaN0AdQD.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-B-j8QbQh.js → chunk-QZHKN3VN-CNEAHnZV.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-B0WO1U-U.js → chunk-YZCP3GAM-BOlts_Dm.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-CumOMdO7.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-CumOMdO7.js +1 -0
- package/dist/assets/clone-C3SZDzs1.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-DQgpss_a.js → cose-bilkent-S5V4N54A-DH4_YiGi.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-CCJBKmKv.js → dagre-KV5264BT-RqusStYI.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-DgUcnfkU.js → diagram-5BDNPKRD-8haVbBnt.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-CRpVvexw.js → diagram-G4DWMVQ6-CsrJNUhT.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-C3IhpsM-.js → diagram-MMDJMWI5-1luPOM38.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-BaVbsrwt.js → diagram-TYMM5635-Dad1XR3W.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-BxRoBUR8.js → erDiagram-SMLLAGMA-Br6-kbp9.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-Du2lIzoM.js → flowDiagram-DWJPFMVM-DuX1mbA7.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-DR1pDqLK.js → ganttDiagram-T4ZO3ILL-BL_XQ958.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-DHAmE20O.js → gitGraphDiagram-UUTBAWPF-DzvW5FEs.js} +1 -1
- package/dist/assets/{graph-D8Yv0IU1.js → graph-CbUmu0Oe.js} +1 -1
- package/dist/assets/{index-B6ZWIxY0.js → index-B3-JUU7J.js} +1 -1
- package/dist/assets/{index-D0pvxpcl.js → index-BgW_nP_0.js} +1 -1
- package/dist/assets/{index-Dpu92rw_.js → index-BjNLFJ0z.js} +2 -2
- package/dist/assets/{index-cCB-lhEO.js → index-CzPHj3iF.js} +1 -1
- package/dist/assets/{index-D2c77__8.js → index-DDlAMOiV.js} +1 -1
- package/dist/assets/{index-Bq95HjYM.js → index-Do5xNTOT.js} +1 -1
- package/dist/assets/{index-AOmFG_I_.js → index-LjVy-Pzg.js} +1 -1
- package/dist/assets/{index-DgtUyP57.js → index-vImWXiMM.js} +1 -1
- package/dist/assets/{infoDiagram-42DDH7IO-BAqRIm2G.js → infoDiagram-42DDH7IO-DGsgRji7.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-CzH4tezb.js → ishikawaDiagram-UXIWVN3A-D3odpv9_.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-Qs4eiyuI.js → journeyDiagram-VCZTEJTY-Dj3NaOWF.js} +1 -1
- package/dist/assets/{jszip.min-C0OccDsk.js → jszip.min-GmVScWAV.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-BAjl0gpV.js → kanban-definition-6JOO6SKY-GKjJaLwD.js} +1 -1
- package/dist/assets/{layout-Cf6cd9xE.js → layout-Bhk66H0F.js} +1 -1
- package/dist/assets/{linear-PzdCkji6.js → linear-CLk9nvqn.js} +1 -1
- package/dist/assets/{mermaid.core-DV9745Lq.js → mermaid.core-DH-GfeNS.js} +2 -2
- package/dist/assets/{min-CHgWxbzS.js → min-DQftOGfo.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-CHUjMdpi.js → mindmap-definition-QFDTVHPH-DgDD6Jbu.js} +1 -1
- package/dist/assets/{pieDiagram-DEJITSTG-D_r2QC0f.js → pieDiagram-DEJITSTG-jy1yFaxA.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-DPzGygdN.js → quadrantDiagram-34T5L4WZ-BdvMwrSD.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-BA2ynnGb.js → requirementDiagram-MS252O5E-CtBOCtbk.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-XHVvXuqU.js → sankeyDiagram-XADWPNL6-DEFlQp-h.js} +1 -1
- package/dist/assets/{seqResourceLoaders-kBEzcKLh.js → seqResourceLoaders-CDRtUSxI.js} +2 -2
- package/dist/assets/{sequenceDiagram-FGHM5R23-C47sA8YH.js → sequenceDiagram-FGHM5R23-0F2R8Ky0.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-BOmer839.js → stateDiagram-FHFEXIEX-D4xOwcSW.js} +1 -1
- package/dist/assets/{stateDiagram-v2-QKLJ7IA2-D-kVvTHA.js → stateDiagram-v2-QKLJ7IA2-DSf_BSF8.js} +1 -1
- package/dist/assets/{timeline-definition-GMOUNBTQ-CTAKVZgs.js → timeline-definition-GMOUNBTQ-B5cPasy_.js} +1 -1
- package/dist/assets/{vendor-antd-BuJ6oz45.js → vendor-antd-DeqwrDxf.js} +2 -2
- package/dist/assets/{vendor-codemirror-D2KAW4ty.js → vendor-codemirror-_NbrtdQc.js} +1 -1
- package/dist/assets/{vendor-mdxeditor-BsFVVUcj.js → vendor-mdxeditor-DG0Nyxw5.js} +2 -2
- package/dist/assets/{vendor-qrcode-BpxSq04x.js → vendor-qrcode-B3HN--HO.js} +1 -1
- package/dist/assets/{vendor-virtuoso-an4WvAvS.js → vendor-virtuoso-BvrezPQx.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-CuXzoroh.js → vennDiagram-DHZGUBPP-a_ybZDST.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-CFCmxnck.js → wardley-RL74JXVD-CKla3KF8.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-gfhDB9SR.js → wardleyDiagram-NUSXRM2D-BKke2aih.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-X-pj66qI.js → xychartDiagram-5P7HB3ND-CozWtcdf.js} +1 -1
- package/dist/index.html +4 -4
- package/findcc.js +58 -0
- package/package.json +1 -1
- package/server/lib/interceptor-core.js +26 -15
- package/server/lib/proxy-stats.js +436 -0
- package/server/lib/stats-worker.js +119 -5
- package/server/proxy.js +50 -7
- package/server/pty-manager.js +8 -0
- package/server/routes/preferences.js +1 -1
- package/server/routes/proxy-stats.js +95 -0
- package/server/server.js +31 -0
- package/src/utils/isProxyMode.js +11 -0
- package/dist/assets/App-CKvCavmF.js +0 -2
- package/dist/assets/Mobile-CWou-jtF.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-Ba3tOmih.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-Ba3tOmih.js +0 -1
- 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
|
|
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 头(
|
|
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) });
|
package/server/pty-manager.js
CHANGED
|
@@ -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
|
+
}
|