chatccc 0.2.219 → 0.2.221
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 +36 -16
- package/config.sample.json +4 -1
- package/package.json +1 -1
- package/src/__tests__/builtin-config.test.ts +3 -0
- package/src/__tests__/builtin-file-tools.test.ts +31 -0
- package/src/__tests__/cards.test.ts +26 -6
- package/src/__tests__/config-reload.test.ts +19 -8
- package/src/__tests__/config-sample.test.ts +6 -2
- package/src/__tests__/orchestrator.test.ts +84 -5
- package/src/__tests__/sim-platform.test.ts +10 -0
- package/src/__tests__/web-ui.test.ts +40 -0
- package/src/builtin/file-tools.ts +14 -4
- package/src/cards.ts +27 -6
- package/src/config.ts +41 -10
- package/src/index.ts +13 -0
- package/src/orchestrator.ts +1553 -1488
- package/src/web-ui.ts +164 -46
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# ChatCCC
|
|
2
2
|
|
|
3
|
-
**用飞书或微信聊天控制 Claude Code / Cursor / Codex。**
|
|
3
|
+
**用飞书或微信聊天控制 Claude Code / Cursor / Codex / CCC Agent。**
|
|
4
4
|
|
|
5
|
-
ChatCCC 把本地 AI 编程工具接入即时通讯软件。你可以在手机上发消息,让 Claude Code、Cursor Agent
|
|
5
|
+
ChatCCC 把本地 AI 编程工具接入即时通讯软件。你可以在手机上发消息,让 Claude Code、Cursor Agent、Codex 或内置 CCC Agent 继续写代码、查问题、跑命令;不用一直守在电脑前。
|
|
6
6
|
|
|
7
7
|
飞书是推荐入口:直接私聊机器人即可持续使用同一个专属会话,需要并行任务时再用 `/new` 创建独立会话群;卡片能流式更新,体验完整。微信 iLink 更适合快速试用或临时使用:扫码即可接入,但只能走私聊文本模式。
|
|
8
8
|
|
|
@@ -21,7 +21,7 @@ ChatCCC 把本地 AI 编程工具接入即时通讯软件。你可以在手机
|
|
|
21
21
|
- **手机上也能用 AI 编程工具**:在飞书或微信发消息,就像在终端给 Agent 下指令。
|
|
22
22
|
- **飞书体验更完整**:私聊可持续对话,`/new` 创建的一群一会话支持多任务并行,CardKit 卡片可流式更新。
|
|
23
23
|
- **微信接入更轻**:不用创建飞书应用,启动后扫码即可在微信私聊里使用。
|
|
24
|
-
- **多 Agent 切换**:`/new` 使用默认 Agent,也可以用 `/new claude`、`/new cursor`、`/new codex` 指定工具。
|
|
24
|
+
- **多 Agent 切换**:`/new` 使用默认 Agent,也可以用 `/new claude`、`/new cursor`、`/new codex`、`/new ccc` 指定工具。
|
|
25
25
|
- **群里能跑 git**:`/git status`、`/git pull`、`/git log` 会在当前会话工作目录执行,并把输出发回聊天窗口。
|
|
26
26
|
|
|
27
27
|
## 飞书和微信的差异
|
|
@@ -208,7 +208,7 @@ chatccc
|
|
|
208
208
|
|
|
209
209
|
### 3. AI 工具配置
|
|
210
210
|
|
|
211
|
-
|
|
211
|
+
Claude Code、Cursor 和 Codex 需要对应的本地工具;CCC Agent 内置于 ChatCCC,只需配置 DeepSeek 兼容 API。
|
|
212
212
|
|
|
213
213
|
#### Claude Code
|
|
214
214
|
|
|
@@ -237,7 +237,13 @@ codex login
|
|
|
237
237
|
codex --version
|
|
238
238
|
```
|
|
239
239
|
|
|
240
|
-
Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理,也可以在 `config.json` 中覆盖。
|
|
240
|
+
Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理,也可以在 `config.json` 中覆盖。
|
|
241
|
+
|
|
242
|
+
#### CCC Agent
|
|
243
|
+
|
|
244
|
+
CCC Agent 是 ChatCCC 内置的编程 Agent,不需要额外安装 CLI。在首次配置向导或 Web 管理页中启用后,填写 DeepSeek 兼容 API Key、Base URL 和模型即可使用;它可以设为 `/new` 的默认 Agent,也可以通过 `/new ccc` 显式创建会话。
|
|
245
|
+
|
|
246
|
+
`ccc.alternativeModel` 是单个备选模型,只会加入 `/model` 的人工切换列表,不会在请求失败时自动重试或切换,避免重复执行带副作用的工具调用。
|
|
241
247
|
|
|
242
248
|
#### 可选:Chrome CDP
|
|
243
249
|
|
|
@@ -285,22 +291,32 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
|
|
|
285
291
|
"baseUrl": "",
|
|
286
292
|
"maxTurn": 0
|
|
287
293
|
},
|
|
288
|
-
"cursor": {
|
|
294
|
+
"cursor": {
|
|
289
295
|
"enabled": false,
|
|
290
296
|
"defaultAgent": false,
|
|
291
|
-
"path": "",
|
|
292
|
-
"model": "",
|
|
293
|
-
"
|
|
297
|
+
"path": "",
|
|
298
|
+
"model": "",
|
|
299
|
+
"alternativeModel": "",
|
|
300
|
+
"avatarBatteryMode": "apiPercent",
|
|
294
301
|
"onDemandMonthlyBudget": 1000
|
|
295
302
|
},
|
|
296
|
-
"codex": {
|
|
303
|
+
"codex": {
|
|
297
304
|
"enabled": false,
|
|
298
305
|
"defaultAgent": false,
|
|
299
306
|
"path": "",
|
|
300
307
|
"model": "",
|
|
308
|
+
"alternativeModel": "",
|
|
301
309
|
"effort": "",
|
|
302
310
|
"fastMode": false
|
|
303
|
-
}
|
|
311
|
+
},
|
|
312
|
+
"ccc": {
|
|
313
|
+
"enabled": false,
|
|
314
|
+
"defaultAgent": false,
|
|
315
|
+
"DEEPSEEK_API_KEY": "",
|
|
316
|
+
"DEEPSEEK_BASE_URL": "https://api.deepseek.com/v1",
|
|
317
|
+
"model": "deepseek-v4-pro",
|
|
318
|
+
"alternativeModel": ""
|
|
319
|
+
}
|
|
304
320
|
}
|
|
305
321
|
```
|
|
306
322
|
|
|
@@ -326,13 +342,16 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
|
|
|
326
342
|
| `cursor.onDemandMonthlyBudget` | `avatarBatteryMode=onDemandUse` 时用于计算电量的月预算 |
|
|
327
343
|
| `claude.model` / `claude.subagentModel` / `claude.effort` | 选填;设置后传给 Claude Agent SDK,留空以 `~/.claude/settings.json` 为准 |
|
|
328
344
|
| `claude.apiKey` / `claude.baseUrl` | 选填;设置后传给 Claude Agent SDK,留空以 `~/.claude/settings.json` 为准 |
|
|
329
|
-
| `claude.maxTurn` | 选填;Claude 最大对话轮数,默认 0(无限制),可在 Web UI 编辑 |
|
|
345
|
+
| `claude.maxTurn` | 选填;Claude 最大对话轮数,默认 0(无限制),可在 Web UI 编辑 |
|
|
346
|
+
| `cursor.alternativeModel` / `codex.alternativeModel` / `ccc.alternativeModel` | 单个备选模型;加入 `/model` 人工切换列表,不会自动故障转移 |
|
|
347
|
+
| `ccc.DEEPSEEK_API_KEY` / `ccc.DEEPSEEK_BASE_URL` | CCC Agent 使用的 DeepSeek 兼容 API 凭证和服务地址 |
|
|
348
|
+
| `ccc.model` | CCC Agent 默认模型 |
|
|
330
349
|
|
|
331
350
|
> **权限控制**:普通消息以 `bypassPermissions` 模式运行,跳过 Agent 操作确认。使用 `/plan` 或 `/ask` 前缀时,ChatCCC 自动切换为只读模式:Claude SDK 仅放行 Read + stop-stuck-loop 网络请求,Codex 使用 `--sandbox read-only`,Cursor 使用 `--mode plan/ask`。请只在可信环境中使用。
|
|
332
351
|
|
|
333
352
|
### 5. 开始使用
|
|
334
353
|
|
|
335
|
-
**飞书:** 找到机器人后直接发送普通消息,即可在当前私聊中创建并持续使用专属 AI 会话;私聊工作目录固定为运行 ChatCCC 的系统账号用户目录。默认 Agent 发生变化后,下一条私聊普通消息会触发切换并创建新的空会话;若旧 Agent 正在生成,该消息会先排队,待当前回复完成后再切换。命令不会触发自动切换。需要独立任务时,发送 `/new`、`/new claude`、`/new cursor` 或 `/new
|
|
354
|
+
**飞书:** 找到机器人后直接发送普通消息,即可在当前私聊中创建并持续使用专属 AI 会话;私聊工作目录固定为运行 ChatCCC 的系统账号用户目录。默认 Agent 发生变化后,下一条私聊普通消息会触发切换并创建新的空会话;若旧 Agent 正在生成,该消息会先排队,待当前回复完成后再切换。命令不会触发自动切换。需要独立任务时,发送 `/new`、`/new claude`、`/new cursor`、`/new codex` 或 `/new ccc`,机器人会另外创建会话群。
|
|
336
355
|
|
|
337
356
|
**微信:** 扫码登录后,在机器人私聊里发送 `/new` 或指定 Agent 的 `/new ...` 命令即可开始。功能与飞书基本一致,但展示为纯文本。
|
|
338
357
|
|
|
@@ -348,6 +367,7 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
|
|
|
348
367
|
| `/new claude` | 创建 Claude Code 会话;飞书中会创建新群 |
|
|
349
368
|
| `/new cursor` | 创建 Cursor 会话;飞书中会创建新群 |
|
|
350
369
|
| `/new codex` | 创建 Codex 会话;飞书中会创建新群 |
|
|
370
|
+
| `/new ccc` | 创建内置 CCC Agent 会话;飞书中会创建新群 |
|
|
351
371
|
| `/newh` | 在当前聊天原地重置会话;群聊保留工作目录,飞书私聊固定使用系统用户目录 |
|
|
352
372
|
| `/model` | 查看或切换当前会话的模型 |
|
|
353
373
|
| `/fast` | 查看当前 Codex 会话的 Fast 模式;使用 `/fast on` 或 `/fast off` 切换 |
|
|
@@ -357,7 +377,7 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
|
|
|
357
377
|
| `/cd` | 查看或设置后续新建会话的默认工作目录,不改变当前会话;飞书私聊自身始终使用系统用户目录 |
|
|
358
378
|
| `/sessions` | 查看所有会话状态 |
|
|
359
379
|
| `/session <数字>` | 将当前群聊切换到 `/sessions` 列表中的指定会话;飞书私聊不支持切换 |
|
|
360
|
-
| `/usage` | 查看当前会话对应 Agent 的用量;Codex
|
|
380
|
+
| `/usage` | 查看当前会话对应 Agent 的用量;Codex 显示 5h/7天窗口,Cursor 显示当前周期用量,使用官方 DeepSeek API 的 CCC Agent 显示账户余额 |
|
|
361
381
|
| `/git <子命令>` | 在当前会话工作目录执行 `git ...` 并回传输出 |
|
|
362
382
|
| `/abd<内容>` | 去掉 `/abd` 前缀后把内容发给 Agent,并在消息末尾追加第一性原理需求澄清提示 |
|
|
363
383
|
| `/plan <内容>` | 只读计划模式:仅允许读文件和 stop-stuck-loop 请求,不执行任何写操作 |
|
|
@@ -368,7 +388,7 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
|
|
|
368
388
|
|
|
369
389
|
`/update` 会在执行 npm 更新前把飞书消息或按钮事件 ID 原子写入 `~/.chatccc/state/update-command-guard.json`。同一 ID 跨重启重投时会静默忽略;用户主动发送的新 `/update` 因事件 ID 不同,仍可立即执行。该保护仅作用于 `/update`,普通消息与 `/restart` 的处理不变。
|
|
370
390
|
|
|
371
|
-
> **模型切换**:`/model` 查看当前会话 Agent 的可选模型清单,`/model <名称>` 模糊匹配切换,`/model clear` 恢复默认。可选模型来自当前 Agent 的配置:Claude 使用 `claude.model` / `claude.subagentModel
|
|
391
|
+
> **模型切换**:`/model` 查看当前会话 Agent 的可选模型清单,`/model <名称>` 模糊匹配切换,`/model clear` 恢复默认。可选模型来自当前 Agent 的配置:Claude 使用 `claude.model` / `claude.subagentModel`;Cursor、Codex 和 CCC Agent 使用各自的 `model` / `alternativeModel`。
|
|
372
392
|
|
|
373
393
|
> **Codex Fast 模式**:Web UI 中的“Fast 模式”设置新 Codex 会话的全局默认值,默认关闭。进入 Codex 会话后,`/fast` 查询当前状态,`/fast on` 和 `/fast off` 只覆盖当前会话并从下一条消息生效。ChatCCC 会显式向 Codex CLI 传入 `service_tier="fast"` 或 `service_tier="default"`,因此关闭时不会继承用户 `config.toml` 中可能开启的 Fast。
|
|
374
394
|
|
|
@@ -376,4 +396,4 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
|
|
|
376
396
|
|
|
377
397
|
## 技术栈
|
|
378
398
|
|
|
379
|
-
TypeScript / Node.js >= 20 / tsx / Anthropic Claude Agent SDK / Cursor Agent CLI / Codex CLI / 飞书 WebSocket API / CardKit / 微信 iLink
|
|
399
|
+
TypeScript / Node.js >= 20 / tsx / AI SDK / Anthropic Claude Agent SDK / Cursor Agent CLI / Codex CLI / 飞书 WebSocket API / CardKit / 微信 iLink
|
package/config.sample.json
CHANGED
|
@@ -73,8 +73,11 @@
|
|
|
73
73
|
"fastMode": false
|
|
74
74
|
},
|
|
75
75
|
"ccc": {
|
|
76
|
+
"enabled": false,
|
|
77
|
+
"defaultAgent": false,
|
|
76
78
|
"DEEPSEEK_API_KEY": "",
|
|
77
79
|
"DEEPSEEK_BASE_URL": "https://api.deepseek.com/v1",
|
|
78
|
-
"model": "deepseek-v4-pro"
|
|
80
|
+
"model": "deepseek-v4-pro",
|
|
81
|
+
"alternativeModel": ""
|
|
79
82
|
}
|
|
80
83
|
}
|
package/package.json
CHANGED
|
@@ -18,9 +18,12 @@ afterEach(() => {
|
|
|
18
18
|
describe("builtin ChatSession config", () => {
|
|
19
19
|
it("does not fall back to DEEPSEEK_API_KEY environment variable", () => {
|
|
20
20
|
config.ccc = {
|
|
21
|
+
enabled: false,
|
|
22
|
+
defaultAgent: false,
|
|
21
23
|
DEEPSEEK_API_KEY: "",
|
|
22
24
|
DEEPSEEK_BASE_URL: "https://api.deepseek.com/v1",
|
|
23
25
|
model: "deepseek-v4-pro",
|
|
26
|
+
alternativeModel: "",
|
|
24
27
|
};
|
|
25
28
|
process.env.DEEPSEEK_API_KEY = "sk-env-should-not-be-used";
|
|
26
29
|
|
|
@@ -158,6 +158,37 @@ describe("builtin file tools", () => {
|
|
|
158
158
|
await expect(readFile(file, "utf8")).resolves.toBe("alpha\nBETA\ngamma\n");
|
|
159
159
|
});
|
|
160
160
|
|
|
161
|
+
it("edits a CRLF file with LF oldText by normalizing line endings", async () => {
|
|
162
|
+
const dir = await makeTempDir();
|
|
163
|
+
const file = join(dir, "crlf.txt");
|
|
164
|
+
const crlfContent = "alpha\r\nbeta\r\ngamma\r\n";
|
|
165
|
+
await writeFile(file, crlfContent, "utf8");
|
|
166
|
+
|
|
167
|
+
const result = await editFileForTool(dir, {
|
|
168
|
+
path: "crlf.txt",
|
|
169
|
+
expectedSha256: sha256(crlfContent),
|
|
170
|
+
edits: [{ oldText: "alpha\nbeta", newText: "ALPHA\nBETA" }],
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
expect(result).toEqual(expect.objectContaining({
|
|
174
|
+
changed: true,
|
|
175
|
+
editsApplied: 1,
|
|
176
|
+
beforeSha256: sha256(crlfContent),
|
|
177
|
+
afterSha256: sha256("ALPHA\r\nBETA\r\ngamma\r\n"),
|
|
178
|
+
}));
|
|
179
|
+
await expect(readFile(file, "utf8")).resolves.toBe("ALPHA\r\nBETA\r\ngamma\r\n");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("rejects a multi-line oldText that still does not match after EOL normalization", async () => {
|
|
183
|
+
const dir = await makeTempDir();
|
|
184
|
+
await writeFile(join(dir, "crlf.txt"), "alpha\r\nbeta\r\n", "utf8");
|
|
185
|
+
|
|
186
|
+
await expect(editFileForTool(dir, {
|
|
187
|
+
path: "crlf.txt",
|
|
188
|
+
edits: [{ oldText: "alpha\ngamma", newText: "x" }],
|
|
189
|
+
})).rejects.toThrow("oldText was not found");
|
|
190
|
+
});
|
|
191
|
+
|
|
161
192
|
it("rejects edits when the SHA-256 precondition does not match", async () => {
|
|
162
193
|
const dir = await makeTempDir();
|
|
163
194
|
await writeFile(join(dir, "edit.txt"), "current\n", "utf8");
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
buildButtons,
|
|
14
14
|
truncateContent,
|
|
15
15
|
getToolEmoji,
|
|
16
|
+
normalizeToolName,
|
|
16
17
|
} from "../cards.ts";
|
|
17
18
|
import { ABD_HELP_LINE } from "../shared-prefix.ts";
|
|
18
19
|
|
|
@@ -83,6 +84,25 @@ describe("getToolEmoji", () => {
|
|
|
83
84
|
expect(getToolEmoji("AskUserQuestion")).toBe("\u{2753}");// ❓
|
|
84
85
|
});
|
|
85
86
|
|
|
87
|
+
it("returns correct emoji for CCC builtin tool names (snake_case)", () => {
|
|
88
|
+
expect(getToolEmoji("read_file")).toBe("\u{1F4D6}");
|
|
89
|
+
expect(getToolEmoji("list_dir")).toBe("\u{1F4C2}");
|
|
90
|
+
expect(getToolEmoji("search_code")).toBe("\u{1F50E}");
|
|
91
|
+
expect(getToolEmoji("run_command")).toBe("\u{1F5A5}\u{FE0F}");
|
|
92
|
+
expect(getToolEmoji("edit_file")).toBe("\u{270F}\u{FE0F}");
|
|
93
|
+
expect(getToolEmoji("create_file")).toBe("\u{270D}\u{FE0F}");
|
|
94
|
+
expect(getToolEmoji("delete_file")).toBe("\u{1F5D1}\u{FE0F}");
|
|
95
|
+
expect(getToolEmoji("move_file")).toBe("\u{1F4E6}");
|
|
96
|
+
expect(getToolEmoji("apply_patch")).toBe("\u{1F4CB}");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("normalizeToolName converts snake_case to PascalCase", () => {
|
|
100
|
+
expect(normalizeToolName("read_file")).toBe("ReadFile");
|
|
101
|
+
expect(normalizeToolName("run_command")).toBe("RunCommand");
|
|
102
|
+
expect(normalizeToolName("Read")).toBe("Read");
|
|
103
|
+
expect(normalizeToolName("")).toBe("");
|
|
104
|
+
});
|
|
105
|
+
|
|
86
106
|
it("returns wrench for unknown tool names", () => {
|
|
87
107
|
expect(getToolEmoji("UnknownTool")).toBe("\u{1F527}");
|
|
88
108
|
expect(getToolEmoji("cat")).toBe("\u{1F527}");
|
|
@@ -161,7 +181,7 @@ describe("buildHelpCard", () => {
|
|
|
161
181
|
const parsed = JSON.parse(card);
|
|
162
182
|
const action = parsed.elements[2];
|
|
163
183
|
expect(action.tag).toBe("action");
|
|
164
|
-
expect(action.actions).toHaveLength(
|
|
184
|
+
expect(action.actions).toHaveLength(8);
|
|
165
185
|
});
|
|
166
186
|
|
|
167
187
|
it("adds ABD prefix help as the final help line", () => {
|
|
@@ -169,17 +189,17 @@ describe("buildHelpCard", () => {
|
|
|
169
189
|
const parsed = JSON.parse(card);
|
|
170
190
|
const lines = parsed.elements[1].text.content.split("\n");
|
|
171
191
|
|
|
172
|
-
expect(lines).toContain("发送 **/usage**
|
|
192
|
+
expect(lines).toContain("发送 **/usage** 查看当前 Agent 的用量或余额");
|
|
173
193
|
expect(lines.at(-1)).toBe(ABD_HELP_LINE);
|
|
174
194
|
});
|
|
175
195
|
|
|
176
|
-
it("
|
|
196
|
+
it("advertises CCC Agent as a normal session option", () => {
|
|
177
197
|
const card = buildHelpCard("test");
|
|
178
198
|
const parsed = JSON.parse(card);
|
|
179
199
|
const text = JSON.stringify(parsed);
|
|
180
200
|
|
|
181
|
-
expect(text).
|
|
182
|
-
expect(text).
|
|
201
|
+
expect(text).toContain("/new ccc");
|
|
202
|
+
expect(text).toContain("CCC Agent");
|
|
183
203
|
});
|
|
184
204
|
});
|
|
185
205
|
|
|
@@ -419,7 +439,7 @@ describe("buildSessionsCard", () => {
|
|
|
419
439
|
expect(content).not.toContain("Cursor 会话");
|
|
420
440
|
});
|
|
421
441
|
|
|
422
|
-
it("labels
|
|
442
|
+
it("labels CCC sessions without grouping them as Claude Code", () => {
|
|
423
443
|
const card = buildSessionsCard([
|
|
424
444
|
{ sessionId: "session-20260702-121530-a1b2c3", chatName: "", chatId: "oc_ccc", active: false, turnCount: 1, elapsedSeconds: null, model: "deepseek-v4-pro", tool: "ccc" },
|
|
425
445
|
]);
|
|
@@ -72,7 +72,7 @@ const baseAppConfig: AppConfig = {
|
|
|
72
72
|
onDemandMonthlyBudget: 1000,
|
|
73
73
|
},
|
|
74
74
|
codex: { enabled: true, defaultAgent: false, path: "/initial/codex", model: "initial-codex-model", alternativeModel: "initial-codex-alt-model", effort: "initial-codex-effort", fastMode: false },
|
|
75
|
-
ccc: { DEEPSEEK_API_KEY: "initial-ccc-key", DEEPSEEK_BASE_URL: "https://initial.deepseek.test/v1", model: "initial-ccc-model" },
|
|
75
|
+
ccc: { enabled: true, defaultAgent: false, DEEPSEEK_API_KEY: "initial-ccc-key", DEEPSEEK_BASE_URL: "https://initial.deepseek.test/v1", model: "initial-ccc-model", alternativeModel: "initial-ccc-alt-model" },
|
|
76
76
|
};
|
|
77
77
|
|
|
78
78
|
// 把 module 状态抢救快照:每个 it 跑前重置回这个状态,避免污染相邻测试。
|
|
@@ -177,15 +177,18 @@ describe("applyLoadedConfig — 刷新 export let 常量", () => {
|
|
|
177
177
|
expect(CURSOR_AGENT_COMMAND).toBe("C:/custom/cursor.exe");
|
|
178
178
|
});
|
|
179
179
|
|
|
180
|
-
it("/model 候选列表包含 Cursor/Codex 的单个备选模型并保持主模型优先", () => {
|
|
180
|
+
it("/model 候选列表包含 Cursor/Codex/CCC 的单个备选模型并保持主模型优先", () => {
|
|
181
181
|
const cfg = structuredClone(baseAppConfig);
|
|
182
182
|
cfg.cursor.model = "cursor-main";
|
|
183
183
|
cfg.cursor.alternativeModel = "cursor-alt";
|
|
184
184
|
cfg.codex.model = "codex-main";
|
|
185
|
-
cfg.codex.alternativeModel = "codex-main";
|
|
185
|
+
cfg.codex.alternativeModel = "codex-main";
|
|
186
|
+
cfg.ccc.model = "ccc-main";
|
|
187
|
+
cfg.ccc.alternativeModel = "ccc-alt";
|
|
186
188
|
|
|
187
189
|
expect(getAllModelsForTool("cursor", cfg)).toEqual(["cursor-main", "cursor-alt"]);
|
|
188
|
-
expect(getAllModelsForTool("codex", cfg)).toEqual(["codex-main"]);
|
|
190
|
+
expect(getAllModelsForTool("codex", cfg)).toEqual(["codex-main"]);
|
|
191
|
+
expect(getAllModelsForTool("ccc", cfg)).toEqual(["ccc-main", "ccc-alt"]);
|
|
189
192
|
});
|
|
190
193
|
|
|
191
194
|
it("不修改 CHATCCC_PORT(端口在 setup 切换时必须保持不变)", () => {
|
|
@@ -262,12 +265,20 @@ describe("resolveDefaultAgentTool", () => {
|
|
|
262
265
|
expect(resolveDefaultAgentTool(cfg)).toBe("cursor");
|
|
263
266
|
});
|
|
264
267
|
|
|
265
|
-
it("defaultAgent 指向未启用 Agent 时回退到第一个已启用 Agent", () => {
|
|
268
|
+
it("defaultAgent 指向未启用 Agent 时回退到第一个已启用 Agent", () => {
|
|
266
269
|
const cfg = structuredClone(baseAppConfig);
|
|
267
270
|
cfg.claude.enabled = false;
|
|
268
271
|
cfg.claude.defaultAgent = true;
|
|
269
272
|
cfg.cursor.defaultAgent = false;
|
|
270
273
|
|
|
271
|
-
expect(resolveDefaultAgentTool(cfg)).toBe("cursor");
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
+
expect(resolveDefaultAgentTool(cfg)).toBe("cursor");
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("支持把已启用的 CCC Agent 设为默认 Agent", () => {
|
|
278
|
+
const cfg = structuredClone(baseAppConfig);
|
|
279
|
+
for (const tool of ["claude", "cursor", "codex"] as const) cfg[tool].defaultAgent = false;
|
|
280
|
+
cfg.ccc.defaultAgent = true;
|
|
281
|
+
|
|
282
|
+
expect(resolveDefaultAgentTool(cfg)).toBe("ccc");
|
|
283
|
+
});
|
|
284
|
+
});
|
|
@@ -33,19 +33,23 @@ describe("config.sample.json", () => {
|
|
|
33
33
|
const sample = JSON.parse(readFileSync(configSamplePath, "utf8")) as {
|
|
34
34
|
cursor?: { alternativeModel?: unknown };
|
|
35
35
|
codex?: { alternativeModel?: unknown; fastMode?: unknown };
|
|
36
|
+
ccc?: { alternativeModel?: unknown };
|
|
36
37
|
};
|
|
37
38
|
|
|
38
39
|
expect(sample.cursor?.alternativeModel).toBe("");
|
|
39
40
|
expect(sample.codex?.alternativeModel).toBe("");
|
|
41
|
+
expect(sample.ccc?.alternativeModel).toBe("");
|
|
40
42
|
expect(sample.codex?.fastMode).toBe(false);
|
|
41
43
|
});
|
|
42
44
|
|
|
43
45
|
it("sets ccc agent DeepSeek defaults in the sample config", () => {
|
|
44
46
|
const configSamplePath = join(process.cwd(), "config.sample.json");
|
|
45
47
|
const sample = JSON.parse(readFileSync(configSamplePath, "utf8")) as {
|
|
46
|
-
ccc?: { DEEPSEEK_API_KEY?: unknown; DEEPSEEK_BASE_URL?: unknown; model?: unknown };
|
|
48
|
+
ccc?: { enabled?: unknown; defaultAgent?: unknown; DEEPSEEK_API_KEY?: unknown; DEEPSEEK_BASE_URL?: unknown; model?: unknown };
|
|
47
49
|
};
|
|
48
50
|
|
|
51
|
+
expect(sample.ccc?.enabled).toBe(false);
|
|
52
|
+
expect(sample.ccc?.defaultAgent).toBe(false);
|
|
49
53
|
expect(sample.ccc?.DEEPSEEK_API_KEY).toBe("");
|
|
50
54
|
expect(sample.ccc?.DEEPSEEK_BASE_URL).toBe("https://api.deepseek.com/v1");
|
|
51
55
|
expect(sample.ccc?.model).toBe("deepseek-v4-pro");
|
|
@@ -89,4 +93,4 @@ describe("config.sample.json", () => {
|
|
|
89
93
|
expect(sample.rawStreamLogs?.[tool]?.keepCompleted).toBe(false);
|
|
90
94
|
}
|
|
91
95
|
});
|
|
92
|
-
});
|
|
96
|
+
});
|
|
@@ -148,6 +148,12 @@ describe("handleCommand WeChat processing ack", () => {
|
|
|
148
148
|
config.cursor.defaultAgent = false;
|
|
149
149
|
config.codex.defaultAgent = false;
|
|
150
150
|
config.codex.fastMode = false;
|
|
151
|
+
config.ccc.enabled = true;
|
|
152
|
+
config.ccc.defaultAgent = false;
|
|
153
|
+
config.ccc.DEEPSEEK_API_KEY = "sk-test-ccc";
|
|
154
|
+
config.ccc.DEEPSEEK_BASE_URL = "https://api.deepseek.com/v1";
|
|
155
|
+
config.ccc.model = "deepseek-v4-pro";
|
|
156
|
+
config.ccc.alternativeModel = "deepseek-v4-flash";
|
|
151
157
|
mockStreamStates.clear();
|
|
152
158
|
mockGetCodexUsageSummary.mockReset();
|
|
153
159
|
mockGetCursorUsageSummary.mockReset();
|
|
@@ -204,7 +210,8 @@ describe("handleCommand WeChat processing ack", () => {
|
|
|
204
210
|
_clearAdapterCacheForTest();
|
|
205
211
|
_resetSessionRegistryFileForTest();
|
|
206
212
|
_resetSessionToolsFileForTest();
|
|
207
|
-
vi.useRealTimers();
|
|
213
|
+
vi.useRealTimers();
|
|
214
|
+
vi.unstubAllGlobals();
|
|
208
215
|
await rm(tempDir, { recursive: true, force: true });
|
|
209
216
|
});
|
|
210
217
|
|
|
@@ -1013,7 +1020,7 @@ describe("handleCommand WeChat processing ack", () => {
|
|
|
1013
1020
|
expect(card.elements[0].text.content).toContain("ChatGPT browser session has no access token.");
|
|
1014
1021
|
});
|
|
1015
1022
|
|
|
1016
|
-
it("handles /usage as Cursor usage in Cursor chats", async () => {
|
|
1023
|
+
it("handles /usage as Cursor usage in Cursor chats", async () => {
|
|
1017
1024
|
const platform = mockPlatform("feishu");
|
|
1018
1025
|
await recordSessionRegistry({
|
|
1019
1026
|
chatId: "cursor-chat",
|
|
@@ -1113,6 +1120,64 @@ describe("handleCommand WeChat processing ack", () => {
|
|
|
1113
1120
|
expect(claudeReadyCall?.[2]).not.toContain("/usage");
|
|
1114
1121
|
});
|
|
1115
1122
|
|
|
1123
|
+
it("handles /usage as DeepSeek balance in CCC Agent chats", async () => {
|
|
1124
|
+
const platform = mockPlatform("feishu");
|
|
1125
|
+
const fetchMock = vi.fn(async () => new Response(JSON.stringify({
|
|
1126
|
+
is_available: true,
|
|
1127
|
+
balance_infos: [{
|
|
1128
|
+
currency: "CNY",
|
|
1129
|
+
total_balance: "12.34",
|
|
1130
|
+
topped_up_balance: "10.00",
|
|
1131
|
+
granted_balance: "2.34",
|
|
1132
|
+
}],
|
|
1133
|
+
}), { status: 200 }));
|
|
1134
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
1135
|
+
await recordSessionRegistry({
|
|
1136
|
+
chatId: "ccc-chat",
|
|
1137
|
+
sessionId: "sid-ccc",
|
|
1138
|
+
tool: "ccc",
|
|
1139
|
+
chatName: "ccc-session",
|
|
1140
|
+
running: false,
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
await handleCommand(platform, "/usage", "ccc-chat", "ou-user", Date.now(), "group");
|
|
1144
|
+
|
|
1145
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
1146
|
+
"https://api.deepseek.com/user/balance",
|
|
1147
|
+
expect.objectContaining({ headers: { Authorization: "Bearer sk-test-ccc" } }),
|
|
1148
|
+
);
|
|
1149
|
+
expect(platform.sendCard).toHaveBeenCalledWith(
|
|
1150
|
+
"ccc-chat",
|
|
1151
|
+
"CCC Usage",
|
|
1152
|
+
expect.stringContaining("总余额: 12.34"),
|
|
1153
|
+
"blue",
|
|
1154
|
+
);
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1157
|
+
it("does not query balance for a non-official CCC API endpoint", async () => {
|
|
1158
|
+
const platform = mockPlatform("feishu");
|
|
1159
|
+
const fetchMock = vi.fn();
|
|
1160
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
1161
|
+
config.ccc.DEEPSEEK_BASE_URL = "https://deepseek-proxy.example.com/v1";
|
|
1162
|
+
await recordSessionRegistry({
|
|
1163
|
+
chatId: "ccc-proxy-chat",
|
|
1164
|
+
sessionId: "sid-ccc-proxy",
|
|
1165
|
+
tool: "ccc",
|
|
1166
|
+
chatName: "ccc-proxy-session",
|
|
1167
|
+
running: false,
|
|
1168
|
+
});
|
|
1169
|
+
|
|
1170
|
+
await handleCommand(platform, "/usage", "ccc-proxy-chat", "ou-user", Date.now(), "group");
|
|
1171
|
+
|
|
1172
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
1173
|
+
expect(platform.sendCard).toHaveBeenCalledWith(
|
|
1174
|
+
"ccc-proxy-chat",
|
|
1175
|
+
"CCC Usage",
|
|
1176
|
+
expect.stringContaining("仅支持官方 DeepSeek API"),
|
|
1177
|
+
"blue",
|
|
1178
|
+
);
|
|
1179
|
+
});
|
|
1180
|
+
|
|
1116
1181
|
it("reloads runtime config for the exact /reload command", async () => {
|
|
1117
1182
|
const platform = mockPlatform("feishu");
|
|
1118
1183
|
|
|
@@ -1129,7 +1194,7 @@ describe("handleCommand WeChat processing ack", () => {
|
|
|
1129
1194
|
);
|
|
1130
1195
|
});
|
|
1131
1196
|
|
|
1132
|
-
it("allows the
|
|
1197
|
+
it("allows and advertises the /new ccc entry", async () => {
|
|
1133
1198
|
const platform = mockPlatform("feishu");
|
|
1134
1199
|
_setAdapterForToolForTest("ccc", mockAdapter("session-20260702-121530-a1b2c3"));
|
|
1135
1200
|
|
|
@@ -1150,7 +1215,21 @@ describe("handleCommand WeChat processing ack", () => {
|
|
|
1150
1215
|
const helpPlatform = mockPlatform("feishu");
|
|
1151
1216
|
await handleCommand(helpPlatform, "hello", "feishu-group-help", "ou-user", Date.now(), "group");
|
|
1152
1217
|
const helpCard = vi.mocked(helpPlatform.sendRawCard).mock.calls.at(-1)?.[1] as string;
|
|
1153
|
-
expect(helpCard).
|
|
1154
|
-
expect(helpCard).
|
|
1218
|
+
expect(helpCard).toContain("/new ccc");
|
|
1219
|
+
expect(helpCard).toContain("CCC Agent");
|
|
1220
|
+
});
|
|
1221
|
+
|
|
1222
|
+
it("shows the CCC model list when CCC Agent is the unbound default", async () => {
|
|
1223
|
+
const platform = mockPlatform("feishu");
|
|
1224
|
+
config.claude.defaultAgent = false;
|
|
1225
|
+
config.cursor.defaultAgent = false;
|
|
1226
|
+
config.codex.defaultAgent = false;
|
|
1227
|
+
config.ccc.defaultAgent = true;
|
|
1228
|
+
|
|
1229
|
+
await handleCommand(platform, "/model", "feishu-p2p-ccc-model", "ou-user", Date.now(), "p2p");
|
|
1230
|
+
|
|
1231
|
+
const card = vi.mocked(platform.sendRawCard).mock.calls.at(-1)?.[1] as string;
|
|
1232
|
+
expect(card).toContain("deepseek-v4-pro");
|
|
1233
|
+
expect(card).toContain("deepseek-v4-flash");
|
|
1155
1234
|
});
|
|
1156
1235
|
});
|
|
@@ -80,4 +80,14 @@ describe("SimulatedPlatform", () => {
|
|
|
80
80
|
// 近期消息不触发
|
|
81
81
|
expect(SimulatedPlatform.formatDelayNotice(Date.now(), "test")).toBeNull();
|
|
82
82
|
});
|
|
83
|
+
|
|
84
|
+
it("formatDelayNotice 边界:恰好 15 分钟触发,未来时间戳不触发", () => {
|
|
85
|
+
const now = Date.now();
|
|
86
|
+
// 恰好等于阈值(15 分钟)应触发提醒
|
|
87
|
+
expect(SimulatedPlatform.formatDelayNotice(now - 15 * 60 * 1000, "边界", now)).not.toBeNull();
|
|
88
|
+
// 14 分 59 秒不触发
|
|
89
|
+
expect(SimulatedPlatform.formatDelayNotice(now - (15 * 60 * 1000 - 1000), "边界", now)).toBeNull();
|
|
90
|
+
// 未来时间戳(时钟偏移)不触发
|
|
91
|
+
expect(SimulatedPlatform.formatDelayNotice(now + 60 * 1000, "边界", now)).toBeNull();
|
|
92
|
+
});
|
|
83
93
|
});
|
|
@@ -86,6 +86,28 @@ describe("unflattenConfig", () => {
|
|
|
86
86
|
});
|
|
87
87
|
});
|
|
88
88
|
|
|
89
|
+
it("maps CCC Agent settings into ccc config", () => {
|
|
90
|
+
expect(
|
|
91
|
+
unflattenConfig({
|
|
92
|
+
CHATCCC_CCC_ENABLED: true,
|
|
93
|
+
CHATCCC_CCC_DEFAULT_AGENT: true,
|
|
94
|
+
CHATCCC_CCC_API_KEY: "sk-test-ccc",
|
|
95
|
+
CHATCCC_CCC_BASE_URL: "https://api.deepseek.com/v1",
|
|
96
|
+
CHATCCC_CCC_MODEL: "deepseek-v4-flash",
|
|
97
|
+
CHATCCC_CCC_ALTERNATIVE_MODEL: "deepseek-v4-pro",
|
|
98
|
+
}),
|
|
99
|
+
).toEqual({
|
|
100
|
+
ccc: {
|
|
101
|
+
enabled: true,
|
|
102
|
+
defaultAgent: true,
|
|
103
|
+
DEEPSEEK_API_KEY: "sk-test-ccc",
|
|
104
|
+
DEEPSEEK_BASE_URL: "https://api.deepseek.com/v1",
|
|
105
|
+
model: "deepseek-v4-flash",
|
|
106
|
+
alternativeModel: "deepseek-v4-pro",
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
89
111
|
it("maps Chrome CDP guard fields into chromeDevtools config", () => {
|
|
90
112
|
expect(
|
|
91
113
|
unflattenConfig({
|
|
@@ -153,6 +175,14 @@ describe("getRestartRequiredReasons", () => {
|
|
|
153
175
|
effort: "",
|
|
154
176
|
fastMode: false,
|
|
155
177
|
},
|
|
178
|
+
ccc: {
|
|
179
|
+
enabled: true,
|
|
180
|
+
defaultAgent: false,
|
|
181
|
+
DEEPSEEK_API_KEY: "sk-test-ccc",
|
|
182
|
+
DEEPSEEK_BASE_URL: "https://api.deepseek.com/v1",
|
|
183
|
+
model: "deepseek-v4-pro",
|
|
184
|
+
alternativeModel: "",
|
|
185
|
+
},
|
|
156
186
|
};
|
|
157
187
|
|
|
158
188
|
it("does not require restart for agent and Chrome runtime settings", () => {
|
|
@@ -215,6 +245,16 @@ describe("dashboard edit modal", () => {
|
|
|
215
245
|
expect(PAGE_HTML).toContain("备选模型");
|
|
216
246
|
});
|
|
217
247
|
|
|
248
|
+
it("shows CCC Agent in the wizard and dashboard with model switching fields", () => {
|
|
249
|
+
expect(PAGE_HTML).toContain('id="agent-card-ccc"');
|
|
250
|
+
expect(PAGE_HTML).toContain('id="agent-enable-ccc"');
|
|
251
|
+
expect(PAGE_HTML).toContain('id="agent-default-ccc"');
|
|
252
|
+
expect(PAGE_HTML).toContain('id="field-CHATCCC_CCC_MODEL"');
|
|
253
|
+
expect(PAGE_HTML).toContain('id="field-CHATCCC_CCC_ALTERNATIVE_MODEL"');
|
|
254
|
+
expect(PAGE_HTML).toContain('id="dash-ccc"');
|
|
255
|
+
expect(PAGE_HTML).toContain("editSection('ccc')");
|
|
256
|
+
});
|
|
257
|
+
|
|
218
258
|
it("shows config effect scope hints", () => {
|
|
219
259
|
expect(PAGE_HTML).toContain("生效范围:保存后下一条消息或下个新会话生效");
|
|
220
260
|
expect(PAGE_HTML).toContain("生效范围:飞书开关、App ID、App Secret 或平台类型变更需要重启 ChatCCC");
|
|
@@ -979,13 +979,19 @@ export async function editFileForTool(cwd: string, input: EditFileInput): Promis
|
|
|
979
979
|
const before = await readEditableTextFile(filePath);
|
|
980
980
|
assertExpectedSha256(filePath, before.sha, input.expectedSha256);
|
|
981
981
|
|
|
982
|
-
|
|
982
|
+
// Normalize line endings before matching so that LF-based oldText/newText
|
|
983
|
+
// (which is what models typically emit) works against CRLF files checked
|
|
984
|
+
// out on Windows. The file's dominant EOL style is restored on write.
|
|
985
|
+
const eol = detectEol(before.text);
|
|
986
|
+
let text = eol === "\r\n" ? before.text.replace(/\r\n/g, "\n") : before.text;
|
|
983
987
|
let editsApplied = 0;
|
|
984
988
|
for (const [index, edit] of input.edits.entries()) {
|
|
985
989
|
if (!edit.oldText) {
|
|
986
990
|
throw new Error(`edit ${index + 1} oldText must not be empty`);
|
|
987
991
|
}
|
|
988
|
-
const
|
|
992
|
+
const oldText = edit.oldText.replace(/\r\n/g, "\n");
|
|
993
|
+
const newText = edit.newText.replace(/\r\n/g, "\n");
|
|
994
|
+
const count = countOccurrences(text, oldText);
|
|
989
995
|
if (count === 0) {
|
|
990
996
|
throw new Error(`edit ${index + 1} oldText was not found in ${filePath}`);
|
|
991
997
|
}
|
|
@@ -993,10 +999,14 @@ export async function editFileForTool(cwd: string, input: EditFileInput): Promis
|
|
|
993
999
|
throw new Error(`edit ${index + 1} oldText matched ${count} times in ${filePath}; set replaceAll=true or provide more context`);
|
|
994
1000
|
}
|
|
995
1001
|
text = edit.replaceAll
|
|
996
|
-
? replaceAllLiteral(text,
|
|
997
|
-
: text.replace(
|
|
1002
|
+
? replaceAllLiteral(text, oldText, newText)
|
|
1003
|
+
: text.replace(oldText, newText);
|
|
998
1004
|
editsApplied += edit.replaceAll ? count : 1;
|
|
999
1005
|
}
|
|
1006
|
+
if (eol === "\r\n") {
|
|
1007
|
+
// After normalization above the buffer contains only \n, so this is safe.
|
|
1008
|
+
text = text.replace(/\n/g, "\r\n");
|
|
1009
|
+
}
|
|
1000
1010
|
|
|
1001
1011
|
assertTextSize(filePath, text, MAX_EDIT_BYTES);
|
|
1002
1012
|
const afterSha = sha256(text);
|