deepccc 0.2.25 → 0.2.27

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
@@ -57,9 +57,9 @@ npm run dev
57
57
 
58
58
  ![DeepCCC API 与 Web 设置](docs/deepccc-api-settings.png)
59
59
 
60
- ## 核心能力
61
-
62
- - 有界结构化决策:程序化 `DecisionSession` 支持外部管理上下文、显式工具白名单、运行时结果校验和整轮截止时间;不改变默认聊天行为。详见 [接入文档](docs/decision-session.md)。
60
+ ## 核心能力
61
+
62
+ - 有界结构化决策:程序化 `DecisionSession` 支持外部管理上下文、显式工具白名单、运行时结果校验和整轮截止时间;不改变默认聊天行为。详见 [接入文档](docs/decision-session.md)。
63
63
 
64
64
  - Web-first:多会话、持久化历史、实时流式过程、停止和恢复
65
65
  - 工具时间线:ChatCCC 风格 emoji 摘要、参数/结果折叠、省略行展开和状态记忆
@@ -163,7 +163,11 @@ Web UI 支持新建、恢复、重命名和删除多会话,多个会话可以
163
163
 
164
164
  模型文本、reasoning 心跳和工具事件通过 SSE 实时更新。每轮消息按真实发生顺序持久化和回放,因此刷新后仍会保持“阶段说明 → 工具调用 → 后续结论”的交错时间线;旧版会话没有顺序数据时,会兼容显示为“工具调用 → 最终回答”。每次工具调用与对应结果合并成一张卡片,折叠态显示 emoji、工具名、状态和关键参数;展开后调用参数默认保留前 8/后 4 行,工具结果保留前 12/后 6 行,省略内容可继续展开。工具卡及省略行的展开状态保存在当前浏览器标签页的 `sessionStorage`,持续生成、切换会话和刷新页面均不会自动收起。消息正文支持标题、表格、列表、引用、链接和代码块等常用 Markdown。
165
165
 
166
- 图片始终按本地附件处理,不转换为 Provider 原生多模态消息。Web 支持文件选择、剪贴板粘贴和拖拽,每条消息最多 10 张 PNG/JPEG/WebP、单张最大 20 MB;附件复制到 `~/.deepccc/attachments/<session-id>/`,Agent 收到本地绝对路径后使用可用工具自行处理。Agent 可调用 `present_file` 把当前工作目录或会话附件目录中的图片直接展示在会话中;删除会话时对应附件一并清理。
166
+ 图片会转换成模型可见的原生视觉输入。Web 支持选择、粘贴和拖拽 PNG/JPEG/WebP/GIF;CLI 可用 `--image` 或交互命令 `/image`。每条消息最多 10 张、单图最大 20 MiB、图片合计最大 24 MiB,单边最多 8192 像素;超限时提示分图或缩小,保留原图细节。附件复制到 `~/.deepccc/attachments/<session-id>/`,聊天历史只保存引用,raw 日志过滤图片编码。删除 Web 会话时附件一并清理。
167
+
168
+ 默认由当前模型直接看图。若主模型不支持视觉,可在 API 设置中指定 `visionModel`(如 `deepseek-flash`),或设置 `DEEPCCC_VISION_MODEL` / CLI `--vision-model`;该模型使用同一 API 协议、Base URL 和 Key,先分析图片,再由主模型结合观察继续任务。界面及 JSONL 会显示实际图片模型和模式。未配置图片模型的 `deepseek-v4-pro` 收图会明确报错;其他兼容模型需服务端支持图像,接口错误不会降级成“假装看过”。官方 DeepSeek 端点的旧 Flash 名称仅在视觉请求中映射到当前 `deepseek-flash`,自定义网关保留用户配置的名称。
169
+
170
+ Agent 可用 `read_image` 查看本地截图、图表或工具产物,并通过 `question` 指定需要观察的细节;`present_file` 用于把图片展示给用户。后续追问会按预算重新加载近期图片;较早图片保留路径供 `read_image` 重新查看。独立图片模型的观察按原文问题、模型、端点与图片内容哈希缓存;细节追问可重新调用 `read_image`。压缩后的摘要应保留重要图片引用,缺失或损坏图片会明确报错。本版本支持图片输入,不包含音频、视频或 PDF 原生输入。
167
171
 
168
172
  API 设置采用单一 Provider 配置,支持 OpenAI-compatible 与 Anthropic Messages。完整 API Key 只保存在本机 `~/.deepccc/config.json`,浏览器读取设置时仅返回掩码。危险命令会在会话中暂停并请求“拒绝、允许一次、本会话允许、永久允许”,浏览器断开或审批超时默认拒绝。
169
173
 
@@ -240,11 +244,22 @@ deepccc-cli --provider anthropic --base-url https://api.example.com --api-key "$
240
244
  deepccc-cli --cwd /path/to/project
