chatccc 0.2.203 → 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 CHANGED
@@ -133,7 +133,7 @@ Write-Host ''
133
133
  chatccc
134
134
  ```
135
135
 
136
- 如果一切顺利,浏览器会自动打开 `http://127.0.0.1:18080` 的 Web 配置页面。按页面提示填入飞书 App ID / App Secret,点击"保存并启动"即可。
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
- 首次启动时,如果还没有有效配置,ChatCCC 会自动打开本地 Web 配置向导(默认 `http://127.0.0.1:18080`)。
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
- "chromeDevtools": {
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
- | `chromeDevtools.enabled` | 是否启用常驻 Chrome CDP;默认 false |
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 配置面板和中继服务端口 |
@@ -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
- "chromeDevtools": {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatccc",
3
- "version": "0.2.203",
3
+ "version": "0.2.204",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -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
- chromeDevtools: { enabled: false, port: 15166, chromePath: "" },
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
+ });
@@ -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
- chromeDevtools: ChromeDevtoolsConfig;
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
- chromeDevtools: { enabled: false, port: 15166, chromePath: "" },
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
- chromeDevtools: {
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
package/src/index.ts CHANGED
@@ -28,12 +28,18 @@ import { WSClient, EventDispatcher, Domain } from "@larksuiteoapi/node-sdk";
28
28
  import WebSocket from "ws";
29
29
 
30
30
  import { appendStartupTrace, attachRelayWebSocket, ensureSingleInstance, freeRelayListenPort, installCrashLogging, waitForPortFree } from "./shared.ts";
31
- import { createUiRouter, setExtraApiHandler, setReloadConfigHook, startSetupMode } from "./web-ui.ts";
31
+ import { createUiRouter, setExtraApiHandler, setReloadConfigHook, startSetupMode } from "./web-ui.ts";
32
+ import {
33
+ buildWebUiUrl,
34
+ openWebUiInDefaultBrowser,
35
+ shouldAutoOpenWebUi,
36
+ } from "./startup-lifecycle.ts";
32
37
  import { buildPlatformStartupPlan } from "./platform-startup.ts";
33
38
  import { makeTraceId, logTrace } from "./trace.ts";
34
39
  import {
35
- CHATCCC_PORT,
36
- APP_ID,
40
+ CHATCCC_PORT,
41
+ config,
42
+ APP_ID,
37
43
  APP_SECRET,
38
44
  FEISHU_ENABLED,
39
45
  FEISHU_PLATFORM_TYPE,
@@ -720,11 +726,17 @@ async function startConfiguredPlatforms(
720
726
  // Main
721
727
  // ---------------------------------------------------------------------------
722
728
 
723
- async function main(): Promise<void> {
724
- appendStartupTrace("main: entered", {
725
- argv: process.argv.join(" ").slice(0, 400),
726
- CHATCCC_PORT,
727
- PROJECT_ROOT,
729
+ async function main(): Promise<void> {
730
+ // 用户直接运行 chatccc 时打开系统默认浏览器;由 `/restart`、`/update`
731
+ // Web UI 拉起的替代进程会携带内部标记,不重复打扰用户。
732
+ const autoOpenWebUi = shouldAutoOpenWebUi({
733
+ openOnStart: config.webUi.openOnStart,
734
+ });
735
+ appendStartupTrace("main: entered", {
736
+ argv: process.argv.join(" ").slice(0, 400),
737
+ CHATCCC_PORT,
738
+ PROJECT_ROOT,
739
+ autoOpenWebUi,
728
740
  });
729
741
 
730
742
  // 黑匣子:所有未捕获异常 / 信号 / beforeExit 都同步写入 startup-trace.log(appendFileSync)。
@@ -780,9 +792,18 @@ async function main(): Promise<void> {
780
792
  console.log(`${"=".repeat(60)}`);
781
793
  console.log(` 发送消息: POST http://127.0.0.1:${SIM_PORT}/api/sim/inject-message`);
782
794
  console.log(` 消息日志: ~/.chatccc/sim/messages.jsonl`);
783
- console.log(`${"=".repeat(60)}\n`);
784
-
785
- installShutdownHandlers(simServer);
795
+ console.log(`${"=".repeat(60)}\n`);
796
+
797
+ if (autoOpenWebUi) {
798
+ const url = buildWebUiUrl(SIM_PORT);
799
+ const opened = openWebUiInDefaultBrowser(SIM_PORT);
800
+ appendStartupTrace(
801
+ opened ? "web-ui: opening simulate browser" : "web-ui: simulate browser unavailable",
802
+ { url },
803
+ );
804
+ }
805
+
806
+ installShutdownHandlers(simServer);
786
807
  return;
787
808
  }
788
809
 
@@ -830,8 +851,9 @@ async function main(): Promise<void> {
830
851
  if (!APP_ID.trim() || !APP_SECRET.trim()) {
831
852
  // 凭证不全:进 setup 向导。注入 onActivate 回调让用户点"保存并启动"
832
853
  // 时,原地(同进程)调用 startBotService,复用 setup HTTP server。
833
- startSetupMode(CHATCCC_PORT, {
834
- onActivate: async (httpServer: Server) => {
854
+ startSetupMode(CHATCCC_PORT, {
855
+ openBrowser: autoOpenWebUi,
856
+ onActivate: async (httpServer: Server) => {
835
857
  reloadRuntimeConfig("setup-activate");
836
858
  appendStartupTrace("setup-activate: reloaded config from disk", {
837
859
  appIdMaskAfterReload: maskAppId(APP_ID),
@@ -867,17 +889,35 @@ async function main(): Promise<void> {
867
889
  await waitForPortFree(CHATCCC_PORT);
868
890
  appendStartupTrace("main: port free confirmed", { CHATCCC_PORT });
869
891
  }
870
- const httpServer = createServer(createUiRouter());
871
- await listenWithRetry(httpServer, CHATCCC_PORT, "127.0.0.1").catch((err: NodeJS.ErrnoException) => {
892
+ const httpServer = createServer(createUiRouter());
893
+ await listenWithRetry(httpServer, CHATCCC_PORT, "127.0.0.1").catch((err: NodeJS.ErrnoException) => {
872
894
  console.error(`\n[启动] 本地中继 WebSocket 监听失败:端口 ${CHATCCC_PORT}(${err.code ?? "?"} — ${err.message})`);
873
895
  console.error(
874
896
  " 处理建议: 关闭占用该端口的其它程序,或在 config.json 的 port 字段里改成其它未占用端口(如 18081)。"
875
897
  );
876
898
  printServiceDidNotStart(`本地中继端口 ${CHATCCC_PORT} 无法监听(${err.code ?? "?"} — ${err.message})`);
877
- process.exit(1);
878
- });
879
-
880
- await startConfiguredPlatforms(httpServer, { failOnFeishuError: false });
899
+ process.exit(1);
900
+ });
901
+
902
+ // 必须等 HTTP server 真正监听后再发起打开请求,避免浏览器先到一步看到
903
+ // ERR_CONNECTION_REFUSED。Chrome CDP 守护仍保持自己原有的独立行为。
904
+ if (autoOpenWebUi) {
905
+ const url = buildWebUiUrl(CHATCCC_PORT);
906
+ const opened = openWebUiInDefaultBrowser(CHATCCC_PORT);
907
+ if (opened) {
908
+ console.log(`[WEB-UI] 已请求系统默认浏览器打开: ${url}`);
909
+ appendStartupTrace("web-ui: opening default browser", { url });
910
+ } else {
911
+ appendStartupTrace("web-ui: default browser unavailable", { url });
912
+ }
913
+ } else {
914
+ appendStartupTrace("web-ui: default browser skipped by lifecycle or preference", {
915
+ url: buildWebUiUrl(CHATCCC_PORT),
916
+ openOnStart: config.webUi.openOnStart,
917
+ });
918
+ }
919
+
920
+ await startConfiguredPlatforms(httpServer, { failOnFeishuError: false });
881
921
 
882
922
  installShutdownHandlers(httpServer);
883
923
  }
@@ -91,6 +91,7 @@ import { applySharedPrefix } from "./shared-prefix.ts";
91
91
  import { cwdDisplayName, sessionChatName } from "./session-name.ts";
92
92
  import { reloadRuntimeConfig } from "./runtime-reload.ts";
93
93
  import { acquireUpdateCommandGuard } from "./update-command-guard.ts";
94
+ import { createInternalRestartEnv } from "./startup-lifecycle.ts";
94
95
  export { type PlatformAdapter } from "./platform-adapter.ts";
95
96
  import type { ChatAvatarUsageHints, PlatformAdapter } from "./platform-adapter.ts";
96
97
  import type { CodexUsageSummary } from "./feishu-api.ts";
@@ -480,7 +481,12 @@ function syncUpdateAndRestart(): void {
480
481
 
481
482
  // 3. spawn new chatccc
482
483
  try {
483
- const child = spawn(binPath, [], { detached: true, stdio: "ignore", shell: true });
484
+ const child = spawn(binPath, [], {
485
+ detached: true,
486
+ stdio: "ignore",
487
+ shell: true,
488
+ env: createInternalRestartEnv(),
489
+ });
484
490
  child.unref();
485
491
  updLog(`spawn new chatccc OK, childPid=${child.pid}, bin=${binPath}`);
486
492
  appendStartupTrace("update: spawn OK", { childPid: child.pid, binPath });
@@ -545,9 +551,10 @@ export async function handleCommand(
545
551
  appendStartupTrace("restart: spawn begin", { fromPid: process.pid });
546
552
  const child = spawn("npx", ["tsx", "src/index.ts"], {
547
553
  cwd: PROJECT_ROOT,
548
- detached: true,
549
- stdio: "ignore",
550
- shell: true,
554
+ detached: true,
555
+ stdio: "ignore",
556
+ shell: true,
557
+ env: createInternalRestartEnv(),
551
558
  });
552
559
 
553
560
  child.on("error", (err) => {
@@ -0,0 +1,96 @@
1
+ import { spawn, type ChildProcess } from "node:child_process";
2
+
3
+ /**
4
+ * ChatCCC 自己拉起替代进程时使用的内部标记。
5
+ *
6
+ * 不能用“是否已有配置”判断是否打开控制台:首次配置和日常直接启动都应该
7
+ * 打开,而 `/restart`、`/update` 和 Web UI 重启都不应该打扰用户。环境变量
8
+ * 会自然穿过 cmd/bash/npx 这几层启动器,因此也适用于 Windows 与 Linux。
9
+ */
10
+ export const INTERNAL_RESTART_ENV_VAR = "CHATCCC_INTERNAL_RESTART";
11
+
12
+ type Environment = Record<string, string | undefined>;
13
+
14
+ export function createInternalRestartEnv(
15
+ inherited: Environment = process.env,
16
+ ): NodeJS.ProcessEnv {
17
+ return {
18
+ ...inherited,
19
+ [INTERNAL_RESTART_ENV_VAR]: "1",
20
+ };
21
+ }
22
+
23
+ /** 用户直接启动时打开;ChatCCC 内部重启产生的替代进程不打开。 */
24
+ interface AutoOpenWebUiOptions {
25
+ env?: Environment;
26
+ openOnStart?: boolean;
27
+ }
28
+
29
+ export function shouldAutoOpenWebUi(options: AutoOpenWebUiOptions = {}): boolean {
30
+ const env = options.env ?? process.env;
31
+ return options.openOnStart !== false && env[INTERNAL_RESTART_ENV_VAR] !== "1";
32
+ }
33
+
34
+ /** Web UI 始终使用 localhost,并跟随实际配置端口。 */
35
+ export function buildWebUiUrl(port: number): string {
36
+ return `http://localhost:${port}/`;
37
+ }
38
+
39
+ interface OpenBrowserDeps {
40
+ platform?: NodeJS.Platform;
41
+ env?: Environment;
42
+ spawnImpl?: typeof spawn;
43
+ onError?: (message: string) => void;
44
+ onInfo?: (message: string) => void;
45
+ }
46
+
47
+ /**
48
+ * 调用操作系统默认浏览器打开 Web UI,与 Chrome CDP 守护功能完全独立。
49
+ * 返回值仅表示打开请求是否成功发起;浏览器是否复用标签页由系统浏览器决定。
50
+ */
51
+ export function openWebUiInDefaultBrowser(
52
+ port: number,
53
+ deps: OpenBrowserDeps = {},
54
+ ): boolean {
55
+ const url = buildWebUiUrl(port);
56
+ const platform = deps.platform ?? process.platform;
57
+ const env = deps.env ?? process.env;
58
+ const spawnImpl = deps.spawnImpl ?? spawn;
59
+ const onError = deps.onError ?? ((message: string) => console.error(message));
60
+ const onInfo = deps.onInfo ?? ((message: string) => console.log(message));
61
+
62
+ // Linux 服务器通常没有图形会话。此时调用 xdg-open 只会制造噪音;
63
+ // 直接给出可复制的 SSH 隧道命令,让用户从自己的电脑访问本地 Web UI。
64
+ if (platform === "linux" && !env.DISPLAY && !env.WAYLAND_DISPLAY) {
65
+ onInfo(
66
+ `[WEB-UI] 未检测到 Linux 图形桌面,跳过自动打开浏览器。` +
67
+ `可在本机执行 ssh -L ${port}:127.0.0.1:${port} <user>@<server>,` +
68
+ `然后访问 ${url}`,
69
+ );
70
+ return false;
71
+ }
72
+
73
+ try {
74
+ let child: ChildProcess;
75
+ if (platform === "win32") {
76
+ // `start` 会把第一个带引号的参数当窗口标题,空字符串是必要占位符。
77
+ child = spawnImpl("cmd.exe", ["/c", "start", "", url], {
78
+ detached: true,
79
+ stdio: "ignore",
80
+ windowsHide: true,
81
+ });
82
+ } else if (platform === "darwin") {
83
+ child = spawnImpl("open", [url], { detached: true, stdio: "ignore" });
84
+ } else {
85
+ child = spawnImpl("xdg-open", [url], { detached: true, stdio: "ignore" });
86
+ }
87
+ child.on("error", (err) => {
88
+ onError(`[WEB-UI] 自动打开浏览器失败: ${err.message}`);
89
+ });
90
+ child.unref();
91
+ return true;
92
+ } catch (err) {
93
+ onError(`[WEB-UI] 自动打开浏览器失败: ${(err as Error).message}`);
94
+ return false;
95
+ }
96
+ }
package/src/web-ui.ts CHANGED
@@ -12,7 +12,12 @@ import { readFile, writeFile, stat } from "node:fs/promises";
12
12
  import { homedir } from "node:os";
13
13
  import { join, dirname } from "node:path";
14
14
  import { fileURLToPath } from "node:url";
15
- import { spawn, execSync } from "node:child_process";
15
+ import { spawn, execSync } from "node:child_process";
16
+ import {
17
+ buildWebUiUrl,
18
+ createInternalRestartEnv,
19
+ openWebUiInDefaultBrowser,
20
+ } from "./startup-lifecycle.ts";
16
21
 
17
22
  const __dirname = dirname(fileURLToPath(import.meta.url));
18
23
  const PROJECT_ROOT = join(__dirname, "..");
@@ -32,6 +37,7 @@ interface AppConfig {
32
37
  feishu?: { enabled?: boolean; platformType?: string };
33
38
  ilink?: { enabled?: boolean; reuseTokenOnStart?: boolean };
34
39
  };
40
+ webUi?: { openOnStart?: boolean };
35
41
  chromeDevtools?: { enabled?: boolean; port?: number; chromePath?: string };
36
42
  port?: number;
37
43
  gitTimeoutSeconds?: number;
@@ -225,11 +231,12 @@ function spawnService(): { ok: boolean; pid?: number; error?: string } {
225
231
  return { ok: false, error: `Entry not found: ${indexPath}` };
226
232
  }
227
233
  try {
228
- const child = spawn("npx", ["tsx", "src/index.ts"], {
229
- cwd: PROJECT_ROOT,
230
- detached: true,
231
- stdio: "ignore",
232
- shell: true,
234
+ const child = spawn("npx", ["tsx", "src/index.ts"], {
235
+ cwd: PROJECT_ROOT,
236
+ detached: true,
237
+ stdio: "ignore",
238
+ shell: true,
239
+ env: createInternalRestartEnv(),
233
240
  });
234
241
  child.unref();
235
242
  return { ok: true, pid: child.pid ?? undefined };
@@ -247,17 +254,19 @@ function scheduleRestart(): void {
247
254
  if (!existsSync(indexPath)) return;
248
255
  if (process.platform === "win32") {
249
256
  // Windows: ping 作为 sleep(ping 自己 3 次 ≈ 2 秒延迟)
250
- spawn("cmd.exe", ["/c", "ping -n 3 127.0.0.1 > nul && npx tsx src/index.ts"], {
251
- cwd: PROJECT_ROOT,
252
- detached: true,
253
- stdio: "ignore",
254
- windowsHide: true,
257
+ spawn("cmd.exe", ["/c", "ping -n 3 127.0.0.1 > nul && npx tsx src/index.ts"], {
258
+ cwd: PROJECT_ROOT,
259
+ detached: true,
260
+ stdio: "ignore",
261
+ windowsHide: true,
262
+ env: createInternalRestartEnv(),
255
263
  }).unref();
256
264
  } else {
257
- spawn("bash", ["-c", "sleep 2 && npx tsx src/index.ts"], {
258
- cwd: PROJECT_ROOT,
259
- detached: true,
260
- stdio: "ignore",
265
+ spawn("bash", ["-c", "sleep 2 && npx tsx src/index.ts"], {
266
+ cwd: PROJECT_ROOT,
267
+ detached: true,
268
+ stdio: "ignore",
269
+ env: createInternalRestartEnv(),
261
270
  }).unref();
262
271
  }
263
272
  }
@@ -405,11 +414,14 @@ export function unflattenConfig(flat: Record<string, unknown>): Record<string, u
405
414
  result.platforms = result.platforms || {};
406
415
  (result.platforms as Record<string, unknown>).feishu = (result.platforms as Record<string, unknown>).feishu || {};
407
416
  ((result.platforms as Record<string, unknown>).feishu as Record<string, unknown>).platformType = val;
408
- } else if (key === "CHATCCC_ILINK_ENABLED") {
409
- result.platforms = result.platforms || {};
410
- (result.platforms as Record<string, unknown>).ilink = (result.platforms as Record<string, unknown>).ilink || {};
411
- ((result.platforms as Record<string, unknown>).ilink as Record<string, unknown>).enabled = val === true || val === "true";
412
- } else if (key === "CHATCCC_CHROME_DEVTOOLS_ENABLED") {
417
+ } else if (key === "CHATCCC_ILINK_ENABLED") {
418
+ result.platforms = result.platforms || {};
419
+ (result.platforms as Record<string, unknown>).ilink = (result.platforms as Record<string, unknown>).ilink || {};
420
+ ((result.platforms as Record<string, unknown>).ilink as Record<string, unknown>).enabled = val === true || val === "true";
421
+ } else if (key === "CHATCCC_WEB_UI_OPEN_ON_START") {
422
+ result.webUi = result.webUi || {};
423
+ (result.webUi as Record<string, unknown>).openOnStart = val === true || val === "true";
424
+ } else if (key === "CHATCCC_CHROME_DEVTOOLS_ENABLED") {
413
425
  result.chromeDevtools = result.chromeDevtools || {};
414
426
  (result.chromeDevtools as Record<string, unknown>).enabled = val === true || val === "true";
415
427
  } else if (key === "CHATCCC_CHROME_DEVTOOLS_PORT") {
@@ -585,11 +597,29 @@ async function handleForgetIlink(_req: IncomingMessage, res: ServerResponse): Pr
585
597
  }
586
598
  }
587
599
 
588
- // ---------------------------------------------------------------------------
589
- // HTML page (embedded template)
590
- // ---------------------------------------------------------------------------
591
-
592
- export const PAGE_HTML = `<!DOCTYPE html>
600
+ // ---------------------------------------------------------------------------
601
+ // HTML page (embedded template)
602
+ // ---------------------------------------------------------------------------
603
+
604
+ /** Agent Team 的首个占位页面:按产品约定只展示标题,不提前放入功能内容。 */
605
+ export const AGENT_TEAM_PAGE_HTML = `<!DOCTYPE html>
606
+ <html lang="zh-CN">
607
+ <head>
608
+ <meta charset="UTF-8">
609
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
610
+ <title>Agent Team</title>
611
+ <style>
612
+ *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
613
+ body{min-height:100vh;display:grid;place-items:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:radial-gradient(circle at 50% 20%,#312e81 0,#111827 42%,#020617 100%);color:#fff}
614
+ h1{font-size:clamp(40px,8vw,88px);font-weight:750;letter-spacing:-.04em;text-shadow:0 12px 42px rgba(129,140,248,.45)}
615
+ </style>
616
+ </head>
617
+ <body>
618
+ <h1>Agent Team</h1>
619
+ </body>
620
+ </html>`;
621
+
622
+ export const PAGE_HTML = `<!DOCTYPE html>
593
623
  <html lang="zh-CN">
594
624
  <head>
595
625
  <meta charset="UTF-8">
@@ -598,9 +628,14 @@ export const PAGE_HTML = `<!DOCTYPE html>
598
628
  <style>
599
629
  *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
600
630
  body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#f1f5f9;color:#1e293b;line-height:1.6}
601
- header{background:#0f172a;color:#fff;padding:16px 24px;display:flex;align-items:center;justify-content:space-between}
602
- header h1{font-size:20px;font-weight:600}
603
- header .badge{font-size:13px;padding:4px 12px;border-radius:12px;font-weight:500}
631
+ header{background:#0f172a;color:#fff;padding:16px 24px;display:flex;align-items:center;justify-content:space-between}
632
+ header h1{font-size:20px;font-weight:600}
633
+ header .badge{font-size:13px;padding:4px 12px;border-radius:12px;font-weight:500}
634
+ .header-actions{display:flex;align-items:center;gap:12px}
635
+ .agent-team-entry{display:inline-flex;align-items:center;gap:8px;padding:9px 16px;border:1px solid rgba(255,255,255,.2);border-radius:999px;background:linear-gradient(135deg,#6366f1,#8b5cf6 55%,#d946ef);color:#fff;text-decoration:none;font-size:14px;font-weight:700;letter-spacing:.01em;box-shadow:0 8px 24px rgba(99,102,241,.38);transition:transform .18s ease,box-shadow .18s ease,filter .18s ease}
636
+ .agent-team-entry:hover{transform:translateY(-2px);box-shadow:0 12px 30px rgba(139,92,246,.52);filter:saturate(1.16)}
637
+ .agent-team-entry:focus-visible{outline:3px solid rgba(196,181,253,.65);outline-offset:3px}
638
+ .agent-team-entry .agent-team-icon{font-size:16px;line-height:1}
604
639
  .badge-running{background:#16a34a;color:#fff}
605
640
  .badge-stopped{background:#94a3b8;color:#fff}
606
641
  /* container 完全不限宽、不留左右内边距 —— step-2 是三列卡片需要尽量利用屏幕;
@@ -668,14 +703,18 @@ header .badge{font-size:13px;padding:4px 12px;border-radius:12px;font-weight:500
668
703
  .toast-error{background:#ef4444}
669
704
  @keyframes slideIn{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}}
670
705
  .spinner{display:inline-block;width:14px;height:14px;border:2px solid rgba(255,255,255,.3);border-top-color:#fff;border-radius:50%;animation:spin .6s linear infinite}
671
- @keyframes spin{to{transform:rotate(360deg)}}
672
- </style>
706
+ @keyframes spin{to{transform:rotate(360deg)}}
707
+ @media(max-width:520px){header{padding:12px 14px}.header-actions{gap:8px}.agent-team-entry{padding:8px 11px;font-size:13px}header .badge{padding:4px 8px}}
708
+ </style>
673
709
  </head>
674
710
  <body>
675
- <header>
676
- <h1>ChatCCC</h1>
677
- <span id="header-badge" class="badge badge-stopped">未启动</span>
678
- </header>
711
+ <header>
712
+ <h1>ChatCCC</h1>
713
+ <div class="header-actions">
714
+ <a href="/agent-team" class="agent-team-entry"><span class="agent-team-icon" aria-hidden="true">✦</span>Agent Team <span aria-hidden="true">→</span></a>
715
+ <span id="header-badge" class="badge badge-stopped">未启动</span>
716
+ </div>
717
+ </header>
679
718
  <div class="container">
680
719
 
681
720
  <!-- ===== Setup Wizard ===== -->
@@ -723,7 +762,7 @@ header .badge{font-size:13px;padding:4px 12px;border-radius:12px;font-weight:500
723
762
  </div>
724
763
  </div>
725
764
 
726
- <!-- 微信 iLink -->
765
+ <!-- 微信 iLink -->
727
766
  <div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:14px;margin-bottom:12px" id="platform-block-ilink">
728
767
  <div style="display:flex;align-items:center;justify-content:space-between">
729
768
  <div>
@@ -732,9 +771,20 @@ header .badge{font-size:13px;padding:4px 12px;border-radius:12px;font-weight:500
732
771
  </div>
733
772
  <input type="checkbox" class="agent-toggle" id="platform-enable-ilink" checked onchange="onWizardPlatformToggle('ilink', this.checked)">
734
773
  </div>
735
- </div>
736
-
737
- <!-- Chrome CDP -->
774
+ </div>
775
+
776
+ <!-- Web UI 启动行为 -->
777
+ <div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:14px;margin-bottom:12px" id="web-ui-startup-block">
778
+ <div style="display:flex;align-items:center;justify-content:space-between;gap:16px">
779
+ <div>
780
+ <div style="font-weight:600;font-size:14px">启动时打开 Web UI</div>
781
+ <div style="font-size:12px;color:#64748b">直接运行 ChatCCC 时用系统默认浏览器打开管理页面;内部重启始终不打开</div>
782
+ </div>
783
+ <input type="checkbox" class="agent-toggle" id="field-CHATCCC_WEB_UI_OPEN_ON_START" checked>
784
+ </div>
785
+ </div>
786
+
787
+ <!-- Chrome CDP -->
738
788
  <div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:14px;margin-bottom:12px" id="chrome-devtools-block">
739
789
  <div style="display:flex;align-items:center;justify-content:space-between">
740
790
  <div>
@@ -938,8 +988,8 @@ header .badge{font-size:13px;padding:4px 12px;border-radius:12px;font-weight:500
938
988
  </div>
939
989
  </details>
940
990
 
941
- <details class="card config-section">
942
- <summary>微信 iLink</summary>
991
+ <details class="card config-section">
992
+ <summary>微信 iLink</summary>
943
993
  <div class="section-detail">
944
994
  <div class="config-row">
945
995
  <span class="key">状态</span>
@@ -955,8 +1005,17 @@ header .badge{font-size:13px;padding:4px 12px;border-radius:12px;font-weight:500
955
1005
  <div class="hint" style="margin-top:6px;line-height:1.6">生效范围:微信 iLink 开关变更需要重启 ChatCCC。</div>
956
1006
  </div>
957
1007
  </details>
958
-
959
- <details class="card config-section">
1008
+
1009
+ <details class="card config-section">
1010
+ <summary>Web UI</summary>
1011
+ <div class="section-detail">
1012
+ <div class="config-row"><span class="key">直接启动时自动打开</span><span class="val" id="cfg-WEB_UI_OPEN_ON_START">-</span></div>
1013
+ <div class="hint" style="margin-top:6px;line-height:1.6">生效范围:下次直接启动生效;内部重启始终不打开。Linux 无图形桌面时会跳过打开并输出 SSH 隧道提示。</div>
1014
+ <button class="btn btn-outline" style="margin-top:8px" onclick="editSection('webUi')">编辑</button>
1015
+ </div>
1016
+ </details>
1017
+
1018
+ <details class="card config-section">
960
1019
  <summary>Chrome CDP(选填)</summary>
961
1020
  <div class="section-detail">
962
1021
  <div class="config-row"><span class="key">状态</span><span class="val" id="cfg-CHROME_DEVTOOLS_ENABLED">-</span></div>
@@ -1054,8 +1113,9 @@ const AGENT_FIELDS = {
1054
1113
  cursor: ['CHATCCC_CURSOR_PATH','CHATCCC_CURSOR_MODEL','CHATCCC_CURSOR_ALTERNATIVE_MODEL','CHATCCC_CURSOR_AVATAR_BATTERY_MODE','CHATCCC_CURSOR_ON_DEMAND_MONTHLY_BUDGET'],
1055
1114
  codex: ['CHATCCC_CODEX_PATH','CHATCCC_CODEX_MODEL','CHATCCC_CODEX_ALTERNATIVE_MODEL','CHATCCC_CODEX_EFFORT']
1056
1115
  };
1057
- const FEISHU_FIELDS = ['CHATCCC_APP_ID','CHATCCC_APP_SECRET'];
1058
- const CHROME_DEVTOOLS_FIELDS = ['CHATCCC_CHROME_DEVTOOLS_ENABLED','CHATCCC_CHROME_DEVTOOLS_PORT','CHATCCC_CHROME_DEVTOOLS_PATH'];
1116
+ const FEISHU_FIELDS = ['CHATCCC_APP_ID','CHATCCC_APP_SECRET'];
1117
+ const WEB_UI_FIELDS = ['CHATCCC_WEB_UI_OPEN_ON_START'];
1118
+ const CHROME_DEVTOOLS_FIELDS = ['CHATCCC_CHROME_DEVTOOLS_ENABLED','CHATCCC_CHROME_DEVTOOLS_PORT','CHATCCC_CHROME_DEVTOOLS_PATH'];
1059
1119
 
1060
1120
  function cursorBatteryModeLabel(value) {
1061
1121
  return value === 'onDemandUse' ? 'On demand use 金额' : 'API 使用比例';
@@ -1063,6 +1123,7 @@ function cursorBatteryModeLabel(value) {
1063
1123
 
1064
1124
  function configEffectHint(section) {
1065
1125
  if (section === 'feishu') return '生效范围:App ID、App Secret、平台类型或飞书开关变更需要重启 ChatCCC;其它未变更项仅保存。';
1126
+ if (section === 'webUi') return '生效范围:下次直接启动生效;内部重启始终不打开。';
1066
1127
  if (section === 'chromeDevtools') return '生效范围:保存后立即应用到 Chrome CDP 守护进程。';
1067
1128
  if (section === 'claude') return '生效范围:保存后下一条消息或下个新会话生效,当前生成不中断。';
1068
1129
  if (section === 'cursor') return '生效范围:保存后下一条消息或下个新会话生效,当前生成不中断。';
@@ -1312,9 +1373,11 @@ function renderStep1() {
1312
1373
  // 飞书凭证字段初始显隐
1313
1374
  var credFields = document.getElementById('feishu-cred-fields');
1314
1375
  if (credFields) credFields.style.display = feishuEnabled ? '' : 'none';
1315
- var ilToggle = document.getElementById('platform-enable-ilink');
1316
- if (ilToggle) ilToggle.checked = ilinkEnabled;
1317
- var cd = c.chromeDevtools || {};
1376
+ var ilToggle = document.getElementById('platform-enable-ilink');
1377
+ if (ilToggle) ilToggle.checked = ilinkEnabled;
1378
+ var webUiOpenOnStart = document.getElementById('field-CHATCCC_WEB_UI_OPEN_ON_START');
1379
+ if (webUiOpenOnStart) webUiOpenOnStart.checked = !c.webUi || c.webUi.openOnStart !== false;
1380
+ var cd = c.chromeDevtools || {};
1318
1381
  var cdpEnabled = document.getElementById('field-CHATCCC_CHROME_DEVTOOLS_ENABLED');
1319
1382
  if (cdpEnabled) cdpEnabled.checked = cd.enabled === true;
1320
1383
  prefillNested('field-CHATCCC_CHROME_DEVTOOLS_PORT', cd.port || 15166);
@@ -1418,9 +1481,11 @@ function collectAllFields() {
1418
1481
  // 平台类型(下拉选择框,始终发送以保持与服务端同步)
1419
1482
  var ptEl = document.getElementById('field-CHATCCC_FEISHU_PLATFORM_TYPE');
1420
1483
  if (ptEl && ptEl.value.trim()) vars['CHATCCC_FEISHU_PLATFORM_TYPE'] = ptEl.value.trim();
1421
- vars.CHATCCC_FEISHU_ENABLED = !!state.platformsEnabled.feishu;
1422
- vars.CHATCCC_ILINK_ENABLED = !!state.platformsEnabled.ilink;
1423
- var cdpEnabledEl = document.getElementById('field-CHATCCC_CHROME_DEVTOOLS_ENABLED');
1484
+ vars.CHATCCC_FEISHU_ENABLED = !!state.platformsEnabled.feishu;
1485
+ vars.CHATCCC_ILINK_ENABLED = !!state.platformsEnabled.ilink;
1486
+ var webUiOpenOnStartEl = document.getElementById('field-CHATCCC_WEB_UI_OPEN_ON_START');
1487
+ vars.CHATCCC_WEB_UI_OPEN_ON_START = !webUiOpenOnStartEl || !!webUiOpenOnStartEl.checked;
1488
+ var cdpEnabledEl = document.getElementById('field-CHATCCC_CHROME_DEVTOOLS_ENABLED');
1424
1489
  vars.CHATCCC_CHROME_DEVTOOLS_ENABLED = !!(cdpEnabledEl && cdpEnabledEl.checked);
1425
1490
  var cdpPortEl = document.getElementById('field-CHATCCC_CHROME_DEVTOOLS_PORT');
1426
1491
  vars.CHATCCC_CHROME_DEVTOOLS_PORT = (cdpPortEl && cdpPortEl.value.trim()) ? cdpPortEl.value.trim() : '15166';
@@ -1469,14 +1534,17 @@ function renderStep3() {
1469
1534
  lines.push('<div class="config-row"><span class="key">平台类型</span><span class="val">' + ptLabel + '</span></div>');
1470
1535
  }
1471
1536
 
1472
- lines.push('<h3 style="margin:16px 0 8px">微信 iLink</h3>');
1473
- lines.push('<div class="config-row"><span class="key">状态</span><span class="val">' + (state.platformsEnabled.ilink ? '已启用' : '已禁用') + '</span></div>');
1537
+ lines.push('<h3 style="margin:16px 0 8px">微信 iLink</h3>');
1538
+ lines.push('<div class="config-row"><span class="key">状态</span><span class="val">' + (state.platformsEnabled.ilink ? '已启用' : '已禁用') + '</span></div>');
1474
1539
 
1475
1540
  if (!state.platformsEnabled.feishu && !state.platformsEnabled.ilink) {
1476
- lines.push('<div style="color:#ef4444;margin-top:8px">未启用任何平台</div>');
1477
- }
1478
-
1479
- lines.push('<h3 style="margin:16px 0 8px">Chrome CDP</h3>');
1541
+ lines.push('<div style="color:#ef4444;margin-top:8px">未启用任何平台</div>');
1542
+ }
1543
+
1544
+ lines.push('<h3 style="margin:16px 0 8px">Web UI</h3>');
1545
+ lines.push('<div class="config-row"><span class="key">直接启动时自动打开</span><span class="val">' + (vars.CHATCCC_WEB_UI_OPEN_ON_START ? '已启用' : '已禁用') + '</span></div>');
1546
+
1547
+ lines.push('<h3 style="margin:16px 0 8px">Chrome CDP</h3>');
1480
1548
  lines.push('<div class="config-row"><span class="key">状态</span><span class="val">' + (vars.CHATCCC_CHROME_DEVTOOLS_ENABLED ? '已启用' : '已禁用') + '</span></div>');
1481
1549
  if (vars.CHATCCC_CHROME_DEVTOOLS_ENABLED) {
1482
1550
  lines.push('<div class="config-row"><span class="key">CDP 端口</span><span class="val">' + (vars.CHATCCC_CHROME_DEVTOOLS_PORT || '15166') + '</span></div>');
@@ -1684,11 +1752,14 @@ function updateDashboardUI() {
1684
1752
 
1685
1753
  // 微信 iLink "忘记扫码" 按钮:仅在 ilink 已启用且有已保存 token 时显示
1686
1754
  var ilinkForgetRow = document.getElementById('ilink-forget-row');
1687
- if (ilinkForgetRow) {
1688
- ilinkForgetRow.style.display = (ilinkEnabled && state.ilinkAuthExists) ? '' : 'none';
1689
- }
1690
-
1691
- var chromeDevtools = c.chromeDevtools || {};
1755
+ if (ilinkForgetRow) {
1756
+ ilinkForgetRow.style.display = (ilinkEnabled && state.ilinkAuthExists) ? '' : 'none';
1757
+ }
1758
+
1759
+ var webUi = c.webUi || {};
1760
+ document.getElementById('cfg-WEB_UI_OPEN_ON_START').textContent = webUi.openOnStart !== false ? '已启用' : '已禁用';
1761
+
1762
+ var chromeDevtools = c.chromeDevtools || {};
1692
1763
  var cdpPort = chromeDevtools.port || 15166;
1693
1764
  document.getElementById('cfg-CHROME_DEVTOOLS_ENABLED').textContent = chromeDevtools.enabled ? '已启用' : '已禁用';
1694
1765
  document.getElementById('cfg-CHROME_DEVTOOLS_PORT').textContent = String(cdpPort);
@@ -1771,22 +1842,24 @@ async function pollUntilRunning() {
1771
1842
  // ---- Edit Modal ----
1772
1843
  var editSectionType = null;
1773
1844
 
1774
- function editSection(section) {
1845
+ function editSection(section) {
1775
1846
  editSectionType = section;
1776
- var fields;
1777
- if (section === 'feishu') fields = FEISHU_FIELDS;
1778
- else if (section === 'chromeDevtools') fields = CHROME_DEVTOOLS_FIELDS;
1847
+ var fields;
1848
+ if (section === 'feishu') fields = FEISHU_FIELDS;
1849
+ else if (section === 'webUi') fields = WEB_UI_FIELDS;
1850
+ else if (section === 'chromeDevtools') fields = CHROME_DEVTOOLS_FIELDS;
1779
1851
  else fields = AGENT_FIELDS[section] || [];
1780
1852
 
1781
- var titleMap = { feishu: '飞书', chromeDevtools: 'Chrome CDP', claude: 'Claude Agent', cursor: 'Cursor Agent', codex: 'Codex Agent' };
1853
+ var titleMap = { feishu: '飞书', webUi: 'Web UI', chromeDevtools: 'Chrome CDP', claude: 'Claude Agent', cursor: 'Cursor Agent', codex: 'Codex Agent' };
1782
1854
  document.getElementById('edit-modal-title').textContent = '编辑 ' + (titleMap[section] || section);
1783
1855
 
1784
1856
  document.getElementById('edit-modal-effect').textContent = configEffectHint(section);
1785
1857
 
1786
1858
  var html = '';
1787
- var labelMap = {
1788
- 'CHATCCC_APP_ID': 'App ID', 'CHATCCC_APP_SECRET': 'App Secret',
1789
- 'CHATCCC_CHROME_DEVTOOLS_ENABLED': '启用常驻 Chrome CDP(选填)',
1859
+ var labelMap = {
1860
+ 'CHATCCC_APP_ID': 'App ID', 'CHATCCC_APP_SECRET': 'App Secret',
1861
+ 'CHATCCC_WEB_UI_OPEN_ON_START': '直接启动 ChatCCC 时自动打开 Web UI',
1862
+ 'CHATCCC_CHROME_DEVTOOLS_ENABLED': '启用常驻 Chrome CDP(选填)',
1790
1863
  'CHATCCC_CHROME_DEVTOOLS_PORT': 'CDP 端口',
1791
1864
  'CHATCCC_CHROME_DEVTOOLS_PATH': 'Chrome 路径(选填)',
1792
1865
  'CHATCCC_ANTHROPIC_MODEL': '模型', 'CHATCCC_ANTHROPIC_SUBAGENT_MODEL': 'Subagent 模型', 'CHATCCC_ANTHROPIC_EFFORT': 'Effort',
@@ -1796,8 +1869,9 @@ function editSection(section) {
1796
1869
  'CHATCCC_CURSOR_ON_DEMAND_MONTHLY_BUDGET': '每月On demand use预算',
1797
1870
  'CHATCCC_CODEX_PATH': 'CLI 路径', 'CHATCCC_CODEX_MODEL': '模型', 'CHATCCC_CODEX_ALTERNATIVE_MODEL': '备选模型', 'CHATCCC_CODEX_EFFORT': 'Effort'
1798
1871
  };
1799
- var hintMap = {
1800
- 'CHATCCC_CHROME_DEVTOOLS_ENABLED': '依赖:本机 Google Chrome;ChatGPT 订阅到期查询需要在该 CDP Chrome 中登录 ChatGPT。',
1872
+ var hintMap = {
1873
+ 'CHATCCC_WEB_UI_OPEN_ON_START': '关闭后可继续手动访问 http://localhost:<端口>/;/restart、/update Web UI 重启无论此项为何值都不会自动打开。',
1874
+ 'CHATCCC_CHROME_DEVTOOLS_ENABLED': '依赖:本机 Google Chrome;ChatGPT 订阅到期查询需要在该 CDP Chrome 中登录 ChatGPT。',
1801
1875
  'CHATCCC_CHROME_DEVTOOLS_PORT': '默认 15166,健康检查端点为 http://127.0.0.1:15166/json/version。',
1802
1876
  'CHATCCC_CHROME_DEVTOOLS_PATH': '选填。留空时自动探测 Google Chrome。'
1803
1877
  };
@@ -1810,10 +1884,12 @@ function editSection(section) {
1810
1884
  var val = state.config[key] || '';
1811
1885
  // Also check nested config
1812
1886
  if (!val) {
1813
- if (section === 'feishu') {
1887
+ if (section === 'feishu') {
1814
1888
  if (key === 'CHATCCC_APP_ID' && state.config.feishu) val = state.config.feishu.appId || '';
1815
1889
  else if (key === 'CHATCCC_APP_SECRET' && state.config.feishu) val = state.config.feishu.appSecret || '';
1816
- } else if (section === 'chromeDevtools' && state.config.chromeDevtools) {
1890
+ } else if (section === 'webUi') {
1891
+ val = !state.config.webUi || state.config.webUi.openOnStart !== false ? 'true' : 'false';
1892
+ } else if (section === 'chromeDevtools' && state.config.chromeDevtools) {
1817
1893
  if (key === 'CHATCCC_CHROME_DEVTOOLS_ENABLED') val = state.config.chromeDevtools.enabled === true ? 'true' : 'false';
1818
1894
  else if (key === 'CHATCCC_CHROME_DEVTOOLS_PORT') val = state.config.chromeDevtools.port != null ? String(state.config.chromeDevtools.port) : '15166';
1819
1895
  else if (key === 'CHATCCC_CHROME_DEVTOOLS_PATH') val = state.config.chromeDevtools.chromePath || '';
@@ -1838,9 +1914,10 @@ function editSection(section) {
1838
1914
  }
1839
1915
  }
1840
1916
  var isSecret = key.includes('SECRET') || key.includes('API_KEY');
1841
- if (key === 'CHATCCC_CHROME_DEVTOOLS_ENABLED') {
1842
- var checked = val === true || val === 'true';
1843
- html += '<div class="form-group"><label style="display:flex;align-items:center;gap:8px"><input type="checkbox" id="edit-' + key + '"' + (checked ? ' checked' : '') + ' onchange="toggleEditChromeDevtoolsFields(this.checked)"> ' + (labelMap[key] || key) + '</label>';
1917
+ if (key === 'CHATCCC_WEB_UI_OPEN_ON_START' || key === 'CHATCCC_CHROME_DEVTOOLS_ENABLED') {
1918
+ var checked = val === true || val === 'true';
1919
+ var changeHandler = key === 'CHATCCC_CHROME_DEVTOOLS_ENABLED' ? ' onchange="toggleEditChromeDevtoolsFields(this.checked)"' : '';
1920
+ html += '<div class="form-group"><label style="display:flex;align-items:center;gap:8px"><input type="checkbox" id="edit-' + key + '"' + (checked ? ' checked' : '') + changeHandler + '> ' + (labelMap[key] || key) + '</label>';
1844
1921
  if (hintMap[key]) html += '<div class="hint" style="margin-top:6px;line-height:1.5">' + hintMap[key] + '</div>';
1845
1922
  html += '</div>';
1846
1923
  } else if (key === 'CHATCCC_CURSOR_AVATAR_BATTERY_MODE') {
@@ -1894,17 +1971,18 @@ function closeEditModal() {
1894
1971
  editSectionType = null;
1895
1972
  }
1896
1973
 
1897
- async function saveEdit() {
1898
- var fields;
1899
- if (editSectionType === 'feishu') fields = FEISHU_FIELDS;
1900
- else if (editSectionType === 'chromeDevtools') fields = CHROME_DEVTOOLS_FIELDS;
1974
+ async function saveEdit() {
1975
+ var fields;
1976
+ if (editSectionType === 'feishu') fields = FEISHU_FIELDS;
1977
+ else if (editSectionType === 'webUi') fields = WEB_UI_FIELDS;
1978
+ else if (editSectionType === 'chromeDevtools') fields = CHROME_DEVTOOLS_FIELDS;
1901
1979
  else fields = AGENT_FIELDS[editSectionType] || [];
1902
1980
 
1903
1981
  var vars = {};
1904
1982
  fields.forEach(function(key){
1905
1983
  var el = document.getElementById('edit-' + key);
1906
1984
  if (!el) return;
1907
- if (key === 'CHATCCC_CHROME_DEVTOOLS_ENABLED') vars[key] = !!el.checked;
1985
+ if (key === 'CHATCCC_WEB_UI_OPEN_ON_START' || key === 'CHATCCC_CHROME_DEVTOOLS_ENABLED') vars[key] = !!el.checked;
1908
1986
  else vars[key] = el.value.trim();
1909
1987
  });
1910
1988
  if (editSectionType === 'chromeDevtools' && !vars.CHATCCC_CHROME_DEVTOOLS_PORT) {
@@ -1960,9 +2038,10 @@ init();
1960
2038
  // Router
1961
2039
  // ---------------------------------------------------------------------------
1962
2040
 
1963
- async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
1964
- const url = req.url ?? "/";
1965
- const method = req.method ?? "GET";
2041
+ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
2042
+ const url = req.url ?? "/";
2043
+ const method = req.method ?? "GET";
2044
+ const pathname = url.split("?", 1)[0];
1966
2045
 
1967
2046
  if (extraApiHandler && await extraApiHandler(req, res)) return;
1968
2047
 
@@ -1975,7 +2054,13 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise
1975
2054
  if (url === "/api/stop" && method === "POST") return handleStopService(req, res);
1976
2055
  if (url === "/api/restart" && method === "POST") return handleRestartService(req, res);
1977
2056
  if (url === "/api/validate" && method === "POST") return handleValidate(req, res);
1978
- if (url === "/api/ilink/forget" && method === "POST") return handleForgetIlink(req, res);
2057
+ if (url === "/api/ilink/forget" && method === "POST") return handleForgetIlink(req, res);
2058
+
2059
+ if (method === "GET" && (pathname === "/agent-team" || pathname === "/agent-team/")) {
2060
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
2061
+ res.end(AGENT_TEAM_PAGE_HTML);
2062
+ return;
2063
+ }
1979
2064
 
1980
2065
  // Serve HTML page for all other GET requests
1981
2066
  if (method === "GET") {
@@ -2000,40 +2085,7 @@ export function createUiRouter(): (req: IncomingMessage, res: ServerResponse) =>
2000
2085
  // Setup mode entry point — called from index.ts when no credentials
2001
2086
  // ---------------------------------------------------------------------------
2002
2087
 
2003
- /**
2004
- * 跨平台调起本地浏览器。
2005
- * - Windows:cmd /c start "" <url>。注意 start 把第一个被引号包裹的参数当成"窗口标题",
2006
- * 所以这里需要传一个空标题占位,否则当 url 被引号包住时它会被错误地当成标题。
2007
- * - macOS:open <url>
2008
- * - Linux:xdg-open <url>
2009
- *
2010
- * 任何失败都不抛——主流程不依赖浏览器是否真的弹起来,console 已经打印了 url。
2011
- */
2012
- function openInBrowser(url: string): void {
2013
- try {
2014
- if (process.platform === "win32") {
2015
- const child = spawn("cmd.exe", ["/c", "start", "", url], {
2016
- detached: true,
2017
- stdio: "ignore",
2018
- windowsHide: true,
2019
- });
2020
- child.on("error", () => { /* 浏览器未弹起来不影响主流程 */ });
2021
- child.unref();
2022
- } else if (process.platform === "darwin") {
2023
- const child = spawn("open", [url], { detached: true, stdio: "ignore" });
2024
- child.on("error", () => {});
2025
- child.unref();
2026
- } else {
2027
- const child = spawn("xdg-open", [url], { detached: true, stdio: "ignore" });
2028
- child.on("error", () => {});
2029
- child.unref();
2030
- }
2031
- } catch (err) {
2032
- console.error(`[WEB-UI] 自动打开浏览器失败: ${(err as Error).message}`);
2033
- }
2034
- }
2035
-
2036
- /**
2088
+ /**
2037
2089
  * setup → service「在线切换」回调签名:
2038
2090
  * - 入参 httpServer:setup 模式当前监听的 HTTP server,会被复用为 service 的
2039
2091
  * relay server(避免 close + recreate 的端口竞态)。
@@ -2045,8 +2097,10 @@ export type SetupActivateHook = (
2045
2097
  httpServer: ReturnType<typeof createServer>,
2046
2098
  ) => Promise<{ ok: true } | { ok: false; error: string }>;
2047
2099
 
2048
- export interface StartSetupModeOptions {
2049
- onActivate?: SetupActivateHook;
2100
+ export interface StartSetupModeOptions {
2101
+ onActivate?: SetupActivateHook;
2102
+ /** 内部重启进入 setup 模式时设为 false,避免重复弹出系统浏览器。 */
2103
+ openBrowser?: boolean;
2050
2104
  }
2051
2105
 
2052
2106
  // setup HTTP server + onActivate 回调通过模块级变量暴露给 handleStartService。
@@ -2093,20 +2147,24 @@ export function startSetupMode(port: number, options: StartSetupModeOptions = {}
2093
2147
  process.exit(1);
2094
2148
  });
2095
2149
 
2096
- server.listen(port, "127.0.0.1", () => {
2097
- const url = `http://127.0.0.1:${port}`;
2150
+ server.listen(port, "127.0.0.1", () => {
2151
+ const url = buildWebUiUrl(port);
2098
2152
  console.log("");
2099
2153
  console.log("=".repeat(60));
2100
2154
  console.log(" ChatCCC — 首次配置向导");
2101
2155
  console.log("=".repeat(60));
2102
2156
  console.log(" 未检测到已配置的飞书凭证,已启动配置界面。");
2103
- console.log(` 正在自动打开浏览器: ${url}`);
2157
+ if (options.openBrowser !== false) {
2158
+ console.log(` 已启用启动时自动打开浏览器: ${url}`);
2159
+ } else {
2160
+ console.log(` 内部重启不会自动打开浏览器,请按需访问: ${url}`);
2161
+ }
2104
2162
  console.log(" 若浏览器未自动弹出,请手动访问上面的地址。");
2105
2163
  console.log("");
2106
2164
  console.log(" 在向导里填好 App ID / App Secret 后点「保存并启动」,");
2107
2165
  console.log(" 服务会在当前进程内直接激活,不需要重新运行 chatccc。");
2108
2166
  console.log("=".repeat(60));
2109
2167
  console.log("");
2110
- openInBrowser(url);
2111
- });
2112
- }
2168
+ if (options.openBrowser !== false) openWebUiInDefaultBrowser(port);
2169
+ });
2170
+ }