chatccc 0.2.257 → 0.2.259

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/README.md CHANGED
@@ -372,6 +372,7 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
372
372
  | `cursor.alternativeModel` / `codex.alternativeModel` / `ccc.alternativeModel` | 单个备选模型;加入 `/model` 人工切换列表,不会自动故障转移 |
373
373
  | `ccc.DEEPSEEK_API_KEY` / `ccc.DEEPSEEK_BASE_URL` | CCC Agent 的 API Key 和服务地址;**不限于 DeepSeek**——可填任意 OpenAI 兼容端点(OpenAI、Kimi、通义、智谱、Ollama 本地等) |
374
374
  | `ccc.model` | CCC Agent 默认模型 |
375
+ | `ccc.subModel` | CCC Agent 子模型(选填):用于内部轻量环节(压缩摘要生成、task 子代理任务);留空跟随主模型 |
375
376
  | `ccc.compactionTimeoutMs` | CCC Agent 上下文压缩单轮超时(毫秒),默认 300000(5 分钟);压缩超时会让整轮对话失败,建议保持默认或调大 |
376
377
  | `ccc.contextWindow` | CCC Agent 模型上下文窗口(token),默认 1048576(1M,DeepSeek V4 Pro/Flash 原生规格);压缩阈值自动 = 窗口 × 80%;超过模型/服务端实际上限会被 API 拒绝,可在 Web UI 下拉选择或自定义(单位 k) |
377
378
 
@@ -50,6 +50,7 @@
50
50
  "DEEPSEEK_API_KEY": "",
51
51
  "DEEPSEEK_BASE_URL": "https://api.deepseek.com/v1",
52
52
  "model": "deepseek-v4-pro",
53
+ "subModel": "",
53
54
  "alternativeModel": "",
54
55
  "effort": "",
55
56
  "provider": "",
@@ -75,6 +75,7 @@ $env:DEEPCCC_STREAMING="true"
75
75
  "apiKey": "sk-...",
76
76
  "baseURL": "https://api.deepseek.com/v1",
77
77
  "model": "deepseek-v4-pro",
78
+ "subModel": "",
78
79
  "effort": "",
79
80
  "streaming": true,
80
81
  "contextWindow": 1048576,
@@ -102,6 +103,16 @@ $env:DEEPCCC_STREAMING="true"
102
103
  API 拒绝(context length exceeded),实际窗口以模型与所用服务端为准(如 litellm 的
103
104
  `max_input_tokens`)。
104
105
 
106
+ `subModel` 是子模型(选填),默认 `""`(留空跟随主模型)。配置后,DeepCCC 内部的轻量
107
+ 环节——上下文压缩摘要生成、`task` 子代理任务——使用子模型执行,主对话仍用主模型。
108
+ 典型用法:主模型用 pro 承担复杂推理,子模型用 flash 做高频廉价的摘要与子任务。
109
+ 可通过 `DEEPCCC_SUB_MODEL` 环境变量或命令行 `--sub-model` 覆盖。
110
+
111
+ `task` 子代理工具:主模型可把边界清晰的独立子任务(仓库调研、长文档阅读、独立模块生成)
112
+ 委派给子代理执行——子代理使用子模型、独立上下文,不污染主对话上下文;结果截断回传。
113
+ 子代理**不能再次委派**(禁止嵌套),单轮最多 20 个工具步,超时与主会话压缩超时一致。
114
+ 仅在配置了子模型时建议使用(未配置时子代理跟随主模型,节省有限)。
115
+
105
116
  `rawStreamLogs.enabled` 默认 `true`,通过 `DEEPCCC_RAW_STREAM_LOGS` 环境变量或配置 JSON 关闭。
106
117
  开启时,每次对话的原始流按 gzip JSONL 落到 `~/.deepccc/raw-stream-logs/`,供