241
245
  ```
242
246
 
243
- 附加一张或多张本地图片(可重复传入 `--image`,仍采用本地附件路径,不发送原生多模态内容):
247
+ 附加一张或多张本地图片(重复传入 `--image`;路径含空格时加引号):
244
248
 
245
249
  ```bash
246
- deepccc-cli --image ./error.png --image ./expected.webp
247
- ```
250
+ deepccc-cli --image ./error.png --image ./expected.webp
251
+ ```
252
+
253
+ 启动后再加图:输入 `/image "C:\截图\报错截图.png"`,看到“图片已加入下一条消息”后输入问题并回车。多图重复 `/image`;相对路径按会话工作目录(`--cwd`)解析。该命令在当前回复结束后使用。启动时的 `--image` 也只加入队列,随后输入问题发送。
254
+
255
+ 脚本或纯命令模式可直接发送图片并获取 JSONL:
256
+
257
+ ```bash
258
+ deepccc-cli --model deepseek-flash --stream-json --prompt "解释截图里的错误" --image "./error screenshot.png"
259
+ deepccc-cli --model deepseek-v4-pro --vision-model deepseek-flash --stream-json --prompt "对比两张图" --image ./before.png --image ./after.png
260
+ ```
261
+
262
+ 安装 ChatCCC 后提供的 `cccagent` 使用同一 CLI:将上述 `deepccc-cli` 换成 `cccagent` 即可。它读取 `~/.deepccc/config.json`、`DEEPCCC_*` 环境变量或命令行参数;ChatCCC 机器人里的 API 配置不会自动改写独立 CLI 配置。聊天窗口的图片粘贴/拖拽属于 Web 功能,纯终端请使用文件路径。
248
263
 
249
264
  恢复当前工作目录最近一次会话:
250
265
 
