chatccc 0.2.55 → 0.2.57

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.
@@ -53,6 +53,7 @@ interface SdkMessageLike {
53
53
 
54
54
  export interface ClaudeAdapterOptions {
55
55
  model: string;
56
+ subagentModel?: string;
56
57
  effort: string;
57
58
  /** 判断字段是否为"不传给 SDK"的占位(项目约定:空字符串/全空白) */
58
59
  isEmpty: (value: string) => boolean;
@@ -83,28 +84,35 @@ export interface ClaudeAdapterOptions {
83
84
  function buildSdkEnv(
84
85
  apiKey: string | undefined,
85
86
  baseUrl: string | undefined,
87
+ subagentModel: string | undefined,
86
88
  ): Record<string, string | undefined> | undefined {
87
- const apiKeyTrim = (apiKey ?? "").trim();
88
- const baseUrlTrim = (baseUrl ?? "").trim();
89
- if (!apiKeyTrim && !baseUrlTrim) return undefined;
90
-
91
- const env: Record<string, string | undefined> = { ...process.env };
92
- // ChatCCC's Claude API config is authoritative when present. Remove Claude
93
- // Code/user settings env that can silently override gateway/auth/model choice.
94
- delete env.ANTHROPIC_AUTH_TOKEN;
95
- delete env.CLAUDE_CODE_OAUTH_TOKEN;
96
- delete env.ANTHROPIC_MODEL;
97
- delete env.ANTHROPIC_DEFAULT_OPUS_MODEL;
98
- delete env.ANTHROPIC_DEFAULT_SONNET_MODEL;
99
- delete env.ANTHROPIC_DEFAULT_HAIKU_MODEL;
100
- delete env.CLAUDE_CODE_SUBAGENT_MODEL;
101
- delete env.CLAUDE_CODE_EFFORT_LEVEL;
102
-
103
- if (apiKeyTrim) env.ANTHROPIC_API_KEY = apiKeyTrim;
104
- if (baseUrlTrim) env.ANTHROPIC_BASE_URL = baseUrlTrim;
105
- else delete env.ANTHROPIC_BASE_URL;
106
- return env;
107
- }
89
+ const apiKeyTrim = (apiKey ?? "").trim();
90
+ const baseUrlTrim = (baseUrl ?? "").trim();
91
+ const subagentModelTrim = (subagentModel ?? "").trim();
92
+ const hasApiOverride = Boolean(apiKeyTrim || baseUrlTrim);
93
+ if (!hasApiOverride) return undefined;
94
+
95
+ const env: Record<string, string | undefined> = { ...process.env };
96
+ // ChatCCC's third-party Claude API config is authoritative when present.
97
+ // Remove Claude Code/user settings env that can silently override gateway/auth/model choice.
98
+ delete env.ANTHROPIC_AUTH_TOKEN;
99
+ delete env.CLAUDE_CODE_OAUTH_TOKEN;
100
+ delete env.ANTHROPIC_MODEL;
101
+ delete env.ANTHROPIC_DEFAULT_OPUS_MODEL;
102
+ delete env.ANTHROPIC_DEFAULT_SONNET_MODEL;
103
+ delete env.ANTHROPIC_DEFAULT_HAIKU_MODEL;
104
+ delete env.CLAUDE_CODE_SUBAGENT_MODEL;
105
+ delete env.CLAUDE_CODE_EFFORT_LEVEL;
106
+
107
+ if (apiKeyTrim) env.ANTHROPIC_API_KEY = apiKeyTrim;
108
+ if (baseUrlTrim) {
109
+ env.ANTHROPIC_BASE_URL = baseUrlTrim;
110
+ } else {
111
+ delete env.ANTHROPIC_BASE_URL;
112
+ }
113
+ if (subagentModelTrim) env.CLAUDE_CODE_SUBAGENT_MODEL = subagentModelTrim;
114
+ return env;
115
+ }
108
116
 
109
117
  function resolveSettingSources(
110
118
  _apiKey: string | undefined,
@@ -126,6 +134,7 @@ function buildSessionOptions(
126
134
  isEmpty: (value: string) => boolean,
127
135
  apiKey: string | undefined,
128
136
  baseUrl: string | undefined,
137
+ subagentModel: string | undefined,
129
138
  ): Record<string, unknown> {
130
139
  const o: Record<string, unknown> = {
131
140
  cwd,
@@ -136,7 +145,7 @@ function buildSessionOptions(
136
145
  };
137
146
  if (!isEmpty(model)) o.model = model;
138
147
  if (!isEmpty(effort)) o.effort = effort;
139
- const env = buildSdkEnv(apiKey, baseUrl);
148
+ const env = buildSdkEnv(apiKey, baseUrl, subagentModel);
140
149
  if (env) o.env = env;
141
150
  return o;
142
151
  }
@@ -213,6 +222,7 @@ class ClaudeAdapter implements ToolAdapter {
213
222
  readonly sessionDescPrefix = "Claude Code Session:";
214
223
  private model: string;
215
224
  private effort: string;
225
+ private subagentModel: string | undefined;
216
226
  private isEmpty: (value: string) => boolean;
217
227
  private apiKey: string | undefined;
218
228
  private baseUrl: string | undefined;
@@ -220,6 +230,7 @@ class ClaudeAdapter implements ToolAdapter {
220
230
  constructor(options: ClaudeAdapterOptions) {
221
231
  this.model = options.model;
222
232
  this.effort = options.effort;
233
+ this.subagentModel = options.subagentModel;
223
234
  this.isEmpty = options.isEmpty;
224
235
  this.apiKey = options.apiKey;
225
236
  this.baseUrl = options.baseUrl;
@@ -233,6 +244,7 @@ class ClaudeAdapter implements ToolAdapter {
233
244
  this.isEmpty,
234
245
  this.apiKey,
235
246
  this.baseUrl,
247
+ this.subagentModel,
236
248
  );
237
249
  const session = unstable_v2_createSession(sessionOpts as any);
238
250
 
@@ -277,13 +289,21 @@ class ClaudeAdapter implements ToolAdapter {
277
289
  this.isEmpty,
278
290
  this.apiKey,
279
291
  this.baseUrl,
292
+ this.subagentModel,
280
293
  );
281
- const session = unstable_v2_resumeSession(
282
- sessionId,
283
- sessionOpts as any,
284
- );
285
-
286
- await session.send(userText);
294
+ const session = unstable_v2_resumeSession(
295
+ sessionId,
296
+ sessionOpts as any,
297
+ );
298
+
299
+ if (signal?.aborted) {
300
+ session.close();
301
+ return;
302
+ }
303
+ const onAbort = () => { session.close(); };
304
+ signal?.addEventListener("abort", onAbort, { once: true });
305
+
306
+ await session.send(userText);
287
307
 
288
308
  const stream = session.stream();
289
309
 
@@ -296,6 +316,7 @@ class ClaudeAdapter implements ToolAdapter {
296
316
  if (normalized) yield normalized;
297
317
  }
298
318
  } finally {
319
+ signal?.removeEventListener("abort", onAbort);
299
320
  session.close();
300
321
  }
301
322
  }
@@ -242,6 +242,9 @@ class CodexAdapter implements ToolAdapter {
242
242
 
243
243
  const proc = spawnCodex(args, cwd, userText);
244
244
 
245
+ const onAbort = () => { proc.kill(); };
246
+ signal?.addEventListener("abort", onAbort, { once: true });
247
+
245
248
  try {
246
249
  for await (const raw of readJsonLines(proc, signal)) {
247
250
  if (signal?.aborted) break;
@@ -261,6 +264,7 @@ class CodexAdapter implements ToolAdapter {
261
264
  if (normalized) yield normalized;
262
265
  }
263
266
  } finally {
267
+ signal?.removeEventListener("abort", onAbort);
264
268
  proc.kill();
265
269
  }
266
270
  }
@@ -346,6 +346,9 @@ class CursorAdapter implements ToolAdapter {
346
346
  const proc = spawnAgent(["--resume", sessionId], cwd, userText);
347
347
  this.activeProcs.add(proc);
348
348
 
349
+ const onAbort = () => { proc.kill(); };
350
+ signal?.addEventListener("abort", onAbort, { once: true });
351
+
349
352
  try {
350
353
  for await (const raw of readJsonLines(proc, signal)) {
351
354
  if (signal?.aborted) break;
@@ -367,6 +370,7 @@ class CursorAdapter implements ToolAdapter {
367
370
  if (normalized) yield normalized;
368
371
  }
369
372
  } finally {
373
+ signal?.removeEventListener("abort", onAbort);
370
374
  proc.kill();
371
375
  this.activeProcs.delete(proc);
372
376
  }
package/src/config.ts CHANGED
@@ -63,6 +63,7 @@ export interface ClaudeConfig {
63
63
  /** 是否作为 /new 未指定工具时使用的默认 Agent */
64
64
  defaultAgent: boolean;
65
65
  model: string;
66
+ subagentModel: string;
66
67
  effort: string;
67
68
  apiKey: string;
68
69
  baseUrl: string;
@@ -302,7 +303,7 @@ function loadConfig(): AppConfig {
302
303
  port: 18080,
303
304
  gitTimeoutSeconds: 180,
304
305
  allowInterrupt: false,
305
- claude: { enabled: false, defaultAgent: true, model: "", effort: "", apiKey: "", baseUrl: "" },
306
+ claude: { enabled: false, defaultAgent: true, model: "", subagentModel: "", effort: "", apiKey: "", baseUrl: "" },
306
307
  cursor: { enabled: false, defaultAgent: false, path: "", model: "claude-opus-4-7-max" },
307
308
  codex: { enabled: false, defaultAgent: false, path: "", model: "", effort: "" },
308
309
  };
@@ -336,6 +337,9 @@ function loadConfig(): AppConfig {
336
337
  let raw: string;
337
338
  try {
338
339
  raw = readFileSync(CONFIG_FILE, "utf-8");
340
+ // 移除可能意外写入的 UTF-8 BOM(如通过记事本编辑等场景),
341
+ // 避免 JSON.parse 因 BOM 失败导致返回空默认值、丢失所有配置。
342
+ if (raw.charCodeAt(0) === 0xfeff) raw = raw.slice(1);
339
343
  } catch (err) {
340
344
  console.error(`[CONFIG] 无法读取 config.json: ${(err as Error).message}`);
341
345
  return defaults;
@@ -378,6 +382,7 @@ function loadConfig(): AppConfig {
378
382
  const claudeNonEmpty = (): boolean =>
379
383
  Boolean(
380
384
  (typeof claude.model === "string" && claude.model.trim()) ||
385
+ (typeof claude.subagentModel === "string" && claude.subagentModel.trim()) ||
381
386
  (typeof claude.effort === "string" && claude.effort.trim()) ||
382
387
  (typeof claude.apiKey === "string" && claude.apiKey.trim()) ||
383
388
  (typeof claude.baseUrl === "string" && claude.baseUrl.trim()),
@@ -438,6 +443,7 @@ function loadConfig(): AppConfig {
438
443
  enabled: claudeEnabled,
439
444
  defaultAgent: defaultTool === "claude",
440
445
  model: normalizeOptionalConfigField(claude.model, { label: "claude.model" }),
446
+ subagentModel: normalizeOptionalConfigField(claude.subagentModel, { label: "claude.subagentModel" }),
441
447
  effort: normalizeOptionalConfigField(claude.effort, { label: "claude.effort" }),
442
448
  apiKey: claude.apiKey ?? "",
443
449
  baseUrl: claude.baseUrl ?? "",
@@ -498,6 +504,7 @@ export const CHATCCC_PORT = config.port;
498
504
  export const LOCAL_RELAY_URL = `ws://127.0.0.1:${CHATCCC_PORT}`;
499
505
 
500
506
  export let CLAUDE_MODEL = config.claude.model;
507
+ export let CLAUDE_SUBAGENT_MODEL = config.claude.subagentModel;
501
508
  export let CLAUDE_EFFORT = config.claude.effort;
502
509
  /** Anthropic 兼容网关的 API key(仅经 SDK 子进程 env 传递,从不写入主进程 process.env) */
503
510
  export let CLAUDE_API_KEY = config.claude.apiKey;
@@ -573,6 +580,7 @@ export function applyLoadedConfig(next: AppConfig): void {
573
580
  ILINK_ENABLED = next.platforms.ilink.enabled;
574
581
  ILINK_REUSE_TOKEN_ON_START = next.platforms.ilink.reuseTokenOnStart ?? true;
575
582
  CLAUDE_MODEL = next.claude.model;
583
+ CLAUDE_SUBAGENT_MODEL = next.claude.subagentModel;
576
584
  CLAUDE_EFFORT = next.claude.effort;
577
585
  CLAUDE_API_KEY = next.claude.apiKey;
578
586
  CLAUDE_BASE_URL = next.claude.baseUrl;
@@ -705,6 +713,11 @@ export function reportEnvironmentVariableReadout(): void {
705
713
  ` Claude 模型: ${anthropicConfigDisplay(CLAUDE_MODEL)}(留空时不向 SDK 传 model)`
706
714
  );
707
715
 
716
+ console.log(` [默认] [可选] claude.subagentModel`);
717
+ console.log(
718
+ ` Claude Subagent 模型: ${anthropicConfigDisplay(CLAUDE_SUBAGENT_MODEL)}(留空时不向 SDK 传 CLAUDE_CODE_SUBAGENT_MODEL)`
719
+ );
720
+
708
721
  console.log(` [默认] [可选] claude.effort`);
709
722
  console.log(
710
723
  ` 思考深度: ${anthropicConfigDisplay(CLAUDE_EFFORT)}(留空时不向 SDK 传 effort)`
package/src/index.ts CHANGED
@@ -84,8 +84,9 @@ import {
84
84
  updateCardKitCard,
85
85
  } from "./cardkit.ts";
86
86
  import {
87
- MAX_PROCESSED,
88
- loadSessionRegistryForBinding,
87
+ MAX_PROCESSED,
88
+ clearAdapterCache,
89
+ loadSessionRegistryForBinding,
89
90
  processedMessages,
90
91
  rebuildBindingsFromRegistry,
91
92
  resetState,
@@ -743,11 +744,12 @@ async function main(): Promise<void> {
743
744
  // "保存并启动" 时,web-ui 会调用本回调,把磁盘上刚保存的 config.json
744
745
  // 刷进进程内的 export let 常量(live binding 让 CLAUDE_MODEL 等下次创建
745
746
  // 会话时自动看到新值)。setup 首次激活走 onActivate 路径,不依赖此 hook。
746
- setReloadConfigHook(() => {
747
- reloadConfigFromDisk();
748
- appendStartupTrace("reload-from-ui: config reloaded", {
749
- appIdMask: maskAppId(APP_ID),
750
- });
747
+ setReloadConfigHook(() => {
748
+ reloadConfigFromDisk();
749
+ clearAdapterCache();
750
+ appendStartupTrace("reload-from-ui: config reloaded", {
751
+ appIdMask: maskAppId(APP_ID),
752
+ });
751
753
  });
752
754
  setExtraApiHandler(async (req, res) => {
753
755
  return (await handleAgentImageRequest(req, res)) || (await handleAgentFileRequest(req, res));
@@ -767,11 +769,12 @@ async function main(): Promise<void> {
767
769
  // 凭证不全:进 setup 向导。注入 onActivate 回调让用户点"保存并启动"
768
770
  // 时,原地(同进程)调用 startBotService,复用 setup HTTP server。
769
771
  startSetupMode(CHATCCC_PORT, {
770
- onActivate: async (httpServer: Server) => {
771
- reloadConfigFromDisk();
772
- appendStartupTrace("setup-activate: reloaded config from disk", {
773
- appIdMaskAfterReload: maskAppId(APP_ID),
774
- });
772
+ onActivate: async (httpServer: Server) => {
773
+ reloadConfigFromDisk();
774
+ clearAdapterCache();
775
+ appendStartupTrace("setup-activate: reloaded config from disk", {
776
+ appIdMaskAfterReload: maskAppId(APP_ID),
777
+ });
775
778
  try {
776
779
  await startBotService({ httpServer, port: CHATCCC_PORT });
777
780
  installShutdownHandlers(httpServer);
package/src/privacy.ts CHANGED
@@ -1,68 +1,68 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
- import { join } from "node:path";
3
- import { USER_DATA_DIR } from "./config.ts";
4
-
5
- // ---------------------------------------------------------------------------
6
- // 隐私替换规则
7
- // ---------------------------------------------------------------------------
8
-
9
- interface PrivacyRules {
10
- [key: string]: string;
11
- }
12
-
13
- let rules: PrivacyRules | null = null;
14
- let loaded = false;
15
-
16
- function loadRules(): PrivacyRules {
17
- const filePath = join(USER_DATA_DIR, "privacy.json");
18
- if (!existsSync(filePath)) {
19
- return {};
20
- }
21
- try {
22
- const raw = readFileSync(filePath, "utf-8");
23
- const parsed = JSON.parse(raw);
24
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
25
- console.error(`[PRIVACY] privacy.json 格式错误:应为对象`);
26
- return {};
27
- }
28
- for (const [k, v] of Object.entries(parsed)) {
29
- if (typeof v !== "string") {
30
- console.error(`[PRIVACY] privacy.json 值必须为字符串,跳过 "${k}"`);
31
- delete (parsed as Record<string, unknown>)[k];
32
- }
33
- }
34
- return parsed as PrivacyRules;
35
- } catch (err) {
36
- console.error(`[PRIVACY] 读取 privacy.json 失败: ${(err as Error).message}`);
37
- return {};
38
- }
39
- }
40
-
41
- export function getPrivacyRules(): PrivacyRules {
42
- if (!loaded) {
43
- rules = loadRules();
44
- loaded = true;
45
- }
46
- return rules!;
47
- }
48
-
49
- /** 重新加载规则(热更新用) */
50
- export function reloadPrivacyRules(): void {
51
- loaded = false;
52
- rules = null;
53
- }
54
-
55
- /**
56
- * 对文本应用隐私替换规则。
57
- * 若无规则或文本为空,直接返回原文。
58
- */
59
- export function applyPrivacy(text: string): string {
60
- const r = getPrivacyRules();
61
- if (Object.keys(r).length === 0 || !text) return text;
62
- let result = text;
63
- for (const [from, to] of Object.entries(r)) {
64
- // 用 split+join 替代 replaceAll,避免正则特殊字符问题
65
- result = result.split(from).join(to);
66
- }
67
- return result;
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { USER_DATA_DIR } from "./config.ts";
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // 隐私替换规则
7
+ // ---------------------------------------------------------------------------
8
+
9
+ interface PrivacyRules {
10
+ [key: string]: string;
11
+ }
12
+
13
+ let rules: PrivacyRules | null = null;
14
+ let loaded = false;
15
+
16
+ function loadRules(): PrivacyRules {
17
+ const filePath = join(USER_DATA_DIR, "privacy.json");
18
+ if (!existsSync(filePath)) {
19
+ return {};
20
+ }
21
+ try {
22
+ const raw = readFileSync(filePath, "utf-8");
23
+ const parsed = JSON.parse(raw);
24
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
25
+ console.error(`[PRIVACY] privacy.json 格式错误:应为对象`);
26
+ return {};
27
+ }
28
+ for (const [k, v] of Object.entries(parsed)) {
29
+ if (typeof v !== "string") {
30
+ console.error(`[PRIVACY] privacy.json 值必须为字符串,跳过 "${k}"`);
31
+ delete (parsed as Record<string, unknown>)[k];
32
+ }
33
+ }
34
+ return parsed as PrivacyRules;
35
+ } catch (err) {
36
+ console.error(`[PRIVACY] 读取 privacy.json 失败: ${(err as Error).message}`);
37
+ return {};
38
+ }
39
+ }
40
+
41
+ export function getPrivacyRules(): PrivacyRules {
42
+ if (!loaded) {
43
+ rules = loadRules();
44
+ loaded = true;
45
+ }
46
+ return rules!;
47
+ }
48
+
49
+ /** 重新加载规则(热更新用) */
50
+ export function reloadPrivacyRules(): void {
51
+ loaded = false;
52
+ rules = null;
53
+ }
54
+
55
+ /**
56
+ * 对文本应用隐私替换规则。
57
+ * 若无规则或文本为空,直接返回原文。
58
+ */
59
+ export function applyPrivacy(text: string): string {
60
+ const r = getPrivacyRules();
61
+ if (Object.keys(r).length === 0 || !text) return text;
62
+ let result = text;
63
+ for (const [from, to] of Object.entries(r)) {
64
+ // 用 split+join 替代 replaceAll,避免正则特殊字符问题
65
+ result = result.split(from).join(to);
66
+ }
67
+ return result;
68
68
  }
package/src/session.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  CLAUDE_BASE_URL,
7
7
  CLAUDE_EFFORT,
8
8
  CLAUDE_MODEL,
9
+ CLAUDE_SUBAGENT_MODEL,
9
10
  CHATCCC_PORT,
10
11
  PROJECT_ROOT,
11
12
  SESSIONS_FILE,
@@ -172,6 +173,7 @@ export function getAdapterForTool(tool: string): ToolAdapter {
172
173
  } else {
173
174
  adapter = createClaudeAdapter({
174
175
  model: CLAUDE_MODEL,
176
+ subagentModel: CLAUDE_SUBAGENT_MODEL,
175
177
  effort: CLAUDE_EFFORT,
176
178
  isEmpty: isAnthropicConfigEmpty,
177
179
  apiKey: CLAUDE_API_KEY,
@@ -391,7 +393,8 @@ export function accumulateBlockContent(
391
393
  switch (block.type) {
392
394
  case "thinking":
393
395
  state.chunkCount++;
394
- state.accumulatedContent += block.thinking;
396
+ // 用引用块标记思考内容(中文无法斜体,引用块有视觉区分)
397
+ state.accumulatedContent += `\n> ${block.thinking.replace(/\n/g, "\n> ")}\n`;
395
398
  break;
396
399
  case "tool_use": {
397
400
  // 记录 tool_use 信息供后续 tool_result 使用
@@ -606,7 +609,7 @@ function formatToolConfigForLog(tool: string, sessionModel?: string): string {
606
609
  : "effort=(由 codex config.toml 决定)";
607
610
  return `model=${modelStr}, ${effortStr}`;
608
611
  }
609
- return `model=${anthropicConfigDisplay(CLAUDE_MODEL)}, effort=${anthropicConfigDisplay(CLAUDE_EFFORT)}`;
612
+ return `model=${anthropicConfigDisplay(CLAUDE_MODEL)}, subagentModel=${anthropicConfigDisplay(CLAUDE_SUBAGENT_MODEL)}, effort=${anthropicConfigDisplay(CLAUDE_EFFORT)}`;
610
613
  }
611
614
 
612
615
  export async function initClaudeSession(tool: string, overrideCwd?: string, chatId?: string): Promise<{ sessionId: string; cwd: string }> {
@@ -1322,6 +1325,10 @@ export function _setAdapterForToolForTest(tool: string, adapter: ToolAdapter): v
1322
1325
  adapterCache.set(tool, adapter);
1323
1326
  }
1324
1327
 
1325
- export function _clearAdapterCacheForTest(): void {
1328
+ export function clearAdapterCache(): void {
1326
1329
  adapterCache.clear();
1327
1330
  }
1331
+
1332
+ export function _clearAdapterCacheForTest(): void {
1333
+ clearAdapterCache();
1334
+ }