chatccc 0.2.202 → 0.2.204
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 +20 -14
- package/config.sample.json +8 -5
- package/package.json +1 -1
- package/src/__tests__/config-reload.test.ts +19 -6
- package/src/__tests__/config-sample.test.ts +10 -1
- package/src/__tests__/startup-lifecycle.test.ts +98 -0
- package/src/__tests__/update-command-guard.test.ts +144 -0
- package/src/__tests__/web-ui.test.ts +59 -0
- package/src/config.ts +19 -11
- package/src/index.ts +107 -28
- package/src/orchestrator.ts +54 -21
- package/src/startup-lifecycle.ts +96 -0
- package/src/update-command-guard.ts +165 -0
- package/src/web-ui.ts +185 -127
package/README.md
CHANGED
|
@@ -133,7 +133,7 @@ Write-Host ''
|
|
|
133
133
|
chatccc
|
|
134
134
|
```
|
|
135
135
|
|
|
136
|
-
|
|
136
|
+
如果一切顺利,系统默认浏览器会自动打开 `http://localhost:18080/` 的 Web 配置页面。按页面提示填入飞书 App ID / App Secret,点击"保存并启动"即可。
|
|
137
137
|
|
|
138
138
|
> **只想装 ChatCCC 本体?** 如果你已经有 Node.js,直接 `npm install -g chatccc && chatccc` 即可,不需要跑上面的完整脚本。
|
|
139
139
|
|
|
@@ -151,7 +151,7 @@ chatccc
|
|
|
151
151
|
|
|
152
152
|
旧版本留在仓库或包目录下的 `config.json`、`logs/`、`state/` 会在首次启动时自动迁移到用户目录。
|
|
153
153
|
|
|
154
|
-
|
|
154
|
+
每次直接运行 `chatccc` 时,无论是否已经完成配置,ChatCCC 默认都会用系统默认浏览器打开本地 Web UI(默认 `http://localhost:18080/`,修改 `config.port` 后跟随实际端口)。可在首次配置向导或管理页的 **Web UI** 设置中关闭;关闭后从下一次直接启动起生效。由 `/restart`、`/update` 或 Web UI 发起的内部重启始终不会重复打开浏览器。Linux 服务器没有 `DISPLAY`/`WAYLAND_DISPLAY` 时会跳过打开,并在终端输出 SSH 隧道访问提示。主页顶部的 **Agent Team** 入口会跳转到独立的 `/agent-team` 页面。
|
|
155
155
|
|
|
156
156
|
#### 从源码运行
|
|
157
157
|
|
|
@@ -260,11 +260,14 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
|
|
|
260
260
|
"appId": "cli_xxxxxxxxxxxx",
|
|
261
261
|
"appSecret": "xxxxxxxxxxxxxxxxxxxxxxxxxx"
|
|
262
262
|
},
|
|
263
|
-
"platforms": {
|
|
264
|
-
"feishu": { "enabled": true, "platformType": "feishu" },
|
|
265
|
-
"ilink": { "enabled": true, "reuseTokenOnStart": true }
|
|
266
|
-
},
|
|
267
|
-
"
|
|
263
|
+
"platforms": {
|
|
264
|
+
"feishu": { "enabled": true, "platformType": "feishu" },
|
|
265
|
+
"ilink": { "enabled": true, "reuseTokenOnStart": true }
|
|
266
|
+
},
|
|
267
|
+
"webUi": {
|
|
268
|
+
"openOnStart": true
|
|
269
|
+
},
|
|
270
|
+
"chromeDevtools": {
|
|
268
271
|
"enabled": false,
|
|
269
272
|
"port": 15166,
|
|
270
273
|
"chromePath": ""
|
|
@@ -305,9 +308,10 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
|
|
|
305
308
|
| `feishu.appId` / `feishu.appSecret` | 飞书应用凭证 |
|
|
306
309
|
| `platforms.feishu.enabled` | 是否启用飞书 |
|
|
307
310
|
| `platforms.feishu.platformType` | 飞书平台类型,默认 `feishu` |
|
|
308
|
-
| `platforms.ilink.enabled` | 是否启用微信 iLink |
|
|
309
|
-
| `platforms.ilink.reuseTokenOnStart` | 启动时是否复用已有微信登录 token |
|
|
310
|
-
| `
|
|
311
|
+
| `platforms.ilink.enabled` | 是否启用微信 iLink |
|
|
312
|
+
| `platforms.ilink.reuseTokenOnStart` | 启动时是否复用已有微信登录 token |
|
|
313
|
+
| `webUi.openOnStart` | 直接启动时是否打开系统默认浏览器;默认 true,内部重启始终跳过 |
|
|
314
|
+
| `chromeDevtools.enabled` | 是否启用常驻 Chrome CDP;默认 false |
|
|
311
315
|
| `chromeDevtools.port` | Chrome CDP 端口;默认 15166 |
|
|
312
316
|
| `chromeDevtools.chromePath` | Chrome 可执行文件路径;留空时自动探测 |
|
|
313
317
|
| `port` | 本地 Web 配置面板和中继服务端口 |
|
|
@@ -352,10 +356,12 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
|
|
|
352
356
|
| `/plan <内容>` | 只读计划模式:仅允许读文件和 stop-stuck-loop 请求,不执行任何写操作 |
|
|
353
357
|
| `/ask <内容>` | 只读问答模式:与 /plan 相同,仅允许读文件和 stop-stuck-loop 请求 |
|
|
354
358
|
| `/restart` | 重启机器人进程 |
|
|
355
|
-
| `/update` | 更新 npm 全局包并重启(仅限 `npm install -g chatccc`
|
|
356
|
-
| `/deleteg` | 解散当前飞书会话群;Agent 会话记录保留 |
|
|
357
|
-
|
|
358
|
-
|
|
359
|
+
| `/update` | 更新 npm 全局包并重启(仅限 `npm install -g chatccc` 安装的全局进程;同一飞书事件跨重启去重) |
|
|
360
|
+
| `/deleteg` | 解散当前飞书会话群;Agent 会话记录保留 |
|
|
361
|
+
|
|
362
|
+
`/update` 会在执行 npm 更新前把飞书消息或按钮事件 ID 原子写入 `~/.chatccc/state/update-command-guard.json`。同一 ID 跨重启重投时会静默忽略;用户主动发送的新 `/update` 因事件 ID 不同,仍可立即执行。该保护仅作用于 `/update`,普通消息与 `/restart` 的处理不变。
|
|
363
|
+
|
|
364
|
+
> **模型切换**:`/model` 查看当前会话 Agent 的可选模型清单,`/model <名称>` 模糊匹配切换,`/model clear` 恢复默认。可选模型来自当前 Agent 的配置:Claude 使用 `claude.model` / `claude.subagentModel`,Cursor 使用 `cursor.model`,Codex 使用 `codex.model`。
|
|
359
365
|
|
|
360
366
|
---
|
|
361
367
|
|
package/config.sample.json
CHANGED
|
@@ -3,11 +3,14 @@
|
|
|
3
3
|
"appId": "",
|
|
4
4
|
"appSecret": ""
|
|
5
5
|
},
|
|
6
|
-
"platforms": {
|
|
7
|
-
"feishu": { "enabled": true, "platformType": "feishu" },
|
|
8
|
-
"ilink": { "enabled": true, "reuseTokenOnStart": true }
|
|
9
|
-
},
|
|
10
|
-
"
|
|
6
|
+
"platforms": {
|
|
7
|
+
"feishu": { "enabled": true, "platformType": "feishu" },
|
|
8
|
+
"ilink": { "enabled": true, "reuseTokenOnStart": true }
|
|
9
|
+
},
|
|
10
|
+
"webUi": {
|
|
11
|
+
"openOnStart": true
|
|
12
|
+
},
|
|
13
|
+
"chromeDevtools": {
|
|
11
14
|
"enabled": false,
|
|
12
15
|
"port": 15166,
|
|
13
16
|
"chromePath": ""
|
package/package.json
CHANGED
|
@@ -38,10 +38,11 @@ import {
|
|
|
38
38
|
// 原地切换复用同一 server,重新读端口只会引入混乱)
|
|
39
39
|
// ---------------------------------------------------------------------------
|
|
40
40
|
|
|
41
|
-
const baseAppConfig: AppConfig = {
|
|
42
|
-
feishu: { appId: "INITIAL_APP", appSecret: "INITIAL_SECRET" },
|
|
43
|
-
platforms: { feishu: { enabled: true }, ilink: { enabled: true } },
|
|
44
|
-
|
|
41
|
+
const baseAppConfig: AppConfig = {
|
|
42
|
+
feishu: { appId: "INITIAL_APP", appSecret: "INITIAL_SECRET" },
|
|
43
|
+
platforms: { feishu: { enabled: true }, ilink: { enabled: true } },
|
|
44
|
+
webUi: { openOnStart: true },
|
|
45
|
+
chromeDevtools: { enabled: false, port: 15166, chromePath: "" },
|
|
45
46
|
port: 18080,
|
|
46
47
|
gitTimeoutSeconds: 180,
|
|
47
48
|
allowInterrupt: false,
|
|
@@ -216,7 +217,7 @@ describe("applyLoadedConfig — config 对象引用契约", () => {
|
|
|
216
217
|
expect(config.codex.path).toBe("/refresh/codex");
|
|
217
218
|
});
|
|
218
219
|
|
|
219
|
-
it("刷新 chromeDevtools 配置但保持 config 引用", () => {
|
|
220
|
+
it("刷新 chromeDevtools 配置但保持 config 引用", () => {
|
|
220
221
|
const refBefore = config;
|
|
221
222
|
|
|
222
223
|
applyLoadedConfig({
|
|
@@ -226,7 +227,19 @@ describe("applyLoadedConfig — config 对象引用契约", () => {
|
|
|
226
227
|
|
|
227
228
|
expect(config).toBe(refBefore);
|
|
228
229
|
expect(config.chromeDevtools).toEqual({ enabled: true, port: 15166, chromePath: "C:/Chrome/chrome.exe" });
|
|
229
|
-
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("刷新 webUi 启动开关但保持 config 引用", () => {
|
|
233
|
+
const refBefore = config;
|
|
234
|
+
|
|
235
|
+
applyLoadedConfig({
|
|
236
|
+
...structuredClone(baseAppConfig),
|
|
237
|
+
webUi: { openOnStart: false },
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
expect(config).toBe(refBefore);
|
|
241
|
+
expect(config.webUi).toEqual({ openOnStart: false });
|
|
242
|
+
});
|
|
230
243
|
|
|
231
244
|
it("空 feishu 凭证也能正确刷入(向导回滚到空的反向场景)", () => {
|
|
232
245
|
applyLoadedConfig({
|
|
@@ -59,7 +59,16 @@ describe("config.sample.json", () => {
|
|
|
59
59
|
expect(sample.chromeDevtools?.enabled).toBe(false);
|
|
60
60
|
expect(sample.chromeDevtools?.port).toBe(15166);
|
|
61
61
|
expect(sample.chromeDevtools?.chromePath).toBe("");
|
|
62
|
-
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("opens the Web UI on direct startup by default", () => {
|
|
65
|
+
const configSamplePath = join(process.cwd(), "config.sample.json");
|
|
66
|
+
const sample = JSON.parse(readFileSync(configSamplePath, "utf8")) as {
|
|
67
|
+
webUi?: { openOnStart?: unknown };
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
expect(sample.webUi?.openOnStart).toBe(true);
|
|
71
|
+
});
|
|
63
72
|
|
|
64
73
|
it("keeps raw stream logs disabled by default for every agent", () => {
|
|
65
74
|
const configSamplePath = join(process.cwd(), "config.sample.json");
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
INTERNAL_RESTART_ENV_VAR,
|
|
5
|
+
buildWebUiUrl,
|
|
6
|
+
createInternalRestartEnv,
|
|
7
|
+
openWebUiInDefaultBrowser,
|
|
8
|
+
shouldAutoOpenWebUi,
|
|
9
|
+
} from "../startup-lifecycle.ts";
|
|
10
|
+
|
|
11
|
+
describe("ChatCCC startup lifecycle", () => {
|
|
12
|
+
it("opens the Web UI by default for a direct start", () => {
|
|
13
|
+
expect(shouldAutoOpenWebUi({ env: {} })).toBe(true);
|
|
14
|
+
expect(shouldAutoOpenWebUi({ env: {}, openOnStart: true })).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("respects the persisted openOnStart preference", () => {
|
|
18
|
+
expect(shouldAutoOpenWebUi({ env: {}, openOnStart: false })).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("never opens the Web UI for an internal restart", () => {
|
|
22
|
+
expect(shouldAutoOpenWebUi({
|
|
23
|
+
env: { [INTERNAL_RESTART_ENV_VAR]: "1" },
|
|
24
|
+
openOnStart: true,
|
|
25
|
+
})).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("marks internal restart children without losing the inherited environment", () => {
|
|
29
|
+
expect(createInternalRestartEnv({ PATH: "test-path", CUSTOM: "value" })).toEqual({
|
|
30
|
+
PATH: "test-path",
|
|
31
|
+
CUSTOM: "value",
|
|
32
|
+
[INTERNAL_RESTART_ENV_VAR]: "1",
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("uses localhost and the configured port for the Web UI URL", () => {
|
|
37
|
+
expect(buildWebUiUrl(18080)).toBe("http://localhost:18080/");
|
|
38
|
+
expect(buildWebUiUrl(18081)).toBe("http://localhost:18081/");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("opens the configured URL in the Windows default browser", () => {
|
|
42
|
+
const child = {
|
|
43
|
+
on: vi.fn().mockReturnThis(),
|
|
44
|
+
unref: vi.fn(),
|
|
45
|
+
};
|
|
46
|
+
const spawnImpl = vi.fn(() => child as never);
|
|
47
|
+
|
|
48
|
+
expect(openWebUiInDefaultBrowser(18081, {
|
|
49
|
+
platform: "win32",
|
|
50
|
+
spawnImpl,
|
|
51
|
+
})).toBe(true);
|
|
52
|
+
expect(spawnImpl).toHaveBeenCalledWith(
|
|
53
|
+
"cmd.exe",
|
|
54
|
+
["/c", "start", "", "http://localhost:18081/"],
|
|
55
|
+
{
|
|
56
|
+
detached: true,
|
|
57
|
+
stdio: "ignore",
|
|
58
|
+
windowsHide: true,
|
|
59
|
+
},
|
|
60
|
+
);
|
|
61
|
+
expect(child.on).toHaveBeenCalledWith("error", expect.any(Function));
|
|
62
|
+
expect(child.unref).toHaveBeenCalledOnce();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("opens the configured URL with xdg-open on a Linux desktop", () => {
|
|
66
|
+
const child = {
|
|
67
|
+
on: vi.fn().mockReturnThis(),
|
|
68
|
+
unref: vi.fn(),
|
|
69
|
+
};
|
|
70
|
+
const spawnImpl = vi.fn(() => child as never);
|
|
71
|
+
|
|
72
|
+
expect(openWebUiInDefaultBrowser(18080, {
|
|
73
|
+
platform: "linux",
|
|
74
|
+
env: { DISPLAY: ":0" },
|
|
75
|
+
spawnImpl,
|
|
76
|
+
})).toBe(true);
|
|
77
|
+
expect(spawnImpl).toHaveBeenCalledWith(
|
|
78
|
+
"xdg-open",
|
|
79
|
+
["http://localhost:18080/"],
|
|
80
|
+
{ detached: true, stdio: "ignore" },
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("skips xdg-open and prints an SSH tunnel hint on headless Linux", () => {
|
|
85
|
+
const spawnImpl = vi.fn();
|
|
86
|
+
const onInfo = vi.fn();
|
|
87
|
+
|
|
88
|
+
expect(openWebUiInDefaultBrowser(18080, {
|
|
89
|
+
platform: "linux",
|
|
90
|
+
env: {},
|
|
91
|
+
spawnImpl,
|
|
92
|
+
onInfo,
|
|
93
|
+
})).toBe(false);
|
|
94
|
+
expect(spawnImpl).not.toHaveBeenCalled();
|
|
95
|
+
expect(onInfo).toHaveBeenCalledWith(expect.stringContaining("ssh -L 18080:127.0.0.1:18080"));
|
|
96
|
+
expect(onInfo).toHaveBeenCalledWith(expect.stringContaining("http://localhost:18080/"));
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
acquireUpdateCommandGuard,
|
|
8
|
+
buildUpdateCommandId,
|
|
9
|
+
extractFeishuEventId,
|
|
10
|
+
} from "../update-command-guard.ts";
|
|
11
|
+
|
|
12
|
+
describe("Feishu update command IDs", () => {
|
|
13
|
+
it("extracts and namespaces a card callback event_id", () => {
|
|
14
|
+
const eventId = extractFeishuEventId({
|
|
15
|
+
schema: "2.0",
|
|
16
|
+
header: { event_id: "evt_update_001" },
|
|
17
|
+
event: { action: { value: { action: "update" } } },
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
expect(buildUpdateCommandId("card", eventId)).toBe("card:evt_update_001");
|
|
21
|
+
expect(buildUpdateCommandId("message", "om_update_001")).toBe("message:om_update_001");
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("acquireUpdateCommandGuard", () => {
|
|
26
|
+
let tempDir: string;
|
|
27
|
+
let guardFile: string;
|
|
28
|
+
|
|
29
|
+
beforeEach(async () => {
|
|
30
|
+
tempDir = await mkdtemp(join(tmpdir(), "chatccc-update-guard-"));
|
|
31
|
+
guardFile = join(tempDir, "state", "update-command-guard.json");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterEach(async () => {
|
|
35
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("persists an accepted update ID before returning", async () => {
|
|
39
|
+
const result = acquireUpdateCommandGuard({
|
|
40
|
+
filePath: guardFile,
|
|
41
|
+
commandId: "message:om_update_001",
|
|
42
|
+
now: 1_000,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
expect(result).toEqual({ allowed: true, reason: "accepted" });
|
|
46
|
+
const saved = JSON.parse(await readFile(guardFile, "utf8"));
|
|
47
|
+
expect(saved).toEqual({
|
|
48
|
+
version: 1,
|
|
49
|
+
processed: [{ id: "message:om_update_001", recordedAt: 1_000 }],
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("rejects the same ID after a simulated process restart", () => {
|
|
54
|
+
expect(acquireUpdateCommandGuard({
|
|
55
|
+
filePath: guardFile,
|
|
56
|
+
commandId: "message:om_update_001",
|
|
57
|
+
now: 1_000,
|
|
58
|
+
}).allowed).toBe(true);
|
|
59
|
+
|
|
60
|
+
// 第二次调用不共享任何内存状态,只通过落盘文件模拟新进程重启后的判断。
|
|
61
|
+
expect(acquireUpdateCommandGuard({
|
|
62
|
+
filePath: guardFile,
|
|
63
|
+
commandId: "message:om_update_001",
|
|
64
|
+
now: 9_999_999,
|
|
65
|
+
})).toEqual({ allowed: false, reason: "duplicate_id" });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("accepts different IDs immediately without a cooldown", () => {
|
|
69
|
+
expect(acquireUpdateCommandGuard({
|
|
70
|
+
filePath: guardFile,
|
|
71
|
+
commandId: "message:om_update_001",
|
|
72
|
+
now: 1_000,
|
|
73
|
+
}).allowed).toBe(true);
|
|
74
|
+
|
|
75
|
+
expect(acquireUpdateCommandGuard({
|
|
76
|
+
filePath: guardFile,
|
|
77
|
+
commandId: "message:om_update_002",
|
|
78
|
+
now: 1_001,
|
|
79
|
+
})).toEqual({ allowed: true, reason: "accepted" });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("repairs a corrupt state file and warns without creating an update loop", async () => {
|
|
83
|
+
await mkdir(join(tempDir, "state"), { recursive: true });
|
|
84
|
+
await writeFile(guardFile, "not-json", "utf8");
|
|
85
|
+
const warn = vi.fn();
|
|
86
|
+
|
|
87
|
+
expect(acquireUpdateCommandGuard({
|
|
88
|
+
filePath: guardFile,
|
|
89
|
+
commandId: "message:om_update_001",
|
|
90
|
+
now: 1_000,
|
|
91
|
+
warn,
|
|
92
|
+
})).toEqual({ allowed: true, reason: "accepted" });
|
|
93
|
+
expect(warn).toHaveBeenCalledOnce();
|
|
94
|
+
|
|
95
|
+
// 损坏文件已被有效状态覆盖,因此重启后的重复投递仍会被拦截。
|
|
96
|
+
expect(acquireUpdateCommandGuard({
|
|
97
|
+
filePath: guardFile,
|
|
98
|
+
commandId: "message:om_update_001",
|
|
99
|
+
now: 2_000,
|
|
100
|
+
warn,
|
|
101
|
+
})).toEqual({ allowed: false, reason: "duplicate_id" });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("fails closed when the accepted ID cannot be persisted", async () => {
|
|
105
|
+
const blocker = join(tempDir, "not-a-directory");
|
|
106
|
+
await writeFile(blocker, "block", "utf8");
|
|
107
|
+
const warn = vi.fn();
|
|
108
|
+
|
|
109
|
+
expect(acquireUpdateCommandGuard({
|
|
110
|
+
filePath: join(blocker, "update-command-guard.json"),
|
|
111
|
+
commandId: "message:om_update_001",
|
|
112
|
+
now: 1_000,
|
|
113
|
+
warn,
|
|
114
|
+
})).toEqual({ allowed: false, reason: "state_write_failed" });
|
|
115
|
+
expect(warn).toHaveBeenCalledOnce();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("allows sources without a stable ID without writing a misleading record", async () => {
|
|
119
|
+
expect(acquireUpdateCommandGuard({
|
|
120
|
+
filePath: guardFile,
|
|
121
|
+
commandId: undefined,
|
|
122
|
+
now: 1_000,
|
|
123
|
+
})).toEqual({ allowed: true, reason: "missing_id" });
|
|
124
|
+
await expect(readFile(guardFile, "utf8")).rejects.toMatchObject({ code: "ENOENT" });
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("keeps only the newest configured number of update IDs", async () => {
|
|
128
|
+
for (let i = 0; i < 4; i++) {
|
|
129
|
+
expect(acquireUpdateCommandGuard({
|
|
130
|
+
filePath: guardFile,
|
|
131
|
+
commandId: `message:om_update_${i}`,
|
|
132
|
+
now: i,
|
|
133
|
+
maxEntries: 3,
|
|
134
|
+
}).allowed).toBe(true);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const saved = JSON.parse(await readFile(guardFile, "utf8"));
|
|
138
|
+
expect(saved.processed.map((entry: { id: string }) => entry.id)).toEqual([
|
|
139
|
+
"message:om_update_1",
|
|
140
|
+
"message:om_update_2",
|
|
141
|
+
"message:om_update_3",
|
|
142
|
+
]);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -1,11 +1,44 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import type { AddressInfo } from "node:net";
|
|
1
3
|
import { describe, it, expect } from "vitest";
|
|
2
4
|
import {
|
|
5
|
+
AGENT_TEAM_PAGE_HTML,
|
|
3
6
|
PAGE_HTML,
|
|
4
7
|
chooseStartPath,
|
|
8
|
+
createUiRouter,
|
|
5
9
|
getRestartRequiredReasons,
|
|
6
10
|
unflattenConfig,
|
|
7
11
|
} from "../web-ui.ts";
|
|
8
12
|
|
|
13
|
+
describe("Agent Team page", () => {
|
|
14
|
+
it("adds a prominent global entry to the ChatCCC page", () => {
|
|
15
|
+
expect(PAGE_HTML).toContain('href="/agent-team"');
|
|
16
|
+
expect(PAGE_HTML).toContain('class="agent-team-entry"');
|
|
17
|
+
expect(PAGE_HTML).toContain("Agent Team");
|
|
18
|
+
expect(PAGE_HTML).toContain("linear-gradient");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("contains only the Agent Team title as page content", () => {
|
|
22
|
+
expect(AGENT_TEAM_PAGE_HTML).toContain("<title>Agent Team</title>");
|
|
23
|
+
expect(AGENT_TEAM_PAGE_HTML).toContain("<h1>Agent Team</h1>");
|
|
24
|
+
expect(AGENT_TEAM_PAGE_HTML).not.toContain("dashboard-view");
|
|
25
|
+
expect(AGENT_TEAM_PAGE_HTML).not.toContain("wizard-view");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("serves the Agent Team page from its own route", async () => {
|
|
29
|
+
const server = createServer(createUiRouter());
|
|
30
|
+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
31
|
+
const port = (server.address() as AddressInfo).port;
|
|
32
|
+
try {
|
|
33
|
+
const response = await fetch(`http://127.0.0.1:${port}/agent-team`);
|
|
34
|
+
expect(response.status).toBe(200);
|
|
35
|
+
expect(await response.text()).toBe(AGENT_TEAM_PAGE_HTML);
|
|
36
|
+
} finally {
|
|
37
|
+
await new Promise<void>((resolve, reject) => server.close((err) => err ? reject(err) : resolve()));
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
9
42
|
describe("unflattenConfig", () => {
|
|
10
43
|
it("maps Claude subagent model into claude.subagentModel", () => {
|
|
11
44
|
expect(
|
|
@@ -66,6 +99,18 @@ describe("unflattenConfig", () => {
|
|
|
66
99
|
},
|
|
67
100
|
});
|
|
68
101
|
});
|
|
102
|
+
|
|
103
|
+
it("maps the Web UI startup preference into webUi config", () => {
|
|
104
|
+
expect(
|
|
105
|
+
unflattenConfig({
|
|
106
|
+
CHATCCC_WEB_UI_OPEN_ON_START: false,
|
|
107
|
+
}),
|
|
108
|
+
).toEqual({
|
|
109
|
+
webUi: {
|
|
110
|
+
openOnStart: false,
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
});
|
|
69
114
|
});
|
|
70
115
|
|
|
71
116
|
describe("getRestartRequiredReasons", () => {
|
|
@@ -75,6 +120,7 @@ describe("getRestartRequiredReasons", () => {
|
|
|
75
120
|
feishu: { enabled: true, platformType: "feishu" },
|
|
76
121
|
ilink: { enabled: true, reuseTokenOnStart: true },
|
|
77
122
|
},
|
|
123
|
+
webUi: { openOnStart: true },
|
|
78
124
|
chromeDevtools: { enabled: false, port: 15166, chromePath: "" },
|
|
79
125
|
port: 18080,
|
|
80
126
|
claude: {
|
|
@@ -142,6 +188,12 @@ describe("getRestartRequiredReasons", () => {
|
|
|
142
188
|
});
|
|
143
189
|
|
|
144
190
|
describe("dashboard edit modal", () => {
|
|
191
|
+
it("keeps the embedded dashboard script syntactically valid", () => {
|
|
192
|
+
const script = PAGE_HTML.match(/<script>([\s\S]*)<\/script>/)?.[1];
|
|
193
|
+
expect(script).toBeTruthy();
|
|
194
|
+
expect(() => new Function(script ?? "")).not.toThrow();
|
|
195
|
+
});
|
|
196
|
+
|
|
145
197
|
it("shows the edit modal and overlay when a section edit button is clicked", () => {
|
|
146
198
|
expect(PAGE_HTML).toContain("function editSection(section)");
|
|
147
199
|
expect(PAGE_HTML).toContain("document.getElementById('edit-modal').classList.remove('hidden');");
|
|
@@ -158,6 +210,13 @@ describe("dashboard edit modal", () => {
|
|
|
158
210
|
expect(PAGE_HTML).toContain("生效范围:保存后下一条消息或下个新会话生效");
|
|
159
211
|
expect(PAGE_HTML).toContain("生效范围:飞书开关、App ID、App Secret 或平台类型变更需要重启 ChatCCC");
|
|
160
212
|
});
|
|
213
|
+
|
|
214
|
+
it("shows the Web UI startup switch in both the wizard and dashboard", () => {
|
|
215
|
+
expect(PAGE_HTML).toContain('id="field-CHATCCC_WEB_UI_OPEN_ON_START"');
|
|
216
|
+
expect(PAGE_HTML).toContain('id="cfg-WEB_UI_OPEN_ON_START"');
|
|
217
|
+
expect(PAGE_HTML).toContain("editSection('webUi')");
|
|
218
|
+
expect(PAGE_HTML).toContain("下次直接启动生效;内部重启始终不打开");
|
|
219
|
+
});
|
|
161
220
|
});
|
|
162
221
|
|
|
163
222
|
// ---------------------------------------------------------------------------
|
package/src/config.ts
CHANGED
|
@@ -150,10 +150,11 @@ export interface RawStreamLogsConfig {
|
|
|
150
150
|
ccc: RawStreamAgentLogConfig;
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
-
export interface AppConfig {
|
|
154
|
-
feishu: FeishuConfig;
|
|
155
|
-
platforms: PlatformsConfig;
|
|
156
|
-
|
|
153
|
+
export interface AppConfig {
|
|
154
|
+
feishu: FeishuConfig;
|
|
155
|
+
platforms: PlatformsConfig;
|
|
156
|
+
webUi: { openOnStart: boolean };
|
|
157
|
+
chromeDevtools: ChromeDevtoolsConfig;
|
|
157
158
|
port: number;
|
|
158
159
|
gitTimeoutSeconds: number;
|
|
159
160
|
/** 若为 false,AI 生成过程中用户发送消息不会打断,须先点「停止」再发送新消息 */
|
|
@@ -418,9 +419,10 @@ function normalizeRawStreamAgentLogConfig(raw: unknown): RawStreamAgentLogConfig
|
|
|
418
419
|
|
|
419
420
|
function loadConfig(): AppConfig {
|
|
420
421
|
const defaults: AppConfig = {
|
|
421
|
-
feishu: { appId: "", appSecret: "" },
|
|
422
|
-
platforms: { feishu: { enabled: true }, ilink: { enabled: true } },
|
|
423
|
-
|
|
422
|
+
feishu: { appId: "", appSecret: "" },
|
|
423
|
+
platforms: { feishu: { enabled: true }, ilink: { enabled: true } },
|
|
424
|
+
webUi: { openOnStart: true },
|
|
425
|
+
chromeDevtools: { enabled: false, port: 15166, chromePath: "" },
|
|
424
426
|
port: 18080,
|
|
425
427
|
gitTimeoutSeconds: 180,
|
|
426
428
|
allowInterrupt: false,
|
|
@@ -495,6 +497,7 @@ function loadConfig(): AppConfig {
|
|
|
495
497
|
};
|
|
496
498
|
codex?: { enabled?: unknown; defaultAgent?: unknown; path?: unknown; command?: unknown; model?: unknown; alternativeModel?: unknown; effort?: unknown };
|
|
497
499
|
ccc?: { DEEPSEEK_API_KEY?: unknown; DEEPSEEK_BASE_URL?: unknown; model?: unknown };
|
|
500
|
+
webUi?: { openOnStart?: unknown };
|
|
498
501
|
chromeDevtools?: { enabled?: unknown; port?: unknown; chromePath?: unknown };
|
|
499
502
|
rawStreamLogs?: unknown;
|
|
500
503
|
};
|
|
@@ -510,6 +513,7 @@ function loadConfig(): AppConfig {
|
|
|
510
513
|
const cursorRaw = (parsed.cursor ?? {}) as NonNullable<typeof parsed.cursor>;
|
|
511
514
|
const codexRaw = (parsed.codex ?? {}) as NonNullable<typeof parsed.codex>;
|
|
512
515
|
const cccRaw = (parsed.ccc ?? {}) as NonNullable<typeof parsed.ccc>;
|
|
516
|
+
const webUiRaw = (parsed.webUi ?? {}) as NonNullable<typeof parsed.webUi>;
|
|
513
517
|
const chromeDevtoolsRaw = (parsed.chromeDevtools ?? {}) as NonNullable<typeof parsed.chromeDevtools>;
|
|
514
518
|
const rawStreamLogsRaw = typeof parsed.rawStreamLogs === "object" && parsed.rawStreamLogs !== null
|
|
515
519
|
? parsed.rawStreamLogs as unknown as Record<string, unknown>
|
|
@@ -577,7 +581,7 @@ function loadConfig(): AppConfig {
|
|
|
577
581
|
appId: feishu.appId ?? "",
|
|
578
582
|
appSecret: feishu.appSecret ?? "",
|
|
579
583
|
},
|
|
580
|
-
platforms: {
|
|
584
|
+
platforms: {
|
|
581
585
|
feishu: {
|
|
582
586
|
enabled: typeof (parsed.platforms as unknown as Record<string, unknown> | undefined)?.feishu === "object"
|
|
583
587
|
? Boolean(((parsed.platforms as unknown as Record<string, unknown>).feishu as Record<string, unknown>).enabled ?? true)
|
|
@@ -595,9 +599,13 @@ function loadConfig(): AppConfig {
|
|
|
595
599
|
reuseTokenOnStart: typeof (parsed.platforms as unknown as Record<string, unknown> | undefined)?.ilink === "object"
|
|
596
600
|
? Boolean(((parsed.platforms as unknown as Record<string, unknown>).ilink as Record<string, unknown>).reuseTokenOnStart ?? true)
|
|
597
601
|
: true,
|
|
598
|
-
},
|
|
599
|
-
},
|
|
600
|
-
|
|
602
|
+
},
|
|
603
|
+
},
|
|
604
|
+
webUi: {
|
|
605
|
+
// 兼容升级前没有 webUi 字段的 config.json:缺省仍按“自动打开”处理。
|
|
606
|
+
openOnStart: typeof webUiRaw.openOnStart === "boolean" ? webUiRaw.openOnStart : true,
|
|
607
|
+
},
|
|
608
|
+
chromeDevtools: {
|
|
601
609
|
enabled: typeof chromeDevtoolsRaw.enabled === "boolean" ? chromeDevtoolsRaw.enabled : false,
|
|
602
610
|
port: Number.isInteger(chromeDevtoolsPort) && chromeDevtoolsPort >= 1 && chromeDevtoolsPort <= 65535
|
|
603
611
|
? chromeDevtoolsPort
|