107
118
  `session_search` 工具在会话被压缩后找回被压缩消息的精确原文(检索时设置
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deepccc",
3
- "version": "0.1.25",
3
+ "version": "0.1.27",
4
4
  "description": "A lightweight coding agent with OpenAI-compatible and Anthropic Messages API support.",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [
@@ -57,6 +57,10 @@ function parseArgs(argv = process.argv.slice(2)) {
57
57
  config.model = next;
58
58
  i++;
59
59
  }
60
+ else if (arg === "--sub-model" && next !== undefined) {
61
+ config.subModel = next;
62
+ i++;
63
+ }
60
64
  else if (arg === "--effort" && next !== undefined) {
61
65
  config.effort = next;
62
66
  i++;
@@ -125,6 +129,7 @@ function printHelp(appConfig) {
125
129
  "Options:",
126
130
  ` --provider <name> API protocol: openai or anthropic (current default ${appConfig.provider})`,
127
131
  ` --model <name> Model name (current default ${appConfig.model})`,
132
+ ` --sub-model <name> Sub-model for lightweight steps (compaction/task; empty = follow main model)`,
128
133
  ` --effort <level> Reasoning effort: none/minimal/low/medium/high/xhigh/max (overrides config.effort)`,
129
134
  ` --base-url <url> Provider API base URL (current default ${appConfig.baseURL})`,
130
135
  " --api-key <key> API key",
@@ -12,6 +12,7 @@ export const DEFAULT_CONFIG = {
12
12
  apiKey: "",
13
13
  baseURL: "https://api.deepseek.com/v1",
14
14
  model: "deepseek-v4-pro",
15
+ subModel: "",
15
16
  effort: "",
16
17
  streaming: true,
17
18
  contextWindow: 1_048_576,
@@ -66,6 +67,7 @@ function loadConfig() {
66
67
  apiKey: env("DEEPCCC_API_KEY") ?? env("DEEPSEEK_API_KEY") ?? file.apiKey ?? DEFAULT_CONFIG.apiKey,
67
68
  baseURL: env("DEEPCCC_BASE_URL") ?? env("DEEPSEEK_BASE_URL") ?? file.baseURL ?? DEFAULT_CONFIG.baseURL,
68
69
  model: env("DEEPCCC_MODEL") ?? env("DEEPSEEK_MODEL") ?? file.model ?? DEFAULT_CONFIG.model,
70
+ subModel: env("DEEPCCC_SUB_MODEL") ?? file.subModel ?? DEFAULT_CONFIG.subModel,
69
71
  effort: env("DEEPCCC_EFFORT") ?? env("DEEPSEEK_EFFORT") ?? file.effort ?? DEFAULT_CONFIG.effort,
70
72
  streaming: boolEnv("DEEPCCC_STREAMING") ?? file.streaming ?? DEFAULT_CONFIG.streaming,
71
73
  contextWindow: numberEnv("DEEPCCC_CONTEXT_WINDOW") ?? file.contextWindow ?? DEFAULT_CONFIG.contextWindow,
@@ -2,6 +2,7 @@ import { createHash, randomBytes } from "node:crypto";
2
2
  import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import { hasMalformedToolProtocolText } from "./tool-protocol.js";
5
6
  export const DEFAULT_BUILTIN_CONTEXT_DIR = join(homedir(), ".deepccc", "sessions");
6
7
  /** 默认模型上下文窗口:1M(DeepSeek V4 Pro/Flash 原生规格)。 */
7
8
  export const DEFAULT_CONTEXT_WINDOW_TOKENS = 1_048_576;
@@ -290,14 +291,19 @@ export class BuiltinContextManager {
290
291
  ].join("\n"),
291
292
  });
292
293
  }
293
- messages.push(...this.state.messages);
294
+ // Keep malformed provider output on disk for diagnosis, but quarantine it
295
+ // from future prompts so one protocol failure cannot teach the model to
296
+ // repeat the same invalid tool syntax on every later turn.
297
+ messages.push(...this.modelSafeMessages());
294
298
  return messages;
295
299
  }
296
300
  planCompaction() {
297
- const estimated = estimateBuiltinContextTokens(this.state.summary, this.state.messages);
301
+ // The compaction model is still a model input: exclude quarantined protocol
302
+ // leaks here too, otherwise a later summary could reintroduce the bad syntax.
303
+ const messages = this.modelSafeMessages();
304
+ const estimated = estimateBuiltinContextTokens(this.state.summary, messages);
298
305
  if (estimated <= this.compactAtTokens)
299
306
  return null;
300
- const messages = this.state.messages;
301
307
  if (messages.length <= 1)
302
308
  return null;
303
309
  const earliestAllowed = Math.max(0, messages.length - this.keepRecentMessages);
@@ -340,6 +346,9 @@ export class BuiltinContextManager {
340
346
  writeFileSync(tmp, content, "utf8");
341
347
  renameSync(tmp, this.contextFilePath);
342
348
  }
349
+ modelSafeMessages() {
350
+ return this.state.messages.filter((message) => message.role !== "assistant" || !hasMalformedToolProtocolText(message.content));
351
+ }
343
352
  load() {
344
353
  if (!this.persist || !existsSync(this.contextFilePath))
345
354
  return emptyState(this.sessionId, this.cwd);
@@ -22,6 +22,8 @@ const SEARCH_TIMEOUT_MS = 15_000;
22
22
  const MAX_COMMAND_OUTPUT_BYTES = 256 * 1024;
23
23
  const DEFAULT_COMMAND_TIMEOUT_MS = 120_000;
24
24
  const MAX_COMMAND_TIMEOUT_MS = 900_000;
25
+ /** task 子代理工具:子任务结果回传主会话前的最大字符数(防止子代理长输出撑爆主上下文) */
26
+ export const MAX_TASK_OUTPUT_CHARS = 32_000;
25
27
  const requireFromHere = createRequire(import.meta.url);
26
28
  const FALLBACK_SKIPPED_DIRECTORIES = new Set([".git", "node_modules"]);
27
29
  /**
@@ -939,6 +941,7 @@ export async function applyPatchForTool(cwd, input) {
939
941
  }
940
942
  export function createBuiltinFileTools(cwd, options = {}) {
941
943
  const gate = options.permissionGate;
944
+ const runTask = options.runTask;
942
945
  /** 文件路径的候选匹配键:原始路径 + 绝对路径 + 相对 cwd 路径(正/反斜杠双版本,规则可任选其一) */
943
946
  const pathKeys = (p) => {
944
947
  const abs = resolveToolPath(cwd, p);
@@ -1023,6 +1026,25 @@ export function createBuiltinFileTools(cwd, options = {}) {
1023
1026
  return runCommandForTool(cwd, input, options.abortSignal);
1024
1027
  },
1025
1028
  }),
1029
+ task: tool({
1030
+ description: "把独立子任务委派给子代理执行:子代理使用子模型、拥有独立上下文,不污染主对话上下文。适合边界清晰、可独立交付的调研/代码生成子任务(如扫描整个仓库、阅读长文档、生成独立模块)。子代理不能再次委派任务(禁止嵌套),结果会截断回传。",
1031
+ inputSchema: jsonSchema({
1032
+ type: "object",
1033
+ additionalProperties: false,
1034
+ properties: {
1035
+ description: { type: "string", description: "子任务描述:目标、约束与交付物。请写清楚子代理需要返回什么。" },
1036
+ cwd: { type: "string", description: "可选子任务工作目录(绝对路径或相对主会话 cwd),默认继承主会话工作目录。" },
1037
+ },
1038
+ required: ["description"],
1039
+ }),
1040
+ execute: async (input, execOptions) => {
1041
+ if (!runTask) {
1042
+ throw new Error("task 工具不可用:当前环境未启用子代理执行器");
1043
+ }
1044
+ const result = await runTask(input, execOptions.abortSignal);
1045
+ return { result };
1046
+ },
1047
+ }),
1026
1048
  edit_file: tool({
1027
1049
  description: "通过精确的 oldText -> newText 替换编辑现有 UTF-8 文本文件。可行时使用 SHA-256 前置条件以避免覆盖并发编辑。",
1028
1050
  inputSchema: jsonSchema({
@@ -8,13 +8,14 @@ import { createAnthropic } from "@ai-sdk/anthropic";
8
8
  import { generateText, isLoopFinished, stepCountIs, streamText } from "ai";
9
9
  import { existsSync, readFileSync } from "node:fs";
10
10
  import { homedir } from "node:os";
11
- import { join } from "node:path";
11
+ import { isAbsolute, join, resolve } from "node:path";
12
12
  import { fileURLToPath } from "node:url";
13
13
  import { config as appConfig, normalizeDeepCccProvider, RAW_STREAM_LOGS_DIR, } from "./config.js";
14
14
  import { createRawStreamLog, } from "./raw-stream-log.js";
15
15
  import { buildPersistedAssistantMessage, buildSummaryPrompt, BuiltinContextManager, defaultBuiltinSessionId, } from "./context.js";
16
- import { createBuiltinFileTools } from "./file-tools.js";
16
+ import { createBuiltinFileTools, MAX_TASK_OUTPUT_CHARS } from "./file-tools.js";
17
17
  import { PermissionGate } from "./permissions.js";
18
+ import { hasMalformedToolProtocolText, TOOL_PROTOCOL_RECOVERY_PROMPT, } from "./tool-protocol.js";
18
19
  import { buildDefaultSkillDirs, buildSkillsIndexPrompt, scanSkillsDirs, } from "./skills.js";
19
20
  import { applyPrivacy, applyPrivacyToJson } from "./privacy.js";
20
21
  // ---------------------------------------------------------------------------
@@ -76,6 +77,15 @@ const COMPACTION_RECOVERY_HINT_DISABLED = [
76
77
  ].join("\n");
77
78
  export const DEFAULT_COMPACTION_TIMEOUT_MS = 5 * 60 * 1000;
78
79
  const MAX_COMPACTION_OUTPUT_TOKENS = 16_384;
80
+ /** task 子代理工具:子代理单轮对话的最大工具步数(防失控循环,结果收敛后即结束) */
81
+ const TASK_MAX_STEPS = 20;
82
+ /** 子代理最终输出截断:保留前 MAX_TASK_OUTPUT_CHARS 字符,尾部注明截断信息 */
83
+ function truncateTaskOutput(text) {
84
+ if (text.length <= MAX_TASK_OUTPUT_CHARS)
85
+ return text;
86
+ return (text.slice(0, MAX_TASK_OUTPUT_CHARS) +
87
+ `\n…[子代理输出已截断,共 ${text.length} 字符,仅保留前 ${MAX_TASK_OUTPUT_CHARS} 字符]`);
88
+ }
79
89
  const ANTHROPIC_TOOL_JSON_COMPATIBILITY_NOTE = [
80
90
  "[Protocol compatibility note]",
81
91
  "tool-call arguments use JSON encoding; the final reply does not need to be JSON unless the user requests it.",
@@ -217,17 +227,71 @@ function normalizeAnthropicBaseURL(baseURL) {
217
227
  }
218
228
  export class ChatSession {
219
229
  model;
230
+ /** 子模型实例;未配置 subModel 时与主模型同一实例 */
231
+ subModel;
220
232
  provider;
233
+ apiKey;
234
+ baseURL;
235
+ modelId;
236
+ subModelId;
221
237
  cwd;
222
238
  context;
223
239
  compactionTimeoutMs;
224
240
  maxSteps;
225
241
  effort;
242
+ permissionMode;
243
+ permissionResolver;
226
244
  permissionGate;
227
245
  skillDirs;
228
246
  customSystemPrompt;
229
247
  /** 最近一次 chat() 使用的 system prompt(供 history 等读取) */
230
248
  systemPrompt = "";
249
+ /**
250
+ * task 子代理工具执行器:用子模型开独立子会话(独立上下文、独立 cwd)执行子任务,
251
+ * 回传最终文本(截断 + 隐私替换)。单层:子会话的工具集不包含 runTask,天然禁止嵌套。
252
+ */
253
+ runTask = async (input, signal) => {
254
+ const rawCwd = input.cwd?.trim();
255
+ const taskCwd = rawCwd ? (isAbsolute(rawCwd) ? rawCwd : resolve(this.cwd, rawCwd)) : this.cwd;
256
+ const timeoutController = new AbortController();
257
+ const timeout = setTimeout(() => timeoutController.abort(), this.compactionTimeoutMs);
258
+ timeout.unref?.();
259
+ const taskSignal = signal
260
+ ? AbortSignal.any([signal, timeoutController.signal])
261
+ : timeoutController.signal;
262
+ try {
263
+ const child = new ChatSession({
264
+ provider: this.provider,
265
+ apiKey: this.apiKey,
266
+ baseURL: this.baseURL,
267
+ model: this.subModelId || this.modelId,
268
+ effort: this.effort,
269
+ }, {
270
+ cwd: taskCwd,
271
+ persist: false,
272
+ permissionMode: this.permissionMode,
273
+ permissionResolver: this.permissionResolver,
274
+ maxSteps: TASK_MAX_STEPS,
275
+ compactionTimeoutMs: this.compactionTimeoutMs,
276
+ skillsDirs: this.skillDirs.map((s) => s.dir),
277
+ });
278
+ let full = "";
279
+ for await (const event of child.chat(input.description, taskSignal)) {
280
+ if (event.type === "text") {
281
+ full += event.text;
282
+ }
283
+ else if (event.type === "error") {
284
+ throw new Error(`task 子代理执行失败: ${event.message}`);
285
+ }
286
+ }
287
+ if (!full.trim())
288
+ return "(子代理未返回文本内容)";
289
+ return applyPrivacy(truncateTaskOutput(full));
290
+ }
291
+ finally {
292
+ clearTimeout(timeout);
293
+ }
294
+ };
231
295
  constructor(overrides = {}, options = {}) {
232
296
  const apiKey = overrides.apiKey ?? appConfig.apiKey;
233
297
  if (!apiKey) {
@@ -235,28 +299,32 @@ export class ChatSession {
235
299
  }
236
300
  const baseURL = overrides.baseURL ?? appConfig.baseURL;
237
301
  const modelId = overrides.model ?? appConfig.model;
302
+ this.modelId = modelId;
303
+ this.subModelId = (overrides.subModel ?? appConfig.subModel ?? "").trim();
238
304
  this.provider = normalizeDeepCccProvider(overrides.provider ?? appConfig.provider);
239
305
  this.effort = (overrides.effort ?? appConfig.effort ?? "").trim();
240
- if (this.provider === "anthropic") {
241
- const provider = createAnthropic({
306
+ this.apiKey = apiKey;
307
+ this.baseURL = baseURL;
308
+ const provider = this.provider === "anthropic"
309
+ ? createAnthropic({
242
310
  baseURL: normalizeAnthropicBaseURL(baseURL),
243
311
  apiKey,
244
- });
245
- this.model = provider(modelId);
246
- }
247
- else {
248
- const provider = createOpenAICompatible({
312
+ })
313
+ : createOpenAICompatible({
249
314
  name: "deepccc",
250
315
  baseURL,
251
316
  apiKey,
252
317
  includeUsage: true,
253
318
  });
254
- this.model = provider(modelId);
255
- }
319
+ this.model = provider(modelId);
320
+ // 子模型:留空时与主模型共用同一实例(行为与旧版完全一致,零开销)
321
+ this.subModel = this.subModelId ? provider(this.subModelId) : this.model;
256
322
  this.cwd = options.cwd ?? process.cwd();
257
323
  this.maxSteps = normalizeMaxSteps(options.maxSteps);
258
324
  this.compactionTimeoutMs = Math.max(1, options.compactionTimeoutMs ?? DEFAULT_COMPACTION_TIMEOUT_MS);
259
325
  this.customSystemPrompt = options.systemPrompt ?? "";
326
+ this.permissionMode = options.permissionMode ?? "ask";
327
+ this.permissionResolver = options.permissionResolver;
260
328
  // 技能目录在构造时确定;技能内容在每次 chat() 前重新扫描(mtime 热加载),
261
329
  // 因此创建/修改技能后下一次对话自动生效,无需重启。
262
330
  this.skillDirs =
@@ -271,7 +339,7 @@ export class ChatSession {
271
339
  compactAtTokens: options.compactAtTokens,
272
340
  keepRecentMessages: options.keepRecentMessages,
273
341
  });
274
- this.permissionGate = new PermissionGate(options.permissionMode ?? "ask", options.permissionResolver);
342
+ this.permissionGate = new PermissionGate(this.permissionMode, this.permissionResolver);
275
343
  }
276
344
  /**
277
345
  * 组装系统提示词。顺序遵循“稳定性优先”原则(缓存命中友好):
@@ -333,7 +401,6 @@ export class ChatSession {
333
401
  catch (err) {
334
402
  console.error(`[DeepCCC raw stream log] create failed: ${errorMessage(err)}`);
335
403
  }
336
- const toolContext = [];
337
404
  const maxSteps = this.maxSteps;
338
405
  // 每次对话前重新扫描技能索引(并行 + mtime 缓存,开销极小):
339
406
  // 新技能/修改的技能在下一次对话自动生效(热加载)。
@@ -356,97 +423,143 @@ export class ChatSession {
356
423
  ? { deepseek: { reasoningEffort: this.effort } }
357
424
  : { anthropic: { effort: this.effort } };
358
425
  }
359
- const generationOptions = {
426
+ const baseGenerationOptions = {
360
427
  model: this.model,
361
428
  system,
362
- messages: modelMessages,
363
- tools: createBuiltinFileTools(this.cwd, { permissionGate: this.permissionGate }),
429
+ tools: createBuiltinFileTools(this.cwd, {
430
+ permissionGate: this.permissionGate,
431
+ runTask: this.runTask,
432
+ }),
364
433
  stopWhen: maxSteps !== undefined ? stepCountIs(maxSteps) : isLoopFinished(),
365
434
  abortSignal: signal,
366
435
  ...(effortProviderOptions ? { providerOptions: effortProviderOptions } : {}),
367
436
  };
368
- let stream;
369
- if (appConfig.streaming) {
370
- const result = streamText(generationOptions);
371
- stream = result.fullStream ?? textStreamToFullStream(result.textStream);
372
- }
373
- else {
374
- const result = await generateText(generationOptions);
375
- stream = generateResultToFullStream(result);
376
- }
377
- for await (const part of stream) {
378
- rawLog?.writeLine(safeRawStreamJson(part));
379
- if (part.type === "text-delta") {
380
- fullText += part.text;
381
- // 隐私替换只在展示层:safeAccumulated 供事件消费者(终端/JSONL)使用,
382
- // fullText 原文用于持久化上下文,避免替换结果回流污染上下文。
383
- const safeText = applyPrivacy(part.text);
384
- safeAccumulated += safeText;
385
- yield { type: "text", text: safeText, accumulated: safeAccumulated };
437
+ for (let attempt = 0; attempt < 2; attempt += 1) {
438
+ fullText = "";
439
+ safeAccumulated = "";
440
+ const toolContext = [];
441
+ toolCallsById.clear();
442
+ toolCallOrder.length = 0;
443
+ let lastReasoningProgressAt;
444
+ const attemptMessages = attempt === 0
445
+ ? modelMessages
446
+ : [...modelMessages, { role: "user", content: TOOL_PROTOCOL_RECOVERY_PROMPT }];
447
+ const generationOptions = {
448
+ ...baseGenerationOptions,
449
+ messages: attemptMessages,
450
+ };
451
+ let stream;
452
+ if (appConfig.streaming) {
453
+ const result = streamText(generationOptions);
454
+ stream = result.fullStream ?? textStreamToFullStream(result.textStream);
386
455
  }
387
- else if (part.type === "tool-call") {
388
- toolContext.push(`tool_call ${part.toolName}: ${safeJson(part.input)}`);
389
- toolCallsById.set(part.toolCallId, { name: part.toolName, input: safeJson(part.input) });
390
- toolCallOrder.push(part.toolCallId);
391
- yield {
392
- type: "tool_use",
393
- id: part.toolCallId,
394
- name: part.toolName,
395
- input: applyPrivacyToJson(part.input),
396
- };
456
+ else {
457
+ const result = await generateText(generationOptions);
458
+ stream = generateResultToFullStream(result);
397
459
  }
398
- else if (part.type === "tool-result") {
399
- toolContext.push(`tool_result ${part.toolName}: ${truncateToolContext(safeJson(part.output))}`);
400
- const call = toolCallsById.get(part.toolCallId);
401
- if (call) {
402
- call.output = truncateToolContext(safeJson(part.output));
460
+ for await (const part of stream) {
461
+ rawLog?.writeLine(safeRawStreamJson(part));
462
+ if (part.type === "reasoning-start" || part.type === "reasoning-delta") {
463
+ // Reasoning content remains private. A throttled heartbeat is enough
464
+ // for ChatCCC to distinguish active inference from a stalled stream.
465
+ const now = Date.now();
466
+ if (lastReasoningProgressAt === undefined || now - lastReasoningProgressAt >= 1_000) {
467
+ lastReasoningProgressAt = now;
468
+ yield { type: "progress", phase: "reasoning" };
469
+ }
403
470
  }
404
- yield {
405
- type: "tool_result",
406
- tool_use_id: part.toolCallId,
407
- name: part.toolName,
408
- content: applyPrivacyToJson(part.output),
409
- is_error: false,
410
- };
411
- }
412
- else if (part.type === "tool-error") {
413
- const message = errorMessage(part.error);
414
- toolContext.push(`tool_error ${part.toolName}: ${message}`);
415
- const call = toolCallsById.get(part.toolCallId);
416
- if (call) {
417
- call.output = message;
418
- call.is_error = true;
471
+ else if (part.type === "text-delta") {
472
+ fullText += part.text;
473
+ // 隐私替换只在展示层:safeAccumulated 供事件消费者(终端/JSONL)使用,
474
+ // fullText 原文用于持久化上下文,避免替换结果回流污染上下文。
475
+ const safeText = applyPrivacy(part.text);
476
+ safeAccumulated += safeText;
477
+ yield { type: "text", text: safeText, accumulated: safeAccumulated };
478
+ }
479
+ else if (part.type === "tool-call") {
480
+ toolContext.push(`tool_call ${part.toolName}: ${safeJson(part.input)}`);
481
+ toolCallsById.set(part.toolCallId, { name: part.toolName, input: safeJson(part.input) });
482
+ toolCallOrder.push(part.toolCallId);
483
+ yield {
484
+ type: "tool_use",
485
+ id: part.toolCallId,
486
+ name: part.toolName,
487
+ input: applyPrivacyToJson(part.input),
488
+ };
489
+ }
490
+ else if (part.type === "tool-result") {
491
+ toolContext.push(`tool_result ${part.toolName}: ${truncateToolContext(safeJson(part.output))}`);
492
+ const call = toolCallsById.get(part.toolCallId);
493
+ if (call)
494
+ call.output = truncateToolContext(safeJson(part.output));
495
+ yield {
496
+ type: "tool_result",
497
+ tool_use_id: part.toolCallId,
498
+ name: part.toolName,
499
+ content: applyPrivacyToJson(part.output),
500
+ is_error: false,
501
+ };
502
+ }
503
+ else if (part.type === "tool-error") {
504
+ const message = errorMessage(part.error);
505
+ toolContext.push(`tool_error ${part.toolName}: ${message}`);
506
+ const call = toolCallsById.get(part.toolCallId);
507
+ if (call) {
508
+ call.output = message;
509
+ call.is_error = true;
510
+ }
511
+ yield {
512
+ type: "tool_result",
513
+ tool_use_id: part.toolCallId,
514
+ name: part.toolName,
515
+ content: applyPrivacy(message),
516
+ is_error: true,
517
+ };
518
+ }
519
+ else if (part.type === "error") {
520
+ const message = errorMessage(part.error);
521
+ yield { type: "error", message: applyPrivacy(message) };
522
+ throw new Error(message);
419
523
  }
420
- yield {
421
- type: "tool_result",
422
- tool_use_id: part.toolCallId,
423
- name: part.toolName,
424
- content: applyPrivacy(message),
425
- is_error: true,
426
- };
427
524
  }
428
- else if (part.type === "error") {
429
- const message = errorMessage(part.error);
430
- yield { type: "error", message: applyPrivacy(message) };
431
- throw new Error(message);
525
+ if (hasMalformedToolProtocolText(fullText)) {
526
+ console.warn(`[DeepCCC] malformed DSML tool output detected for ${this.context.sessionId} `
527
+ + `(attempt ${attempt + 1}/2, structuredToolCalls=${toolCallOrder.length})`);
528
+ rawLog?.writeLine(safeRawStreamJson({
529
+ type: "deepccc_tool_protocol_recovery",
530
+ attempt: attempt + 1,
531
+ structuredToolCalls: toolCallOrder.length,
532
+ }));
533
+ yield { type: "text_reset" };
534
+ if (attempt === 0 && toolCallOrder.length === 0) {
535
+ yield { type: "status", phase: "generating" };
536
+ continue;
537
+ }
538
+ throw new Error(toolCallOrder.length > 0
539
+ ? "工具调用协议异常:检测到混合的结构化与 DSML 文本调用,为避免重复执行工具,本轮已安全终止"
540
+ : "工具调用协议异常:模型重试后仍输出了无效的 DSML 工具调用");
432
541
  }
542
+ completed = true;
543
+ const collectedToolCalls = toolCallOrder
544
+ .map((id) => toolCallsById.get(id))
545
+ .filter((call) => call !== undefined);
546
+ this.context.appendMessage(buildPersistedAssistantMessage({
547
+ fullText,
548
+ transcriptLines: toolContext,
549
+ toolCalls: collectedToolCalls,
550
+ }));
551
+ yield { type: "done", text: safeAccumulated };
552
+ return;
433
553
  }
434
- completed = true;
435
- const collectedToolCalls = toolCallOrder
436
- .map((id) => toolCallsById.get(id))
437
- .filter((call) => call !== undefined);
438
- this.context.appendMessage(buildPersistedAssistantMessage({
439
- fullText,
440
- transcriptLines: toolContext,
441
- toolCalls: collectedToolCalls,
442
- }));
443
- yield { type: "done", text: safeAccumulated };
444
554
  }
445
555
  catch (err) {
446
556
  const message = err instanceof Error ? err.message : String(err);
557
+ const malformedProtocolOutput = hasMalformedToolProtocolText(fullText);
558
+ if (malformedProtocolOutput)
559
+ yield { type: "text_reset" };
447
560
  if (err.name === "AbortError" || signal?.aborted) {
448
561
  // 被中断时,不保存不完整的助手消息
449
- if (fullText) {
562
+ if (fullText && !malformedProtocolOutput) {
450
563
  this.context.appendMessage({ role: "assistant", content: `${fullText}\n[interrupted]` });
451
564
  }
452
565
  yield { type: "done", text: safeAccumulated };
@@ -500,7 +613,7 @@ export class ChatSession {
500
613
  if (!plan)
501
614
  return 0;
502
615
  const result = await generateText({
503
- model: this.model,
616
+ model: this.subModel,
504
617
  system: SUMMARY_SYSTEM_PROMPT,
505
618
  messages: [{ role: "user", content: buildSummaryPrompt(plan) }],
506
619
  abortSignal: compactionSignal,
@@ -57,6 +57,10 @@ export function reduceProgress(prev, event) {
57
57
  case "text":
58
58
  // accumulated 是全文累积,直接全量替换,天然幂等
59
59
  return withProgressView(prev, { text: event.accumulated });
60
+ case "progress":
61
+ return withProgressView(prev, { headerTitle: "思考中..." });
62
+ case "text_reset":
63
+ return withProgressView(prev, { text: "", tools: [] });
60
64
  case "tool_use": {
61
65
  const tool = {
62
66
  id: event.id ?? `tool-${prev.tools.length + 1}`,
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Detects provider output that leaked DeepSeek's internal DSML tool-call syntax
3
+ * into normal assistant text. A trailing closing tag is intentionally required:
4
+ * mentioning DSML in prose or a code sample must not trigger recovery.
5
+ */
6
+ const MALFORMED_DSML_TRAILER = /<\/[||]{2}DSML[||]{2}(?:parameter|invoke)>\s*$/iu;
7
+ const RENDERED_TOOL_CALL = /\[调用\s+[A-Za-z_][\w.-]*\]/u;
8
+ const DSML_INVOKE_TAG = /<[||]{2}DSML[||]{2}invoke\b/iu;
9
+ export function hasMalformedToolProtocolText(text) {
10
+ return MALFORMED_DSML_TRAILER.test(text)
11
+ && (RENDERED_TOOL_CALL.test(text) || DSML_INVOKE_TAG.test(text));
12
+ }
13
+ export const TOOL_PROTOCOL_RECOVERY_PROMPT = [
14
+ "[系统恢复提示] 上一次响应泄漏了内部工具调用协议,因此已被丢弃。",
15
+ "请重新完成当前用户请求。需要调用工具时,只能使用 API 提供的结构化工具调用;不要把 DSML 或工具参数作为普通文本输出。",
16
+ "不要提及本恢复提示,也不要假装工具已经执行。",
17
+ ].join("\n");
@@ -27,6 +27,7 @@ export function createCccAdapter(options = {}) {
27
27
  ...(options.provider !== undefined ? { provider: options.provider } : {}),
28
28
  ...(options.baseURL !== undefined ? { baseURL: options.baseURL } : {}),
29
29
  ...(options.model !== undefined ? { model: options.model } : {}),
30
+ ...(options.subModel !== undefined ? { subModel: options.subModel } : {}),
30
31
  ...(options.effort !== undefined ? { effort: options.effort } : {}),
31
32
  };
32
33
  return {
@@ -54,6 +55,18 @@ export function createCccAdapter(options = {}) {
54
55
  }],
55
56
  };
56
57
  }
58
+ else if (event.type === "progress") {
59
+ yield {
60
+ type: "assistant",
61
+ blocks: [{ type: "agent_progress", phase: event.phase }],
62
+ };
63
+ }
64
+ else if (event.type === "text_reset") {
65
+ yield {
66
+ type: "assistant",
67
+ blocks: [{ type: "text_reset" }],
68
+ };
69
+ }
57
70
  else if (event.type === "text") {
58
71
  yield {
59
72
  type: "assistant",
@@ -67,6 +67,10 @@ export function updateAgentActivity(tracker, block, now = Date.now()) {
67
67
  if (tracker.activeTools.size > 0)
68
68
  return false;
69
69
  switch (block.type) {
70
+ case "agent_progress":
71
+ return setActivity(tracker, { kind: "thinking", startedAt: now });
72
+ case "text_reset":
73
+ return setActivity(tracker, { kind: "responding", startedAt: now });
70
74
  case "agent_status":
71
75
  return setActivity(tracker, {
72
76
  kind: block.status === "compacting" ? "compacting" : "responding",
@@ -0,0 +1,75 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Card action helper: parse button click into text command
3
+ // ---------------------------------------------------------------------------
4
+ import { buildUpdateCommandId, extractFeishuEventId } from "./update-command-guard.js";
5
+ /**
6
+ * 卡片按钮 value → 文本命令的映射表。
7
+ *
8
+ * 新增 help / progress 卡片按钮时,必须同步在此登记,否则点击会被
9
+ * parseCardAction 判定为未知 cmd 并静默丢弃(按钮无任何响应)。
10
+ */
11
+ export const CARD_ACTION_CMD_MAP = {
12
+ stop: "/stop",
13
+ cancel: "/cancel",
14
+ new: "/new",
15
+ "new claude": "/new claude",
16
+ "new cursor": "/new cursor",
17
+ "new codex": "/new codex",
18
+ "new ccc": "/new ccc",
19
+ restart: "/restart",
20
+ update: "/update",
21
+ state: "/state",
22
+ cd: "/cd",
23
+ sessions: "/sessions",
24
+ forget: "/forget",
25
+ };
26
+ /** 把按钮 cmd 映射为文本命令;未登记返回空字符串。 */
27
+ export function cardActionToCommand(cmd) {
28
+ return CARD_ACTION_CMD_MAP[cmd] ?? "";
29
+ }
30
+ export function parseCardAction(data) {
31
+ const raw = data?.event ?? data;
32
+ const action = raw?.action;
33
+ if (!action?.value)
34
+ return null;
35
+ let cmd;
36
+ if (typeof action.value === "object" && action.value !== null) {
37
+ cmd = action.value.action;
38
+ }
39
+ else if (typeof action.value === "string") {
40
+ try {
41
+ let v = JSON.parse(action.value);
42
+ if (typeof v === "string")
43
+ v = JSON.parse(v);
44
+ cmd = v.cmd ?? v.action;
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ }
50
+ if (!cmd)
51
+ return null;
52
+ let text = cardActionToCommand(cmd);
53
+ if (cmd === "cd" && typeof action.value === "object" && action.value !== null) {
54
+ const path = action.value.path;
55
+ if (path)
56
+ text = `/cd ${path}`;
57
+ }
58
+ // cmd 本身就是以 / 开头的完整指令时,直接使用(如 /model <name> 动态按钮)
59
+ if (!text && cmd.startsWith("/"))
60
+ text = cmd;
61
+ if (!text)
62
+ return null;
63
+ const chatId = raw.open_chat_id ??
64
+ raw.context?.open_chat_id ??
65
+ raw.message?.chat_id ??
66
+ "";
67
+ const openId = raw.operator?.open_id ??
68
+ "";
69
+ return {
70
+ text,
71
+ chatId,
72
+ openId,
73
+ commandId: buildUpdateCommandId("card", extractFeishuEventId(data)),
74
+ };
75
+ }
@@ -309,6 +309,7 @@ function loadConfig() {
309
309
  DEEPSEEK_API_KEY: "",
310
310
  DEEPSEEK_BASE_URL: DEFAULT_CCC_DEEPSEEK_BASE_URL,
311
311
  model: DEFAULT_CCC_MODEL,
312
+ subModel: "",
312
313
  alternativeModel: "",
313
314
  effort: "",
314
315
  provider: "",
@@ -501,6 +502,7 @@ function loadConfig() {
501
502
  fallback: DEFAULT_CCC_DEEPSEEK_BASE_URL,
502
503
  }),
503
504
  model: normalizeOptionalConfigField(cccRaw.model, { label: "ccc.model", fallback: DEFAULT_CCC_MODEL }),
505
+ subModel: normalizeOptionalConfigField(cccRaw.subModel, { label: "ccc.subModel" }),
504
506
  alternativeModel: normalizeOptionalConfigField(cccRaw.alternativeModel, { label: "ccc.alternativeModel" }),
505
507
  effort: normalizeOptionalConfigField(cccRaw.effort, { label: "ccc.effort" }),
506
508
  provider: normalizeCccProviderOverride(cccRaw.provider),
package/dist/src/index.js CHANGED
@@ -113,54 +113,8 @@ function getInnerEvent(data) {
113
113
  return (data.event ?? data);
114
114
  }
115
115
  import { formatMessageContent } from "./format-message.js";
116
- import { buildUpdateCommandId, extractFeishuEventId, } from "./update-command-guard.js";
117
- function parseCardAction(data) {
118
- const raw = data?.event ?? data;
119
- const action = raw?.action;
120
- if (!action?.value)
121
- return null;
122
- let cmd;
123
- if (typeof action.value === "object" && action.value !== null) {
124
- cmd = action.value.action;
125
- }
126
- else if (typeof action.value === "string") {
127
- try {
128
- let v = JSON.parse(action.value);
129
- if (typeof v === "string")
130
- v = JSON.parse(v);
131
- cmd = v.cmd ?? v.action;
132
- }
133
- catch {
134
- return null;
135
- }
136
- }
137
- if (!cmd)
138
- return null;
139
- const CMD_MAP = { stop: "/stop", cancel: "/cancel", new: "/new", "new claude": "/new claude", "new cursor": "/new cursor", "new codex": "/new codex", restart: "/restart", update: "/update", state: "/state", cd: "/cd", sessions: "/sessions", forget: "/forget" };
140
- let text = CMD_MAP[cmd] ?? "";
141
- if (cmd === "cd" && typeof action.value === "object" && action.value !== null) {
142
- const path = action.value.path;
143
- if (path)
144
- text = `/cd ${path}`;
145
- }
146
- // cmd 本身就是以 / 开头的完整指令时,直接使用(如 /model <name> 动态按钮)
147
- if (!text && cmd.startsWith("/"))
148
- text = cmd;
149
- if (!text)
150
- return null;
151
- const chatId = raw.open_chat_id ??
152
- raw.context?.open_chat_id ??
153
- raw.message?.chat_id ??
154
- "";
155
- const openId = raw.operator?.open_id ??
156
- "";
157
- return {
158
- text,
159
- chatId,
160
- openId,
161
- commandId: buildUpdateCommandId("card", extractFeishuEventId(data)),
162
- };
163
- }
116
+ import { buildUpdateCommandId } from "./update-command-guard.js";
117
+ import { parseCardAction } from "./card-action-parser.js";
164
118
  // ---------------------------------------------------------------------------
165
119
  // WebSocket relay broadcast
166
120
  // ---------------------------------------------------------------------------
@@ -57,6 +57,10 @@ export function reduceProgress(prev, event) {
57
57
  case "text":
58
58
  // accumulated 是全文累积,直接全量替换,天然幂等
59
59
  return withProgressView(prev, { text: event.accumulated });
60
+ case "progress":
61
+ return withProgressView(prev, { headerTitle: "思考中..." });
62
+ case "text_reset":
63
+ return withProgressView(prev, { text: "", tools: [] });
60
64
  case "tool_use": {
61
65
  const tool = {
62
66
  id: event.id ?? `tool-${prev.tools.length + 1}`,
@@ -2,10 +2,10 @@
2
2
  * Tracks how long the displayed output character count has remained unchanged.
3
3
  * Leaving a monitored phase clears the window; returning starts a fresh one.
4
4
  */
5
- export function observeResponseProgress(previous, isMonitoredPhase, totalChars, now = Date.now()) {
5
+ export function observeResponseProgress(previous, isMonitoredPhase, totalChars, now = Date.now(), heartbeat = false) {
6
6
  if (!isMonitoredPhase)
7
7
  return undefined;
8
- if (previous?.totalChars === totalChars)
8
+ if (!heartbeat && previous?.totalChars === totalChars)
9
9
  return previous;
10
10
  return { totalChars, unchangedSince: now };
11
11
  }
@@ -233,7 +233,7 @@ function formatAutoEndedReply(finalReply) {
233
233
  * 状态或资源保护,不能把它们消耗的时间算入回复停滞窗口。
234
234
  */
235
235
  function monitorsOutputProgress(kind) {
236
- return kind === "responding";
236
+ return kind === "responding" || kind === "thinking";
237
237
  }
238
238
  function formatTerminalReply(status, finalReply, terminalError) {
239
239
  if (status === "auto_ended")
@@ -528,11 +528,12 @@ export function getAdapterForTool(tool, sessionId) {
528
528
  apiKey: config.ccc.DEEPSEEK_API_KEY,
529
529
  baseURL: config.ccc.DEEPSEEK_BASE_URL,
530
530
  model: effectiveModel || undefined,
531
- effort: effectiveEffort || undefined,
532
531
  compactionTimeoutMs: config.ccc.compactionTimeoutMs,
533
532
  contextWindow: config.ccc.contextWindow,
533
+ ...(effectiveEffort ? { effort: effectiveEffort } : {}),
534
534
  // 留空("")不传 → ChatSession 跟随 DeepCCC 内核配置(~/.deepccc/config.json 或 DEEPCCC_PROVIDER)
535
535
  ...(config.ccc.provider ? { provider: config.ccc.provider } : {}),
536
+ ...(config.ccc.subModel ? { subModel: config.ccc.subModel } : {}),
536
537
  });
537
538
  }
538
539
  else {
@@ -760,6 +761,14 @@ export function accumulateBlockContent(block, state, toolCallMap) {
760
761
  // 覆盖而非追加:适配器已保证这是一段完整最终文本(如 Cursor 流末快照)
761
762
  state.finalCompleteText = block.text;
762
763
  break;
764
+ case "text_reset":
765
+ state.accumulatedContent = "";
766
+ state.finalText = "";
767
+ state.finalCompleteText = "";
768
+ state.chunkCount = 0;
769
+ break;
770
+ case "agent_progress":
771
+ break;
763
772
  case "compact_boundary": {
764
773
  const triggerLabel = block.trigger === "manual" ? "手动" : "自动"; // 手动 / 自动
765
774
  state.accumulatedContent +=
@@ -1237,7 +1246,15 @@ export async function runAgentSession(sessionId, userText, platform, _chatId, ms
1237
1246
  }
1238
1247
  }
1239
1248
  let activityChanged = false;
1249
+ let progressHeartbeat = false;
1250
+ let outputReset = false;
1240
1251
  for (const block of unifiedMsg.blocks) {
1252
+ if (block.type === "agent_progress")
1253
+ progressHeartbeat = true;
1254
+ if (block.type === "text_reset") {
1255
+ outputReset = true;
1256
+ toolCallMap.clear();
1257
+ }
1241
1258
  if (updateAgentActivity(activityTracker, block))
1242
1259
  activityChanged = true;
1243
1260
  accumulateBlockContent(block, state, toolCallMap);
@@ -1262,11 +1279,11 @@ export async function runAgentSession(sessionId, userText, platform, _chatId, ms
1262
1279
  prompt.responseProgress = observeResponseProgress(
1263
1280
  // starting → responding 本身是一次有效进展,即便首个文本块仍为空也应
1264
1281
  // 重新计时;其它活动阶段会清空观察窗口。
1265
- activityChanged ? undefined : prompt.responseProgress, monitorsOutputProgress(activityTracker.activity.kind), totalChars, Date.now());
1282
+ activityChanged ? undefined : prompt.responseProgress, monitorsOutputProgress(activityTracker.activity.kind), totalChars, Date.now(), progressHeartbeat);
1266
1283
  }
1267
1284
  // 定时写入文件
1268
1285
  const now2 = Date.now();
1269
- if (activityChanged || now2 - lastFileWrite >= FILE_WRITE_INTERVAL_MS) {
1286
+ if (activityChanged || outputReset || now2 - lastFileWrite >= FILE_WRITE_INTERVAL_MS) {
1270
1287
  lastFileWrite = now2;
1271
1288
  await writeStreamState({
1272
1289
  sessionId,
@@ -483,6 +483,10 @@ export function unflattenConfig(flat) {
483
483
  result.ccc = result.ccc || {};
484
484
  result.ccc.model = val;
485
485
  }
486
+ else if (key === "CHATCCC_CCC_SUB_MODEL") {
487
+ result.ccc = result.ccc || {};
488
+ result.ccc.subModel = val;
489
+ }
486
490
  else if (key === "CHATCCC_CCC_ALTERNATIVE_MODEL") {
487
491
  result.ccc = result.ccc || {};
488
492
  result.ccc.alternativeModel = val;
@@ -864,6 +868,10 @@ header .badge{font-size:13px;padding:4px 12px;border-radius:12px;font-weight:500
864
868
  <label>模型</label>
865
869
  <input type="text" id="field-CHATCCC_CCC_MODEL" placeholder="deepseek-v4-pro">
866
870
  </div>
871
+ <div class="form-group">
872
+ <label>子模型(选填)</label>
873
+ <input type="text" id="field-CHATCCC_CCC_SUB_MODEL" placeholder="留空跟随主模型;用于压缩摘要、子代理任务等轻量环节">
874
+ </div>
867
875
  <div class="form-group">
868
876
  <label>备选模型(选填)</label>
869
877
  <input type="text" id="field-CHATCCC_CCC_ALTERNATIVE_MODEL" placeholder="加入 /model 列表,便于会话内切换">
@@ -1178,6 +1186,7 @@ header .badge{font-size:13px;padding:4px 12px;border-radius:12px;font-weight:500
1178
1186
  <div class="config-row"><span class="key">Base URL</span><span class="val" id="cfg-CCC_BASE_URL">-</span></div>
1179
1187
  <div class="config-row"><span class="key">API 协议</span><span class="val" id="cfg-CCC_PROVIDER">-</span></div>
1180
1188
  <div class="config-row"><span class="key">模型</span><span class="val" id="cfg-CCC_MODEL">-</span></div>
1189
+ <div class="config-row"><span class="key">子模型</span><span class="val" id="cfg-CCC_SUB_MODEL">-</span></div>
1181
1190
  <div class="config-row"><span class="key">备选模型</span><span class="val" id="cfg-CCC_ALTERNATIVE_MODEL">-</span></div>
1182
1191
  <label class="agent-default-row" style="margin-top:10px"><input type="checkbox" id="dash-default-ccc" onchange="setDashboardDefaultAgent('ccc', this.checked)"> 设为默认 Agent</label>
1183
1192
  <div class="hint" style="margin-top:6px;line-height:1.6">备选模型仅加入 /model 人工切换列表;保存后下一条消息或下个新会话生效。</div>
@@ -1229,7 +1238,7 @@ const AGENT_FIELDS = {
1229
1238
  claude: ['CHATCCC_ANTHROPIC_MODEL','CHATCCC_ANTHROPIC_SUBAGENT_MODEL','CHATCCC_ANTHROPIC_EFFORT','CHATCCC_ANTHROPIC_API_KEY','CHATCCC_ANTHROPIC_BASE_URL','CHATCCC_ANTHROPIC_MAX_TURN'],
1230
1239
  cursor: ['CHATCCC_CURSOR_PATH','CHATCCC_CURSOR_MODEL','CHATCCC_CURSOR_ALTERNATIVE_MODEL','CHATCCC_CURSOR_AVATAR_BATTERY_MODE','CHATCCC_CURSOR_ON_DEMAND_MONTHLY_BUDGET'],
1231
1240
  codex: ['CHATCCC_CODEX_PATH','CHATCCC_CODEX_MODEL','CHATCCC_CODEX_ALTERNATIVE_MODEL','CHATCCC_CODEX_EFFORT','CHATCCC_CODEX_FAST_MODE'],
1232
- ccc: ['CHATCCC_CCC_API_KEY','CHATCCC_CCC_BASE_URL','CHATCCC_CCC_MODEL','CHATCCC_CCC_ALTERNATIVE_MODEL','CHATCCC_CCC_EFFORT','CHATCCC_CCC_PROVIDER','CHATCCC_CCC_CONTEXT_WINDOW']
1241
+ ccc: ['CHATCCC_CCC_API_KEY','CHATCCC_CCC_BASE_URL','CHATCCC_CCC_MODEL','CHATCCC_CCC_SUB_MODEL','CHATCCC_CCC_ALTERNATIVE_MODEL','CHATCCC_CCC_EFFORT','CHATCCC_CCC_PROVIDER','CHATCCC_CCC_CONTEXT_WINDOW']
1233
1242
  };
1234
1243
  const FEISHU_FIELDS = ['CHATCCC_APP_ID','CHATCCC_APP_SECRET'];
1235
1244
  const WEB_UI_FIELDS = ['CHATCCC_WEB_UI_OPEN_ON_START'];
@@ -1616,6 +1625,7 @@ function renderStep2() {
1616
1625
  prefillNested('field-CHATCCC_CCC_BASE_URL', c.ccc.DEEPSEEK_BASE_URL);
1617
1626
  prefillNested('field-CHATCCC_CCC_PROVIDER', c.ccc.provider);
1618
1627
  prefillNested('field-CHATCCC_CCC_MODEL', c.ccc.model);
1628
+ prefillNested('field-CHATCCC_CCC_SUB_MODEL', c.ccc.subModel);
1619
1629
  prefillNested('field-CHATCCC_CCC_ALTERNATIVE_MODEL', c.ccc.alternativeModel);
1620
1630
  prefillNested('field-CHATCCC_CCC_EFFORT', c.ccc.effort);
1621
1631
  prefillContextWindow('field-', c.ccc.contextWindow);
@@ -1802,6 +1812,7 @@ function renderStep3() {
1802
1812
  lines.push('<div class="config-row"><span class="key">API Key</span><span class="val">' + (vars.CHATCCC_CCC_API_KEY ? '***已设置***' : '(留空)') + '</span></div>');
1803
1813
  lines.push('<div class="config-row"><span class="key">Base URL</span><span class="val">' + (vars.CHATCCC_CCC_BASE_URL || '(留空)') + '</span></div>');
1804
1814
  lines.push('<div class="config-row"><span class="key">模型</span><span class="val">' + (vars.CHATCCC_CCC_MODEL || '(留空)') + '</span></div>');
1815
+ lines.push('<div class="config-row"><span class="key">子模型</span><span class="val">' + (vars.CHATCCC_CCC_SUB_MODEL || '(留空,跟随主模型)') + '</span></div>');
1805
1816
  lines.push('<div class="config-row"><span class="key">备选模型</span><span class="val">' + (vars.CHATCCC_CCC_ALTERNATIVE_MODEL || '(留空)') + '</span></div>');
1806
1817
  lines.push('<div class="config-row"><span class="key">Effort</span><span class="val">' + (vars.CHATCCC_CCC_EFFORT || '(留空)') + '</span></div>');
1807
1818
  lines.push('<div class="config-row"><span class="key">上下文窗口</span><span class="val">' + contextWindowTokensLabel(vars.CHATCCC_CCC_CONTEXT_WINDOW || 1048576) + '</span></div>');
@@ -2023,6 +2034,7 @@ function updateDashboardUI() {
2023
2034
  document.getElementById('cfg-CCC_BASE_URL').textContent = (c.ccc && c.ccc.DEEPSEEK_BASE_URL) || '(留空)';
2024
2035
  document.getElementById('cfg-CCC_PROVIDER').textContent = (c.ccc && c.ccc.provider) ? c.ccc.provider : '(跟随 DeepCCC 内核配置)';
2025
2036
  document.getElementById('cfg-CCC_MODEL').textContent = (c.ccc && c.ccc.model) || '(留空)';
2037
+ document.getElementById('cfg-CCC_SUB_MODEL').textContent = (c.ccc && c.ccc.subModel) || '(留空,跟随主模型)';
2026
2038
  document.getElementById('cfg-CCC_ALTERNATIVE_MODEL').textContent = (c.ccc && c.ccc.alternativeModel) || '(留空)';
2027
2039
  }
2028
2040
 
@@ -2100,7 +2112,7 @@ function editSection(section) {
2100
2112
  'CHATCCC_CODEX_FAST_MODE': 'Fast 模式',
2101
2113
  'CHATCCC_CCC_API_KEY': 'API Key', 'CHATCCC_CCC_BASE_URL': 'Base URL',
2102
2114
  'CHATCCC_CCC_PROVIDER': 'API 协议(选填)',
2103
- 'CHATCCC_CCC_MODEL': '模型', 'CHATCCC_CCC_ALTERNATIVE_MODEL': '备选模型', 'CHATCCC_CCC_EFFORT': 'Effort', 'CHATCCC_CCC_CONTEXT_WINDOW': '上下文窗口'
2115
+ 'CHATCCC_CCC_MODEL': '模型', 'CHATCCC_CCC_SUB_MODEL': '子模型', 'CHATCCC_CCC_ALTERNATIVE_MODEL': '备选模型', 'CHATCCC_CCC_EFFORT': 'Effort', 'CHATCCC_CCC_CONTEXT_WINDOW': '上下文窗口'
2104
2116
  };
2105
2117
  var hintMap = {
2106
2118
  'CHATCCC_WEB_UI_OPEN_ON_START': '关闭后可继续手动访问 http://localhost:<端口>/;/restart、/update 和 Web UI 重启无论此项为何值都不会自动打开。',
@@ -2108,7 +2120,8 @@ function editSection(section) {
2108
2120
  'CHATCCC_CHROME_DEVTOOLS_PORT': '默认 15166,健康检查端点为 http://127.0.0.1:15166/json/version。',
2109
2121
  'CHATCCC_CHROME_DEVTOOLS_PATH': '选填。留空时自动探测 Google Chrome。',
2110
2122
  'CHATCCC_CCC_PROVIDER': '与 Base URL 强相关:OpenAI 兼容端点选 openai;Anthropic Messages 端点选 anthropic。留空 = 跟随 DeepCCC 内核配置(~/.deepccc/config.json 或 DEEPCCC_PROVIDER),改动需重启 ChatCCC 生效。',
2111
- 'CHATCCC_CCC_CONTEXT_WINDOW': '压缩阈值自动 = 窗口 × 80%(超出即把较早消息压缩为摘要)。⚠️ 超过模型/服务端实际上限时请求会被 API 直接拒绝(context length exceeded),实际窗口以模型与所用服务端为准(如 litellm 代理的 max_input_tokens);单位 k = 1024 tokens,1M = 1,048,576 tokens。'
2123
+ 'CHATCCC_CCC_CONTEXT_WINDOW': '压缩阈值自动 = 窗口 × 80%(超出即把较早消息压缩为摘要)。⚠️ 超过模型/服务端实际上限时请求会被 API 直接拒绝(context length exceeded),实际窗口以模型与所用服务端为准(如 litellm 代理的 max_input_tokens);单位 k = 1024 tokens,1M = 1,048,576 tokens。',
2124
+ 'CHATCCC_CCC_SUB_MODEL': '用于 DeepCCC 内部轻量环节(上下文压缩摘要生成、task 子代理任务)。留空 = 跟随主模型;改动需重启 ChatCCC 生效。'
2112
2125
  };
2113
2126
 
2114
2127
  if (section === 'chromeDevtools') {
@@ -2152,6 +2165,7 @@ function editSection(section) {
2152
2165
  else if (key === 'CHATCCC_CCC_BASE_URL') val = state.config.ccc.DEEPSEEK_BASE_URL || '';
2153
2166
  else if (key === 'CHATCCC_CCC_PROVIDER') val = state.config.ccc.provider || '';
2154
2167
  else if (key === 'CHATCCC_CCC_MODEL') val = state.config.ccc.model || '';
2168
+ else if (key === 'CHATCCC_CCC_SUB_MODEL') val = state.config.ccc.subModel || '';
2155
2169
  else if (key === 'CHATCCC_CCC_ALTERNATIVE_MODEL') val = state.config.ccc.alternativeModel || '';
2156
2170
  else if (key === 'CHATCCC_CCC_EFFORT') val = state.config.ccc.effort || '';
2157
2171
  else if (key === 'CHATCCC_CCC_CONTEXT_WINDOW') val = state.config.ccc.contextWindow || '1048576';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatccc",
3
- "version": "0.2.257",
3
+ "version": "0.2.259",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",