cc-viewer 1.6.271 → 1.6.273
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 +44 -43
- package/concepts/ar/GlobalSettings.md +2 -2
- package/concepts/ar/MainAgent.md +1 -1
- package/concepts/ar/ProxySwitch.md +1 -1
- package/concepts/da/GlobalSettings.md +2 -2
- package/concepts/da/MainAgent.md +1 -1
- package/concepts/da/ProxySwitch.md +1 -1
- package/concepts/de/GlobalSettings.md +2 -2
- package/concepts/de/MainAgent.md +1 -1
- package/concepts/de/ProxySwitch.md +1 -1
- package/concepts/en/GlobalSettings.md +2 -2
- package/concepts/en/MainAgent.md +1 -1
- package/concepts/en/ProxySwitch.md +1 -1
- package/concepts/es/GlobalSettings.md +2 -2
- package/concepts/es/MainAgent.md +1 -1
- package/concepts/es/ProxySwitch.md +1 -1
- package/concepts/fr/GlobalSettings.md +2 -2
- package/concepts/fr/MainAgent.md +1 -1
- package/concepts/fr/ProxySwitch.md +1 -1
- package/concepts/it/GlobalSettings.md +2 -2
- package/concepts/it/MainAgent.md +1 -1
- package/concepts/it/ProxySwitch.md +1 -1
- package/concepts/ja/GlobalSettings.md +2 -2
- package/concepts/ja/MainAgent.md +1 -1
- package/concepts/ja/ProxySwitch.md +1 -1
- package/concepts/ko/GlobalSettings.md +2 -2
- package/concepts/ko/MainAgent.md +1 -1
- package/concepts/ko/ProxySwitch.md +1 -1
- package/concepts/no/GlobalSettings.md +2 -2
- package/concepts/no/MainAgent.md +1 -1
- package/concepts/no/ProxySwitch.md +1 -1
- package/concepts/pl/GlobalSettings.md +2 -2
- package/concepts/pl/MainAgent.md +1 -1
- package/concepts/pl/ProxySwitch.md +1 -1
- package/concepts/pt-BR/GlobalSettings.md +2 -2
- package/concepts/pt-BR/MainAgent.md +1 -1
- package/concepts/pt-BR/ProxySwitch.md +1 -1
- package/concepts/ru/GlobalSettings.md +2 -2
- package/concepts/ru/MainAgent.md +1 -1
- package/concepts/ru/ProxySwitch.md +1 -1
- package/concepts/th/GlobalSettings.md +2 -2
- package/concepts/th/MainAgent.md +1 -1
- package/concepts/th/ProxySwitch.md +1 -1
- package/concepts/tr/GlobalSettings.md +2 -2
- package/concepts/tr/MainAgent.md +1 -1
- package/concepts/tr/ProxySwitch.md +1 -1
- package/concepts/uk/GlobalSettings.md +2 -2
- package/concepts/uk/MainAgent.md +1 -1
- package/concepts/uk/ProxySwitch.md +1 -1
- package/concepts/zh/GlobalSettings.md +2 -2
- package/concepts/zh/MainAgent.md +1 -1
- package/concepts/zh/ProxySwitch.md +1 -1
- package/concepts/zh-TW/GlobalSettings.md +2 -2
- package/concepts/zh-TW/MainAgent.md +1 -1
- package/concepts/zh-TW/ProxySwitch.md +1 -1
- package/dist/assets/App-DLdA05Yx.js +1 -0
- package/dist/assets/App-TGGslOeT.css +1 -0
- package/dist/assets/{MdxEditorPanel-B7oOvR3k.js → MdxEditorPanel-D2Wt6kg-.js} +1 -1
- package/dist/assets/Mobile-Dhkz2rBB.js +1 -0
- package/dist/assets/{_baseUniq-Dgkw4IXM.js → _baseUniq-CPJrFyUF.js} +1 -1
- package/dist/assets/{arc-AiHQLijx.js → arc-BLBrFElt.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-CPRvAIHK.js → architectureDiagram-Q4EWVU46-CbnBsMiQ.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-CK2cwrfX.js → blockDiagram-DXYQGD6D-0mYr6-Fl.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-BP-UBbgv.js → c4Diagram-AHTNJAMY-CS7vcr0z.js} +1 -1
- package/dist/assets/{channel-Ny3Nm_-t.js → channel-CF3zZzSR.js} +1 -1
- package/dist/assets/{chunk-4BX2VUAB-DdsULqPZ.js → chunk-4BX2VUAB-1FZYtnJ7.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-BDSjQHh0.js → chunk-4TB4RGXK-COs1qui5.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-DrKr3wBa.js → chunk-55IACEB6-p77Qw3wN.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-o_0SUbAB.js → chunk-EDXVE4YY-5qaIrQKg.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-Ca_AgqWi.js → chunk-FMBD7UC4-DmCR8mDZ.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-CyWWbq5o.js → chunk-OYMX7WX6-D6xDfgW3.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-5rXHErSL.js → chunk-QZHKN3VN-B6cmUU0N.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-DznXBadU.js → chunk-YZCP3GAM-l-OyOqnn.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-BiCYgTHO.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-BiCYgTHO.js +1 -0
- package/dist/assets/clone-ChTCnPsO.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-BhGyix0v.js → cose-bilkent-S5V4N54A-CEzaS8XS.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-CzzHxIvc.js → dagre-KV5264BT-B3U2njWW.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-tu3BXl0c.js → diagram-5BDNPKRD-BRSDbyBr.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-C6WkK7sj.js → diagram-G4DWMVQ6-BZfOi9B7.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-DBeD_WW-.js → diagram-MMDJMWI5-CWpb3Cg0.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-BXUyHHJ4.js → diagram-TYMM5635-CTJyBSVj.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-Bye5tnW2.js → erDiagram-SMLLAGMA-CHtTRd5S.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-C3pYOs38.js → flowDiagram-DWJPFMVM-Bda8X-WJ.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-DxXkI_FW.js → ganttDiagram-T4ZO3ILL-CPfnGu5V.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-nsxsXsGX.js → gitGraphDiagram-UUTBAWPF-B5QxesQg.js} +1 -1
- package/dist/assets/{graph-Da-Z9hB7.js → graph-ChltdhTU.js} +1 -1
- package/dist/assets/{index-4gmR7Eun.js → index-B7lK5fJz.js} +1 -1
- package/dist/assets/{index-C8w5Sxw3.js → index-BK4sui_O.js} +1 -1
- package/dist/assets/{index-CA8JGh5J.js → index-C2fhupP6.js} +1 -1
- package/dist/assets/{index-CLfbZzwF.js → index-C3dxIBgt.js} +2 -2
- package/dist/assets/{index-D7XF7UJ8.js → index-C5PA4OJg.js} +1 -1
- package/dist/assets/{index-C0PhJcXG.js → index-CfEkC3bc.js} +1 -1
- package/dist/assets/{index-Brh2V8V0.js → index-DyGa-jNv.js} +1 -1
- package/dist/assets/{index-CsuhosSl.js → index-yClPXlMf.js} +1 -1
- package/dist/assets/{infoDiagram-42DDH7IO-Ca9j90t5.js → infoDiagram-42DDH7IO-CD4TS4O2.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-DhjV0XPD.js → ishikawaDiagram-UXIWVN3A-jhiYabQ7.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-CRSHLZPV.js → journeyDiagram-VCZTEJTY-BDzIXxxt.js} +1 -1
- package/dist/assets/{jszip.min-CcCCdMNW.js → jszip.min-CuGGBMI4.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-Bg0CUwgc.js → kanban-definition-6JOO6SKY-D34MYATQ.js} +1 -1
- package/dist/assets/{layout-CWNu13XT.js → layout-D6sLAapX.js} +1 -1
- package/dist/assets/{linear-Dcmw1639.js → linear-CFV4P-wn.js} +1 -1
- package/dist/assets/{mermaid.core-1heNIJ5f.js → mermaid.core-YmJi7T-s.js} +2 -2
- package/dist/assets/{min-CC9CkAxn.js → min-akJQqRMn.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-Gr0ex_Ny.js → mindmap-definition-QFDTVHPH-w5zaJyrN.js} +1 -1
- package/dist/assets/{pieDiagram-DEJITSTG-D7P3sUJY.js → pieDiagram-DEJITSTG-B6EM4Ow6.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-Bov3lcpV.js → quadrantDiagram-34T5L4WZ-2TmBxFy-.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-BcLptaOU.js → requirementDiagram-MS252O5E-EOjvbxUy.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-B2qAUsON.js → sankeyDiagram-XADWPNL6-BmTQD5eT.js} +1 -1
- package/dist/assets/seqResourceLoaders-Dov_BuQp.js +2 -0
- package/dist/assets/{seqResourceLoaders-N07Gfom9.css → seqResourceLoaders-DwpfKCub.css} +2 -2
- package/dist/assets/{sequenceDiagram-FGHM5R23-Do62Uz-a.js → sequenceDiagram-FGHM5R23-CLjtqA1D.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-Wu8aqa8C.js → stateDiagram-FHFEXIEX-Cbq90iZ8.js} +1 -1
- package/dist/assets/{stateDiagram-v2-QKLJ7IA2-BfYq7Jgo.js → stateDiagram-v2-QKLJ7IA2-BKDg-3t_.js} +1 -1
- package/dist/assets/{timeline-definition-GMOUNBTQ-DYfz5xD6.js → timeline-definition-GMOUNBTQ-f3kEDay0.js} +1 -1
- package/dist/assets/{vendor-antd-BG1SvzuN.js → vendor-antd-5xE7sz6B.js} +1 -1
- package/dist/assets/{vendor-codemirror-8NDhydlF.js → vendor-codemirror-ib-jPbXC.js} +1 -1
- package/dist/assets/{vendor-mdxeditor-BB4hhpxM.js → vendor-mdxeditor-BdKMdw6O.js} +2 -2
- package/dist/assets/{vendor-qrcode-DMsNGQ10.js → vendor-qrcode-vKlE-WYu.js} +1 -1
- package/dist/assets/{vendor-virtuoso-BUT96ALa.js → vendor-virtuoso-DOIfjLfU.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-CGr-cc7e.js → vennDiagram-DHZGUBPP-0GDSFVnH.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-BN899vMf.js → wardley-RL74JXVD-B2rj5j7G.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-xUsI1E7h.js → wardleyDiagram-NUSXRM2D-COVTciJP.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-woQrslzB.js → xychartDiagram-5P7HB3ND-DXoLnGxX.js} +1 -1
- package/dist/index.html +4 -4
- package/findcc.js +26 -7
- package/interceptor.js +11 -1061
- package/package.json +6 -12
- package/plugins/.gitkeep +0 -0
- package/server/_paths.js +35 -0
- package/server/interceptor.js +1057 -0
- package/{lib → server/lib}/approval-modal-prefs.js +2 -1
- package/{lib → server/lib}/ask-store.js +51 -10
- package/server/lib/cli-inject.js +81 -0
- package/{lib → server/lib}/context-watcher.js +41 -1
- package/{lib → server/lib}/delta-reconstructor.js +1 -0
- package/{lib → server/lib}/enrich-plan-input.js +2 -2
- package/{lib → server/lib}/ensure-hooks.js +76 -12
- package/{lib → server/lib}/extract-plugin-name.mjs +1 -1
- package/{lib → server/lib}/file-access-policy.js +2 -2
- package/{lib → server/lib}/jsonl-archive.js +1 -1
- package/{lib → server/lib}/plugin-loader.js +5 -4
- package/{lib → server/lib}/plugin-manager.js +1 -1
- package/{lib → server/lib}/sdk-manager.js +1 -1
- package/{lib → server/lib}/session-transcript-reader.js +1 -1
- package/{lib → server/lib}/terminal-env.js +1 -1
- package/{lib → server/lib}/tools-xml-formatter.js +2 -1
- package/{lib → server/lib}/updater.js +3 -2
- package/{lib → server/lib}/voice-pack-events.js +4 -3
- package/{lib → server/lib}/voice-pack-manager.js +4 -8
- package/{proxy.js → server/proxy.js} +1 -1
- package/{pty-manager.js → server/pty-manager.js} +19 -5
- package/{scratch-pty-manager.js → server/scratch-pty-manager.js} +16 -4
- package/server/server.js +5467 -0
- package/{workspace-registry.js → server/workspace-registry.js} +2 -2
- package/server.js +5 -5459
- package/dist/assets/App-DOYmReD4.css +0 -1
- package/dist/assets/App-LEYaH-CM.js +0 -1
- package/dist/assets/Mobile-Cwf21Pmq.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-CLYcbnwx.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-CLYcbnwx.js +0 -1
- package/dist/assets/clone-5GFhU8Pv.js +0 -1
- package/dist/assets/seqResourceLoaders-CmFg0jyW.js +0 -2
- /package/{i18n.js → server/i18n.js} +0 -0
- /package/{lib → server/lib}/ask-bridge.js +0 -0
- /package/{lib → server/lib}/ask-constants.js +0 -0
- /package/{lib → server/lib}/ccv-editor.js +0 -0
- /package/{lib → server/lib}/claude-md-discovery.js +0 -0
- /package/{lib → server/lib}/file-api.js +0 -0
- /package/{lib → server/lib}/git-diff.js +0 -0
- /package/{lib → server/lib}/interceptor-core.js +0 -0
- /package/{lib → server/lib}/kv-cache-analyzer.js +0 -0
- /package/{lib → server/lib}/log-management.js +0 -0
- /package/{lib → server/lib}/log-stream.js +0 -0
- /package/{lib → server/lib}/log-watcher.js +0 -0
- /package/{lib → server/lib}/perm-bridge.js +0 -0
- /package/{lib → server/lib}/proxy-env.js +0 -0
- /package/{lib → server/lib}/proxy-errors.js +0 -0
- /package/{lib → server/lib}/sdk-adapter.js +0 -0
- /package/{lib → server/lib}/skills-api.js +0 -0
- /package/{lib → server/lib}/sse-backpressure.js +0 -0
- /package/{lib → server/lib}/stats-worker.js +0 -0
- /package/{lib → server/lib}/team-runtime.js +0 -0
- /package/{lib → server/lib}/turn-end-bridge.js +0 -0
- /package/{lib → server/lib}/user-profile.js +0 -0
- /package/{lib → server/lib}/zip-safety.js +0 -0
|
@@ -0,0 +1,1057 @@
|
|
|
1
|
+
// LLM Request Interceptor
|
|
2
|
+
// 拦截并记录所有Claude API请求
|
|
3
|
+
// Wire format 协议详见 docs/WIRE_FORMAT.md(mainAgent entry 形态 / 关键字段 / 信号链路)
|
|
4
|
+
|
|
5
|
+
// 非交互命令(如 claude -v, claude --help)不需要启动 ccv
|
|
6
|
+
const _ccvSkipArgs = ['--version', '-v', '--v', '--help', '-h', 'doctor', 'install', 'update', 'upgrade', 'auth', 'setup-token', 'agents', 'plugin', 'plugins', 'mcp'];
|
|
7
|
+
const _ccvSkip = _ccvSkipArgs.includes(process.argv[2]);
|
|
8
|
+
|
|
9
|
+
import './lib/proxy-env.js';
|
|
10
|
+
import { appendFileSync, mkdirSync, readFileSync, writeFileSync, statSync, unlinkSync, existsSync, watchFile } from 'node:fs';
|
|
11
|
+
import { renameSyncWithRetry } from './lib/file-api.js';
|
|
12
|
+
import http from 'node:http';
|
|
13
|
+
import https from 'node:https';
|
|
14
|
+
import { homedir } from 'node:os';
|
|
15
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
16
|
+
import { dirname, join, basename } from 'node:path';
|
|
17
|
+
import { LOG_DIR } from '../findcc.js';
|
|
18
|
+
import { assembleStreamMessage, createStreamAssembler, cleanupTempFiles, findRecentLog, isAnthropicApiPath, isMainAgentRequest, rotateLogFile, fingerprintMsg } from './lib/interceptor-core.js';
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
23
|
+
const __dirname = dirname(__filename);
|
|
24
|
+
|
|
25
|
+
// Live-streaming 用的端口:由 server.js 在 listen 成功后通过 setLivePort 注入。
|
|
26
|
+
// 不用 process.env.CCVIEWER_PORT 是为了避免主进程 env 污染被 child_process.spawn
|
|
27
|
+
// 继承到 Bash 工具子进程 / MCP server / Electron tab-worker 等无关进程。
|
|
28
|
+
let _livePort = null;
|
|
29
|
+
let _liveProtocol = 'http';
|
|
30
|
+
export function setLivePort(port, protocol) { _livePort = port ? String(port) : null; _liveProtocol = protocol || 'http'; }
|
|
31
|
+
|
|
32
|
+
// 流式请求的实时状态(供 server.js SSE 推送)
|
|
33
|
+
export const streamingState = { active: false, requestId: null, startTime: null, model: null, bytesReceived: 0, chunksReceived: 0 };
|
|
34
|
+
export function resetStreamingState() {
|
|
35
|
+
streamingState.active = false;
|
|
36
|
+
streamingState.requestId = null;
|
|
37
|
+
streamingState.startTime = null;
|
|
38
|
+
streamingState.model = null;
|
|
39
|
+
streamingState.bytesReceived = 0;
|
|
40
|
+
streamingState.chunksReceived = 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 缓存从请求 headers 中提取的 API Key 或 Authorization header
|
|
44
|
+
export let _cachedApiKey = null;
|
|
45
|
+
export let _cachedAuthHeader = null;
|
|
46
|
+
// 缓存从请求 body 中提取的模型名,供翻译接口使用
|
|
47
|
+
export let _cachedModel = null;
|
|
48
|
+
// 缓存 haiku 模型名(从实际请求中捕获),翻译接口优先使用
|
|
49
|
+
export let _cachedHaikuModel = null;
|
|
50
|
+
|
|
51
|
+
// Proxy profile hot-switch support
|
|
52
|
+
// 数据模型:
|
|
53
|
+
// profile.json (全局共享): 仅存 profiles 列表,watchFile 跨 ccv 进程同步 CRUD。
|
|
54
|
+
// 兼容老数据:若文件里仍有 active 字段,读为"全局回退默认";但本模块不再写它。
|
|
55
|
+
// <projectDir>/active-profile.json (每 workspace 独占): 仅存 { activeId };
|
|
56
|
+
// 切换 active 只影响当前 ccv 进程的 workspace,不污染其他实例。
|
|
57
|
+
// profile.json 存放在 LOG_DIR 下,受 --log-dir / CCV_LOG_DIR 影响
|
|
58
|
+
const PROFILE_PATH = join(LOG_DIR, 'profile.json');
|
|
59
|
+
let _activeProfile = null; // { id, name, baseURL?, apiKey?, models?, activeModel? }
|
|
60
|
+
|
|
61
|
+
// 启动时捕获的原始配置(首次 API 请求时记录,不可变)
|
|
62
|
+
let _defaultConfig = null; // { origin, authType, model }
|
|
63
|
+
|
|
64
|
+
function _getActiveProfileFilePath() {
|
|
65
|
+
// _projectName/_logDir 声明在 ~line 218;本函数只会在这些变量初始化后被调用
|
|
66
|
+
// (_loadProxyProfile 的初始调用被挪到 line ~237 之后;watchFile 回调、HTTP handler 也都在之后)
|
|
67
|
+
if (!_projectName || !_logDir) return null;
|
|
68
|
+
return join(_logDir, 'active-profile.json');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function _readWorkspaceActiveId() {
|
|
72
|
+
const p = _getActiveProfileFilePath();
|
|
73
|
+
if (!p) return null;
|
|
74
|
+
try {
|
|
75
|
+
if (existsSync(p)) {
|
|
76
|
+
const data = JSON.parse(readFileSync(p, 'utf-8'));
|
|
77
|
+
return typeof data?.activeId === 'string' ? data.activeId : null;
|
|
78
|
+
}
|
|
79
|
+
} catch { }
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function _writeWorkspaceActiveId(activeId) {
|
|
84
|
+
const p = _getActiveProfileFilePath();
|
|
85
|
+
if (!p) {
|
|
86
|
+
// 诊断用:能把"为什么 workspace 路径不可用"暴露到启动 ccv 的终端
|
|
87
|
+
console.error('[ccv proxy-profile] skip workspace write: ' +
|
|
88
|
+
`_projectName="${_projectName}" _logDir="${_logDir}" (both required)`);
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
93
|
+
const payload = { activeId: (activeId && typeof activeId === 'string') ? activeId : 'max' };
|
|
94
|
+
writeFileSync(p, JSON.stringify(payload, null, 2), { mode: 0o600 });
|
|
95
|
+
return true;
|
|
96
|
+
} catch (err) {
|
|
97
|
+
console.error('[ccv proxy-profile] workspace write failed:', p, err && err.message);
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function _loadProxyProfile() {
|
|
103
|
+
try {
|
|
104
|
+
const data = JSON.parse(readFileSync(PROFILE_PATH, 'utf-8'));
|
|
105
|
+
// active 解析优先级:workspace override > profile.json.active (兼容老数据 / 全局回退) > null
|
|
106
|
+
const wsActive = _readWorkspaceActiveId();
|
|
107
|
+
const activeId = wsActive || data.active;
|
|
108
|
+
const active = data.profiles?.find(p => p.id === activeId);
|
|
109
|
+
_activeProfile = (active && active.id !== 'max') ? active : null;
|
|
110
|
+
} catch (err) {
|
|
111
|
+
_activeProfile = null;
|
|
112
|
+
if (process.env.CCV_DEBUG_HOTSWITCH) {
|
|
113
|
+
console.error('[ccv hotswitch] _loadProxyProfile failed:', err && err.message);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 为 server.js::POST /api/proxy-profiles 使用,切换当前 workspace 的 active。
|
|
119
|
+
// 同时写两个位置,彼此互为兜底:
|
|
120
|
+
// (1) <logDir>/active-profile.json —— 每 workspace 独占,读取优先级最高
|
|
121
|
+
// (2) profile.json.active —— 全局默认,watchFile 跨实例同步;用作
|
|
122
|
+
// UI 在 workspace 文件读失败 / 不存在时的回落,避免"切换后立刻回切"的幽灵 revert
|
|
123
|
+
// 回落一致性:其他 ccv 实例如果自己 workspace 文件已存在,_loadProxyProfile 会优先用自己
|
|
124
|
+
// 的,不受这里改动影响;只有"从未切过"的实例会跟随最新全局默认(符合直觉)。
|
|
125
|
+
// 返回 { workspace: bool, profile: bool } 指示两条路径的落盘结果。
|
|
126
|
+
function setActiveProfileForWorkspace(activeId) {
|
|
127
|
+
const normalizedId = (activeId && typeof activeId === 'string') ? activeId : 'max';
|
|
128
|
+
const result = { workspace: false, profile: false };
|
|
129
|
+
|
|
130
|
+
// (1) workspace override
|
|
131
|
+
result.workspace = _writeWorkspaceActiveId(normalizedId);
|
|
132
|
+
|
|
133
|
+
// (2) profile.json.active —— 幂等更新,老数据兼容 + UI GET 回落兜底
|
|
134
|
+
try {
|
|
135
|
+
const data = existsSync(PROFILE_PATH)
|
|
136
|
+
? JSON.parse(readFileSync(PROFILE_PATH, 'utf-8'))
|
|
137
|
+
: { profiles: [{ id: 'max', name: 'Default' }] };
|
|
138
|
+
if (data.active !== normalizedId) {
|
|
139
|
+
data.active = normalizedId;
|
|
140
|
+
mkdirSync(dirname(PROFILE_PATH), { recursive: true });
|
|
141
|
+
writeFileSync(PROFILE_PATH, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
142
|
+
}
|
|
143
|
+
result.profile = true;
|
|
144
|
+
} catch { /* 双失败场景下 result 全 false,由调用方自行兜底 */ }
|
|
145
|
+
|
|
146
|
+
_loadProxyProfile(); // 立刻刷新本进程 _activeProfile
|
|
147
|
+
return result;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function getActiveProfileId() {
|
|
151
|
+
// UI 需要知道当前 workspace 的 active(优先 workspace 文件,回退 profile.json.active)
|
|
152
|
+
const ws = _readWorkspaceActiveId();
|
|
153
|
+
if (ws) return ws;
|
|
154
|
+
try {
|
|
155
|
+
const data = JSON.parse(readFileSync(PROFILE_PATH, 'utf-8'));
|
|
156
|
+
return data.active || 'max';
|
|
157
|
+
} catch { return 'max'; }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// _loadProxyProfile 的初始调用 + watchFile 挂载挪到 _projectName/_logDir 初始化之后
|
|
161
|
+
// (见 "初始化日志文件路径" 段后的 _kickoffProxyProfileWatcher 调用),避免 TDZ。
|
|
162
|
+
|
|
163
|
+
// 纯函数:把 headers 里任意大小写的 authorization / x-api-key 替换为 profile 的 apiKey;
|
|
164
|
+
// 两者都不存在时强制植入 x-api-key(第三方代理最常见的鉴权形式)。
|
|
165
|
+
// 返回 { headers, matchedAuthKey, matchedXApiKey },诊断日志据此判断是否真正写入。
|
|
166
|
+
function _replaceProxyAuthHeaders(headers, apiKey) {
|
|
167
|
+
const newHeaders = { ...headers };
|
|
168
|
+
let matchedAuthKey = null, matchedXApiKey = null;
|
|
169
|
+
for (const k of Object.keys(newHeaders)) {
|
|
170
|
+
const lk = k.toLowerCase();
|
|
171
|
+
if (lk === 'authorization') matchedAuthKey = k;
|
|
172
|
+
else if (lk === 'x-api-key') matchedXApiKey = k;
|
|
173
|
+
}
|
|
174
|
+
if (matchedAuthKey) newHeaders[matchedAuthKey] = `Bearer ${apiKey}`;
|
|
175
|
+
if (matchedXApiKey) newHeaders[matchedXApiKey] = apiKey;
|
|
176
|
+
if (!matchedAuthKey && !matchedXApiKey) newHeaders['x-api-key'] = apiKey;
|
|
177
|
+
return { headers: newHeaders, matchedAuthKey, matchedXApiKey };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export { _activeProfile, _defaultConfig, _loadProxyProfile, PROFILE_PATH, setActiveProfileForWorkspace, getActiveProfileId };
|
|
181
|
+
|
|
182
|
+
// 生成新的日志文件路径
|
|
183
|
+
function generateNewLogFilePath() {
|
|
184
|
+
const now = new Date();
|
|
185
|
+
const ts = now.getFullYear().toString()
|
|
186
|
+
+ String(now.getMonth() + 1).padStart(2, '0')
|
|
187
|
+
+ String(now.getDate()).padStart(2, '0')
|
|
188
|
+
+ '_'
|
|
189
|
+
+ String(now.getHours()).padStart(2, '0')
|
|
190
|
+
+ String(now.getMinutes()).padStart(2, '0')
|
|
191
|
+
+ String(now.getSeconds()).padStart(2, '0');
|
|
192
|
+
let cwd;
|
|
193
|
+
try { cwd = process.cwd(); } catch { cwd = homedir(); }
|
|
194
|
+
const projectName = basename(cwd).replace(/[^a-zA-Z0-9_\-\.]/g, '_');
|
|
195
|
+
const dir = join(LOG_DIR, projectName);
|
|
196
|
+
try { mkdirSync(dir, { recursive: true }); } catch { }
|
|
197
|
+
return { filePath: join(dir, `${projectName}_${ts}.jsonl`), dir, projectName };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Resume 状态(供 server.js 使用)
|
|
201
|
+
let _resumeState = null;
|
|
202
|
+
let _resolveChoice = null;
|
|
203
|
+
const _choicePromise = new Promise(resolve => { _resolveChoice = resolve; });
|
|
204
|
+
|
|
205
|
+
function resolveResumeChoice(choice) {
|
|
206
|
+
if (!_resumeState) return;
|
|
207
|
+
const { recentFile, tempFile } = _resumeState;
|
|
208
|
+
try {
|
|
209
|
+
if (choice === 'continue') {
|
|
210
|
+
// 将临时文件内容追加到旧日志
|
|
211
|
+
if (existsSync(tempFile)) {
|
|
212
|
+
const tempContent = readFileSync(tempFile, 'utf-8');
|
|
213
|
+
if (tempContent.trim()) {
|
|
214
|
+
appendFileSync(recentFile, tempContent);
|
|
215
|
+
}
|
|
216
|
+
unlinkSync(tempFile);
|
|
217
|
+
}
|
|
218
|
+
LOG_FILE = recentFile;
|
|
219
|
+
} else {
|
|
220
|
+
// new: 将临时文件 rename 为正式新日志文件名(空文件直接删除)
|
|
221
|
+
const newPath = tempFile.replace('_temp.jsonl', '.jsonl');
|
|
222
|
+
if (existsSync(tempFile)) {
|
|
223
|
+
const sz = statSync(tempFile).size;
|
|
224
|
+
if (sz > 0) {
|
|
225
|
+
renameSyncWithRetry(tempFile, newPath);
|
|
226
|
+
} else {
|
|
227
|
+
try { unlinkSync(tempFile); } catch { }
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
LOG_FILE = newPath;
|
|
231
|
+
}
|
|
232
|
+
} catch (err) {
|
|
233
|
+
console.error('[CC Viewer] resolveResumeChoice error:', err);
|
|
234
|
+
}
|
|
235
|
+
const result = { logFile: LOG_FILE };
|
|
236
|
+
_resumeState = null;
|
|
237
|
+
_resolveChoice(result);
|
|
238
|
+
return result;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Delta storage: 增量存储开关和状态(默认开启,设置 CCV_DISABLE_DELTA=1 关闭)
|
|
242
|
+
// 注意:delta 计算原本依赖 mainAgent 请求串行假设。实证发现 teammate 终止 / 多 SSE 通道
|
|
243
|
+
// 注入等情况会让两条 mainAgent 请求 30ms 内连续到达(前一条流式响应未完成时后一条已发起),
|
|
244
|
+
// 导致仅在 completed 时更新 _lastMessagesCount/_lastTailFp 出现状态滞后 → Plan C 漏检。
|
|
245
|
+
const _deltaStorageEnabled = process.env.CCV_DISABLE_DELTA !== '1';
|
|
246
|
+
// In-place last-msg replace 检测开关(默认开启,设置 CCV_DISABLE_TAIL_FP_CHECKPOINT=1 关闭)。
|
|
247
|
+
// 关闭后回退到旧行为(仅按长度算 delta,遇到末位原地替换会丢失"末位换内容"信息)。
|
|
248
|
+
const _tailFpCheckEnabled = process.env.CCV_DISABLE_TAIL_FP_CHECKPOINT !== '1';
|
|
249
|
+
// 这两个变量代表"截至本次请求开始前的最新已知态"。请求开始处理时即同步更新(eager),
|
|
250
|
+
// 不再等到 _commitDeltaState(completed 时执行)。Plan C 检测使用进入函数前的快照。
|
|
251
|
+
// 异常分支(请求失败 / 服务端不发送)不会回滚——下一个成功请求覆盖即可,状态不会永久错位。
|
|
252
|
+
// 命名说明:变量名保留 `_last` 前缀(历史命名),但语义已由"上次 commit 后"变为"上次见到的最新态"。
|
|
253
|
+
// 三路并发场景下,连续 3 个请求若 length 非单调(如 257→259→258),_lastMessagesCount 会跟随
|
|
254
|
+
// 最新一次 startRequest,可能让早到的更大值被覆盖;这种情况 Plan C 走 length 不等支路最终
|
|
255
|
+
// 命中 needsCheckpoint 写完整快照,client 拿到正确数据,不破坏正确性。
|
|
256
|
+
let _lastMessagesCount = 0; // 截至最近一次 startRequest 的完整 messages 数量(eager-updated)
|
|
257
|
+
let _lastTailFp = ''; // 截至最近一次 startRequest 的末位 message 指纹(eager-updated)
|
|
258
|
+
let _mainAgentDeltaCount = 0; // mainAgent 请求计数器(用于触发定期 checkpoint)
|
|
259
|
+
const CHECKPOINT_INTERVAL = 10; // 每 N 条 mainAgent 请求写一个 checkpoint
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Delta storage: completed 写入成功后更新状态。
|
|
263
|
+
*
|
|
264
|
+
* 幂等守卫(`originalLength > _lastMessagesCount`):eager-update 已在请求开始时把
|
|
265
|
+
* _lastMessagesCount/_lastTailFp 推到本次值;本函数对同 originalLength 的 commit no-op,
|
|
266
|
+
* 且必须严格大于才更新——防止两条 mainAgent 请求乱序完成时,先到的较短 commit 把
|
|
267
|
+
* 已被 eager 推高的状态倒推回去(A 流式数秒、B 短先 commit、A 后 commit 时 _lastMessagesCount
|
|
268
|
+
* 与 _lastTailFp 都会被 A.length/fp 覆盖,下条 C 拿陈旧 prev → Plan C 漏/误检 → doubled-history
|
|
269
|
+
* 残余)。等长情况下也不动 fp:等长 in-place replace 的 _lastTailFp 已被 eager 推到 latest 值,
|
|
270
|
+
* commit 倒推会让下条请求的 Plan C 误判 in-place。
|
|
271
|
+
*
|
|
272
|
+
* 作用域纠正(不是真的兜底):本函数与 eager 块共享 "可写入" 前置条件——caller 传
|
|
273
|
+
* `_deltaOriginalMessagesLength`,该值只有在 `_deltaStorageEnabled && mainAgent &&
|
|
274
|
+
* Array.isArray(messages) && messages.length>0` 时才非 0;其它分支传 0 进来这里就
|
|
275
|
+
* short-circuit。即 `_deltaStorageEnabled=false / messages 非数组` 等 eager 跳过的分支
|
|
276
|
+
* 本函数同样跳过,不能视作异常路径的兜底。保留本函数是为了首次启动 / 定期 checkpoint
|
|
277
|
+
* 后的快路径回写(与 eager 等价但解耦 commit 时序),让未来 eager 块若重构调用顺序时
|
|
278
|
+
* commit 仍能把状态推上去。请求失败 / 服务端不发送时永远不调本函数;状态由 eager 残留,
|
|
279
|
+
* 下个成功请求覆盖即可,不会永久错位。
|
|
280
|
+
*/
|
|
281
|
+
function _commitDeltaState(originalLength, originalTailFp) {
|
|
282
|
+
if (_deltaStorageEnabled && originalLength > 0 && originalLength > _lastMessagesCount) {
|
|
283
|
+
_lastMessagesCount = originalLength;
|
|
284
|
+
if (typeof originalTailFp === 'string') {
|
|
285
|
+
_lastTailFp = originalTailFp;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Teammate 子进程检测:--parent-session-id(旧模式)或 --agent-name(原生 team 模式)
|
|
291
|
+
const _isTeammate = process.argv.includes('--parent-session-id') || process.argv.includes('--agent-name');
|
|
292
|
+
// 提取 teammate 元数据(--agent-name worker-1 --team-name fix-ts-errors)
|
|
293
|
+
let _teammateName = null;
|
|
294
|
+
let _teamName = null;
|
|
295
|
+
{
|
|
296
|
+
const args = process.argv;
|
|
297
|
+
const nameIdx = args.indexOf('--agent-name');
|
|
298
|
+
if (nameIdx !== -1 && nameIdx + 1 < args.length) _teammateName = args[nameIdx + 1];
|
|
299
|
+
const teamIdx = args.indexOf('--team-name');
|
|
300
|
+
if (teamIdx !== -1 && teamIdx + 1 < args.length) _teamName = args[teamIdx + 1];
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// 初始化日志文件路径(异步,支持用户交互)
|
|
304
|
+
// 工作区模式下延迟到选择工作区后再初始化
|
|
305
|
+
let _newLogFile, _logDir, _projectName;
|
|
306
|
+
if (process.env.CCV_WORKSPACE_MODE === '1') {
|
|
307
|
+
_newLogFile = '';
|
|
308
|
+
_logDir = '';
|
|
309
|
+
_projectName = '';
|
|
310
|
+
} else if (_isTeammate) {
|
|
311
|
+
// Teammate 子进程:只需 projectName 和 logDir 来查找 leader 日志,不生成新文件路径
|
|
312
|
+
let cwd;
|
|
313
|
+
try { cwd = process.cwd(); } catch { cwd = homedir(); }
|
|
314
|
+
_projectName = basename(cwd).replace(/[^a-zA-Z0-9_\-\.]/g, '_');
|
|
315
|
+
_logDir = join(LOG_DIR, _projectName);
|
|
316
|
+
const _leaderLog = findRecentLog(_logDir, _projectName);
|
|
317
|
+
_newLogFile = _leaderLog || ''; // 没有 leader 日志时不写入
|
|
318
|
+
} else {
|
|
319
|
+
({ filePath: _newLogFile, dir: _logDir, projectName: _projectName } = generateNewLogFilePath());
|
|
320
|
+
// 启动时清理残留临时文件
|
|
321
|
+
cleanupTempFiles(_logDir, _projectName);
|
|
322
|
+
}
|
|
323
|
+
let LOG_FILE = _newLogFile;
|
|
324
|
+
|
|
325
|
+
// 现在 _projectName/_logDir 已初始化,可以安全加载 proxy profile(含 workspace override)
|
|
326
|
+
// 并挂载 watchFile 同步列表变化。
|
|
327
|
+
_loadProxyProfile();
|
|
328
|
+
try { watchFile(PROFILE_PATH, { interval: 1500 }, _loadProxyProfile); } catch { }
|
|
329
|
+
|
|
330
|
+
const _initPromise = (async () => {
|
|
331
|
+
if (!_logDir || !_projectName) return; // 工作区模式下跳过
|
|
332
|
+
if (_isTeammate) return; // Teammate 已在上方同步初始化,跳过 async resume 流程
|
|
333
|
+
try {
|
|
334
|
+
const recentLog = findRecentLog(_logDir, _projectName);
|
|
335
|
+
if (recentLog) {
|
|
336
|
+
// Leader / 普通进程:走 resume 交互流程
|
|
337
|
+
const tempFile = _newLogFile.replace('.jsonl', '_temp.jsonl');
|
|
338
|
+
LOG_FILE = tempFile;
|
|
339
|
+
_resumeState = {
|
|
340
|
+
recentFile: recentLog,
|
|
341
|
+
recentFileName: basename(recentLog),
|
|
342
|
+
tempFile,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
} catch { }
|
|
346
|
+
})();
|
|
347
|
+
|
|
348
|
+
export { LOG_FILE, _initPromise, _resumeState, _choicePromise, resolveResumeChoice, _projectName, _logDir };
|
|
349
|
+
|
|
350
|
+
// 工作区模式:动态初始化指定路径的日志文件
|
|
351
|
+
// 如果有 1 小时内的最近日志,自动复用(与单目录模式行为一致)
|
|
352
|
+
export function initForWorkspace(projectPath, { forceNew = false } = {}) {
|
|
353
|
+
const projectName = basename(projectPath).replace(/[^a-zA-Z0-9_\-\.]/g, '_');
|
|
354
|
+
const dir = join(LOG_DIR, projectName);
|
|
355
|
+
try { mkdirSync(dir, { recursive: true }); } catch {}
|
|
356
|
+
|
|
357
|
+
cleanupTempFiles(dir, projectName);
|
|
358
|
+
|
|
359
|
+
// 检查是否有最近的日志文件可以复用(始终复用最新日志)
|
|
360
|
+
// forceNew: Electron multi-tab 模式下强制创建新文件,避免与已有 ccv 实例共享日志
|
|
361
|
+
const recentLog = !forceNew && findRecentLog(dir, projectName);
|
|
362
|
+
if (recentLog) {
|
|
363
|
+
_projectName = projectName;
|
|
364
|
+
_logDir = dir;
|
|
365
|
+
LOG_FILE = recentLog;
|
|
366
|
+
// workspace 切换后,重读该 workspace 的 active-profile.json(可能和上一个 workspace 不同)
|
|
367
|
+
_loadProxyProfile();
|
|
368
|
+
return { filePath: recentLog, dir, projectName, resumed: true };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// 没有最近日志,创建新文件
|
|
372
|
+
const now = new Date();
|
|
373
|
+
const ts = now.getFullYear().toString()
|
|
374
|
+
+ String(now.getMonth() + 1).padStart(2, '0')
|
|
375
|
+
+ String(now.getDate()).padStart(2, '0')
|
|
376
|
+
+ '_'
|
|
377
|
+
+ String(now.getHours()).padStart(2, '0')
|
|
378
|
+
+ String(now.getMinutes()).padStart(2, '0')
|
|
379
|
+
+ String(now.getSeconds()).padStart(2, '0');
|
|
380
|
+
|
|
381
|
+
const filePath = join(dir, `${projectName}_${ts}.jsonl`);
|
|
382
|
+
|
|
383
|
+
_projectName = projectName;
|
|
384
|
+
_logDir = dir;
|
|
385
|
+
LOG_FILE = filePath;
|
|
386
|
+
_loadProxyProfile(); // 同上
|
|
387
|
+
|
|
388
|
+
return { filePath, dir, projectName, resumed: false };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// 工作区模式:重置日志状态(返回工作区列表时调用)
|
|
392
|
+
export function resetWorkspace() {
|
|
393
|
+
_projectName = '';
|
|
394
|
+
_logDir = '';
|
|
395
|
+
LOG_FILE = '';
|
|
396
|
+
_loadProxyProfile(); // workspace 上下文消失,回落到 profile.json.active
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const MAX_LOG_SIZE = 300 * 1024 * 1024; // 300MB
|
|
400
|
+
|
|
401
|
+
function checkAndRotateLogFile() {
|
|
402
|
+
// Teammate 不做日志轮转,由 leader 负责
|
|
403
|
+
if (_isTeammate) return;
|
|
404
|
+
try {
|
|
405
|
+
if (!existsSync(LOG_FILE) || statSync(LOG_FILE).size < MAX_LOG_SIZE) return;
|
|
406
|
+
} catch { return; }
|
|
407
|
+
const { filePath } = generateNewLogFilePath();
|
|
408
|
+
const result = rotateLogFile(LOG_FILE, filePath, MAX_LOG_SIZE);
|
|
409
|
+
if (result.rotated) {
|
|
410
|
+
LOG_FILE = result.newFile;
|
|
411
|
+
// 重置 delta 状态,强制下一条 mainAgent 请求写完整 checkpoint
|
|
412
|
+
if (_deltaStorageEnabled) {
|
|
413
|
+
_lastMessagesCount = 0;
|
|
414
|
+
_lastTailFp = '';
|
|
415
|
+
_mainAgentDeltaCount = 0;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// 从环境变量 ANTHROPIC_BASE_URL 提取域名用于请求匹配
|
|
421
|
+
function getBaseUrlHost() {
|
|
422
|
+
try {
|
|
423
|
+
const baseUrl = process.env.ANTHROPIC_BASE_URL;
|
|
424
|
+
if (baseUrl) {
|
|
425
|
+
return new URL(baseUrl).hostname;
|
|
426
|
+
}
|
|
427
|
+
} catch { }
|
|
428
|
+
return null;
|
|
429
|
+
}
|
|
430
|
+
const CUSTOM_API_HOST = getBaseUrlHost();
|
|
431
|
+
|
|
432
|
+
// 保存 viewer 模块引用
|
|
433
|
+
let viewerModule = null;
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Fire-and-forget POST a streaming chunk to cc-viewer server.
|
|
437
|
+
* Non-blocking: returns immediately, errors silently ignored.
|
|
438
|
+
* Only active when _livePort has been set (via setLivePort, by server.js).
|
|
439
|
+
* @param {function(boolean)} [onDone] - optional callback: true=success, false=413 (payload too large)
|
|
440
|
+
*/
|
|
441
|
+
export function sendStreamChunk(entry, chunkSeq, onDone) {
|
|
442
|
+
const port = _livePort;
|
|
443
|
+
if (!port) return;
|
|
444
|
+
try {
|
|
445
|
+
const payload = JSON.stringify({ ...entry, _chunkSeq: chunkSeq });
|
|
446
|
+
const mod = _liveProtocol === 'https' ? https : http;
|
|
447
|
+
const req = mod.request({
|
|
448
|
+
hostname: '127.0.0.1',
|
|
449
|
+
port: Number(port),
|
|
450
|
+
path: '/api/stream-chunk',
|
|
451
|
+
method: 'POST',
|
|
452
|
+
headers: {
|
|
453
|
+
'Content-Type': 'application/json',
|
|
454
|
+
'Content-Length': Buffer.byteLength(payload),
|
|
455
|
+
'x-cc-viewer-internal': '1',
|
|
456
|
+
},
|
|
457
|
+
timeout: 500,
|
|
458
|
+
rejectUnauthorized: false,
|
|
459
|
+
}, (res) => {
|
|
460
|
+
// 413 = payload too large → notify caller to stop sending further chunks
|
|
461
|
+
if (onDone) onDone(res.statusCode !== 413);
|
|
462
|
+
res.resume(); // drain
|
|
463
|
+
});
|
|
464
|
+
req.on('error', () => { if (onDone) onDone(true); }); // network error: keep trying
|
|
465
|
+
req.on('timeout', () => { try { req.destroy(); } catch {} if (onDone) onDone(true); });
|
|
466
|
+
req.write(payload);
|
|
467
|
+
req.end();
|
|
468
|
+
} catch { if (onDone) onDone(true); }
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
export function setupInterceptor() {
|
|
472
|
+
// 避免重复拦截
|
|
473
|
+
if (globalThis._ccViewerInterceptorInstalled) {
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
globalThis._ccViewerInterceptorInstalled = true;
|
|
477
|
+
|
|
478
|
+
// 启动 viewer 服务。Teammate 子进程跳过,避免端口冲突(leader 已启动 viewer)
|
|
479
|
+
if (!_isTeammate) {
|
|
480
|
+
// Windows 下 import(绝对路径) 会被拒 (ERR_UNSUPPORTED_ESM_URL_SCHEME);统一走 pathToFileURL。
|
|
481
|
+
const serverPath = join(__dirname, 'server.js');
|
|
482
|
+
import(pathToFileURL(serverPath).href).then(module => {
|
|
483
|
+
viewerModule = module;
|
|
484
|
+
}).catch((err) => {
|
|
485
|
+
console.warn('[cc-viewer] failed to load viewer server module:', err?.message || err);
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// 注册退出处理器
|
|
490
|
+
// NB: 三个 handler 被 setupInterceptor 上面 `_ccViewerInterceptorInstalled` 守卫保护,
|
|
491
|
+
// 同一进程内重复 import 不会重复注册(cleanupViewer 闭包也不会堆积)。
|
|
492
|
+
const cleanupViewer = async () => {
|
|
493
|
+
if (viewerModule && typeof viewerModule.stopViewer === 'function') {
|
|
494
|
+
try {
|
|
495
|
+
await viewerModule.stopViewer();
|
|
496
|
+
} catch (err) {
|
|
497
|
+
// Silently fail
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
process.on('SIGINT', () => {
|
|
503
|
+
cleanupViewer().finally(() => process.exit(0));
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
process.on('SIGTERM', () => {
|
|
507
|
+
cleanupViewer().finally(() => process.exit(0));
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
process.on('beforeExit', () => {
|
|
511
|
+
cleanupViewer();
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
const _originalFetch = globalThis.fetch;
|
|
515
|
+
|
|
516
|
+
globalThis.fetch = async function (url, options) {
|
|
517
|
+
// cc-viewer 内部请求(翻译等)直接透传,不拦截
|
|
518
|
+
const internalHeader = options?.headers?.['x-cc-viewer-internal']
|
|
519
|
+
|| (options?.headers instanceof Headers && options.headers.get('x-cc-viewer-internal'));
|
|
520
|
+
if (internalHeader) {
|
|
521
|
+
return _originalFetch.apply(this, arguments);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const startTime = Date.now();
|
|
525
|
+
let requestEntry = null;
|
|
526
|
+
|
|
527
|
+
try {
|
|
528
|
+
const urlStr = typeof url === 'string' ? url : url?.url || String(url);
|
|
529
|
+
// 检查 headers 中是否包含 x-cc-viewer-trace 标记
|
|
530
|
+
const headers = options?.headers || {};
|
|
531
|
+
const isProxyTrace = headers['x-cc-viewer-trace'] === 'true' || headers['x-cc-viewer-trace'] === true;
|
|
532
|
+
|
|
533
|
+
// 如果是 proxy 转发的,或者符合 URL 规则
|
|
534
|
+
if (isProxyTrace || urlStr.includes('anthropic') || urlStr.includes('claude') || (CUSTOM_API_HOST && urlStr.includes(CUSTOM_API_HOST)) || isAnthropicApiPath(urlStr)) {
|
|
535
|
+
// 如果是 proxy 转发的,需要清理掉标记 header 避免发给上游
|
|
536
|
+
if (isProxyTrace && options?.headers) {
|
|
537
|
+
delete options.headers['x-cc-viewer-trace'];
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const timestamp = new Date().toISOString();
|
|
541
|
+
let body = null;
|
|
542
|
+
if (options?.body) {
|
|
543
|
+
try {
|
|
544
|
+
body = JSON.parse(options.body);
|
|
545
|
+
} catch {
|
|
546
|
+
body = String(options.body).slice(0, 500);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// 转换 headers 为普通对象(支持 Request 对象、options.headers、Headers 实例)
|
|
551
|
+
let headers = {};
|
|
552
|
+
const rawHeaders = options?.headers || (url instanceof Request ? url.headers : null);
|
|
553
|
+
if (rawHeaders) {
|
|
554
|
+
if (rawHeaders instanceof Headers) {
|
|
555
|
+
headers = Object.fromEntries(rawHeaders.entries());
|
|
556
|
+
} else if (typeof rawHeaders === 'object') {
|
|
557
|
+
headers = { ...rawHeaders };
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// 缓存 API Key / Authorization 供翻译接口使用(缓存原始值)
|
|
562
|
+
if (headers['x-api-key'] && !_cachedApiKey) {
|
|
563
|
+
_cachedApiKey = headers['x-api-key'];
|
|
564
|
+
}
|
|
565
|
+
if (headers['authorization'] && !_cachedAuthHeader) {
|
|
566
|
+
_cachedAuthHeader = headers['authorization'];
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// 首次 API 请求时捕获原始配置(仅一次,用于 Default profile 展示和自动匹配)
|
|
570
|
+
if (!_defaultConfig) {
|
|
571
|
+
try {
|
|
572
|
+
const _u = new URL(urlStr);
|
|
573
|
+
_defaultConfig = {
|
|
574
|
+
origin: _u.origin,
|
|
575
|
+
authType: headers['authorization'] ? 'OAuth' : headers['x-api-key'] ? 'API Key' : 'Unknown',
|
|
576
|
+
apiKey: headers['x-api-key'] || null,
|
|
577
|
+
model: body?.model || null,
|
|
578
|
+
};
|
|
579
|
+
} catch { }
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// 缓存请求中的模型名(仅 mainAgent 请求,避免 SubAgent 覆盖)
|
|
583
|
+
// 注意:写入移到 requestEntry 构建之后
|
|
584
|
+
|
|
585
|
+
// 脱敏敏感 headers,避免写入日志泄漏凭证
|
|
586
|
+
const safeHeaders = { ...headers };
|
|
587
|
+
if (safeHeaders['x-api-key']) {
|
|
588
|
+
const k = safeHeaders['x-api-key'];
|
|
589
|
+
safeHeaders['x-api-key'] = k.length > 12 ? k.slice(0, 8) + '****' + k.slice(-4) : '****';
|
|
590
|
+
}
|
|
591
|
+
if (safeHeaders['authorization']) {
|
|
592
|
+
const v = safeHeaders['authorization'];
|
|
593
|
+
const spaceIdx = v.indexOf(' ');
|
|
594
|
+
if (spaceIdx > 0) {
|
|
595
|
+
const scheme = v.slice(0, spaceIdx);
|
|
596
|
+
const token = v.slice(spaceIdx + 1);
|
|
597
|
+
safeHeaders['authorization'] = scheme + ' ' + (token.length > 12 ? token.slice(0, 8) + '****' + token.slice(-4) : '****');
|
|
598
|
+
} else {
|
|
599
|
+
safeHeaders['authorization'] = '****';
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
requestEntry = {
|
|
604
|
+
timestamp,
|
|
605
|
+
project: (() => { try { return basename(process.cwd()); } catch { return 'unknown'; } })(),
|
|
606
|
+
url: urlStr,
|
|
607
|
+
method: options?.method || 'GET',
|
|
608
|
+
headers: safeHeaders,
|
|
609
|
+
body: body,
|
|
610
|
+
response: null,
|
|
611
|
+
duration: 0,
|
|
612
|
+
isStream: body?.stream === true,
|
|
613
|
+
isHeartbeat: /\/api\/eval\/sdk-/.test(urlStr),
|
|
614
|
+
isCountTokens: /\/messages\/count_tokens/.test(urlStr),
|
|
615
|
+
mainAgent: isMainAgentRequest(body),
|
|
616
|
+
...(_isTeammate && { teammate: _teammateName, teamName: _teamName })
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
} catch { }
|
|
620
|
+
|
|
621
|
+
// 用户新指令边界:检查日志文件大小,超过 250MB 则切换新文件
|
|
622
|
+
if (requestEntry?.mainAgent) {
|
|
623
|
+
checkAndRotateLogFile();
|
|
624
|
+
// 仅 mainAgent 请求时缓存模型名,避免 SubAgent 覆盖
|
|
625
|
+
if (requestEntry.body?.model && typeof requestEntry.body.model === 'string') {
|
|
626
|
+
_cachedModel = requestEntry.body.model;
|
|
627
|
+
// 捕获 haiku 模型名供翻译接口使用
|
|
628
|
+
if (/haiku/i.test(requestEntry.body.model)) {
|
|
629
|
+
_cachedHaikuModel = requestEntry.body.model;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Delta storage:仅 mainAgent 且开关启用时,将 body.messages 转为增量格式
|
|
635
|
+
let _deltaOriginalMessagesLength = 0; // 缓存本次请求的原始 messages 长度,用于 completed 后更新状态
|
|
636
|
+
let _deltaOriginalTailFp = ''; // 缓存本次请求末位 message 的指纹,用于 completed 后更新 _lastTailFp
|
|
637
|
+
if (_deltaStorageEnabled && requestEntry?.mainAgent && Array.isArray(requestEntry.body?.messages)) {
|
|
638
|
+
const messages = requestEntry.body.messages;
|
|
639
|
+
_deltaOriginalMessagesLength = messages.length;
|
|
640
|
+
// 立即把末位 fp 算成字符串保存(不存对象引用),避免后续 mutation 风险
|
|
641
|
+
_deltaOriginalTailFp = messages.length > 0 ? fingerprintMsg(messages[messages.length - 1]) : '';
|
|
642
|
+
_mainAgentDeltaCount++;
|
|
643
|
+
|
|
644
|
+
// 并发竞态修复(详见模块顶部注释 + history.md Unreleased 段 fix(interceptor) 条目):
|
|
645
|
+
// snapshot 上一请求处理时的 count/fp 给 Plan C 用,然后 eager 把模块级状态推到本次值
|
|
646
|
+
// (不等 _commitDeltaState)。BUG 来源:teammate 终止快速串行让 mainAgent 30ms 内连续
|
|
647
|
+
// firing,旧 commit 时序使 Plan C 拿陈旧 prev 漏检 → client doubled-history。
|
|
648
|
+
const _prevMessagesCount = _lastMessagesCount;
|
|
649
|
+
const _prevTailFp = _lastTailFp;
|
|
650
|
+
if (_deltaOriginalMessagesLength > 0) {
|
|
651
|
+
_lastMessagesCount = _deltaOriginalMessagesLength;
|
|
652
|
+
if (_deltaOriginalTailFp !== '') _lastTailFp = _deltaOriginalTailFp;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// In-place last-msg replace 检测:messages.length 不变但末位 fp 不同。
|
|
656
|
+
// 触发场景:CLI 在 mainAgent 末位"原地替换"user msg(SUGGESTION MODE → 用户真实输入;
|
|
657
|
+
// synthetic recap 通道注入;teammate 终止快速串行 → SUGGESTION MODE 多次替换;等),
|
|
658
|
+
// wire 上长度未变内容变了。旧逻辑 messages.slice(_lastMessagesCount) 算出 delta=[],
|
|
659
|
+
// 丢失了"末位换内容"信息 → 客户端重建拿到错误的"前态末位"。
|
|
660
|
+
// 检测命中即强制写 checkpoint,让客户端拿到完整 wire 真实内容。
|
|
661
|
+
const _sameLenInPlaceReplace =
|
|
662
|
+
_tailFpCheckEnabled &&
|
|
663
|
+
messages.length === _prevMessagesCount &&
|
|
664
|
+
_prevMessagesCount > 0 &&
|
|
665
|
+
_prevTailFp !== '' &&
|
|
666
|
+
_deltaOriginalTailFp !== '' &&
|
|
667
|
+
_deltaOriginalTailFp !== _prevTailFp;
|
|
668
|
+
|
|
669
|
+
// 判断是否需要写 checkpoint
|
|
670
|
+
const needsCheckpoint =
|
|
671
|
+
_prevMessagesCount === 0 || // 进程重启 / 首次请求
|
|
672
|
+
messages.length < _prevMessagesCount || // messages 缩短(/clear、context 压缩)
|
|
673
|
+
(_mainAgentDeltaCount % CHECKPOINT_INTERVAL === 0) || // 定期 checkpoint
|
|
674
|
+
_sameLenInPlaceReplace; // in-place last-msg replace 检测
|
|
675
|
+
|
|
676
|
+
if (needsCheckpoint) {
|
|
677
|
+
// checkpoint:保持完整 messages,标记 _isCheckpoint
|
|
678
|
+
requestEntry._deltaFormat = 1;
|
|
679
|
+
requestEntry._totalMessageCount = messages.length;
|
|
680
|
+
requestEntry._conversationId = 'mainAgent';
|
|
681
|
+
requestEntry._isCheckpoint = true;
|
|
682
|
+
if (_sameLenInPlaceReplace) {
|
|
683
|
+
// 诊断字段:标记此 checkpoint 是被 in-place replace 检测触发的(频率约 1-2%,
|
|
684
|
+
// 用于在生产 jsonl 里事后核对触发率,不影响重建逻辑)。
|
|
685
|
+
// 双方协议(KEEP IN SYNC: src/utils/sessionManager.js applyInPlaceLastMsgReplace):
|
|
686
|
+
// 客户端 helper 看到此字段=true(与 _isCheckpoint:true 同时存在)时直接 in-place 替换
|
|
687
|
+
// lastSession.messages 末位,跳过 sessionMerge prefix-overlap 算法(避开 doubled-history)。
|
|
688
|
+
// 字段重命名 / 删除前需同步两端 + 重跑双向回归测试。
|
|
689
|
+
requestEntry._inPlaceReplaceDetected = true;
|
|
690
|
+
}
|
|
691
|
+
} else {
|
|
692
|
+
// delta:只保留新增的 messages(必须用 _prevMessagesCount,不是 eager 已更新的 _lastMessagesCount)
|
|
693
|
+
const delta = messages.slice(_prevMessagesCount);
|
|
694
|
+
requestEntry._deltaFormat = 1;
|
|
695
|
+
requestEntry._totalMessageCount = messages.length;
|
|
696
|
+
requestEntry._conversationId = 'mainAgent';
|
|
697
|
+
requestEntry._isCheckpoint = false;
|
|
698
|
+
requestEntry.body.messages = delta;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// 生成唯一请求 ID,用于关联在途请求和完成请求
|
|
703
|
+
const requestId = `${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
|
|
704
|
+
if (requestEntry) {
|
|
705
|
+
requestEntry.requestId = requestId;
|
|
706
|
+
requestEntry.inProgress = true; // 标记为在途请求
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// 在发起请求前先写入一条未完成的条目,让前端可以检测在途请求
|
|
710
|
+
// 例外:live-streaming 场景下,placeholder 由 sendStreamChunk 通过 HTTP 即时投递,
|
|
711
|
+
// 跳过磁盘预写可避免 log-watcher 500ms 后用空 placeholder 覆盖已显示的流式内容
|
|
712
|
+
if (requestEntry) {
|
|
713
|
+
const willLiveStream = !!_livePort && requestEntry.mainAgent && !_isTeammate;
|
|
714
|
+
if (!willLiveStream) {
|
|
715
|
+
try {
|
|
716
|
+
appendFileSync(LOG_FILE, JSON.stringify(requestEntry) + '\n---\n');
|
|
717
|
+
} catch { }
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// 流式请求状态追踪(仅对 Claude API 流式请求)
|
|
722
|
+
if (requestEntry?.isStream) {
|
|
723
|
+
streamingState.active = true;
|
|
724
|
+
streamingState.requestId = requestId;
|
|
725
|
+
streamingState.startTime = Date.now();
|
|
726
|
+
streamingState.model = requestEntry.body?.model || '';
|
|
727
|
+
streamingState.bytesReceived = 0;
|
|
728
|
+
streamingState.chunksReceived = 0;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// Proxy profile request rewriting
|
|
732
|
+
let _fetchUrl = url;
|
|
733
|
+
let _fetchOpts = options;
|
|
734
|
+
if (_activeProfile && _activeProfile.baseURL && requestEntry) {
|
|
735
|
+
try {
|
|
736
|
+
// 1. URL 重写: 用 baseURL 替换 origin,智能处理路径重叠
|
|
737
|
+
// baseURL="https://proxy.com/v1" + pathname="/v1/messages" → "https://proxy.com/v1/messages"(去重 /v1)
|
|
738
|
+
// baseURL="https://proxy.com" + pathname="/v1/messages" → "https://proxy.com/v1/messages"(无重叠)
|
|
739
|
+
if (typeof _fetchUrl === 'string') {
|
|
740
|
+
const _origUrl = new URL(_fetchUrl);
|
|
741
|
+
const _baseUrl = new URL(_activeProfile.baseURL);
|
|
742
|
+
const _basePath = _baseUrl.pathname.replace(/\/+$/, '');
|
|
743
|
+
const _origPath = _origUrl.pathname;
|
|
744
|
+
// 如果原始路径以 baseURL 的路径开头(如都有 /v1/),去掉重叠部分
|
|
745
|
+
// 使用 _basePath + '/' 避免 /api 误匹配 /api-v2
|
|
746
|
+
const _finalPath = (!_basePath || _origPath === _basePath || _origPath.startsWith(_basePath + '/')) ? _origPath : _basePath + _origPath;
|
|
747
|
+
_fetchUrl = _baseUrl.origin + _finalPath + _origUrl.search;
|
|
748
|
+
}
|
|
749
|
+
// 2. Auth 替换 —— 兼容 lowercase / TitleCase,且 x-api-key / Authorization 同时替换以覆盖两种鉴权形式
|
|
750
|
+
if (_activeProfile.apiKey && _fetchOpts?.headers) {
|
|
751
|
+
const h = _fetchOpts.headers;
|
|
752
|
+
if (typeof h === 'object' && !(h instanceof Headers)) {
|
|
753
|
+
const { headers: newHeaders, matchedAuthKey, matchedXApiKey } =
|
|
754
|
+
_replaceProxyAuthHeaders(h, _activeProfile.apiKey);
|
|
755
|
+
_fetchOpts = { ..._fetchOpts, headers: newHeaders };
|
|
756
|
+
|
|
757
|
+
// 诊断日志:让 stderr 能看到替换是否真的发生
|
|
758
|
+
// 只输出"是否命中/是否写入"布尔,绝不输出任何 apiKey 明文或片段
|
|
759
|
+
// (日志聚合/审计规则会把尾 N 字符一并标记为敏感泄漏)
|
|
760
|
+
if (process.env.CCV_DEBUG_HOTSWITCH) {
|
|
761
|
+
console.error('[ccv hotswitch]', {
|
|
762
|
+
profile: _activeProfile.name,
|
|
763
|
+
url: _fetchUrl,
|
|
764
|
+
matchedAuth: matchedAuthKey || '(none)',
|
|
765
|
+
matchedXApiKey: matchedXApiKey || '(none)',
|
|
766
|
+
authSet: !!(matchedAuthKey && newHeaders[matchedAuthKey]),
|
|
767
|
+
xApiKeySet: !!(newHeaders[matchedXApiKey] || newHeaders['x-api-key']),
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
// 3. Model 替换
|
|
773
|
+
if (_activeProfile.activeModel && _fetchOpts?.body) {
|
|
774
|
+
try {
|
|
775
|
+
const _b = JSON.parse(_fetchOpts.body);
|
|
776
|
+
if (_b.model) {
|
|
777
|
+
_b.model = _activeProfile.activeModel;
|
|
778
|
+
_fetchOpts = { ..._fetchOpts, body: JSON.stringify(_b) };
|
|
779
|
+
}
|
|
780
|
+
} catch { }
|
|
781
|
+
}
|
|
782
|
+
// 记录 proxy 信息到日志条目
|
|
783
|
+
requestEntry.proxyProfile = _activeProfile.name;
|
|
784
|
+
requestEntry.proxyUrl = _fetchUrl;
|
|
785
|
+
} catch { }
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
let response;
|
|
789
|
+
try {
|
|
790
|
+
response = await _originalFetch.call(this, _fetchUrl, _fetchOpts);
|
|
791
|
+
} catch (err) {
|
|
792
|
+
if (requestEntry?.isStream) resetStreamingState();
|
|
793
|
+
throw err;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
if (requestEntry) {
|
|
797
|
+
const duration = Date.now() - startTime;
|
|
798
|
+
requestEntry.duration = duration;
|
|
799
|
+
|
|
800
|
+
// 对于流式响应,拦截并捕获内容
|
|
801
|
+
if (requestEntry.isStream) {
|
|
802
|
+
try {
|
|
803
|
+
requestEntry.response = {
|
|
804
|
+
status: response.status,
|
|
805
|
+
statusText: response.statusText,
|
|
806
|
+
headers: Object.fromEntries(response.headers.entries()),
|
|
807
|
+
body: { events: [] }
|
|
808
|
+
};
|
|
809
|
+
|
|
810
|
+
const originalBody = response.body;
|
|
811
|
+
const reader = originalBody.getReader();
|
|
812
|
+
const decoder = new TextDecoder();
|
|
813
|
+
// 延迟物化:避免 V8 ConsString 多次 O(n) 拷贝
|
|
814
|
+
let streamedChunks = [];
|
|
815
|
+
let streamedContentLen = 0;
|
|
816
|
+
|
|
817
|
+
// 实时流式:仅对 mainAgent 且 server live-port 已注入时启用
|
|
818
|
+
let liveStreamEnabled = !!_livePort && requestEntry.mainAgent && !_isTeammate;
|
|
819
|
+
const liveAssembler = liveStreamEnabled ? createStreamAssembler() : null;
|
|
820
|
+
let livePendingBuffer = '';
|
|
821
|
+
let liveChunkSeq = 0;
|
|
822
|
+
let liveLastFlushMs = 0;
|
|
823
|
+
let liveLastFlushBytes = 0;
|
|
824
|
+
let liveFlushInFlight = false;
|
|
825
|
+
let liveHasPendingSnapshot = false;
|
|
826
|
+
let liveFlushTimer = null;
|
|
827
|
+
|
|
828
|
+
// 非阻塞 flush:合并待发快照(latest-wins),单 in-flight。
|
|
829
|
+
// payload 只包含 server 实际消费的 4 字段(timestamp/url/content/model)+ _chunkSeq,
|
|
830
|
+
// 避免克隆完整 requestEntry(含 headers/messages/tools,每次 O(N) 序列化导致 O(N²) 累计)。
|
|
831
|
+
const liveFlush = () => {
|
|
832
|
+
if (!liveStreamEnabled || !liveAssembler || !liveAssembler.hasMessage()) return;
|
|
833
|
+
if (liveFlushInFlight) {
|
|
834
|
+
liveHasPendingSnapshot = true;
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
liveFlushInFlight = true;
|
|
838
|
+
liveHasPendingSnapshot = false;
|
|
839
|
+
const snap = liveAssembler.snapshot();
|
|
840
|
+
const chunkEntry = {
|
|
841
|
+
timestamp: requestEntry.timestamp,
|
|
842
|
+
url: requestEntry.url,
|
|
843
|
+
response: { body: snap },
|
|
844
|
+
body: { model: requestEntry.body?.model },
|
|
845
|
+
};
|
|
846
|
+
sendStreamChunk(chunkEntry, ++liveChunkSeq, (ok) => {
|
|
847
|
+
// 413 → 禁用当次流式,后续全由最终 appendFileSync 交付
|
|
848
|
+
if (!ok) liveStreamEnabled = false;
|
|
849
|
+
});
|
|
850
|
+
// 短延迟后清标志,允许下一次发送;若中途有新快照等待,立即再发
|
|
851
|
+
if (liveFlushTimer) clearTimeout(liveFlushTimer);
|
|
852
|
+
liveFlushTimer = setTimeout(() => {
|
|
853
|
+
liveFlushTimer = null;
|
|
854
|
+
liveFlushInFlight = false;
|
|
855
|
+
if (liveHasPendingSnapshot && liveStreamEnabled) liveFlush();
|
|
856
|
+
}, 50);
|
|
857
|
+
};
|
|
858
|
+
|
|
859
|
+
// 首次:立即 POST 当前 inProgress 骨架(无 body),保证前端先看到占位条目。
|
|
860
|
+
// 传 onDone 回调熔断:若 skeleton 就触发 413(极少见但可能,例如 requestEntry 本身异常大),
|
|
861
|
+
// 立即禁用当次 live-stream,后续仅走最终 entry 落盘路径。
|
|
862
|
+
if (liveStreamEnabled) {
|
|
863
|
+
sendStreamChunk({
|
|
864
|
+
timestamp: requestEntry.timestamp,
|
|
865
|
+
url: requestEntry.url,
|
|
866
|
+
response: { body: null },
|
|
867
|
+
body: { model: requestEntry.body?.model },
|
|
868
|
+
}, 0, (ok) => { if (!ok) liveStreamEnabled = false; });
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
const stream = new ReadableStream({
|
|
872
|
+
async start(controller) {
|
|
873
|
+
try {
|
|
874
|
+
while (true) {
|
|
875
|
+
const { done, value } = await reader.read();
|
|
876
|
+
if (done) {
|
|
877
|
+
// flush decoder 残留字节
|
|
878
|
+
{
|
|
879
|
+
const tail = decoder.decode();
|
|
880
|
+
if (tail) { streamedChunks.push(tail); streamedContentLen += tail.length; }
|
|
881
|
+
}
|
|
882
|
+
// 流结束,组装完整的消息对象。
|
|
883
|
+
// 此处一次性 join — 流式累积期间唯一的物化点(错误路径除外)。
|
|
884
|
+
const fullContent = streamedChunks.join('');
|
|
885
|
+
try {
|
|
886
|
+
// HTTP SSE 规范是 \r\n\r\n 分块,POSIX 上常被 normalize 成 \n\n
|
|
887
|
+
// 但 Windows 直接收到的就是 CRLF,硬切 '\n\n' 在 Win 上整块响应当一个事件解析失败。
|
|
888
|
+
const events = fullContent.split(/\r?\n\r?\n/)
|
|
889
|
+
.filter(block => block.trim())
|
|
890
|
+
.map(block => {
|
|
891
|
+
// SSE 块可能包含多行: event: xxx\ndata: {...}
|
|
892
|
+
const lines = block.split(/\r?\n/);
|
|
893
|
+
const dataLine = lines.find(l => l.startsWith('data:'));
|
|
894
|
+
if (dataLine) {
|
|
895
|
+
// 处理 "data:" 或 "data: " 两种格式
|
|
896
|
+
const jsonStr = dataLine.startsWith('data: ')
|
|
897
|
+
? dataLine.substring(6)
|
|
898
|
+
: dataLine.substring(5);
|
|
899
|
+
try {
|
|
900
|
+
return JSON.parse(jsonStr);
|
|
901
|
+
} catch {
|
|
902
|
+
return jsonStr;
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
return null;
|
|
906
|
+
})
|
|
907
|
+
.filter(Boolean);
|
|
908
|
+
|
|
909
|
+
// 组装完整的 message 对象(GLM 使用标准格式,但 data: 后无空格)
|
|
910
|
+
const assembledMessage = assembleStreamMessage(events);
|
|
911
|
+
|
|
912
|
+
// 直接使用组装后的 message 对象作为 response.body
|
|
913
|
+
// 如果组装失败(例如非标准 SSE),则使用原始流内容
|
|
914
|
+
requestEntry.response.body = assembledMessage || fullContent;
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
// 移除在途请求标记,保持原始报文
|
|
918
|
+
delete requestEntry.inProgress;
|
|
919
|
+
delete requestEntry.requestId;
|
|
920
|
+
appendFileSync(LOG_FILE, JSON.stringify(requestEntry) + '\n---\n');
|
|
921
|
+
_commitDeltaState(_deltaOriginalMessagesLength, _deltaOriginalTailFp);
|
|
922
|
+
// Release memory: clear large objects after disk write
|
|
923
|
+
streamedChunks = [];
|
|
924
|
+
streamedContentLen = 0;
|
|
925
|
+
requestEntry.response = null;
|
|
926
|
+
resetStreamingState();
|
|
927
|
+
} catch (err) {
|
|
928
|
+
requestEntry.response.body = fullContent.slice(0, 1000);
|
|
929
|
+
delete requestEntry.inProgress;
|
|
930
|
+
delete requestEntry.requestId;
|
|
931
|
+
appendFileSync(LOG_FILE, JSON.stringify(requestEntry) + '\n---\n');
|
|
932
|
+
_commitDeltaState(_deltaOriginalMessagesLength, _deltaOriginalTailFp);
|
|
933
|
+
streamedChunks = [];
|
|
934
|
+
streamedContentLen = 0;
|
|
935
|
+
requestEntry.response = null;
|
|
936
|
+
resetStreamingState();
|
|
937
|
+
}
|
|
938
|
+
controller.close();
|
|
939
|
+
break;
|
|
940
|
+
}
|
|
941
|
+
streamingState.bytesReceived += value.byteLength;
|
|
942
|
+
streamingState.chunksReceived++;
|
|
943
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
944
|
+
streamedChunks.push(chunk);
|
|
945
|
+
streamedContentLen += chunk.length;
|
|
946
|
+
controller.enqueue(value);
|
|
947
|
+
|
|
948
|
+
// 实时流式:增量解析完整的 SSE events 并触发节流 flush
|
|
949
|
+
if (liveAssembler && liveStreamEnabled) {
|
|
950
|
+
livePendingBuffer += chunk;
|
|
951
|
+
let sawBlockStop = false;
|
|
952
|
+
let idx;
|
|
953
|
+
while ((idx = livePendingBuffer.indexOf('\n\n')) !== -1) {
|
|
954
|
+
const eventBlock = livePendingBuffer.slice(0, idx);
|
|
955
|
+
livePendingBuffer = livePendingBuffer.slice(idx + 2);
|
|
956
|
+
if (!eventBlock.trim()) continue;
|
|
957
|
+
const lines = eventBlock.split('\n');
|
|
958
|
+
const dataLine = lines.find(l => l.startsWith('data:'));
|
|
959
|
+
if (!dataLine) continue;
|
|
960
|
+
const jsonStr = dataLine.startsWith('data: ')
|
|
961
|
+
? dataLine.substring(6)
|
|
962
|
+
: dataLine.substring(5);
|
|
963
|
+
try {
|
|
964
|
+
const ev = JSON.parse(jsonStr);
|
|
965
|
+
liveAssembler.feed(ev);
|
|
966
|
+
if (ev.type === 'content_block_stop') sawBlockStop = true;
|
|
967
|
+
} catch {}
|
|
968
|
+
}
|
|
969
|
+
const now = Date.now();
|
|
970
|
+
const overdue = (now - liveLastFlushMs) >= 100;
|
|
971
|
+
const bigChunk = (streamedContentLen - liveLastFlushBytes) > 16384;
|
|
972
|
+
if (sawBlockStop || overdue || bigChunk) {
|
|
973
|
+
liveLastFlushMs = now;
|
|
974
|
+
liveLastFlushBytes = streamedContentLen;
|
|
975
|
+
liveFlush();
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
} catch (err) {
|
|
980
|
+
resetStreamingState();
|
|
981
|
+
controller.error(err);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
// 返回带有代理流的新响应
|
|
987
|
+
return new Response(stream, {
|
|
988
|
+
status: response.status,
|
|
989
|
+
statusText: response.statusText,
|
|
990
|
+
headers: response.headers
|
|
991
|
+
});
|
|
992
|
+
} catch (err) {
|
|
993
|
+
requestEntry.response = {
|
|
994
|
+
status: response.status,
|
|
995
|
+
statusText: response.statusText,
|
|
996
|
+
headers: Object.fromEntries(response.headers.entries()),
|
|
997
|
+
body: '[Streaming Response - Capture failed]'
|
|
998
|
+
};
|
|
999
|
+
delete requestEntry.inProgress;
|
|
1000
|
+
delete requestEntry.requestId;
|
|
1001
|
+
appendFileSync(LOG_FILE, JSON.stringify(requestEntry) + '\n---\n');
|
|
1002
|
+
_commitDeltaState(_deltaOriginalMessagesLength, _deltaOriginalTailFp);
|
|
1003
|
+
resetStreamingState();
|
|
1004
|
+
}
|
|
1005
|
+
} else {
|
|
1006
|
+
// 对于非流式响应,可以安全读取body
|
|
1007
|
+
try {
|
|
1008
|
+
const clonedResponse = response.clone();
|
|
1009
|
+
const responseText = await clonedResponse.text();
|
|
1010
|
+
let responseData = null;
|
|
1011
|
+
|
|
1012
|
+
try {
|
|
1013
|
+
responseData = JSON.parse(responseText);
|
|
1014
|
+
} catch {
|
|
1015
|
+
responseData = responseText.slice(0, 1000);
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
requestEntry.response = {
|
|
1019
|
+
status: response.status,
|
|
1020
|
+
statusText: response.statusText,
|
|
1021
|
+
headers: Object.fromEntries(response.headers.entries()),
|
|
1022
|
+
body: responseData
|
|
1023
|
+
};
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
delete requestEntry.inProgress;
|
|
1027
|
+
delete requestEntry.requestId;
|
|
1028
|
+
|
|
1029
|
+
appendFileSync(LOG_FILE, JSON.stringify(requestEntry) + '\n---\n');
|
|
1030
|
+
_commitDeltaState(_deltaOriginalMessagesLength, _deltaOriginalTailFp);
|
|
1031
|
+
} catch (err) {
|
|
1032
|
+
delete requestEntry.inProgress;
|
|
1033
|
+
delete requestEntry.requestId;
|
|
1034
|
+
appendFileSync(LOG_FILE, JSON.stringify(requestEntry) + '\n---\n');
|
|
1035
|
+
_commitDeltaState(_deltaOriginalMessagesLength, _deltaOriginalTailFp);
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
return response;
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
// 自动执行拦截器设置
|
|
1045
|
+
// proxy 模式下(ccv CLI 或 ccv run),外层 proxy.js 已显式调用 setupInterceptor(),
|
|
1046
|
+
// 这里跳过自动执行,避免 Claude 进程中重复拦截 fetch
|
|
1047
|
+
// Teammate 子进程即使继承了 CCV_PROXY_MODE 也需要启用拦截(它是独立 claude 进程,不走 proxy)
|
|
1048
|
+
if (!_ccvSkip && (!process.env.CCV_PROXY_MODE || _isTeammate)) setupInterceptor();
|
|
1049
|
+
|
|
1050
|
+
// 等待日志文件初始化完成后启动 Web Viewer 服务
|
|
1051
|
+
// 如果是 ccv --c 通过 proxy 模式启动的,外层已有 server,跳过
|
|
1052
|
+
// Teammate 子进程也跳过,避免端口冲突(leader 已启动 viewer)
|
|
1053
|
+
if (!_ccvSkip && !process.env.CCV_PROXY_MODE && !_isTeammate) {
|
|
1054
|
+
_initPromise.then(() => import('./server.js')).catch((err) => {
|
|
1055
|
+
console.error('[CC-Viewer] Failed to start viewer server:', err);
|
|
1056
|
+
});
|
|
1057
|
+
}
|