cc-viewer 1.7.21 → 1.7.22

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/server/proxy.js CHANGED
@@ -8,13 +8,17 @@ 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';
11
+ import { isAnthropicApiPath, classifyProxyRole } from './lib/interceptor-core.js';
12
12
  import { executeRequest, extractModel } from './lib/proxy-retry.js';
13
13
  import { buildRecord, appendRecord, dailyFilePath, todayStr, emitProxyStatsUpdate } from './lib/proxy-stats.js';
14
+ import { reportSwallowed } from './lib/error-report.js';
14
15
  import { LOG_DIR } from '../findcc.js';
15
16
 
16
17
  // Setup interceptor to patch fetch
17
18
  setupInterceptor();
19
+ // 官方端点判定注册:本进程的 _defaultConfig 捕获的是出站 URL(可能被当时的 profile 污染),
20
+ // 休眠判定的"官方端点"应回答「main=Default 时实际会去哪」——即 settings/env 默认链。
21
+ interceptor.setDefaultEndpointResolver(() => getOriginalBaseUrl(null));
18
22
 
19
23
  // 通知 stats-worker 重扫 proxy 明细(review P2:原实现动态 import('./server.js') 反向触达
20
24
  // statsWorker 单例——若未来出现 proxy-only 进程,首条请求会因模块加载副作用意外拉起第二个
@@ -64,12 +68,14 @@ function getBaseUrlFromSettings(settingsPath) {
64
68
  return null;
65
69
  }
66
70
 