@@ -1,17 +1,17 @@
1
- #!/usr/bin/env node
2
- import { existsSync } from "node:fs";
3
- import { spawnSync } from "node:child_process";
4
- import { createRequire } from "node:module";
5
- import { dirname, join } from "node:path";
6
-
7
- const require = createRequire(import.meta.url);
8
- const pkgRoot = dirname(require.resolve("../package.json"));
9
- const distCli = join(pkgRoot, "dist", "cli.js");
10
- let args;
11
- if (existsSync(distCli)) {
12
- args = [distCli, ...process.argv.slice(2)];
13
- } else {
14
- args = [require.resolve("tsx/cli"), join(pkgRoot, "src", "cli.ts"), ...process.argv.slice(2)];
15
- }
16
- const result = spawnSync(process.execPath, args, { stdio: "inherit", cwd: process.cwd(), env: process.env });
17
- process.exit(result.status === null ? 1 : result.status);
1
+ #!/usr/bin/env node
2
+ import { existsSync } from "node:fs";
3
+ import { spawnSync } from "node:child_process";
4
+ import { createRequire } from "node:module";
5
+ import { dirname, join } from "node:path";
6
+
7
+ const require = createRequire(import.meta.url);
8
+ const pkgRoot = dirname(require.resolve("../package.json"));
9
+ const distCli = join(pkgRoot, "dist", "cli.js");
10
+ let args;
11
+ if (existsSync(distCli)) {
12
+ args = [distCli, ...process.argv.slice(2)];
13
+ } else {
14
+ args = [require.resolve("tsx/cli"), join(pkgRoot, "src", "cli.ts"), ...process.argv.slice(2)];
15
+ }
16
+ const result = spawnSync(process.execPath, args, { stdio: "inherit", cwd: process.cwd(), env: process.env });
17
+ process.exit(result.status === null ? 1 : result.status);
package/bin/deepccc.mjs CHANGED
@@ -1,32 +1,32 @@
1
- #!/usr/bin/env node
2
- import { existsSync } from "node:fs";
3
- import { spawnSync } from "node:child_process";
4
- import { createRequire } from "node:module";
5
- import { dirname, join } from "node:path";
6
-
7
- const require = createRequire(import.meta.url);
8
- const pkgRoot = dirname(require.resolve("../package.json"));
9
- const rawArgs = process.argv.slice(2);
10
- const distWebEntry = join(pkgRoot, "dist", "web-entry.js");
11
- const distCli = join(pkgRoot, "dist", "cli.js");
12
- const sourceMode = !existsSync(distWebEntry);
13
- const isLegacy = rawArgs.length > 0 && rawArgs[0] !== "web" && !["--reuse-existing", "--port", "--no-open", "--help", "-h"].includes(rawArgs[0]);
14
-
15
- if (isLegacy) {
16
- console.error("[DeepCCC] CLI commands have moved to deepccc-cli; forwarding this invocation for compatibility.");
17
- const args = sourceMode
18
- ? [require.resolve("tsx/cli"), join(pkgRoot, "src", "cli.ts"), ...rawArgs]
19
- : [distCli, ...rawArgs];
20
- const result = spawnSync(process.execPath, args, { stdio: "inherit", cwd: process.cwd(), env: process.env });
21
- process.exit(result.status === null ? 1 : result.status);
22
- }
23
-
24
- if (sourceMode) {
25
- const result = spawnSync(process.execPath, [require.resolve("tsx/cli"), join(pkgRoot, "src", "web-entry.ts"), ...rawArgs], {
26
- stdio: "inherit", cwd: process.cwd(), env: process.env,
27
- });
28
- process.exit(result.status === null ? 1 : result.status);
29
- }
30
-
31
- const { runWebEntry } = await import(new URL("../dist/web-entry.js", import.meta.url));
32
- await runWebEntry(rawArgs);
1
+ #!/usr/bin/env node
2
+ import { existsSync } from "node:fs";
3
+ import { spawnSync } from "node:child_process";
4
+ import { createRequire } from "node:module";
5
+ import { dirname, join } from "node:path";
6
+
7
+ const require = createRequire(import.meta.url);
8
+ const pkgRoot = dirname(require.resolve("../package.json"));
9
+ const rawArgs = process.argv.slice(2);
10
+ const distWebEntry = join(pkgRoot, "dist", "web-entry.js");
11
+ const distCli = join(pkgRoot, "dist", "cli.js");
12
+ const sourceMode = !existsSync(distWebEntry);
13
+ const isLegacy = rawArgs.length > 0 && rawArgs[0] !== "web" && !["--reuse-existing", "--port", "--no-open", "--help", "-h"].includes(rawArgs[0]);
14
+
15
+ if (isLegacy) {
16
+ console.error("[DeepCCC] CLI commands have moved to deepccc-cli; forwarding this invocation for compatibility.");
17
+ const args = sourceMode
18
+ ? [require.resolve("tsx/cli"), join(pkgRoot, "src", "cli.ts"), ...rawArgs]
19
+ : [distCli, ...rawArgs];
20
+ const result = spawnSync(process.execPath, args, { stdio: "inherit", cwd: process.cwd(), env: process.env });
21
+ process.exit(result.status === null ? 1 : result.status);
22
+ }
23
+
24
+ if (sourceMode) {
25
+ const result = spawnSync(process.execPath, [require.resolve("tsx/cli"), join(pkgRoot, "src", "web-entry.ts"), ...rawArgs], {
26
+ stdio: "inherit", cwd: process.cwd(), env: process.env,
27
+ });
28
+ process.exit(result.status === null ? 1 : result.status);
29
+ }
30
+
31
+ const { runWebEntry } = await import(new URL("../dist/web-entry.js", import.meta.url));
32
+ await runWebEntry(rawArgs);
@@ -5,7 +5,7 @@ export declare const ATTACHMENT_PROMPT_END = "</deepccc-attachments>";
5
5
  export interface AttachmentMeta {
6
6
  attachmentId: string;
7
7
  originalName: string;
8
- mimeType: "image/png" | "image/jpeg" | "image/webp";
8
+ mimeType: "image/png" | "image/jpeg" | "image/webp" | "image/gif";
9
9
  size: number;
10
10
  absolutePath: string;
11
11
  }
@@ -22,7 +22,7 @@ export class AttachmentStore {
22
22
  throw new Error("Image attachment exceeds the 20 MB limit");
23
23
  const mimeType = detectImageMime(bytes);
24
24
  if (!mimeType)
25
- throw new Error("Only PNG, JPEG, or WebP image attachments are supported");
25
+ throw new Error("Only PNG, JPEG, or WebP image attachments (also GIF) are supported");
26
26
  const normalizedSessionId = normalizeBuiltinSessionId(sessionId);
27
27
  const attachmentId = normalizeAttachmentId(this.idFactory());
28
28
  const extension = extensionForMime(mimeType);
@@ -98,7 +98,7 @@ export class AttachmentStore {
98
98
  return null;
99
99
  if (typeof value.originalName !== "string" || typeof value.size !== "number")
100
100
  return null;
101
- if (value.mimeType !== "image/png" && value.mimeType !== "image/jpeg" && value.mimeType !== "image/webp")
101
+ if (value.mimeType !== "image/png" && value.mimeType !== "image/jpeg" && value.mimeType !== "image/webp" && value.mimeType !== "image/gif")
102
102
  return null;
103
103
  if (value.fileName !== `${normalizedId}${extensionForMime(value.mimeType)}`)
104
104
  return null;
@@ -133,7 +133,7 @@ export function buildAttachmentPrompt(text, attachments) {
133
133
  ATTACHMENT_PROMPT_START,
134
134
  JSON.stringify(manifest),
135
135
  ATTACHMENT_PROMPT_END,
136
- "以上图片以本地附件文件提供。不要假设模型原生支持图片;请使用可用工具读取这些绝对路径并自行处理。",
136
+ "图片由内核转换为视觉输入;历史中只保存附件引用,需要重新查看时使用 read_image。",
137
137
  ].join("\n");
138
138
  }
139
139
  export function parseAttachmentPrompt(content) {
@@ -155,6 +155,8 @@ export function parseAttachmentPrompt(content) {
155
155
  }
156
156
  export function detectImageMime(bytes) {
157
157
  const buffer = Buffer.from(bytes);
158
+ if (buffer.length >= 10 && /GIF8[79]a/.test(buffer.subarray(0, 6).toString("ascii")))
159
+ return "image/gif";
158
160
  if (buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])))
