chatccc 0.2.203 → 0.2.205
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 +14 -10
- package/config.sample.json +8 -5
- package/package.json +1 -1
- package/src/__tests__/agent-activity.test.ts +76 -0
- package/src/__tests__/codex-adapter.test.ts +7 -7
- package/src/__tests__/config-reload.test.ts +19 -6
- package/src/__tests__/config-sample.test.ts +10 -1
- package/src/__tests__/cursor-adapter.test.ts +5 -5
- package/src/__tests__/session.test.ts +97 -17
- package/src/__tests__/startup-lifecycle.test.ts +98 -0
- package/src/__tests__/web-ui.test.ts +59 -0
- package/src/adapters/codex-adapter.ts +1 -0
- package/src/adapters/cursor-adapter.ts +1 -0
- package/src/agent-activity.ts +170 -0
- package/src/config.ts +19 -11
- package/src/index.ts +59 -19
- package/src/orchestrator.ts +11 -4
- package/src/session-chat-binding.ts +6 -4
- package/src/session.ts +89 -60
- package/src/startup-lifecycle.ts +96 -0
- package/src/stream-state.ts +13 -7
- 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 配置面板和中继服务端口 |
|
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
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createAgentActivityTracker,
|
|
5
|
+
formatAgentActivityTitle,
|
|
6
|
+
updateAgentActivity,
|
|
7
|
+
} from "../agent-activity.ts";
|
|
8
|
+
|
|
9
|
+
describe("agent activity", () => {
|
|
10
|
+
it("starts with an explicit startup status", () => {
|
|
11
|
+
const tracker = createAgentActivityTracker(1_000);
|
|
12
|
+
|
|
13
|
+
expect(formatAgentActivityTitle(tracker.activity, 4_000)).toBe("正在启动 Agent · 3秒");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("keeps the thinking timer stable across repeated thinking blocks", () => {
|
|
17
|
+
const tracker = createAgentActivityTracker(1_000);
|
|
18
|
+
|
|
19
|
+
expect(updateAgentActivity(tracker, { type: "thinking", thinking: "first" }, 2_000)).toBe(true);
|
|
20
|
+
expect(updateAgentActivity(tracker, { type: "thinking", thinking: "second" }, 5_000)).toBe(false);
|
|
21
|
+
expect(formatAgentActivityTitle(tracker.activity, 8_000)).toBe("思考中 · 6秒");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("shows the active tool name and elapsed time", () => {
|
|
25
|
+
const tracker = createAgentActivityTracker(1_000);
|
|
26
|
+
|
|
27
|
+
updateAgentActivity(tracker, {
|
|
28
|
+
type: "tool_use",
|
|
29
|
+
id: "tool-1",
|
|
30
|
+
name: "Bash",
|
|
31
|
+
input: { command: "npm test" },
|
|
32
|
+
}, 3_000);
|
|
33
|
+
|
|
34
|
+
expect(formatAgentActivityTitle(tracker.activity, 38_000)).toBe("正在执行 Bash · 35秒");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("tracks parallel tools and keeps the remaining tool after one result", () => {
|
|
38
|
+
const tracker = createAgentActivityTracker(1_000);
|
|
39
|
+
|
|
40
|
+
updateAgentActivity(tracker, { type: "tool_use", id: "read", name: "Read", input: {} }, 2_000);
|
|
41
|
+
updateAgentActivity(tracker, { type: "tool_use", id: "grep", name: "Grep", input: {} }, 3_000);
|
|
42
|
+
expect(formatAgentActivityTitle(tracker.activity, 4_000)).toBe("正在执行 Read 等 2 项 · 2秒");
|
|
43
|
+
|
|
44
|
+
updateAgentActivity(tracker, {
|
|
45
|
+
type: "tool_result",
|
|
46
|
+
tool_use_id: "read",
|
|
47
|
+
content: "done",
|
|
48
|
+
}, 5_000);
|
|
49
|
+
expect(formatAgentActivityTitle(tracker.activity, 7_000)).toBe("正在执行 Grep · 4秒");
|
|
50
|
+
|
|
51
|
+
updateAgentActivity(tracker, {
|
|
52
|
+
type: "tool_result",
|
|
53
|
+
tool_use_id: "grep",
|
|
54
|
+
content: "done",
|
|
55
|
+
}, 8_000);
|
|
56
|
+
expect(formatAgentActivityTitle(tracker.activity, 10_000)).toBe("正在处理工具结果 · 2秒");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("switches to response and context-compaction statuses", () => {
|
|
60
|
+
const tracker = createAgentActivityTracker(1_000);
|
|
61
|
+
|
|
62
|
+
updateAgentActivity(tracker, { type: "text", text: "answer" }, 2_000);
|
|
63
|
+
expect(formatAgentActivityTitle(tracker.activity, 3_000)).toBe("正在生成回复 · 1秒");
|
|
64
|
+
|
|
65
|
+
updateAgentActivity(tracker, {
|
|
66
|
+
type: "compact_boundary",
|
|
67
|
+
trigger: "auto",
|
|
68
|
+
pre_tokens: 100_000,
|
|
69
|
+
}, 4_000);
|
|
70
|
+
expect(formatAgentActivityTitle(tracker.activity, 5_000)).toBe("正在整理上下文 · 1秒");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("formats longer elapsed time without decorative animation", () => {
|
|
74
|
+
expect(formatAgentActivityTitle({ kind: "thinking", startedAt: 1_000 }, 74_000)).toBe("思考中 · 1分13秒");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -76,12 +76,12 @@ describe("normalizeCodexMessage", () => {
|
|
|
76
76
|
status: "in_progress",
|
|
77
77
|
},
|
|
78
78
|
});
|
|
79
|
-
expect(result).not.toBeNull();
|
|
80
|
-
expect(result!.type).toBe("assistant");
|
|
81
|
-
expect(result!.blocks).toEqual([
|
|
82
|
-
{ type: "tool_use", name: "Bash", input: { command: "powershell.exe -Command ls" } },
|
|
83
|
-
]);
|
|
84
|
-
});
|
|
79
|
+
expect(result).not.toBeNull();
|
|
80
|
+
expect(result!.type).toBe("assistant");
|
|
81
|
+
expect(result!.blocks).toEqual([
|
|
82
|
+
{ type: "tool_use", id: "item_0", name: "Bash", input: { command: "powershell.exe -Command ls" } },
|
|
83
|
+
]);
|
|
84
|
+
});
|
|
85
85
|
|
|
86
86
|
it("normalizes command_execution completion as tool_result (success)", () => {
|
|
87
87
|
const result = normalizeCodexMessage({
|
|
@@ -302,4 +302,4 @@ describe("createCodexAdapter", () => {
|
|
|
302
302
|
});
|
|
303
303
|
expect(await adapter.getSessionInfo("sid-C")).toBeUndefined();
|
|
304
304
|
});
|
|
305
|
-
});
|
|
305
|
+
});
|
|
@@ -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");
|
|
@@ -660,11 +660,11 @@ describe("Cursor stream fixture - 端到端不重复", () => {
|
|
|
660
660
|
session_id: "sid",
|
|
661
661
|
timestamp_ms: 1000,
|
|
662
662
|
} as Parameters<typeof normalizeCursorMessage>[0]);
|
|
663
|
-
expect(result).not.toBeNull();
|
|
664
|
-
expect(result!.blocks).toEqual([
|
|
665
|
-
{ type: "tool_use", name: "Bash", input: { command: "ls" } },
|
|
666
|
-
]);
|
|
667
|
-
});
|
|
663
|
+
expect(result).not.toBeNull();
|
|
664
|
+
expect(result!.blocks).toEqual([
|
|
665
|
+
{ type: "tool_use", id: "toolu_abc", name: "Bash", input: { command: "ls" } },
|
|
666
|
+
]);
|
|
667
|
+
});
|
|
668
668
|
|
|
669
669
|
it("normalizes tool_call completed → tool_result block (success)", () => {
|
|
670
670
|
const result = normalizeCursorMessage({
|
|
@@ -4,10 +4,16 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
|
|
6
6
|
// mock stream-state 以支持在测试中控制累积长度
|
|
7
|
-
const mockStreamStates = new Map<string, {
|
|
8
|
-
accumulatedContent: string;
|
|
9
|
-
finalReply: string;
|
|
10
|
-
|
|
7
|
+
const mockStreamStates = new Map<string, {
|
|
8
|
+
accumulatedContent: string;
|
|
9
|
+
finalReply: string;
|
|
10
|
+
activity?: {
|
|
11
|
+
kind: "starting" | "thinking" | "tool" | "processing" | "responding" | "compacting";
|
|
12
|
+
startedAt: number;
|
|
13
|
+
toolName?: string;
|
|
14
|
+
toolCount?: number;
|
|
15
|
+
};
|
|
16
|
+
status?: "running" | "done" | "stopped" | "error";
|
|
11
17
|
turnCount?: number;
|
|
12
18
|
finalReplySentTurn?: number;
|
|
13
19
|
finalReplySentAt?: number;
|
|
@@ -18,8 +24,9 @@ vi.mock("../stream-state.ts", () => ({
|
|
|
18
24
|
if (!state) return null;
|
|
19
25
|
return {
|
|
20
26
|
sessionId: sid,
|
|
21
|
-
accumulatedContent: state.accumulatedContent,
|
|
22
|
-
finalReply: state.finalReply,
|
|
27
|
+
accumulatedContent: state.accumulatedContent,
|
|
28
|
+
finalReply: state.finalReply,
|
|
29
|
+
activity: state.activity,
|
|
23
30
|
finalReplySentTurn: state.finalReplySentTurn,
|
|
24
31
|
finalReplySentAt: state.finalReplySentAt,
|
|
25
32
|
status: state.status ?? "running",
|
|
@@ -34,24 +41,31 @@ vi.mock("../stream-state.ts", () => ({
|
|
|
34
41
|
writeStreamState: async (state: {
|
|
35
42
|
sessionId: string;
|
|
36
43
|
accumulatedContent: string;
|
|
37
|
-
finalReply: string;
|
|
44
|
+
finalReply: string;
|
|
45
|
+
activity?: {
|
|
46
|
+
kind: "starting" | "thinking" | "tool" | "processing" | "responding" | "compacting";
|
|
47
|
+
startedAt: number;
|
|
48
|
+
toolName?: string;
|
|
49
|
+
toolCount?: number;
|
|
50
|
+
};
|
|
38
51
|
status?: "running" | "done" | "stopped" | "error";
|
|
39
52
|
turnCount?: number;
|
|
40
53
|
finalReplySentTurn?: number;
|
|
41
54
|
finalReplySentAt?: number;
|
|
42
55
|
}) => {
|
|
43
56
|
mockStreamStates.set(state.sessionId, {
|
|
44
|
-
accumulatedContent: state.accumulatedContent,
|
|
45
|
-
finalReply: state.finalReply,
|
|
57
|
+
accumulatedContent: state.accumulatedContent,
|
|
58
|
+
finalReply: state.finalReply,
|
|
59
|
+
activity: state.activity,
|
|
46
60
|
status: state.status,
|
|
47
61
|
turnCount: state.turnCount,
|
|
48
62
|
finalReplySentTurn: state.finalReplySentTurn,
|
|
49
63
|
finalReplySentAt: state.finalReplySentAt,
|
|
50
64
|
});
|
|
51
65
|
},
|
|
52
|
-
createEmptyStreamState: (sid: string, cwd: string, tool: string, turnCount: number) => ({
|
|
53
|
-
sessionId: sid, status: "running" as const, accumulatedContent: "", finalReply: "", chunkCount: 0, turnCount, contextTokens: 0, updatedAt: Date.now(), cwd, tool,
|
|
54
|
-
}),
|
|
66
|
+
createEmptyStreamState: (sid: string, cwd: string, tool: string, turnCount: number) => ({
|
|
67
|
+
sessionId: sid, status: "running" as const, accumulatedContent: "", finalReply: "", activity: { kind: "starting" as const, startedAt: Date.now() }, chunkCount: 0, turnCount, contextTokens: 0, updatedAt: Date.now(), cwd, tool,
|
|
68
|
+
}),
|
|
55
69
|
isFinalReplySentForTurn: (state: { turnCount: number; finalReplySentTurn?: number }) => state.finalReplySentTurn === state.turnCount,
|
|
56
70
|
markFinalReplySent: async (sid: string, turnCount: number, sentAt = Date.now()) => {
|
|
57
71
|
const state = mockStreamStates.get(sid);
|
|
@@ -569,7 +583,7 @@ describe("runAgentSession process monitor", () => {
|
|
|
569
583
|
});
|
|
570
584
|
});
|
|
571
585
|
|
|
572
|
-
describe("unified display loop WeChat delta", () => {
|
|
586
|
+
describe("unified display loop WeChat delta", () => {
|
|
573
587
|
beforeEach(() => {
|
|
574
588
|
vi.useFakeTimers();
|
|
575
589
|
resetState();
|
|
@@ -636,10 +650,76 @@ describe("unified display loop WeChat delta", () => {
|
|
|
636
650
|
"chat-wechat",
|
|
637
651
|
"tool output",
|
|
638
652
|
);
|
|
639
|
-
});
|
|
640
|
-
});
|
|
641
|
-
|
|
642
|
-
describe("unified display loop
|
|
653
|
+
});
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
describe("unified display loop activity status", () => {
|
|
657
|
+
beforeEach(() => {
|
|
658
|
+
vi.useFakeTimers();
|
|
659
|
+
vi.setSystemTime(new Date("2026-07-16T08:00:00.000Z"));
|
|
660
|
+
resetState();
|
|
661
|
+
resetBindingState();
|
|
662
|
+
mockStreamStates.clear();
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
afterEach(() => {
|
|
666
|
+
stopUnifiedDisplayLoop();
|
|
667
|
+
resetBindingState();
|
|
668
|
+
vi.useRealTimers();
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
it("renders explicit activity and elapsed time alongside the liveness dots", async () => {
|
|
672
|
+
const platform = mockPlatform("feishu");
|
|
673
|
+
setSessionPlatform(platform);
|
|
674
|
+
|
|
675
|
+
bindChatToSession("sid-activity", "chat-activity");
|
|
676
|
+
recordLastActiveChat("sid-activity", "chat-activity");
|
|
677
|
+
sessionInfoMap.set("chat-activity", {
|
|
678
|
+
sessionId: "sid-activity",
|
|
679
|
+
turnCount: 1,
|
|
680
|
+
lastContextTokens: 0,
|
|
681
|
+
startTime: Date.now() - 12_000,
|
|
682
|
+
tool: "codex",
|
|
683
|
+
});
|
|
684
|
+
displayCards.set("chat-activity", {
|
|
685
|
+
cardId: "card-activity",
|
|
686
|
+
sequence: 1,
|
|
687
|
+
cardBusy: false,
|
|
688
|
+
cardCreatedAt: Date.now(),
|
|
689
|
+
lastSentContent: "",
|
|
690
|
+
streamErrorNotified: false,
|
|
691
|
+
sessionId: "sid-activity",
|
|
692
|
+
turnCount: 1,
|
|
693
|
+
dotCount: 0,
|
|
694
|
+
});
|
|
695
|
+
mockStreamStates.set("sid-activity", {
|
|
696
|
+
accumulatedContent: "正在检查日志",
|
|
697
|
+
finalReply: "",
|
|
698
|
+
activity: {
|
|
699
|
+
kind: "tool",
|
|
700
|
+
startedAt: Date.now() - 12_000,
|
|
701
|
+
toolName: "Shell",
|
|
702
|
+
toolCount: 1,
|
|
703
|
+
},
|
|
704
|
+
status: "running",
|
|
705
|
+
turnCount: 1,
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
startUnifiedDisplayLoop();
|
|
709
|
+
await vi.advanceTimersByTimeAsync(3_000);
|
|
710
|
+
|
|
711
|
+
const payload = vi.mocked(platform.cardUpdate).mock.calls[0]?.[1];
|
|
712
|
+
expect(payload).toBeTypeOf("string");
|
|
713
|
+
const card = JSON.parse(payload as string) as {
|
|
714
|
+
header: { title: { content: string } };
|
|
715
|
+
body: { elements: Array<{ tag: string; content?: string }> };
|
|
716
|
+
};
|
|
717
|
+
expect(card.header.title.content).toBe("正在执行 Shell · 15秒");
|
|
718
|
+
expect(card.body.elements[0]?.content).toBe("正在检查日志\n。");
|
|
719
|
+
});
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
describe("unified display loop terminal card update", () => {
|
|
643
723
|
beforeEach(() => {
|
|
644
724
|
vi.useFakeTimers();
|
|
645
725
|
resetState();
|
|
@@ -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
|
+
});
|
|
@@ -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
|
// ---------------------------------------------------------------------------
|