67
- function getOriginalBaseUrl() {
71
+ function getOriginalBaseUrl(profileOverride) {
68
72
  // 热切换 profile 最高优先:UI 里用户选中的 baseURL 直接作为上游目标,
69
73
  // 让 log/UI 显示的 URL 与实际去向一致,且避免 settings.json 残留的本地
70
74
  // 代理 URL(如 127.0.0.1:xxxx)导致 ccv proxy 自环 404。
71
75
  // Via namespace import to pick up watchFile 刷新(ES module live binding)。
72
- const ap = interceptor._activeProfile;
76
+ // profileOverride:调用方按角色解析后的有效 profile(undefined = 沿用 main 活跃 profile,
77
+ // null = 该角色显式无 profile → 落到 settings/env 默认链)。
78
+ const ap = profileOverride !== undefined ? profileOverride : interceptor._activeProfile;
73
79
  if (ap && ap.baseURL) return ap.baseURL;
74
80
 
75
81
  let cwd;
@@ -100,8 +106,6 @@ function getOriginalBaseUrl() {
100
106
  export function startProxy() {
101
107
  return new Promise((resolve, reject) => {
102
108
  const server = createServer(async (req, res) => {
103
- const originalBaseUrl = getOriginalBaseUrl();
104
-
105
109
  // Use the patched fetch (which logs to cc-viewer)
106
110
  try {
107
111
  // Convert incoming headers
@@ -116,6 +120,32 @@ export function startProxy() {
116
120
  }
117
121
  const body = Buffer.concat(buffers);
118
122
 
123
+ // 按角色选源:LLM 路径在 body 缓冲后分类(同进程 team 标记 / cc_is_subagent),
124
+ // 解析该角色的有效 profile(含休眠)。roleProfile 三态:
125
+ // undefined = 未分类(非 LLM 路径 / utility / 无显式角色分配)→ 沿用 main 活跃 profile
126
+ // null = 该角色显式无 profile(Default/休眠)→ settings/env 默认链
127
+ // object = 该角色自己的 profile → 其 baseURL 作上游
128
+ // utility(count_tokens/heartbeat)按 URL 先行排除(跟随 main,且免解析大 body);
129
+ // 无显式角色分配时分类结果不影响选路,直接跳过解析(-c checkpoint 可达数十 MB)。
130
+ // 与 interceptor fetch hook 对 trace 请求的改写输入相同、结论一致(URL 幂等,
131
+ // auth 由 hook 注入,model 由重试引擎按同一 profile 替换)。已知窗口:配置在选路后、
132
+ // hook 改写前被热改时两层解析可不一致(单请求自愈,与 main 切换的既有竞态同类;backlog)。
133
+ // req.url 是 origin-form 相对路径,new URL 需补基址,否则锚定判定落空到宽松正则。
134
+ const _reqPath = (() => { try { return new URL(req.url || '/', 'http://x').pathname; } catch { return req.url || ''; } })();
135
+ const _isUtility = /\/messages\/count_tokens$/.test(_reqPath) || /^\/api\/eval\/sdk-/.test(_reqPath);
136
+ let roleProfile;
137
+ if (body.length > 0 && isAnthropicApiPath(_reqPath) && !_isUtility && interceptor.hasExplicitRoleAssignments()) {
138
+ let parsedBody = null;
139
+ try { parsedBody = JSON.parse(body.toString('utf8')); } catch { /* 非 JSON body → 保持 main 语义 */ }
140
+ if (parsedBody && typeof parsedBody === 'object') {
141
+ try {
142
+ const role = classifyProxyRole(parsedBody, {});
143
+ roleProfile = interceptor.getEffectiveRoleProfile(role);
144
+ } catch (err) { reportSwallowed('proxy.role-classify', err); }
145
+ }
146
+ }
147
+ const originalBaseUrl = getOriginalBaseUrl(roleProfile);
148
+
119
149
  const fetchOptions = {
120
150
  method: req.method,
121
151
  headers: headers,
@@ -136,7 +166,7 @@ export function startProxy() {
136
166
  // 模型替换由重试引擎用 resolveProfileModel 纯函数完成(interceptor 对 trace 请求会再跑一次,幂等 no-op)。
137
167
  // 非大模型 API 请求走原逻辑(同样用 x-cc-viewer-trace 让 interceptor 记录,但不经重试引擎)。
138
168
  if (isAnthropicApiPath(fullUrl)) {
139
- await handleLlmApiRequest(req, res, fullUrl, fetchOptions, body, proxyDispatcher);
169
+ await handleLlmApiRequest(req, res, fullUrl, fetchOptions, body, proxyDispatcher, roleProfile);
140
170
  return;
141
171
  }
142
172
 
@@ -226,10 +256,11 @@ export function startProxy() {
226
256
  // 每请求取最新值,UI 改 retry-config.json 后下一个请求即生效,无需重启。
227
257
  const _retryConfigGetter = () => interceptor._retryConfigState;
228
258
 
229
- async function handleLlmApiRequest(req, res, fullUrl, fetchOptions, body, proxyDispatcher) {
259
+ async function handleLlmApiRequest(req, res, fullUrl, fetchOptions, body, proxyDispatcher, roleProfile) {
230
260
  const statsEnabled = process.env.CCV_PROXY_STATS !== 'off';
231
261
  const retryConfig = _retryConfigGetter(); // live binding:每请求取最新重试配置
232
- const profile = interceptor._activeProfile || null;
262
+ // 角色解析后的有效 profile(上游/模型替换/统计归属)。undefined = 未分类 沿用 main 活跃 profile。
263
+ const profile = roleProfile !== undefined ? roleProfile : (interceptor._activeProfile || null);
233
264
 
234
265
  // 清理可能的 trace 头(singleFetch 会重新加,避免旧值干扰);interceptor 会正常记录请求
235
266
  // 同时清理 hop-by-hop / 传输编码头:body 已被 buffer 成完整 Buffer,
@@ -239,6 +239,12 @@ async function _spawnClaudeImpl(proxyPort, cwd, extraArgs = [], claudePath = nul
239
239
  env.CCV_LOG_DIR = LOG_DIR; // 让 fork 出的 Claude Code 进程找到同一份 profile.json 等资源
240
240
  // 剥离 cc-viewer 的内部短路开关,避免泄漏给 claude 子进程
241
241
  delete env.CCV_SKIP_THINKING_DISPLAY;
242
+ // 剥离 server 专属的模式标记:spawned claude(尤其 teammate 子进程,它们会装 fetch hook)
243
+ // 不是 ccv server —— 继承 CCV_WORKSPACE_MODE 会让 interceptor 的 workspace 绑定终生为空
244
+ // (teammate 角色分配静默失效);CCV_ELECTRON_MULTITAB 同理只应由 server 进程持有
245
+ // (im-process-manager 对 IM worker 已有同款剥离先例)。
246
+ delete env.CCV_WORKSPACE_MODE;
247
+ delete env.CCV_ELECTRON_MULTITAB;
242
248
  // Claude Code NO_FLICKER 会让嵌入式 xterm 走 alt-screen 并丢失 scrollback。
243
249
  // cc-viewer 默认剥离继承值;确实需要时可显式设 CCV_KEEP_CLAUDE_CODE_NO_FLICKER=1。
244
250
  stripClaudeNoFlickerUnlessOptedIn(env);
@@ -7,11 +7,15 @@
7
7
  // 鉴权沿用 dispatch 之前的全局鉴权(与 files-fs 写操作一致,不额外 gate isLocal)。
8
8
  import { join } from 'node:path';
9
9
  import { readFileSync } from 'node:fs';
10
- import { readWorkspaceSystemText, writeWorkspaceSystemText } from '../lib/system-prompt-files.js';
10
+ import {
11
+ readWorkspaceSystemText, writeWorkspaceSystemText,
12
+ isNonEmptyFile, SYSTEM_PROMPT_FILE, APPEND_SYSTEM_PROMPT_FILE, DISABLE_AUTO_SYSTEM_PROMPT_ENV,
13
+ } from '../lib/system-prompt-files.js';
11
14
  import {
12
15
  MODEL_PROMPT_DIR, normalizeModelName, listModelPrompts,
13
- writeModelPrompt, deleteModelPrompt,
16
+ writeModelPrompt, deleteModelPrompt, matchModelPrompt,
14
17
  } from '../lib/model-system-prompts.js';
18
+ import { resolveSpawnModel } from '../lib/spawn-model-resolver.js';
15
19
  import { listSystemPromptPresets, groupPresetsByCategory, getSystemPromptVariablesDoc } from '../lib/system-prompt-presets.js';
16
20
  import { LOG_DIR } from '../../findcc.js';
17
21
 
@@ -37,6 +41,30 @@ function sendJson(res, code, obj) {
37
41
  } catch { /* socket 已关闭:忽略 */ }
38
42
  }
39
43
 
44
+ // Single source for "is a custom system prompt configured to inject" — mirrors the
45
+ // spawn-time injection semantics of buildSystemPromptFileArgs: the env kill switch wins;
46
+ // a matched model entry supersedes the Default sentinels for activation purposes.
47
+ // Fidelity note: spawn-only gates this helper cannot see (insideLogDir skip, manual
48
+ // --system-prompt-file flags, one-shot skip tokens) may rarely make "active" a false
49
+ // positive — acceptable for a UI hint.
50
+ function computeSystemPromptStatus(dir) {
51
+ if (process.env[DISABLE_AUTO_SYSTEM_PROMPT_ENV] === '1') {
52
+ return { active: false, modelId: null, matched: null, defaultActive: false };
53
+ }
54
+ const defaultActive = !!dir && (
55
+ isNonEmptyFile(join(dir, SYSTEM_PROMPT_FILE)) || isNonEmptyFile(join(dir, APPEND_SYSTEM_PROMPT_FILE))
56
+ );
57
+ const modelId = resolveSpawnModel(dir, process.env);
58
+ const match = modelId
59
+ ? matchModelPrompt(modelId, [
60
+ { dir: dir ? join(dir, MODEL_PROMPT_DIR) : null, scope: 'workspace' },
61
+ { dir: join(LOG_DIR, MODEL_PROMPT_DIR), scope: 'global' },
62
+ ])
63
+ : null;
64
+ const matched = match ? { scope: match.scope, name: match.name, mode: match.mode } : null;
65
+ return { active: !!matched || defaultActive, modelId, matched, defaultActive };
66
+ }
67
+
40
68
  async function getSystemText(req, res, parsedUrl, isLocal, deps) {
41
69
  try {
42
70
  const dir = await resolveDir(deps);
@@ -95,12 +123,17 @@ async function getModelPrompts(req, res, parsedUrl, isLocal, deps) {
95
123
  try {
96
124
  const dir = await resolveDir(deps);
97
125
  const globalDir = join(LOG_DIR, MODEL_PROMPT_DIR);
126
+ const status = computeSystemPromptStatus(dir);
98
127
  sendJson(res, 200, {
99
128
  workspaceDir: dir || null,
100
129
  workspaceActive: !!dir,
101
130
  globalDir,
102
131
  workspace: dir ? collectModelEntries(join(dir, MODEL_PROMPT_DIR)) : [],
103
132
  global: collectModelEntries(globalDir),
133
+ // 当前生效配置解析出的模型 id 及其命中的条目(未命中为 null)——弹窗据此把默认页签
134
+ // 指向命中条目;matched.name 为规范化大写名,与页签 key 的构成一致。
135
+ modelId: status.modelId,
136
+ matched: status.matched,
104
137
  });
105
138
  } catch (e) {
106
139
  console.error('[CC Viewer] expert model-prompts GET failed:', e.message);
@@ -108,6 +141,17 @@ async function getModelPrompts(req, res, parsedUrl, isLocal, deps) {
108
141
  }
109
142
  }
110
143
 
144
+ // 轻量状态查询(不内联提示词文本):头部工具栏据此决定是否自动露出「系统提示词修改」入口。
145
+ async function getSystemPromptStatus(req, res, parsedUrl, isLocal, deps) {
146
+ try {
147
+ const dir = await resolveDir(deps);
148
+ sendJson(res, 200, computeSystemPromptStatus(dir));
149
+ } catch (e) {
150
+ console.error('[CC Viewer] expert system-prompt-status GET failed:', e.message);
151
+ sendJson(res, 500, { error: 'read_failed' });
152
+ }
153
+ }
154
+
111
155
  function postModelPrompts(req, res, parsedUrl, isLocal, deps) {
112
156
  let body = '';
113
157
  let truncated = false;
@@ -174,4 +218,5 @@ export const expertRoutes = [
174
218
  { method: 'GET', match: 'exact', path: '/api/expert/model-prompts', handler: getModelPrompts },
175
219
  { method: 'POST', match: 'exact', path: '/api/expert/model-prompts', handler: postModelPrompts },
176
220
  { method: 'GET', match: 'exact', path: '/api/expert/system-prompt-presets', handler: getSystemPromptPresets },
221
+ { method: 'GET', match: 'exact', path: '/api/expert/system-prompt-status', handler: getSystemPromptStatus },
177
222
  ];
@@ -3,8 +3,8 @@ 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, discoverClaudeExecutables, resolveExplicitClaudePath, CLAUDE_EXECUTABLE_PREF_KEY } from '../../findcc.js';
6
- import { PROFILE_PATH, _defaultConfig, getActiveProfileId, setActiveProfileForWorkspace, _loadProxyProfile, RETRY_CONFIG_PATH, _retryConfigState, _loadRetryConfigState } from '../interceptor.js';
7
- import { migrateProxyProfileList } from '../lib/interceptor-core.js';
6
+ import { PROFILE_PATH, _defaultConfig, getActiveProfileId, getStoredRoles, isOfficialDefaultEndpoint, setActiveProfileForWorkspace, _loadProxyProfile, RETRY_CONFIG_PATH, _retryConfigState, _loadRetryConfigState } from '../interceptor.js';
7
+ import { migrateProxyProfileList, isValidRoleValue, PROXY_ROLE_KEYS } from '../lib/interceptor-core.js';
8
8
  import { DEFAULT_RETRY_CONFIG, validateRetryConfig, resolveRetryConfig } from '../lib/proxy-retry.js';
9
9
  import { discoverCcSwitchProviders, mergeImportedProfiles } from '../lib/ccswitch-import.js';
10
10
  import { reportSwallowed } from '../lib/error-report.js';
@@ -307,7 +307,9 @@ function proxyProfilesGet(req, res, parsedUrl, isLocal, deps) {
307
307
  // 本机(127.0.0.1)= admin:下发明文 profile.apiKey 供本人在编辑表单(👁 折叠)里查阅/复制;已授权
308
308
  // 的远程客户端只拿脱敏值(****+后4位)。保存时若回传脱敏值,POST 侧 isMasked() 会保留磁盘原值。
309
309
  // 镜像 /api/auth/state 的密码、/api/dingtalk/status 的 appSecret 策略。
310
- const full = { ...data, active: effectiveActive };
310
+ // roles 返回**存储值**(不做休眠调整):休眠只是请求侧不生效,若返回有效值,UI 缓存后
311
+ // 在下次无关 POST 回写会把休眠中的分配清掉。officialDefault 驱动 UI 隐藏角色分配区。
312
+ const full = { ...data, active: effectiveActive, roles: getStoredRoles(), officialDefault: isOfficialDefaultEndpoint() };
311
313
  const payload = isLocal ? full : deps.maskProfiles(full);
312
314
  // defaultConfig.apiKey 始终脱敏:它在列表里是常显文本(无 👁 折叠),且 Max/OAuth 默认配置的 key
313
315
  // 可能是 OAuth token;只有可编辑 profile 的 key 才按 isLocal 明文下发。
@@ -351,9 +353,24 @@ function proxyProfilesPost(req, res, parsedUrl, isLocal, deps) {
351
353
  const dir = dirname(PROFILE_PATH);
352
354
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
353
355
  writeFileSync(PROFILE_PATH, JSON.stringify(toWrite, null, 2), { mode: 0o600 });
354
- // active workspace 级别存储(当前进程独占)
355
- if (typeof incoming.active === 'string' && incoming.active) {
356
- setActiveProfileForWorkspace(incoming.active);
356
+ // 角色分配校验:只认 subagent/teammate 两个 key(roles.main 之类的杂键直接丢弃);
357
+ // 值必须是 follow / max / 入参 profiles 里存在的 id(按将落盘的列表校验,而非旧文件),
358
+ // 非法值归 'follow'(宽容风格,不 400)。被删 profile 的悬空角色由读取时归 follow 兜底。
359
+ let sanitizedRoles;
360
+ if (incoming.roles && typeof incoming.roles === 'object' && !Array.isArray(incoming.roles)) {
361
+ const byId = new Map(incoming.profiles.map(p => [p.id, p]));
362
+ sanitizedRoles = {};
363
+ for (const k of PROXY_ROLE_KEYS) {
364
+ if (!(k in incoming.roles)) continue; // 未提交的 key 不进 patch → 合并时保留存储值
365
+ const v = incoming.roles[k];
366
+ // key 提交了但值非法(非字符串 / 悬空 id)→ 归 'follow'(显式纠正,不是保留)
367
+ sanitizedRoles[k] = (typeof v === 'string' && v && isValidRoleValue(v, byId)) ? v : 'follow';
368
+ }
369
+ }
370
+ // active/roles 走 workspace 级别存储(当前进程独占);合并语义:缺省字段保留文件现值
371
+ const hasActive = typeof incoming.active === 'string' && incoming.active;
372
+ if (hasActive || sanitizedRoles) {
373
+ setActiveProfileForWorkspace(hasActive ? incoming.active : undefined, sanitizedRoles);
357
374
  } else {
358
375
  _loadProxyProfile(); // 仅列表变化时也刷新一次以反映删除 / 重命名
359
376
  }
@@ -361,7 +378,7 @@ function proxyProfilesPost(req, res, parsedUrl, isLocal, deps) {
361
378
  const effectiveActive = getActiveProfileId();
362
379
  const activeProfile = incoming.profiles?.find(p => p.id === effectiveActive) || null;
363
380
  const maskedProfile = activeProfile?.apiKey ? { ...activeProfile, apiKey: deps.maskApiKey(activeProfile.apiKey) } : activeProfile;
364
- sendEventToClients(deps.clients, 'proxy_profile', { active: effectiveActive, profile: maskedProfile });
381
+ sendEventToClients(deps.clients, 'proxy_profile', { active: effectiveActive, profile: maskedProfile, roles: getStoredRoles() });
365
382
  res.writeHead(200, { 'Content-Type': 'application/json' });
366
383
  res.end(JSON.stringify({ ok: true }));
367
384
  } catch {
@@ -95,6 +95,7 @@ export function createV3Assembler() {
95
95
  // teammates, persisted server-side (see agent-id.js).
96
96
  if (row.agent) entry.agent = row.agent;
97
97
  if (row.proxyUrl) entry.proxyUrl = row.proxyUrl;
98
+ if (row.proxyRole) entry.proxyRole = row.proxyRole;
98
99
  if (isSnapshot) entry._isCheckpoint = true;
99
100
  if (row.inProgress) {
100
101
  entry.inProgress = true;
@@ -17,6 +17,7 @@ export function rowToListItem(row) {
17
17
  timestamp: row.timestamp,
18
18
  url: row.url,
19
19
  proxyUrl: row.proxyUrl,
20
+ proxyRole: row.proxyRole,
20
21
  duration: row.duration,
21
22
  inProgress: row.inProgress === true,
22
23
  isHeartbeat: row.kind === 'heartbeat',