159
161
  return "image/png";
160
162
  if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff)
@@ -164,7 +166,7 @@ export function detectImageMime(bytes) {
164
166
  return null;
165
167
  }
166
168
  function extensionForMime(mimeType) {
167
- return mimeType === "image/png" ? ".png" : mimeType === "image/jpeg" ? ".jpg" : ".webp";
169
+ return mimeType === "image/png" ? ".png" : mimeType === "image/jpeg" ? ".jpg" : mimeType === "image/gif" ? ".gif" : ".webp";
168
170
  }
169
171
  function cleanOriginalName(value, extension) {
170
172
  const cleaned = basename(value || `image${extension}`).replace(/[\u0000-\u001f<>:"/\\|?*]+/g, "_").slice(0, 180);
@@ -188,5 +190,5 @@ function isAttachmentMeta(value) {
188
190
  && typeof attachment.originalName === "string"
189
191
  && typeof attachment.absolutePath === "string"
190
192
  && typeof attachment.size === "number"
191
- && (attachment.mimeType === "image/png" || attachment.mimeType === "image/jpeg" || attachment.mimeType === "image/webp");
193
+ && (attachment.mimeType === "image/png" || attachment.mimeType === "image/jpeg" || attachment.mimeType === "image/webp" || attachment.mimeType === "image/gif");
192
194
  }
package/dist/cli.js CHANGED
@@ -59,6 +59,10 @@ function parseArgs(argv = process.argv.slice(2)) {
59
59
  config.model = next;
60
60
  i++;
61
61
  }
62
+ else if (arg === "--vision-model" && next !== undefined) {
63
+ config.visionModel = next;
64
+ i++;
65
+ }
62
66
  else if (arg === "--sub-model" && next !== undefined) {
63
67
  config.subModel = next;
64
68
  i++;
@@ -140,6 +144,7 @@ function printHelp(appConfig) {
140
144
  ` --provider <name> API protocol: openai or anthropic (current default ${appConfig.provider})`,
141
145
  ` --model <name> Model name (current default ${appConfig.model})`,
142
146
  ` --sub-model <name> Sub-model for lightweight steps (compaction/task; empty = follow main model)`,
147
+ " --vision-model <name> Optional image analysis model; empty uses native images in the main model",
143
148
  ` --effort <level> Reasoning effort: none/minimal/low/medium/high/xhigh/max (overrides config.effort)`,
144
149
  ` --max-output-tokens <n> Maximum output tokens (unset = Provider default)`,
145
150
  ` --base-url <url> Provider API base URL (current default ${appConfig.baseURL})`,
@@ -150,7 +155,8 @@ function printHelp(appConfig) {
150
155
  " --list-sessions List saved sessions and exit",
151
156
  " --stream-json One-shot mode: write JSONL events to stdout",
152
157
  " --prompt <text> Prompt text for --stream-json",
153
- " --image <path> Attach a PNG/JPEG/WebP file (repeatable; copied to the session attachment store)",
158
+ " --image <path> Attach a PNG/JPEG/WebP/GIF file (repeatable; quote paths with spaces)",
159
+ " Interactive: /image <path> queues one image for your next message; repeat for multiple images",
154
160
  " --plain Force plain streaming output (no progress block renderer)",
155
161
  " --dangerously-bypass-permissions Skip all permission prompts (aligns with chatccc's bypass mode)",
156
162
  " --help, -h Show help",
@@ -220,7 +226,10 @@ function writeJsonLine(event) {
220
226
  process.stdout.write(`${JSON.stringify(event)}\n`);
221
227
  }
222
228
  function streamJsonEvent(event) {
223
- if (event.type === "status") {
229
+ if (event.type === "image_input") {
230
+ writeJsonLine({ ...event });
231
+ }
232
+ else if (event.type === "status") {
224
233
  writeJsonLine({
225
234
  type: "status",
226
235
  phase: event.phase,
@@ -319,7 +328,7 @@ async function runStreamJson(args) {
319
328
  }
320
329
  let attachments;
321
330
  try {
322
- attachments = await importCliAttachments(resolvedSession.sessionId, args.images);
331
+ attachments = await importCliAttachments(resolvedSession.sessionId, args.images, cwd);
323
332
  }
324
333
  catch (err) {
325
334
  writeJsonLine({ type: "error", message: err.message });
@@ -448,7 +457,7 @@ async function runRepl(args) {
448
457
  }
449
458
  let pendingAttachments;
450
459
  try {
451
- pendingAttachments = await importCliAttachments(resolvedSession.sessionId, args.images);
460
+ pendingAttachments = await importCliAttachments(resolvedSession.sessionId, args.images, cwd);
452
461
  }
453
462
  catch (err) {
454
463
  console.error(`${C.yellow}${err.message}${C.reset}`);
@@ -463,10 +472,41 @@ async function runRepl(args) {
463
472
  // model step 边界 drain 后吸收到当前 turn(而非等待整轮结束)。
464
473
  let chatRunning = false;
465
474
  const pendingInjections = [];
475
+ let attachmentImport = Promise.resolve();
466
476
  rl.prompt();
467
477
  rl.on("line", async (line) => {
468
478
  ctrlCState.reset();
469
479
  const input = line.trim();
480
+ if (/^\/image(?:\s|$)/.test(input)) {
481
+ if (chatRunning) {
482
+ console.log("请等待当前回复结束后用 /image 添加图片。");
483
+ return;
484
+ }
485
+ let path = input.slice(6).trim();
486
+ if ((path.startsWith('"') && path.endsWith('"')) || (path.startsWith("'") && path.endsWith("'")))
487
+ path = path.slice(1, -1);
488
+ if (!path) {
489
+ console.log('用法:/image "C:\\path with spaces\\image.png"');
490
+ rl.prompt();
491
+ return;
492
+ }
493
+ attachmentImport = attachmentImport.then(async () => {
494
+ try {
495
+ if (pendingAttachments.length >= 10)
496
+ throw new Error("每条消息最多 10 张图片");
497
+ const added = await importCliAttachments(resolvedSession.sessionId, [path], cwd);
498
+ pendingAttachments.push(...added);
499
+ console.log(`图片已加入下一条消息:${added.map(a => a.originalName).join(", ")}。输入问题后回车发送。`);
500
+ }
501
+ catch (error) {
502
+ console.log(`添加图片失败:${error.message}`);
503
+ }
504
+ });
505
+ await attachmentImport;
506
+ rl.prompt();
507
+ return;
508
+ }
509
+ await attachmentImport;
470
510
  if (!input && !pendingAttachments.length) {
471
511
  rl.prompt();
472
512
  return;
@@ -529,13 +569,16 @@ async function runRepl(args) {
529
569
  for await (const event of session.chat(chatInput, signal, () => pendingInjections.shift())) {
530
570
  if (renderer && view) {
531
571
  view = reduceProgress(view, event);
532
- if (event.type === "text" || event.type === "compact" || event.type === "status" || event.type === "input_injected") {
572
+ if (event.type === "text" || event.type === "compact" || event.type === "status" || event.type === "input_injected" || event.type === "image_input") {
533
573
  renderer.render(view);
534
574
  }
535
575
  else {
536
576
  renderer.flush();
537
577
  }
538
578
  }
579
+ else if (event.type === "image_input") {
580
+ console.log(`[图片理解:${event.model},${event.count} 张,${event.mode}]`);
581
+ }
539
582
  else if (event.type === "status") {
540
583
  console.log(`${C.dim}[${event.phase === "compacting" ? "compacting context" : "generating reply"}]${C.reset}`);
541
584
  }
@@ -618,11 +661,13 @@ async function runRepl(args) {
618
661
  process.exit(0);
619
662
  });
620
663
  }
621
- async function importCliAttachments(sessionId, paths) {
664
+ async function importCliAttachments(sessionId, paths, cwd = process.cwd()) {
665
+ if (paths.length > 10)
666
+ throw new Error("每条消息最多 10 张图片");
622
667
  const store = new AttachmentStore();
623
668
  const attachments = [];
624
669
  for (const path of paths)
625
- attachments.push(await store.importFile(sessionId, path));
670
+ attachments.push(await store.importFile(sessionId, resolvePath(cwd, path)));
626
671
  return attachments;
627
672
  }
628
673
  /**
@@ -1,6 +1,7 @@
1
1
  /** Opt-in stateless completion transport for integrations owning their complete prompts. */
2
2
  import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
3
- import { Output, streamText } from 'ai';
3
+ import { streamText } from 'ai';
4
+ import { jsonObjectFetch } from './json-mode-fetch.js';
4
5
  const protectedKeys = new Set(['messages', 'model', 'stream', 'tools', 'tool_choice', 'response_format', 'temperature', 'max_tokens', 'max_completion_tokens']);
5
6
  function positive(n) { if (!Number.isSafeInteger(n) || n < 1)
6
7
  throw new Error('Positive integer budget required'); return n; }
@@ -45,11 +46,12 @@ export class CompletionSession {
45
46
  try {
46
47
  if (controller.signal.aborted)
47
48
  throw new Error('Interrupted');
49
+ const transport = request.json ? jsonObjectFetch() : globalThis.fetch;
48
50
  const client = createOpenAICompatible({ name: 'completion', ...this.config, includeUsage: true,
49
- fetch: async (url, init) => fetch(url, { ...init, body: JSON.stringify({ ...JSON.parse(String(init?.body)), ...extensions }) }) });
51
+ fetch: async (url, init) => transport(url, { ...init, body: JSON.stringify({ ...JSON.parse(String(init?.body)), ...extensions }) }) });
50
52
  calls++;
51
53
  const result = streamText({ model: client(this.config.model), messages, allowSystemInMessages: true, temperature: request.temperature, maxOutputTokens: request.maxOutputTokens,
52
- ...(request.json ? { output: Output.json() } : {}), maxRetries: 0, abortSignal: controller.signal, onError: () => { } });
54
+ maxRetries: 0, abortSignal: controller.signal, onError: () => { } });
53
55
  const iterator = result.fullStream[Symbol.asyncIterator]();
54
56
  let content = '', reasoning = '', finish;
55
57
  try {
package/dist/config.d.ts CHANGED
@@ -10,6 +10,8 @@ export interface DeepCccConfig {
10
10
  * 留空("")时跟随主模型,行为与旧版完全一致。
11
11
  */
12
12
  subModel: string;
13
+ /** Optional dedicated image analysis model, using the same provider credentials. */
14
+ visionModel: string;
13
15
  /** Reasoning effort(none/minimal/low/medium/high/xhigh/max),留空不传 reasoning_effort */
14
16
  effort: string;
15
17
  /** 主对话最大输出 token;未配置时不传,使用 Provider 默认值。 */
@@ -50,7 +52,7 @@ export declare function readConfigFile(): Partial<DeepCccConfig>;
50
52
  export declare function normalizeDeepCccProvider(value: unknown): DeepCccProvider;
51
53
  export declare function loadConfig(): DeepCccConfig;
52
54
  export declare function ensureConfigDir(): void;
53
- export type DeepCccConfigPatch = Partial<Pick<DeepCccConfig, "provider" | "apiKey" | "baseURL" | "model" | "subModel" | "effort" | "maxOutputTokens" | "streaming" | "contextWindow">> & {
55
+ export type DeepCccConfigPatch = Partial<Pick<DeepCccConfig, "provider" | "apiKey" | "baseURL" | "model" | "subModel" | "visionModel" | "effort" | "maxOutputTokens" | "streaming" | "contextWindow">> & {
54
56
  web?: Partial<DeepCccConfig["web"]>;
55
57
  };
56
58
  /** Persist only user-editable Web fields while preserving advanced configuration. */
package/dist/config.js CHANGED
@@ -13,6 +13,7 @@ export const DEFAULT_CONFIG = {
13
13
  baseURL: "https://api.deepseek.com/v1",
14
14
  model: "deepseek-v4-pro",
15
15
  subModel: "",
16
+ visionModel: "",
16
17
  effort: "",
17
18
  streaming: true,
18
19
  contextWindow: 1_048_576,
@@ -88,6 +89,7 @@ export function loadConfig() {
88
89
  baseURL: env("DEEPCCC_BASE_URL") ?? env("DEEPSEEK_BASE_URL") ?? file.baseURL ?? DEFAULT_CONFIG.baseURL,
89
90
  model: env("DEEPCCC_MODEL") ?? env("DEEPSEEK_MODEL") ?? file.model ?? DEFAULT_CONFIG.model,
90
91
  subModel: env("DEEPCCC_SUB_MODEL") ?? file.subModel ?? DEFAULT_CONFIG.subModel,
92
+ visionModel: env("DEEPCCC_VISION_MODEL") ?? file.visionModel ?? DEFAULT_CONFIG.visionModel,
91
93
  effort: env("DEEPCCC_EFFORT") ?? env("DEEPSEEK_EFFORT") ?? file.effort ?? DEFAULT_CONFIG.effort,
92
94
  maxOutputTokens: optionalPositiveInteger(env("DEEPCCC_MAX_OUTPUT_TOKENS"))
93
95
  ?? optionalPositiveInteger(file.maxOutputTokens),
@@ -130,6 +132,8 @@ export function saveConfigPatch(patch) {
130
132
  throw new Error("baseURL must not be empty");
131
133
  if (patch.model !== undefined && !patch.model.trim())
132
134
  throw new Error("model must not be empty");
135
+ if (patch.visionModel !== undefined && typeof patch.visionModel !== "string")
136
+ throw new Error("visionModel must be a string");
133
137
  if (patch.effort !== undefined && !["", "none", "minimal", "low", "medium", "high", "xhigh", "max"].includes(patch.effort)) {
134
138
  throw new Error(`Unsupported effort: ${patch.effort}`);
135
139
  }
package/dist/context.js CHANGED
@@ -285,7 +285,9 @@ export function estimateBuiltinToolContextTokens(messages) {
285
285
  }
286
286
  export function estimateBuiltinContextTokens(summary, messages) {
287
287
  const text = summary + messages.reduce((sum, message) => sum + `${message.role}\n${stripStoredToolTranscript(message.content)}\n`, "");
288
- return estimateTextTokens(text) + estimateBuiltinToolContextTokens(messages);
288
+ // Reserve a conservative visual allowance without counting binary/base64 as text.
289
+ const imageCount = messages.reduce((n, m) => n + (m.role === "user" && m.content.includes("<deepccc-attachments>") ? (m.content.match(/"attachmentId"\s*:/g)?.length ?? 0) : 0), 0);
290
+ return estimateTextTokens(text) + estimateBuiltinToolContextTokens(messages) + Math.min(imageCount, 10) * 2048;
289
291
  }
290
292
  export function serializeMessagesForSummary(messages) {
291
293
  return messages
@@ -531,6 +533,7 @@ export function buildSummaryPrompt(plan) {
531
533
  "- 将会话进度与已核实项目事实分节:项目事实保留能力、实现入口、证据路径/符号及验证范围;推测单列,后来的纠正覆盖此前错误判断。",
532
534
  "- 不把搜索失败/截断/无命中概括为实现不存在,不把辅助模块概括为整个架构。不保留密钥、授权令牌或临时 capability grant。",
533
535
  "- 项目事实是历史证据,不是当前实现保证;提示继续时可用 workspace_map 找到有效证据笔记,再读取源码核验。",
536
+ "- 保留与当前任务相关的图片附件路径、原始问题和已确认观察,标明未确定细节;只保留引用,不包含图片编码。后续需要查看原图可调用 read_image。",
534
537
  "",
535
538
  ];
536
539
  if (plan.previousSummary.trim()) {
@@ -1,7 +1,8 @@
1
1
  /** Bounded, text-only decisions with caller-owned context and explicit capabilities. */
2
2
  import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
3
3
  import { createAnthropic } from "@ai-sdk/anthropic";
4
- import { asSchema, Output, streamText, tool } from "ai";
4
+ import { asSchema, streamText, tool } from "ai";
5
+ import { jsonObjectFetch } from "./json-mode-fetch.js";
5
6
  import { parseJsonResponse } from "./json-response.js";
6
7
  import { performance } from "node:perf_hooks";
7
8
  import { config, normalizeDeepCccProvider } from "./config.js";
@@ -92,7 +93,8 @@ export class DecisionSession {
92
93
  const baseURL = overrides.baseURL ?? config.baseURL;
93
94
  const client = provider === "anthropic"
94
95
  ? createAnthropic({ apiKey, baseURL: baseURL.trim().replace(/\/+$/, "") })
95
- : createOpenAICompatible({ name: "deepccc", apiKey, baseURL, includeUsage: true });
96
+ : createOpenAICompatible({ name: "deepccc", apiKey, baseURL, includeUsage: true,
97
+ ...(this.outputMode === "json" ? { fetch: jsonObjectFetch() } : {}) });
96
98
  this.model = client(overrides.model ?? config.model);
97
99
  const effort = (overrides.effort ?? config.effort ?? "").trim();
98
100
  this.providerOptions = effort ? provider === "anthropic"
@@ -240,7 +242,7 @@ export class DecisionSession {
240
242
  const began = performance.now();
241
243
  metrics.modelCalls++;
242
244
  const result = streamText({ model: this.model, system: this.system, messages: [...messages],
243
- ...(this.outputMode === "json" ? { output: Output.json() } : { tools: this.modelTools, toolChoice: "required" }),
245
+ ...(this.outputMode === "json" ? {} : { tools: this.modelTools, toolChoice: "required" }),
244
246
  maxRetries: 0, abortSignal: signal, maxOutputTokens: this.maxOutputTokens,
245
247
  // The SDK default logs raw errors. Return allowlisted diagnostics instead.
246
248
  onError: () => { },
@@ -1,5 +1,6 @@
1
1
  import { type ToolSet } from "ai";
2
2
  import { type PermissionGate } from "./permissions.js";
3
+ import type { ImageReference, ReadImageInput } from "./multimodal.js";
3
4
  import { type SearchScope } from "./workspace-policy.js";
4
5
  /** task 子代理工具:子任务结果回传主会话前的最大字符数(防止子代理长输出撑爆主上下文) */
5
6
  export declare const MAX_TASK_OUTPUT_CHARS = 32000;
@@ -209,7 +210,7 @@ export interface PresentFileInput {
209
210
  export interface PresentFileOutput {
210
211
  path: string;
211
212
  name: string;
212
- mimeType: "image/png" | "image/jpeg" | "image/webp";
213
+ mimeType: "image/png" | "image/jpeg" | "image/webp" | "image/gif";
213
214
  size: number;
214
215
  caption?: string;
215
216
  }
@@ -244,6 +245,7 @@ export declare function deleteFileForTool(cwd: string, input: DeleteFileInput):
244
245
  export declare function moveFileForTool(cwd: string, input: MoveFileInput): Promise<MoveFileOutput>;
245
246
  export declare function applyPatchForTool(cwd: string, input: ApplyPatchInput): Promise<ApplyPatchOutput>;
246
247
  export interface BuiltinFileToolsOptions {
248
+ readImage?: (input: ReadImageInput) => Promise<ImageReference>;
247
249
  /** 权限门控:副作用工具(进程/脚本/命令执行与文件写操作)执行前会先经过 gate.check */
248
250
  permissionGate?: PermissionGate;
249
251
  /** Git commits created by DeepCCC receive this Co-authored-by trailer when enabled. */
@@ -1513,6 +1513,11 @@ export function createBuiltinFileTools(cwd, options = {}) {
1513
1513
  }),
1514
1514
  execute: (input) => readFileForTool(cwd, input),
1515
1515
  }),
1516
+ ...(options.readImage ? { read_image: tool({
1517
+ description: "查看本地 PNG/JPEG/WebP/GIF 图片。图片作为视觉输入提供给模型,返回的是附件引用而非图片文字。可指定 question 聚焦细节;用于历史图片、报错截图、图表和工具生成的图片。",
1518
+ inputSchema: jsonSchema({ type: "object", additionalProperties: false, properties: { path: { type: "string", description: "图片绝对路径或相对于工作目录的路径" }, question: { type: "string", description: "希望观察的细节或问题" } }, required: ["path"] }),
1519
+ execute: input => options.readImage(input),
1520
+ }) } : {}),
1516
1521
  list_dir: tool({
1517
1522
  description: "列出本地目录中的文件。",
1518
1523
  inputSchema: jsonSchema({
package/dist/index.d.ts CHANGED
@@ -5,6 +5,8 @@
5
5
  */
6
6
  import { type DeepCccProvider } from "./config.js";
7
7
  import { type PermissionMode, type PermissionResolver } from "./permissions.js";
8
+ import { type ImageUseNotice } from "./multimodal.js";
9
+ import type { AttachmentStore } from "./attachments.js";
8
10
  export { DecisionSession } from "./decision-session.js";
9
11
  export { CompletionSession } from "./completion-session.js";
10
12
  export type { CompletionConfig, CompletionRequest, CompletionResult } from "./completion-session.js";
@@ -36,6 +38,8 @@ export interface ChatSessionConfig {
36
38
  * 子模型 id(可选):用于压缩摘要生成与 task 子代理任务。留空跟随主模型。
37
39
  */
38
40
  subModel?: string;
41
+ /** Optional dedicated image analysis model. Empty = native image input to the main model. */
42
+ visionModel?: string;
39
43
  /**
40
44
  * Reasoning effort (none/minimal/low/medium/high/xhigh/max);
41
45
  * overrides config.effort; empty omits the reasoning_effort request field.
@@ -47,6 +51,7 @@ export interface ChatSessionConfig {
47
51
  streaming?: boolean;
48
52
  }
49
53
  export interface ChatSessionOptions {
54
+ attachmentStore?: AttachmentStore;
50
55
  /** Session working directory. */
51
56
  cwd?: string;
52
57
  /** Extra system guidance appended after project instructions. */
@@ -100,7 +105,9 @@ export interface ChatSessionOptions {
100
105
  /**
101
106
  * 流式响应事件
102
107
  */
103
- export type ChatEvent = {
108
+ export type ChatEvent = ({
109
+ type: "image_input";
110
+ } & ImageUseNotice) | {
104
111
  type: "status";
105
112
  phase: "compacting" | "generating";
106
113
  } | {
@@ -152,6 +159,10 @@ export declare class ChatSession {
152
159
  private baseURL;
153
160
  private modelId;
154
161
  private subModelId;
162
+ private visionModelId;
163
+ private imageRuntime;
164
+ private providerFactory;
165
+ private imageNotices;
155
166
  private cwd;
156
167
  private context;
157
168
  private compactionTimeoutMs;