cc-viewer 1.7.3 → 1.7.4
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-CKvCavmF.js +2 -0
- package/dist/assets/App-Dj37fluO.css +1 -0
- package/dist/assets/{MdxEditorPanel-D_dewIIc.js → MdxEditorPanel-DeSJp_ct.js} +1 -1
- package/dist/assets/{Mobile-BIzb7vpA.js → Mobile-CWou-jtF.js} +1 -1
- package/dist/assets/{_baseUniq-D9_E5ZDp.js → _baseUniq-BK7Nhp_x.js} +1 -1
- package/dist/assets/{arc-D4iFrlhb.js → arc-Cm4HMXz6.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-f1neo7hg.js → architectureDiagram-Q4EWVU46-Co8QCowN.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-DWi9e9tG.js → blockDiagram-DXYQGD6D-Cudecf7x.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-DAdj49A2.js → c4Diagram-AHTNJAMY-4JeFoCHX.js} +1 -1
- package/dist/assets/{channel-B3VthT0m.js → channel-K7eT7Hjr.js} +1 -1
- package/dist/assets/{chunk-4BX2VUAB-BrPcAHV3.js → chunk-4BX2VUAB-BBjl6EVW.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-Bvopc1V9.js → chunk-4TB4RGXK-DBP9YnwO.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-Juw-7Z3i.js → chunk-55IACEB6--A_i9gwR.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-DVYP9tpB.js → chunk-EDXVE4YY-UBsL-3qr.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-VQ7VOXl3.js → chunk-FMBD7UC4-DBO0asmI.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-F8Rj4ZUZ.js → chunk-OYMX7WX6-DOCcEaE6.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-Dn7K3yi9.js → chunk-QZHKN3VN-B-j8QbQh.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-UjO-KTsn.js → chunk-YZCP3GAM-B0WO1U-U.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-Ba3tOmih.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-Ba3tOmih.js +1 -0
- package/dist/assets/clone-DjURvTeN.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-BDhNj8rq.js → cose-bilkent-S5V4N54A-DQgpss_a.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-BzdcDuyt.js → dagre-KV5264BT-CCJBKmKv.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-DP_SX65N.js → diagram-5BDNPKRD-DgUcnfkU.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-B-pBFw8Q.js → diagram-G4DWMVQ6-CRpVvexw.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-6XSfN1eN.js → diagram-MMDJMWI5-C3IhpsM-.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-DJvGxvpE.js → diagram-TYMM5635-BaVbsrwt.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-CpqdpGYj.js → erDiagram-SMLLAGMA-BxRoBUR8.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-C9m7wjwI.js → flowDiagram-DWJPFMVM-Du2lIzoM.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-CU0XOfY3.js → ganttDiagram-T4ZO3ILL-DR1pDqLK.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-ipyGULnP.js → gitGraphDiagram-UUTBAWPF-DHAmE20O.js} +1 -1
- package/dist/assets/{graph-BR2KmES9.js → graph-D8Yv0IU1.js} +1 -1
- package/dist/assets/{index-C-be29ey.js → index-AOmFG_I_.js} +1 -1
- package/dist/assets/{index-D5tSeMU7.js → index-B6ZWIxY0.js} +1 -1
- package/dist/assets/{index-D5YruOKD.js → index-Bq95HjYM.js} +1 -1
- package/dist/assets/{index--wgRW_OB.js → index-D0pvxpcl.js} +1 -1
- package/dist/assets/{index-DuZQ6FqR.js → index-D2c77__8.js} +1 -1
- package/dist/assets/{index-C16fiVhv.js → index-DgtUyP57.js} +1 -1
- package/dist/assets/{index-DidP9FCD.js → index-Dpu92rw_.js} +2 -2
- package/dist/assets/{index-OWzSX2T8.js → index-cCB-lhEO.js} +1 -1
- package/dist/assets/{infoDiagram-42DDH7IO-BQWQjfFX.js → infoDiagram-42DDH7IO-BAqRIm2G.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-Df-KH_Hz.js → ishikawaDiagram-UXIWVN3A-CzH4tezb.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-CMbB_4G9.js → journeyDiagram-VCZTEJTY-Qs4eiyuI.js} +1 -1
- package/dist/assets/{jszip.min-B6k2t9Ch.js → jszip.min-C0OccDsk.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-CImdlKN3.js → kanban-definition-6JOO6SKY-BAjl0gpV.js} +1 -1
- package/dist/assets/{layout-DQl4aw09.js → layout-Cf6cd9xE.js} +1 -1
- package/dist/assets/{linear-CLD3aPlK.js → linear-PzdCkji6.js} +1 -1
- package/dist/assets/mermaid.core-DV9745Lq.js +7 -0
- package/dist/assets/{min-_D86henJ.js → min-CHgWxbzS.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-2JVcu1nk.js → mindmap-definition-QFDTVHPH-CHUjMdpi.js} +1 -1
- package/dist/assets/{pieDiagram-DEJITSTG-Dik-m5u7.js → pieDiagram-DEJITSTG-D_r2QC0f.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-Bm5l40Jq.js → quadrantDiagram-34T5L4WZ-DPzGygdN.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-Ck9w5rTj.js → requirementDiagram-MS252O5E-BA2ynnGb.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-BbGVCFEf.js → sankeyDiagram-XADWPNL6-XHVvXuqU.js} +1 -1
- package/dist/assets/{seqResourceLoaders-DFrHmgnK.css → seqResourceLoaders-0RXZfUKp.css} +1 -1
- package/dist/assets/seqResourceLoaders-kBEzcKLh.js +2 -0
- package/dist/assets/{sequenceDiagram-FGHM5R23-vjvEdksk.js → sequenceDiagram-FGHM5R23-C47sA8YH.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-DTqMoiB2.js → stateDiagram-FHFEXIEX-BOmer839.js} +1 -1
- package/dist/assets/{stateDiagram-v2-QKLJ7IA2-CNbAwnhu.js → stateDiagram-v2-QKLJ7IA2-D-kVvTHA.js} +1 -1
- package/dist/assets/{timeline-definition-GMOUNBTQ-Cw8O-52d.js → timeline-definition-GMOUNBTQ-CTAKVZgs.js} +1 -1
- package/dist/assets/{vendor-antd-DI7JL-mE.js → vendor-antd-BuJ6oz45.js} +2 -2
- package/dist/assets/{vendor-codemirror-B9c49dtM.js → vendor-codemirror-D2KAW4ty.js} +1 -1
- package/dist/assets/{vendor-mdxeditor-B6cpBtIE.js → vendor-mdxeditor-BsFVVUcj.js} +2 -2
- package/dist/assets/{vendor-qrcode-Dn90-s-u.js → vendor-qrcode-BpxSq04x.js} +1 -1
- package/dist/assets/{vendor-virtuoso-CpdSKD_6.js → vendor-virtuoso-an4WvAvS.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-DCuqymb7.js → vennDiagram-DHZGUBPP-CuXzoroh.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-BJ20wJUU.js → wardley-RL74JXVD-CFCmxnck.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-Vz6US3qt.js → wardleyDiagram-NUSXRM2D-gfhDB9SR.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-CzkLKrmi.js → xychartDiagram-5P7HB3ND-X-pj66qI.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/proxy.js +115 -11
- package/server/routes/preferences.js +184 -1
- package/server/server.js +12 -1
- package/src/utils/contentFilter.js +17 -1
- package/dist/assets/App-DRsuJXEw.js +0 -2
- package/dist/assets/App-dYPa5-df.css +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
|
@@ -8,6 +8,8 @@ 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 } from './lib/proxy-retry.js';
|
|
11
13
|
|
|
12
14
|
// Setup interceptor to patch fetch
|
|
13
15
|
setupInterceptor();
|
|
@@ -111,27 +113,35 @@ export function startProxy() {
|
|
|
111
113
|
headers: headers,
|
|
112
114
|
};
|
|
113
115
|
|
|
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
116
|
// 走用户的网络代理:Node 内置全局 fetch 既不读 http_proxy/https_proxy,也看不到
|
|
123
117
|
// userland undici 的 setGlobalDispatcher,必须把代理 dispatcher 显式传进来,否则
|
|
124
118
|
// 上游请求会绕过代理直连 api.anthropic.com(详见 lib/proxy-env.js 注释)。
|
|
125
119
|
const proxyDispatcher = getProxyDispatcher();
|
|
126
|
-
if (proxyDispatcher) {
|
|
127
|
-
fetchOptions.dispatcher = proxyDispatcher;
|
|
128
|
-
}
|
|
129
120
|
|
|
130
121
|
// 拼接完整 URL,保留 originalBaseUrl 中的路径前缀
|
|
131
122
|
const cleanBase = originalBaseUrl.endsWith('/') ? originalBaseUrl.slice(0, -1) : originalBaseUrl;
|
|
132
123
|
const cleanReq = req.url.startsWith('/') ? req.url.slice(1) : req.url;
|
|
133
124
|
const fullUrl = `${cleanBase}/${cleanReq}`;
|
|
134
125
|
|
|
126
|
+
// 大模型 API 请求(/v1/messages 等)走重试引擎:serial/race/stagger 重试。
|
|
127
|
+
// 重试引擎内部用 x-cc-viewer-trace 头让 interceptor 记录每次重试尝试到会话日志(网络视图数据源),
|
|
128
|
+
// 模型替换由重试引擎用 resolveProfileModel 纯函数完成(interceptor 对 trace 请求会再跑一次,幂等 no-op)。
|
|
129
|
+
// 非大模型 API 请求走原逻辑(同样用 x-cc-viewer-trace 让 interceptor 记录,但不经重试引擎)。
|
|
130
|
+
if (isAnthropicApiPath(fullUrl)) {
|
|
131
|
+
await handleLlmApiRequest(req, res, fullUrl, fetchOptions, body, proxyDispatcher);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── 非大模型 API:原逻辑(透传,interceptor 记录)──
|
|
136
|
+
fetchOptions.headers['x-cc-viewer-trace'] = 'true';
|
|
137
|
+
|
|
138
|
+
if (body.length > 0) {
|
|
139
|
+
fetchOptions.body = body;
|
|
140
|
+
}
|
|
141
|
+
if (proxyDispatcher) {
|
|
142
|
+
fetchOptions.dispatcher = proxyDispatcher;
|
|
143
|
+
}
|
|
144
|
+
|
|
135
145
|
const response = await fetch(fullUrl, fetchOptions);
|
|
136
146
|
|
|
137
147
|
// fetch 自动解压,需移除编码相关 header 避免客户端重复解压
|
|
@@ -201,3 +211,97 @@ export function startProxy() {
|
|
|
201
211
|
});
|
|
202
212
|
});
|
|
203
213
|
}
|
|
214
|
+
|
|
215
|
+
// ── 大模型 API 请求处理:重试引擎 ──────────────────────
|
|
216
|
+
// mode=off 时退化为单次 fetch(与原透传一致)。
|
|
217
|
+
// 重试配置由 interceptor._retryConfigState 提供(live binding,watchFile 热刷新),
|
|
218
|
+
// 每请求取最新值,UI 改 retry-config.json 后下一个请求即生效,无需重启。
|
|
219
|
+
const _retryConfigGetter = () => interceptor._retryConfigState;
|
|
220
|
+
|
|
221
|
+
async function handleLlmApiRequest(req, res, fullUrl, fetchOptions, body, proxyDispatcher) {
|
|
222
|
+
const retryConfig = _retryConfigGetter(); // live binding:每请求取最新重试配置
|
|
223
|
+
const profile = interceptor._activeProfile || null;
|
|
224
|
+
|
|
225
|
+
// 清理可能的 trace 头(executeRequest 会重新加,避免旧值干扰);interceptor 会正常记录请求
|
|
226
|
+
// 同时清理 hop-by-hop / 传输编码头:body 已被 buffer 成完整 Buffer,
|
|
227
|
+
// 残留的 transfer-encoding:chunked / content-length 会让 undici 拒绝(invalid transfer-encoding header)
|
|
228
|
+
const retryFetchOptions = {
|
|
229
|
+
method: fetchOptions.method,
|
|
230
|
+
headers: { ...fetchOptions.headers },
|
|
231
|
+
};
|
|
232
|
+
delete retryFetchOptions.headers['x-cc-viewer-trace'];
|
|
233
|
+
delete retryFetchOptions.headers['transfer-encoding'];
|
|
234
|
+
delete retryFetchOptions.headers['Transfer-Encoding'];
|
|
235
|
+
delete retryFetchOptions.headers['connection'];
|
|
236
|
+
delete retryFetchOptions.headers['Connection'];
|
|
237
|
+
delete retryFetchOptions.headers['content-length'];
|
|
238
|
+
delete retryFetchOptions.headers['Content-Length'];
|
|
239
|
+
if (body.length > 0) {
|
|
240
|
+
retryFetchOptions.body = body;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Client-disconnect propagation: without this, a client that gave up leaves
|
|
244
|
+
// the retry loop hammering (and billing) the upstream for up to
|
|
245
|
+
// maxRetryDurationMs. res 'close' before the response is finished means the
|
|
246
|
+
// client is gone → abort every in-flight/queued attempt via the engine.
|
|
247
|
+
const clientAbort = new AbortController();
|
|
248
|
+
res.on('close', () => {
|
|
249
|
+
if (!res.writableEnded) {
|
|
250
|
+
try { clientAbort.abort(); } catch { /* best effort */ }
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// 执行带重试的请求
|
|
255
|
+
const result = await executeRequest({
|
|
256
|
+
url: fullUrl,
|
|
257
|
+
fetchOptions: retryFetchOptions,
|
|
258
|
+
retryConfig,
|
|
259
|
+
ctx: { dispatcher: proxyDispatcher, profile, signal: clientAbort.signal },
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const { response, attempts, finalStatus } = result;
|
|
263
|
+
|
|
264
|
+
// Client already gone: nothing to write; the engine has aborted upstream work.
|
|
265
|
+
if (clientAbort.signal.aborted || res.writableEnded) {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// 注入 X-Forward-Attempts 头(告知客户端本次重试了几次,与 llm-retry-proxy 一致)
|
|
270
|
+
const responseHeaders = {};
|
|
271
|
+
if (response.headers && typeof response.headers.entries === 'function') {
|
|
272
|
+
for (const [key, value] of response.headers.entries()) {
|
|
273
|
+
if (key.toLowerCase() !== 'content-encoding' && key.toLowerCase() !== 'transfer-encoding' && key.toLowerCase() !== 'content-length') {
|
|
274
|
+
responseHeaders[key] = value;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
responseHeaders['x-forward-attempts'] = String(attempts);
|
|
279
|
+
|
|
280
|
+
// 网络异常(finalStatus=0)或竞速全失败:返回 502 + Proxy Error,与原 catch 分支一致
|
|
281
|
+
if (finalStatus === 0) {
|
|
282
|
+
res.writeHead(502, { 'x-forward-attempts': String(attempts) });
|
|
283
|
+
res.end('Proxy Error');
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// 响应客户端
|
|
288
|
+
res.writeHead(finalStatus, responseHeaders);
|
|
289
|
+
|
|
290
|
+
if (response.body) {
|
|
291
|
+
const { Readable, pipeline } = await import('node:stream');
|
|
292
|
+
// @ts-ignore
|
|
293
|
+
const nodeStream = Readable.fromWeb(response.body);
|
|
294
|
+
nodeStream.on('error', () => {});
|
|
295
|
+
pipeline(nodeStream, res, (err) => {
|
|
296
|
+
if (err && process.env.CCV_DEBUG) {
|
|
297
|
+
console.error('[CC-Viewer Proxy] Stream pipeline error:', err.message);
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
} else if (response.text) {
|
|
301
|
+
// 竞速失败兜底 response:用 text() 取 body
|
|
302
|
+
const text = await response.text();
|
|
303
|
+
res.end(text);
|
|
304
|
+
} else {
|
|
305
|
+
res.end();
|
|
306
|
+
}
|
|
307
|
+
}
|
|
@@ -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,仿 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
|
];
|
package/server/server.js
CHANGED
|
@@ -76,11 +76,20 @@ function execWithStdin(cmd, args, input, options) {
|
|
|
76
76
|
child.stdin.end();
|
|
77
77
|
});
|
|
78
78
|
}
|
|
79
|
-
import { _initPromise, _projectName, _logDir, _v2Writer, streamingState, resetStreamingState, PROFILE_PATH, setLivePort, getImLiveText, resetImLiveText, markSessionStart } from './interceptor.js';
|
|
79
|
+
import { _initPromise, _projectName, _logDir, _v2Writer, streamingState, resetStreamingState, PROFILE_PATH, RETRY_CONFIG_PATH, _retryConfigState, setLivePort, getImLiveText, resetImLiveText, markSessionStart } from './interceptor.js';
|
|
80
80
|
import { V2LiveFeed } from './lib/v2/live-feed.js';
|
|
81
81
|
import { sanitizePathComponent } from './lib/v2/layout.js';
|
|
82
82
|
import { maybeResumeConvert } from './lib/v2/convert-manager.js';
|
|
83
83
|
import { LOG_DIR, setLogDir, getClaudeConfigDir, isBrowserOpenSuppressed } from '../findcc.js';
|
|
84
|
+
import { loadRetryConfig } from './lib/proxy-retry.js';
|
|
85
|
+
|
|
86
|
+
// 代理重试配置:由 interceptor._retryConfigState 提供(live binding,watchFile 热刷新)。
|
|
87
|
+
// mode 默认 off(向后兼容,不重试)。env 是启动默认/兜底,retry-config.json 覆盖 env。
|
|
88
|
+
// 启动日志读一次首屏值;运行时 proxy.js 每请求读 interceptor._retryConfigState 取最新。
|
|
89
|
+
const _startupRetryConfig = _retryConfigState || loadRetryConfig();
|
|
90
|
+
if (_startupRetryConfig.mode !== 'off' && process.env.CCV_DEBUG) {
|
|
91
|
+
console.error(`[CC Viewer] Proxy retry: mode=${_startupRetryConfig.mode}, maxRetries=${_startupRetryConfig.maxRetries}, interval=${_startupRetryConfig.retryIntervalMs}ms`);
|
|
92
|
+
}
|
|
84
93
|
import { t, getLang, setLang } from './i18n.js';
|
|
85
94
|
import { loadAuthConfig, loadAuthState, saveAuthConfig, clearProjectOverride, generatePassword, decideAuth, parseCookies, renderLoginPage, localeFromAcceptLanguage } from './lib/auth.js';
|
|
86
95
|
import { checkAndUpdate } from './lib/updater.js';
|
|
@@ -520,6 +529,7 @@ const deps = {
|
|
|
520
529
|
get writeToPty() { return _writeToPty; },
|
|
521
530
|
get onPtyData() { return _onPtyData; },
|
|
522
531
|
get statsWorker() { return statsWorker; },
|
|
532
|
+
get retryConfig() { return _retryConfigState; },
|
|
523
533
|
get workspaceLaunched() { return _workspaceLaunched; },
|
|
524
534
|
setWorkspaceLaunched(v) { _workspaceLaunched = v; },
|
|
525
535
|
get launchCallback() { return _launchCallback; },
|
|
@@ -2306,6 +2316,7 @@ async function _doStop() {
|
|
|
2306
2316
|
// 早期请求向已关闭的端口 POST 丢包。新 startViewer 的 listen 回调会再次 setLivePort
|
|
2307
2317
|
setLivePort(null);
|
|
2308
2318
|
try { unwatchFile(PROFILE_PATH); } catch {} // 清理 interceptor 的 StatWatcher
|
|
2319
|
+
try { unwatchFile(RETRY_CONFIG_PATH); } catch {} // 清理 retry-config 的 StatWatcher
|
|
2309
2320
|
}
|
|
2310
2321
|
|
|
2311
2322
|
// ─── 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
|
/**
|