cc-viewer 1.7.3 → 1.7.5
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 +88 -17
- package/dist/assets/App-CCDFxj11.css +1 -0
- package/dist/assets/App-CcLQlV-O.js +2 -0
- package/dist/assets/{MdxEditorPanel-D_dewIIc.js → MdxEditorPanel-CAqHHy1X.js} +1 -1
- package/dist/assets/Mobile-BxRd4DkS.js +1 -0
- package/dist/assets/UnifiedProxyRetryPage-CFz4vOH5.css +1 -0
- package/dist/assets/UnifiedProxyRetryPage-DjL3NQXO.js +1 -0
- package/dist/assets/{_baseUniq-D9_E5ZDp.js → _baseUniq-yiNE5Ckw.js} +1 -1
- package/dist/assets/{arc-D4iFrlhb.js → arc-M-eBYYbN.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-f1neo7hg.js → architectureDiagram-Q4EWVU46-CtweeLKi.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-DWi9e9tG.js → blockDiagram-DXYQGD6D-DCVQ-go_.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-DAdj49A2.js → c4Diagram-AHTNJAMY-C7JB9PqP.js} +1 -1
- package/dist/assets/{channel-B3VthT0m.js → channel-BnzX9zBS.js} +1 -1
- package/dist/assets/{chunk-4BX2VUAB-BrPcAHV3.js → chunk-4BX2VUAB-dVKiUtqe.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-Bvopc1V9.js → chunk-4TB4RGXK-CQjD9-fD.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-Juw-7Z3i.js → chunk-55IACEB6-BZuKI6JQ.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-DVYP9tpB.js → chunk-EDXVE4YY-sk9g7szp.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-VQ7VOXl3.js → chunk-FMBD7UC4-CGL9Lo51.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-F8Rj4ZUZ.js → chunk-OYMX7WX6-DaN0AdQD.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-Dn7K3yi9.js → chunk-QZHKN3VN-CNEAHnZV.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-UjO-KTsn.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-BDhNj8rq.js → cose-bilkent-S5V4N54A-DH4_YiGi.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-BzdcDuyt.js → dagre-KV5264BT-RqusStYI.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-DP_SX65N.js → diagram-5BDNPKRD-8haVbBnt.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-B-pBFw8Q.js → diagram-G4DWMVQ6-CsrJNUhT.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-6XSfN1eN.js → diagram-MMDJMWI5-1luPOM38.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-DJvGxvpE.js → diagram-TYMM5635-Dad1XR3W.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-CpqdpGYj.js → erDiagram-SMLLAGMA-Br6-kbp9.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-C9m7wjwI.js → flowDiagram-DWJPFMVM-DuX1mbA7.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-CU0XOfY3.js → ganttDiagram-T4ZO3ILL-BL_XQ958.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-ipyGULnP.js → gitGraphDiagram-UUTBAWPF-DzvW5FEs.js} +1 -1
- package/dist/assets/{graph-BR2KmES9.js → graph-CbUmu0Oe.js} +1 -1
- package/dist/assets/{index-D5tSeMU7.js → index-B3-JUU7J.js} +1 -1
- package/dist/assets/{index--wgRW_OB.js → index-BgW_nP_0.js} +1 -1
- package/dist/assets/{index-OWzSX2T8.js → index-CzPHj3iF.js} +1 -1
- package/dist/assets/{index-DuZQ6FqR.js → index-DDlAMOiV.js} +1 -1
- package/dist/assets/{index-DidP9FCD.js → index-DJ0AQwus.js} +2 -2
- package/dist/assets/{index-D5YruOKD.js → index-Do5xNTOT.js} +1 -1
- package/dist/assets/{index-C-be29ey.js → index-LjVy-Pzg.js} +1 -1
- package/dist/assets/{index-C16fiVhv.js → index-vImWXiMM.js} +1 -1
- package/dist/assets/{infoDiagram-42DDH7IO-BQWQjfFX.js → infoDiagram-42DDH7IO-DGsgRji7.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-Df-KH_Hz.js → ishikawaDiagram-UXIWVN3A-D3odpv9_.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-CMbB_4G9.js → journeyDiagram-VCZTEJTY-Dj3NaOWF.js} +1 -1
- package/dist/assets/{jszip.min-B6k2t9Ch.js → jszip.min-GmVScWAV.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-CImdlKN3.js → kanban-definition-6JOO6SKY-GKjJaLwD.js} +1 -1
- package/dist/assets/{layout-DQl4aw09.js → layout-Bhk66H0F.js} +1 -1
- package/dist/assets/{linear-CLD3aPlK.js → linear-CLk9nvqn.js} +1 -1
- package/dist/assets/mermaid.core-DH-GfeNS.js +7 -0
- package/dist/assets/{min-_D86henJ.js → min-DQftOGfo.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-2JVcu1nk.js → mindmap-definition-QFDTVHPH-DgDD6Jbu.js} +1 -1
- package/dist/assets/{pieDiagram-DEJITSTG-Dik-m5u7.js → pieDiagram-DEJITSTG-jy1yFaxA.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-Bm5l40Jq.js → quadrantDiagram-34T5L4WZ-BdvMwrSD.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-Ck9w5rTj.js → requirementDiagram-MS252O5E-CtBOCtbk.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-BbGVCFEf.js → sankeyDiagram-XADWPNL6-DEFlQp-h.js} +1 -1
- package/dist/assets/{seqResourceLoaders-DFrHmgnK.css → seqResourceLoaders-0RXZfUKp.css} +1 -1
- package/dist/assets/seqResourceLoaders-D1y8V4z5.js +2 -0
- package/dist/assets/{sequenceDiagram-FGHM5R23-vjvEdksk.js → sequenceDiagram-FGHM5R23-0F2R8Ky0.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-DTqMoiB2.js → stateDiagram-FHFEXIEX-D4xOwcSW.js} +1 -1
- package/dist/assets/{stateDiagram-v2-QKLJ7IA2-CNbAwnhu.js → stateDiagram-v2-QKLJ7IA2-DSf_BSF8.js} +1 -1
- package/dist/assets/{timeline-definition-GMOUNBTQ-Cw8O-52d.js → timeline-definition-GMOUNBTQ-B5cPasy_.js} +1 -1
- package/dist/assets/{vendor-antd-DI7JL-mE.js → vendor-antd-DeqwrDxf.js} +2 -2
- package/dist/assets/{vendor-codemirror-B9c49dtM.js → vendor-codemirror-_NbrtdQc.js} +1 -1
- package/dist/assets/{vendor-mdxeditor-B6cpBtIE.js → vendor-mdxeditor-DG0Nyxw5.js} +2 -2
- package/dist/assets/{vendor-qrcode-Dn90-s-u.js → vendor-qrcode-B3HN--HO.js} +1 -1
- package/dist/assets/{vendor-virtuoso-CpdSKD_6.js → vendor-virtuoso-BvrezPQx.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-DCuqymb7.js → vennDiagram-DHZGUBPP-a_ybZDST.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-BJ20wJUU.js → wardley-RL74JXVD-CKla3KF8.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-Vz6US3qt.js → wardleyDiagram-NUSXRM2D-BKke2aih.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-CzkLKrmi.js → xychartDiagram-5P7HB3ND-CozWtcdf.js} +1 -1
- package/dist/index.html +4 -4
- package/package.json +1 -1
- package/server/i18n.js +100 -55
- package/server/interceptor.js +27 -1
- package/server/lib/ccswitch-import.js +251 -0
- package/server/lib/proxy-retry.js +716 -0
- package/server/lib/proxy-stats.js +436 -0
- package/server/lib/stats-worker.js +119 -5
- package/server/proxy.js +159 -12
- package/server/routes/preferences.js +184 -1
- package/server/routes/proxy-stats.js +95 -0
- package/server/server.js +43 -1
- package/src/utils/contentFilter.js +17 -1
- package/src/utils/isProxyMode.js +11 -0
- package/dist/assets/App-DRsuJXEw.js +0 -2
- package/dist/assets/App-dYPa5-df.css +0 -1
- package/dist/assets/Mobile-BIzb7vpA.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-DmSozxoO.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-DmSozxoO.js +0 -1
- package/dist/assets/clone-BQtvy9Ae.js +0 -1
- package/dist/assets/mermaid.core-CN35dvKJ.js +0 -7
- package/dist/assets/seqResourceLoaders-BtGpfGRW.js +0 -2
package/server/proxy.js
CHANGED
|
@@ -1,17 +1,27 @@
|
|
|
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';
|
|
8
8
|
import { extractApiErrorMessage, formatProxyRequestError } from './lib/proxy-errors.js';
|
|
9
9
|
import { getProxyDispatcher } from './lib/proxy-env.js';
|
|
10
10
|
import { getClaudeConfigDir } from '../findcc.js';
|
|
11
|
+
import { isAnthropicApiPath } from './lib/interceptor-core.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';
|
|
11
15
|
|
|
12
16
|
// Setup interceptor to patch fetch
|
|
13
17
|
setupInterceptor();
|
|
14
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
|
+
|
|
15
25
|
// 强制上游返回未压缩响应,取代仅剥 zstd 的旧策略。
|
|
16
26
|
// 原因:链路中的网关/代理(典型是本地 MITM 网络代理)可能把上游的压缩 body 原样透传,
|
|
17
27
|
// 却把 content-encoding 响应头剥掉。undici 看不到 content-encoding 就不会解压,于是把一坨
|
|
@@ -111,27 +121,35 @@ export function startProxy() {
|
|
|
111
121
|
headers: headers,
|
|
112
122
|
};
|
|
113
123
|
|
|
114
|
-
// 标记此请求为 CC-Viewer 代理转发的 Claude API 请求
|
|
115
|
-
// 拦截器识别到此 Header 会强制记录,忽略 URL 匹配规则
|
|
116
|
-
fetchOptions.headers['x-cc-viewer-trace'] = 'true';
|
|
117
|
-
|
|
118
|
-
if (body.length > 0) {
|
|
119
|
-
fetchOptions.body = body;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
124
|
// 走用户的网络代理:Node 内置全局 fetch 既不读 http_proxy/https_proxy,也看不到
|
|
123
125
|
// userland undici 的 setGlobalDispatcher,必须把代理 dispatcher 显式传进来,否则
|
|
124
126
|
// 上游请求会绕过代理直连 api.anthropic.com(详见 lib/proxy-env.js 注释)。
|
|
125
127
|
const proxyDispatcher = getProxyDispatcher();
|
|
126
|
-
if (proxyDispatcher) {
|
|
127
|
-
fetchOptions.dispatcher = proxyDispatcher;
|
|
128
|
-
}
|
|
129
128
|
|
|
130
129
|
// 拼接完整 URL,保留 originalBaseUrl 中的路径前缀
|
|
131
130
|
const cleanBase = originalBaseUrl.endsWith('/') ? originalBaseUrl.slice(0, -1) : originalBaseUrl;
|
|
132
131
|
const cleanReq = req.url.startsWith('/') ? req.url.slice(1) : req.url;
|
|
133
132
|
const fullUrl = `${cleanBase}/${cleanReq}`;
|
|
134
133
|
|
|
134
|
+
// 大模型 API 请求(/v1/messages 等)走重试引擎:serial/race/stagger 重试 + 明细统计。
|
|
135
|
+
// 重试引擎内部用 x-cc-viewer-trace 头让 interceptor 记录每次重试尝试到会话日志(网络视图数据源),
|
|
136
|
+
// 模型替换由重试引擎用 resolveProfileModel 纯函数完成(interceptor 对 trace 请求会再跑一次,幂等 no-op)。
|
|
137
|
+
// 非大模型 API 请求走原逻辑(同样用 x-cc-viewer-trace 让 interceptor 记录,但不经重试引擎)。
|
|
138
|
+
if (isAnthropicApiPath(fullUrl)) {
|
|
139
|
+
await handleLlmApiRequest(req, res, fullUrl, fetchOptions, body, proxyDispatcher);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── 非大模型 API:原逻辑(透传,interceptor 记录)──
|
|
144
|
+
fetchOptions.headers['x-cc-viewer-trace'] = 'true';
|
|
145
|
+
|
|
146
|
+
if (body.length > 0) {
|
|
147
|
+
fetchOptions.body = body;
|
|
148
|
+
}
|
|
149
|
+
if (proxyDispatcher) {
|
|
150
|
+
fetchOptions.dispatcher = proxyDispatcher;
|
|
151
|
+
}
|
|
152
|
+
|
|
135
153
|
const response = await fetch(fullUrl, fetchOptions);
|
|
136
154
|
|
|
137
155
|
// fetch 自动解压,需移除编码相关 header 避免客户端重复解压
|
|
@@ -201,3 +219,132 @@ export function startProxy() {
|
|
|
201
219
|
});
|
|
202
220
|
});
|
|
203
221
|
}
|
|
222
|
+
|
|
223
|
+
// ── 大模型 API 请求处理:重试引擎 + 明细统计 ──────────────────────
|
|
224
|
+
// mode=off 时仍写 1 条明细(attempts=1)以支撑可用率统计;完全禁用统计用 CCV_PROXY_STATS=off。
|
|
225
|
+
// 重试配置由 interceptor._retryConfigState 提供(live binding,watchFile 热刷新),
|
|
226
|
+
// 每请求取最新值,UI 改 retry-config.json 后下一个请求即生效,无需重启。
|
|
227
|
+
const _retryConfigGetter = () => interceptor._retryConfigState;
|
|
228
|
+
|
|
229
|
+
async function handleLlmApiRequest(req, res, fullUrl, fetchOptions, body, proxyDispatcher) {
|
|
230
|
+
const statsEnabled = process.env.CCV_PROXY_STATS !== 'off';
|
|
231
|
+
const retryConfig = _retryConfigGetter(); // live binding:每请求取最新重试配置
|
|
232
|
+
const profile = interceptor._activeProfile || null;
|
|
233
|
+
|
|
234
|
+
// 清理可能的 trace 头(singleFetch 会重新加,避免旧值干扰);interceptor 会正常记录请求
|
|
235
|
+
// 同时清理 hop-by-hop / 传输编码头:body 已被 buffer 成完整 Buffer,
|
|
236
|
+
// 残留的 transfer-encoding:chunked / content-length 会让 undici 拒绝(invalid transfer-encoding header)
|
|
237
|
+
const retryFetchOptions = {
|
|
238
|
+
method: fetchOptions.method,
|
|
239
|
+
headers: { ...fetchOptions.headers },
|
|
240
|
+
};
|
|
241
|
+
delete retryFetchOptions.headers['x-cc-viewer-trace'];
|
|
242
|
+
delete retryFetchOptions.headers['transfer-encoding'];
|
|
243
|
+
delete retryFetchOptions.headers['Transfer-Encoding'];
|
|
244
|
+
delete retryFetchOptions.headers['connection'];
|
|
245
|
+
delete retryFetchOptions.headers['Connection'];
|
|
246
|
+
delete retryFetchOptions.headers['content-length'];
|
|
247
|
+
delete retryFetchOptions.headers['Content-Length'];
|
|
248
|
+
if (body.length > 0) {
|
|
249
|
+
retryFetchOptions.body = body;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Model name for the stats detail record (extracted from the request body)
|
|
253
|
+
const model = extractModel(body);
|
|
254
|
+
|
|
255
|
+
// Client-disconnect propagation: without this, a client that gave up leaves
|
|
256
|
+
// the retry loop hammering (and billing) the upstream for up to
|
|
257
|
+
// maxRetryDurationMs. res 'close' before the response is finished means the
|
|
258
|
+
// client is gone → abort every in-flight/queued attempt via the engine.
|
|
259
|
+
const clientAbort = new AbortController();
|
|
260
|
+
res.on('close', () => {
|
|
261
|
+
if (!res.writableEnded) {
|
|
262
|
+
try { clientAbort.abort(); } catch { /* best effort */ }
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// 执行带重试的请求
|
|
267
|
+
const result = await executeRequest({
|
|
268
|
+
url: fullUrl,
|
|
269
|
+
fetchOptions: retryFetchOptions,
|
|
270
|
+
retryConfig,
|
|
271
|
+
ctx: { dispatcher: proxyDispatcher, profile, signal: clientAbort.signal },
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const { response, attempts, retryCodes, durationMs, finalStatus, succeeded, upstreamStatus } = result;
|
|
275
|
+
|
|
276
|
+
// Client already gone: nothing to write; the engine has aborted upstream work.
|
|
277
|
+
if (clientAbort.signal.aborted || res.writableEnded) {
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// 注入 X-Forward-Attempts 头(告知客户端本次重试了几次,与 llm-retry-proxy 一致)
|
|
282
|
+
const responseHeaders = {};
|
|
283
|
+
if (response.headers && typeof response.headers.entries === 'function') {
|
|
284
|
+
for (const [key, value] of response.headers.entries()) {
|
|
285
|
+
if (key.toLowerCase() !== 'content-encoding' && key.toLowerCase() !== 'transfer-encoding' && key.toLowerCase() !== 'content-length') {
|
|
286
|
+
responseHeaders[key] = value;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
responseHeaders['x-forward-attempts'] = String(attempts);
|
|
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
|
+
|
|
323
|
+
// 网络异常(finalStatus=0)或竞速全失败:返回 502 + Proxy Error,与原 catch 分支一致
|
|
324
|
+
if (finalStatus === 0) {
|
|
325
|
+
res.writeHead(502, { 'x-forward-attempts': String(attempts) });
|
|
326
|
+
res.end('Proxy Error');
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// 响应客户端
|
|
331
|
+
res.writeHead(finalStatus, responseHeaders);
|
|
332
|
+
|
|
333
|
+
if (response.body) {
|
|
334
|
+
const { Readable, pipeline } = await import('node:stream');
|
|
335
|
+
// @ts-ignore
|
|
336
|
+
const nodeStream = Readable.fromWeb(response.body);
|
|
337
|
+
nodeStream.on('error', () => {});
|
|
338
|
+
pipeline(nodeStream, res, (err) => {
|
|
339
|
+
if (err && process.env.CCV_DEBUG) {
|
|
340
|
+
console.error('[CC-Viewer Proxy] Stream pipeline error:', err.message);
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
} else if (response.text) {
|
|
344
|
+
// 竞速失败兜底 response:用 text() 取 body
|
|
345
|
+
const text = await response.text();
|
|
346
|
+
res.end(text);
|
|
347
|
+
} else {
|
|
348
|
+
res.end();
|
|
349
|
+
}
|
|
350
|
+
}
|
|
@@ -3,8 +3,11 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
|
3
3
|
import { join, dirname } from 'node:path';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { LOG_DIR, setLogDir, getClaudeConfigDir } from '../../findcc.js';
|
|
6
|
-
import { PROFILE_PATH, _defaultConfig, getActiveProfileId, setActiveProfileForWorkspace, _loadProxyProfile } from '../interceptor.js';
|
|
6
|
+
import { PROFILE_PATH, _defaultConfig, getActiveProfileId, setActiveProfileForWorkspace, _loadProxyProfile, RETRY_CONFIG_PATH, _retryConfigState, _loadRetryConfigState } from '../interceptor.js';
|
|
7
7
|
import { migrateProxyProfileList } from '../lib/interceptor-core.js';
|
|
8
|
+
import { DEFAULT_RETRY_CONFIG, validateRetryConfig, resolveRetryConfig } from '../lib/proxy-retry.js';
|
|
9
|
+
import { discoverCcSwitchProviders, mergeImportedProfiles } from '../lib/ccswitch-import.js';
|
|
10
|
+
import { reportSwallowed } from '../lib/error-report.js';
|
|
8
11
|
import { setLang } from '../i18n.js';
|
|
9
12
|
import { reconcileVoicePackPrefs as vpReconcile } from '../lib/voice-pack-manager.js';
|
|
10
13
|
import { readClaudeProjectModel } from '../lib/context-watcher.js';
|
|
@@ -299,6 +302,182 @@ function proxyProfilesPost(req, res, parsedUrl, isLocal, deps) {
|
|
|
299
302
|
});
|
|
300
303
|
}
|
|
301
304
|
|
|
305
|
+
// ── 代理重试配置(GET/POST + SSE,仿 proxyProfilesGet/Post,但无密钥脱敏、无 workspace 隔离)──
|
|
306
|
+
// 返回 { config, defaults }:config = 当前生效(env 基础 + retry-config.json 覆盖后的 live binding);
|
|
307
|
+
// defaults = DEFAULT_RETRY_CONFIG,供 UI「恢复默认」按钮参照。
|
|
308
|
+
|
|
309
|
+
function retryConfigGet(req, res, _parsedUrl, _isLocal, _deps) {
|
|
310
|
+
try {
|
|
311
|
+
// _retryConfigState 是 live binding(watchFile 刷新),即当前生效配置。
|
|
312
|
+
// 缺省兜底:若拦截器尚未初始化(理论上不会,但防御性),即时解析一次。
|
|
313
|
+
const config = _retryConfigState || resolveRetryConfig();
|
|
314
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
315
|
+
res.end(JSON.stringify({ config, defaults: DEFAULT_RETRY_CONFIG }));
|
|
316
|
+
} catch {
|
|
317
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
318
|
+
res.end(JSON.stringify({ config: DEFAULT_RETRY_CONFIG, defaults: DEFAULT_RETRY_CONFIG }));
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function retryConfigPost(req, res, _parsedUrl, isLocal, deps) {
|
|
323
|
+
// Local-only, mirroring ccswitchImportPost: retry config is written to a
|
|
324
|
+
// cross-process file every ccv instance hot-reloads, and race/stagger with
|
|
325
|
+
// high concurrency multiplies the HOST's paid upstream requests — a LAN
|
|
326
|
+
// client must not be able to amplify the host's API spend machine-wide.
|
|
327
|
+
if (!isLocal) {
|
|
328
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
329
|
+
res.end(JSON.stringify({ ok: false, error: 'retry config is local-only' }));
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
let body = '';
|
|
333
|
+
req.on('data', chunk => { body += chunk; if (body.length > deps.MAX_POST_BODY) req.destroy(); });
|
|
334
|
+
req.on('end', () => {
|
|
335
|
+
try {
|
|
336
|
+
const incoming = JSON.parse(body);
|
|
337
|
+
if (!incoming || typeof incoming !== 'object' || !incoming.config || typeof incoming.config !== 'object') {
|
|
338
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
339
|
+
res.end(JSON.stringify({ error: 'Invalid retry config: expected { config: {...} }' }));
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
// 校验 + 归一化(与 resolveRetryConfig 共用 validateRetryConfig,单一真源)
|
|
343
|
+
const validated = validateRetryConfig(incoming.config);
|
|
344
|
+
// 合并默认值,保证写盘的是完整配置(缺字段回落默认,避免读到半截配置)
|
|
345
|
+
const toWrite = { ...DEFAULT_RETRY_CONFIG, ...validated };
|
|
346
|
+
const dir = dirname(RETRY_CONFIG_PATH);
|
|
347
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
348
|
+
writeFileSync(RETRY_CONFIG_PATH, JSON.stringify(toWrite, null, 2), { mode: 0o600 });
|
|
349
|
+
// 立即刷新本进程 live binding(不等 watchFile 1.5s 轮询)
|
|
350
|
+
_loadRetryConfigState();
|
|
351
|
+
// SSE 广播给本进程所有客户端(同 proxy_profile 模式)
|
|
352
|
+
sendEventToClients(deps.clients, 'retry_config', { config: _retryConfigState });
|
|
353
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
354
|
+
res.end(JSON.stringify({ ok: true }));
|
|
355
|
+
} catch {
|
|
356
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
357
|
+
res.end(JSON.stringify({ error: 'Invalid JSON' }));
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ── cc-switch 导入 ─────────────────────────────────────
|
|
363
|
+
// cc-switch 是 Tauri 桌面应用,把供应商凭证存 SQLite(cc-switch.db providers 表)。
|
|
364
|
+
// 本路由跨平台探测 cc-switch 数据目录,只读查 claude 类型供应商,映射成 cc-viewer profile。
|
|
365
|
+
// GET=预览(不落盘);POST=执行导入(merge 进 profile.json + SSE 广播刷新)。
|
|
366
|
+
|
|
367
|
+
async function ccswitchProvidersGet(req, res, _parsedUrl, isLocal, _deps) {
|
|
368
|
+
try {
|
|
369
|
+
const result = await discoverCcSwitchProviders();
|
|
370
|
+
// 脱敏:非本机不下发明文 apiKey(与 proxyProfilesGet 的 maskProfiles 策略一致)
|
|
371
|
+
const profiles = isLocal ? result.profiles : (result.profiles || []).map(p => ({
|
|
372
|
+
...p,
|
|
373
|
+
apiKey: p.apiKey ? `${p.apiKey.slice(0, 4)}****` : '',
|
|
374
|
+
}));
|
|
375
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
376
|
+
res.end(JSON.stringify({
|
|
377
|
+
profiles,
|
|
378
|
+
currentId: result.currentId || null,
|
|
379
|
+
dbPath: result.dbPath,
|
|
380
|
+
error: result.error,
|
|
381
|
+
}));
|
|
382
|
+
} catch (err) {
|
|
383
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
384
|
+
res.end(JSON.stringify({ profiles: [], error: String(err && err.message || err) }));
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// POST body 可选 { setActive: true } —— 是否把 cc-switch 的 current 设为 cc-viewer active。
|
|
389
|
+
// 默认不设(避免覆盖用户当前选择)。导入只 merge 列表,凭证刷新幂等。
|
|
390
|
+
async function ccswitchImportPost(req, res, _parsedUrl, isLocal, deps) {
|
|
391
|
+
if (!isLocal) {
|
|
392
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
393
|
+
res.end(JSON.stringify({ ok: false, error: 'cc-switch import is local-only' }));
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
let body = '';
|
|
397
|
+
req.on('data', chunk => { body += chunk; if (body.length > deps.MAX_POST_BODY) req.destroy(); });
|
|
398
|
+
req.on('end', async () => {
|
|
399
|
+
try {
|
|
400
|
+
const incoming = body ? JSON.parse(body) : {};
|
|
401
|
+
const setActive = incoming && incoming.setActive === true;
|
|
402
|
+
const result = await discoverCcSwitchProviders();
|
|
403
|
+
if (result.error && result.profiles.length === 0) {
|
|
404
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
405
|
+
res.end(JSON.stringify({ ok: false, error: result.error, imported: 0, updated: 0 }));
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
// Read existing profile.json. A missing file is a legitimate first import,
|
|
409
|
+
// but a file that exists and fails to parse must ABORT: merging into an
|
|
410
|
+
// empty base and writing back would silently wipe every user-created
|
|
411
|
+
// proxy_ profile (existing IS the source of truth here, unlike
|
|
412
|
+
// proxyProfilesPost where the client sends the full desired list).
|
|
413
|
+
let existing = { profiles: [] };
|
|
414
|
+
if (existsSync(PROFILE_PATH)) {
|
|
415
|
+
let parsed = null;
|
|
416
|
+
try {
|
|
417
|
+
parsed = JSON.parse(readFileSync(PROFILE_PATH, 'utf-8'));
|
|
418
|
+
} catch (err) {
|
|
419
|
+
reportSwallowed('ccswitch-import.read-existing', err);
|
|
420
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
421
|
+
res.end(JSON.stringify({
|
|
422
|
+
ok: false,
|
|
423
|
+
error: 'existing profile.json is unreadable; import aborted to avoid overwriting your profiles',
|
|
424
|
+
imported: 0, updated: 0,
|
|
425
|
+
}));
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
// JSON that parses to a non-object (null, array, number) is just as
|
|
429
|
+
// destructive to merge into — treat it the same as a parse failure.
|
|
430
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
431
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
432
|
+
res.end(JSON.stringify({
|
|
433
|
+
ok: false,
|
|
434
|
+
error: 'existing profile.json has an unexpected shape; import aborted to avoid overwriting your profiles',
|
|
435
|
+
imported: 0, updated: 0,
|
|
436
|
+
}));
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
existing = parsed;
|
|
440
|
+
}
|
|
441
|
+
// merge
|
|
442
|
+
const merged = mergeImportedProfiles(existing.profiles || [], result.profiles);
|
|
443
|
+
const toWrite = { ...existing, profiles: merged.profiles };
|
|
444
|
+
// 落盘
|
|
445
|
+
const dir = dirname(PROFILE_PATH);
|
|
446
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
447
|
+
writeFileSync(PROFILE_PATH, JSON.stringify(toWrite, null, 2), { mode: 0o600 });
|
|
448
|
+
// active 处理:setActive=true 且 cc-switch 有 current → 切换;否则保持现状
|
|
449
|
+
let activeChanged = false;
|
|
450
|
+
if (setActive && result.currentId) {
|
|
451
|
+
setActiveProfileForWorkspace(result.currentId);
|
|
452
|
+
activeChanged = true;
|
|
453
|
+
} else {
|
|
454
|
+
_loadProxyProfile(); // 刷新列表(可能增删了)
|
|
455
|
+
}
|
|
456
|
+
// SSE 广播给前端刷新(profile: 'refresh' 是 truthy 哨兵,
|
|
457
|
+
// 触发 AppBase SSE handler 的 if(data.profile) 重新 GET 全量列表)
|
|
458
|
+
const effectiveActive = getActiveProfileId();
|
|
459
|
+
sendEventToClients(deps.clients, 'proxy_profile', {
|
|
460
|
+
active: effectiveActive,
|
|
461
|
+
profile: 'refresh',
|
|
462
|
+
});
|
|
463
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
464
|
+
res.end(JSON.stringify({
|
|
465
|
+
ok: true,
|
|
466
|
+
imported: merged.imported,
|
|
467
|
+
updated: merged.updated,
|
|
468
|
+
total: merged.profiles.length,
|
|
469
|
+
activeChanged,
|
|
470
|
+
dbPath: result.dbPath,
|
|
471
|
+
}));
|
|
472
|
+
} catch (err) {
|
|
473
|
+
// ok:false keeps the response contract uniform — the client decides
|
|
474
|
+
// success strictly on data.ok, never on the presence of counter fields.
|
|
475
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
476
|
+
res.end(JSON.stringify({ ok: false, error: String(err && err.message || err) }));
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
|
|
302
481
|
export const preferencesRoutes = [
|
|
303
482
|
{ method: 'GET', match: 'exact', path: '/api/preferences', handler: preferencesGet },
|
|
304
483
|
{ method: 'POST', match: 'exact', path: '/api/preferences', handler: preferencesPost },
|
|
@@ -306,4 +485,8 @@ export const preferencesRoutes = [
|
|
|
306
485
|
{ method: 'POST', match: 'exact', path: '/api/claude-settings', handler: claudeSettingsPost },
|
|
307
486
|
{ method: 'GET', match: 'exact', path: '/api/proxy-profiles', handler: proxyProfilesGet },
|
|
308
487
|
{ method: 'POST', match: 'exact', path: '/api/proxy-profiles', handler: proxyProfilesPost },
|
|
488
|
+
{ method: 'GET', match: 'exact', path: '/api/retry-config', handler: retryConfigGet },
|
|
489
|
+
{ method: 'POST', match: 'exact', path: '/api/retry-config', handler: retryConfigPost },
|
|
490
|
+
{ method: 'GET', match: 'exact', path: '/api/ccswitch-providers', handler: ccswitchProvidersGet },
|
|
491
|
+
{ method: 'POST', match: 'exact', path: '/api/ccswitch-import', handler: ccswitchImportPost },
|
|
309
492
|
];
|
|
@@ -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
|
|
@@ -76,11 +78,20 @@ function execWithStdin(cmd, args, input, options) {
|
|
|
76
78
|
child.stdin.end();
|
|
77
79
|
});
|
|
78
80
|
}
|
|
79
|
-
import { _initPromise, _projectName, _logDir, _v2Writer, streamingState, resetStreamingState, PROFILE_PATH, setLivePort, getImLiveText, resetImLiveText, markSessionStart } from './interceptor.js';
|
|
81
|
+
import { _initPromise, _projectName, _logDir, _v2Writer, streamingState, resetStreamingState, PROFILE_PATH, RETRY_CONFIG_PATH, _retryConfigState, setLivePort, getImLiveText, resetImLiveText, markSessionStart } from './interceptor.js';
|
|
80
82
|
import { V2LiveFeed } from './lib/v2/live-feed.js';
|
|
81
83
|
import { sanitizePathComponent } from './lib/v2/layout.js';
|
|
82
84
|
import { maybeResumeConvert } from './lib/v2/convert-manager.js';
|
|
83
85
|
import { LOG_DIR, setLogDir, getClaudeConfigDir, isBrowserOpenSuppressed } from '../findcc.js';
|
|
86
|
+
import { loadRetryConfig } from './lib/proxy-retry.js';
|
|
87
|
+
|
|
88
|
+
// 代理重试配置:由 interceptor._retryConfigState 提供(live binding,watchFile 热刷新)。
|
|
89
|
+
// mode 默认 off(向后兼容,不重试)。env 是启动默认/兜底,retry-config.json 覆盖 env。
|
|
90
|
+
// 启动日志读一次首屏值;运行时 proxy.js 每请求读 interceptor._retryConfigState 取最新。
|
|
91
|
+
const _startupRetryConfig = _retryConfigState || loadRetryConfig();
|
|
92
|
+
if (_startupRetryConfig.mode !== 'off' && process.env.CCV_DEBUG) {
|
|
93
|
+
console.error(`[CC Viewer] Proxy retry: mode=${_startupRetryConfig.mode}, maxRetries=${_startupRetryConfig.maxRetries}, interval=${_startupRetryConfig.retryIntervalMs}ms`);
|
|
94
|
+
}
|
|
84
95
|
import { t, getLang, setLang } from './i18n.js';
|
|
85
96
|
import { loadAuthConfig, loadAuthState, saveAuthConfig, clearProjectOverride, generatePassword, decideAuth, parseCookies, renderLoginPage, localeFromAcceptLanguage } from './lib/auth.js';
|
|
86
97
|
import { checkAndUpdate } from './lib/updater.js';
|
|
@@ -422,6 +433,34 @@ function notifyStatsWorker(logFile) {
|
|
|
422
433
|
}
|
|
423
434
|
}
|
|
424
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
|
+
|
|
425
464
|
const MIME_TYPES = {
|
|
426
465
|
'.html': 'text/html; charset=utf-8',
|
|
427
466
|
'.js': 'application/javascript; charset=utf-8',
|
|
@@ -520,6 +559,7 @@ const deps = {
|
|
|
520
559
|
get writeToPty() { return _writeToPty; },
|
|
521
560
|
get onPtyData() { return _onPtyData; },
|
|
522
561
|
get statsWorker() { return statsWorker; },
|
|
562
|
+
get retryConfig() { return _retryConfigState; },
|
|
523
563
|
get workspaceLaunched() { return _workspaceLaunched; },
|
|
524
564
|
setWorkspaceLaunched(v) { _workspaceLaunched = v; },
|
|
525
565
|
get launchCallback() { return _launchCallback; },
|
|
@@ -652,6 +692,7 @@ const _routes = [
|
|
|
652
692
|
...teamRoutes,
|
|
653
693
|
...dingtalkRoutes,
|
|
654
694
|
...imRoutes,
|
|
695
|
+
...proxyStatsRoutes,
|
|
655
696
|
];
|
|
656
697
|
const dispatch = createDispatcher(_routes);
|
|
657
698
|
|
|
@@ -2306,6 +2347,7 @@ async function _doStop() {
|
|
|
2306
2347
|
// 早期请求向已关闭的端口 POST 丢包。新 startViewer 的 listen 回调会再次 setLivePort
|
|
2307
2348
|
setLivePort(null);
|
|
2308
2349
|
try { unwatchFile(PROFILE_PATH); } catch {} // 清理 interceptor 的 StatWatcher
|
|
2350
|
+
try { unwatchFile(RETRY_CONFIG_PATH); } catch {} // 清理 retry-config 的 StatWatcher
|
|
2309
2351
|
}
|
|
2310
2352
|
|
|
2311
2353
|
// ─── SDK Mode Exports ──────────────────────────────────────────
|
|
@@ -285,6 +285,17 @@ export function isSyntheticPromptText(text) {
|
|
|
285
285
|
return false;
|
|
286
286
|
}
|
|
287
287
|
|
|
288
|
+
// UltraPlan 输入识别:UltraPlanModal / CLI /ultraplan 都会把 ultraplanTemplates.js
|
|
289
|
+
// 组装的 <system-reminder>[SCOPED INSTRUCTION]…</system-reminder> 模板前置到用户 prompt。
|
|
290
|
+
// 该 marker 仅由 ultraplanTemplates.js 产出,故可作为「本轮是 UltraPlan 输入」的可靠信号。
|
|
291
|
+
// stripSystemTags 会剥掉该块,所以必须在原始(未剥离)文本上判断;用负向前瞻确保 marker
|
|
292
|
+
// 出现在同一个 <system-reminder>…</system-reminder> 块内部(不跨过闭合标签),避免用户正文里
|
|
293
|
+
// 在某个无关 reminder 之后只是「提到」该短语时误判。
|
|
294
|
+
export function isUltraplanText(text) {
|
|
295
|
+
if (!text || typeof text !== 'string') return false;
|
|
296
|
+
return /<system-reminder>(?:(?!<\/system-reminder>)[\s\S])*?\[SCOPED INSTRUCTION\]/i.test(text);
|
|
297
|
+
}
|
|
298
|
+
|
|
288
299
|
export function isSystemText(text) {
|
|
289
300
|
if (!text) return true;
|
|
290
301
|
const trimmed = text.trim();
|
|
@@ -469,7 +480,12 @@ export function classifyUserContent(content) {
|
|
|
469
480
|
// 出现在 textBlocks 中;保留 skillBlocks 键以维持返回 shape(ChatView/ImConversationModal 消费)。
|
|
470
481
|
const skillBlocks = [];
|
|
471
482
|
|
|
472
|
-
|
|
483
|
+
// 本轮是否为 UltraPlan 输入(原始文本仍含 <system-reminder>[SCOPED INSTRUCTION] marker,
|
|
484
|
+
// 此时尚未被 stripSystemTags 剥离)。消费方(ChatView)据此在用户气泡上方渲染 [UltraPlan] 标签。
|
|
485
|
+
const ultraplan = Array.isArray(content)
|
|
486
|
+
&& content.some(b => b && b.type === 'text' && isUltraplanText(b.text));
|
|
487
|
+
|
|
488
|
+
return { commands, textBlocks, skillBlocks, teammateBlocks, taskNotificationBlocks, ultraplan };
|
|
473
489
|
}
|
|
474
490
|
|
|
475
491
|
/**
|
|
@@ -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
|
+
}
|