chatccc 0.2.251 → 0.2.253

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.
Files changed (277) hide show
  1. package/README.md +1 -1
  2. package/bin/cccagent.mjs +12 -3
  3. package/bin/chatccc.mjs +15 -6
  4. package/deepccc-agent/package.json +1 -1
  5. package/dist/deepccc-agent/src/cli.js +634 -0
  6. package/dist/deepccc-agent/src/config.js +76 -0
  7. package/dist/deepccc-agent/src/context.js +348 -0
  8. package/dist/deepccc-agent/src/file-log.js +34 -0
  9. package/dist/deepccc-agent/src/file-tools.js +1208 -0
  10. package/dist/deepccc-agent/src/index.js +571 -0
  11. package/dist/deepccc-agent/src/permissions.js +170 -0
  12. package/dist/deepccc-agent/src/privacy.js +124 -0
  13. package/dist/deepccc-agent/src/proc-tree-kill.js +60 -0
  14. package/dist/deepccc-agent/src/progress/cards-helpers.js +70 -0
  15. package/dist/deepccc-agent/src/progress/reducer.js +102 -0
  16. package/dist/deepccc-agent/src/progress/terminal-renderer.js +264 -0
  17. package/dist/deepccc-agent/src/progress/view.js +30 -0
  18. package/dist/deepccc-agent/src/raw-stream-log.js +106 -0
  19. package/dist/deepccc-agent/src/session-search.js +276 -0
  20. package/dist/deepccc-agent/src/session-select.js +23 -0
  21. package/dist/deepccc-agent/src/sigint.js +26 -0
  22. package/dist/deepccc-agent/src/skills.js +178 -0
  23. package/dist/deepccc-agent/src/web-tools.js +246 -0
  24. package/dist/src/adapters/adapter-interface.js +19 -0
  25. package/dist/src/adapters/ccc-adapter.js +112 -0
  26. package/dist/src/adapters/claude-adapter.js +497 -0
  27. package/dist/src/adapters/claude-session-meta-store.js +92 -0
  28. package/dist/src/adapters/codex-adapter.js +279 -0
  29. package/dist/src/adapters/codex-session-meta-store.js +94 -0
  30. package/dist/src/adapters/cursor-adapter.js +491 -0
  31. package/dist/src/adapters/cursor-session-meta-store.js +116 -0
  32. package/dist/src/adapters/jsonl-stream.js +104 -0
  33. package/{src/adapters/proc-tree-kill.ts → dist/src/adapters/proc-tree-kill.js} +94 -97
  34. package/dist/src/adapters/raw-stream-log.js +106 -0
  35. package/dist/src/adapters/resource-monitor.js +113 -0
  36. package/dist/src/agent-activity.js +133 -0
  37. package/dist/src/agent-delegate-task-rpc.js +129 -0
  38. package/dist/src/agent-delegate-task.js +48 -0
  39. package/dist/src/agent-file-rpc.js +152 -0
  40. package/dist/src/agent-image-rpc.js +148 -0
  41. package/dist/src/agent-platform-routing.js +13 -0
  42. package/dist/src/agent-reload-config-rpc.js +23 -0
  43. package/dist/src/agent-rpc-body.js +87 -0
  44. package/dist/src/agent-stop-stuck.js +110 -0
  45. package/dist/src/card-action-routing.js +7 -0
  46. package/dist/src/card-plain-text.js +101 -0
  47. package/dist/src/cardkit.js +158 -0
  48. package/dist/src/cards.js +573 -0
  49. package/dist/src/chatgpt-subscription-rpc.js +18 -0
  50. package/dist/src/chatgpt-subscription.js +199 -0
  51. package/dist/src/chrome-devtools-guard.js +238 -0
  52. package/dist/src/claude-sdk-installer.js +249 -0
  53. package/dist/src/codex-reset-actions.js +143 -0
  54. package/dist/src/config-utils.js +149 -0
  55. package/dist/src/config.js +804 -0
  56. package/dist/src/cursor-usage.js +77 -0
  57. package/dist/src/exit-banner.js +28 -0
  58. package/dist/src/feishu-api.js +1404 -0
  59. package/dist/src/feishu-message-ingress.js +137 -0
  60. package/dist/src/feishu-platform.js +97 -0
  61. package/dist/src/format-message.js +252 -0
  62. package/dist/src/git-command.js +155 -0
  63. package/dist/src/im-skills.js +121 -0
  64. package/dist/src/index.js +833 -0
  65. package/dist/src/litellm-proxy.js +300 -0
  66. package/dist/src/orchestrator.js +2078 -0
  67. package/dist/src/package-root.js +26 -0
  68. package/dist/src/platform-adapter.js +7 -0
  69. package/dist/src/platform-startup.js +6 -0
  70. package/dist/src/privacy.js +100 -0
  71. package/dist/src/progress/reducer.js +102 -0
  72. package/dist/src/progress/terminal-renderer.js +264 -0
  73. package/dist/src/progress/view.js +30 -0
  74. package/dist/src/response-stall.js +14 -0
  75. package/dist/src/runtime-entry.js +13 -0
  76. package/dist/src/runtime-reload.js +19 -0
  77. package/dist/src/session-chat-binding.js +183 -0
  78. package/dist/src/session-name.js +7 -0
  79. package/dist/src/session.js +2144 -0
  80. package/dist/src/shared-prefix.js +16 -0
  81. package/dist/src/shared.js +493 -0
  82. package/dist/src/sim-agent.js +105 -0
  83. package/dist/src/sim-platform.js +142 -0
  84. package/dist/src/sim-store.js +231 -0
  85. package/dist/src/simplify.js +99 -0
  86. package/dist/src/startup-lifecycle.js +209 -0
  87. package/dist/src/stream-state.js +141 -0
  88. package/dist/src/terminal-error.js +100 -0
  89. package/dist/src/trace.js +50 -0
  90. package/dist/src/turn-cards.js +92 -0
  91. package/dist/src/update-command-guard.js +114 -0
  92. package/{src/web-ui.ts → dist/src/web-ui.js} +749 -823
  93. package/dist/src/wechat-platform.js +545 -0
  94. package/package.json +7 -5
  95. package/deepccc-agent/LICENSE +0 -201
  96. package/deepccc-agent/bin/deepccc.mjs +0 -26
  97. package/deepccc-agent/docs/cache-hit-rate-1.jpg +0 -0
  98. package/deepccc-agent/docs/cache-hit-rate-2.jpg +0 -0
  99. package/deepccc-agent/package-lock.json +0 -2027
  100. package/deepccc-agent/src/__tests__/chat-session.test.ts +0 -852
  101. package/deepccc-agent/src/__tests__/cli-json.test.ts +0 -49
  102. package/deepccc-agent/src/__tests__/config.test.ts +0 -34
  103. package/deepccc-agent/src/__tests__/context.test.ts +0 -341
  104. package/deepccc-agent/src/__tests__/file-tools.test.ts +0 -240
  105. package/deepccc-agent/src/__tests__/permissions.test.ts +0 -199
  106. package/deepccc-agent/src/__tests__/privacy.test.ts +0 -318
  107. package/deepccc-agent/src/__tests__/progress-reducer.test.ts +0 -121
  108. package/deepccc-agent/src/__tests__/session-search.test.ts +0 -262
  109. package/deepccc-agent/src/__tests__/session-select.test.ts +0 -116
  110. package/deepccc-agent/src/__tests__/sigint.test.ts +0 -56
  111. package/deepccc-agent/src/__tests__/skills.test.ts +0 -284
  112. package/deepccc-agent/src/__tests__/terminal-renderer.test.ts +0 -247
  113. package/deepccc-agent/src/__tests__/web-tools.test.ts +0 -220
  114. package/deepccc-agent/src/cli.ts +0 -682
  115. package/deepccc-agent/src/config.ts +0 -101
  116. package/deepccc-agent/src/context.ts +0 -465
  117. package/deepccc-agent/src/file-log.ts +0 -38
  118. package/deepccc-agent/src/file-tools.ts +0 -1493
  119. package/deepccc-agent/src/index.ts +0 -676
  120. package/deepccc-agent/src/permissions.ts +0 -226
  121. package/deepccc-agent/src/privacy.ts +0 -141
  122. package/deepccc-agent/src/proc-tree-kill.ts +0 -61
  123. package/deepccc-agent/src/progress/cards-helpers.ts +0 -76
  124. package/deepccc-agent/src/progress/reducer.ts +0 -113
  125. package/deepccc-agent/src/progress/terminal-renderer.ts +0 -294
  126. package/deepccc-agent/src/progress/view.ts +0 -77
  127. package/deepccc-agent/src/raw-stream-log.ts +0 -124
  128. package/deepccc-agent/src/session-search.ts +0 -370
  129. package/deepccc-agent/src/session-select.ts +0 -48
  130. package/deepccc-agent/src/sigint.ts +0 -50
  131. package/deepccc-agent/src/skills.ts +0 -205
  132. package/deepccc-agent/src/web-tools.ts +0 -313
  133. package/deepccc-agent/tsconfig.build.json +0 -13
  134. package/deepccc-agent/tsconfig.json +0 -13
  135. package/deepccc-agent/vitest.config.ts +0 -7
  136. package/src/__tests__/adapter-interface.test.ts +0 -152
  137. package/src/__tests__/agent-activity.test.ts +0 -86
  138. package/src/__tests__/agent-delegate-task-rpc.test.ts +0 -165
  139. package/src/__tests__/agent-image-rpc.test.ts +0 -34
  140. package/src/__tests__/agent-platform-routing.test.ts +0 -26
  141. package/src/__tests__/agent-reload-config-rpc.test.ts +0 -99
  142. package/src/__tests__/agent-rpc-body.test.ts +0 -42
  143. package/src/__tests__/builtin-chat-session.test.ts +0 -532
  144. package/src/__tests__/builtin-cli-json.test.ts +0 -39
  145. package/src/__tests__/builtin-config.test.ts +0 -26
  146. package/src/__tests__/builtin-context.test.ts +0 -319
  147. package/src/__tests__/builtin-file-tools.test.ts +0 -240
  148. package/src/__tests__/builtin-permissions.test.ts +0 -219
  149. package/src/__tests__/builtin-session-search.test.ts +0 -262
  150. package/src/__tests__/builtin-session-select.test.ts +0 -116
  151. package/src/__tests__/builtin-sigint.test.ts +0 -56
  152. package/src/__tests__/builtin-skills.test.ts +0 -284
  153. package/src/__tests__/builtin-web-tools.test.ts +0 -220
  154. package/src/__tests__/card-action-routing.test.ts +0 -18
  155. package/src/__tests__/card-plain-text.test.ts +0 -45
  156. package/src/__tests__/cardkit.test.ts +0 -60
  157. package/src/__tests__/cards.test.ts +0 -607
  158. package/src/__tests__/ccc-adapter.test.ts +0 -194
  159. package/src/__tests__/chatgpt-subscription-rpc.test.ts +0 -89
  160. package/src/__tests__/chatgpt-subscription.test.ts +0 -135
  161. package/src/__tests__/chrome-devtools-guard.test.ts +0 -165
  162. package/src/__tests__/claude-adapter.test.ts +0 -614
  163. package/src/__tests__/claude-raw-stream-log.test.ts +0 -96
  164. package/src/__tests__/claude-sdk-installer.test.ts +0 -285
  165. package/src/__tests__/codex-adapter.test.ts +0 -331
  166. package/src/__tests__/codex-raw-stream-log.test.ts +0 -170
  167. package/src/__tests__/codex-reset-actions.test.ts +0 -146
  168. package/src/__tests__/config-reload.test.ts +0 -284
  169. package/src/__tests__/config-sample.test.ts +0 -97
  170. package/src/__tests__/config-utils.test.ts +0 -40
  171. package/src/__tests__/config.test.ts +0 -395
  172. package/src/__tests__/crash-logging.test.ts +0 -360
  173. package/src/__tests__/cursor-adapter.test.ts +0 -890
  174. package/src/__tests__/cursor-session-meta-store.test.ts +0 -212
  175. package/src/__tests__/feishu-api.test.ts +0 -60
  176. package/src/__tests__/feishu-avatar.test.ts +0 -504
  177. package/src/__tests__/feishu-message-ingress.test.ts +0 -138
  178. package/src/__tests__/feishu-platform.test.ts +0 -75
  179. package/src/__tests__/fixtures/codex_simple_text.jsonl +0 -4
  180. package/src/__tests__/fixtures/codex_with_tool.jsonl +0 -6
  181. package/src/__tests__/fixtures/cursor_partial_only.jsonl +0 -5
  182. package/src/__tests__/fixtures/cursor_partial_with_final.jsonl +0 -13
  183. package/src/__tests__/fixtures/cursor_with_tool_call.jsonl +0 -12
  184. package/src/__tests__/format-message.test.ts +0 -316
  185. package/src/__tests__/git-command.test.ts +0 -288
  186. package/src/__tests__/im-skills.test.ts +0 -125
  187. package/src/__tests__/jsonl-stream.test.ts +0 -79
  188. package/src/__tests__/orchestrator.test.ts +0 -1268
  189. package/src/__tests__/package-files.test.ts +0 -24
  190. package/src/__tests__/platform-startup.test.ts +0 -19
  191. package/src/__tests__/privacy.test.ts +0 -198
  192. package/src/__tests__/proc-tree-kill.test.ts +0 -108
  193. package/src/__tests__/progress-reducer.test.ts +0 -121
  194. package/src/__tests__/raw-stream-log.test.ts +0 -106
  195. package/src/__tests__/response-stall.test.ts +0 -49
  196. package/src/__tests__/restart.test.ts +0 -232
  197. package/src/__tests__/session-ccc-config.test.ts +0 -66
  198. package/src/__tests__/session.test.ts +0 -3004
  199. package/src/__tests__/shared-prefix.test.ts +0 -36
  200. package/src/__tests__/sim-agent.test.ts +0 -174
  201. package/src/__tests__/sim-platform.test.ts +0 -93
  202. package/src/__tests__/sim-store.test.ts +0 -214
  203. package/src/__tests__/simplify.test.ts +0 -283
  204. package/src/__tests__/startup-lifecycle.test.ts +0 -231
  205. package/src/__tests__/stop-session.test.ts +0 -162
  206. package/src/__tests__/stream-state.test.ts +0 -164
  207. package/src/__tests__/terminal-error.test.ts +0 -54
  208. package/src/__tests__/terminal-renderer.test.ts +0 -247
  209. package/src/__tests__/update-command-guard.test.ts +0 -144
  210. package/src/__tests__/web-ui.test.ts +0 -438
  211. package/src/__tests__/wechat-platform.test.ts +0 -111
  212. package/src/adapters/adapter-interface.ts +0 -217
  213. package/src/adapters/ccc-adapter.ts +0 -150
  214. package/src/adapters/claude-adapter.ts +0 -673
  215. package/src/adapters/claude-session-meta-store.ts +0 -120
  216. package/src/adapters/codex-adapter.ts +0 -426
  217. package/src/adapters/codex-session-meta-store.ts +0 -131
  218. package/src/adapters/cursor-adapter.ts +0 -681
  219. package/src/adapters/cursor-session-meta-store.ts +0 -154
  220. package/src/adapters/jsonl-stream.ts +0 -157
  221. package/src/adapters/raw-stream-log.ts +0 -124
  222. package/src/adapters/resource-monitor.ts +0 -141
  223. package/src/agent-activity.ts +0 -175
  224. package/src/agent-delegate-task-rpc.ts +0 -153
  225. package/src/agent-delegate-task.ts +0 -91
  226. package/src/agent-file-rpc.ts +0 -172
  227. package/src/agent-image-rpc.ts +0 -168
  228. package/src/agent-platform-routing.ts +0 -28
  229. package/src/agent-reload-config-rpc.ts +0 -34
  230. package/src/agent-rpc-body.ts +0 -92
  231. package/src/agent-stop-stuck.ts +0 -129
  232. package/src/card-action-routing.ts +0 -14
  233. package/src/card-plain-text.ts +0 -108
  234. package/src/cardkit.ts +0 -179
  235. package/src/cards.ts +0 -684
  236. package/src/chatgpt-subscription-rpc.ts +0 -27
  237. package/src/chatgpt-subscription.ts +0 -299
  238. package/src/chrome-devtools-guard.ts +0 -318
  239. package/src/claude-sdk-installer.ts +0 -324
  240. package/src/codex-reset-actions.ts +0 -184
  241. package/src/config-utils.ts +0 -211
  242. package/src/config.ts +0 -1063
  243. package/src/cursor-usage.ts +0 -128
  244. package/src/exit-banner.ts +0 -33
  245. package/src/feishu-api.ts +0 -1616
  246. package/src/feishu-message-ingress.ts +0 -195
  247. package/src/feishu-platform.ts +0 -159
  248. package/src/format-message.ts +0 -293
  249. package/src/git-command.ts +0 -202
  250. package/src/im-skills.ts +0 -149
  251. package/src/index.ts +0 -1089
  252. package/src/litellm-proxy.ts +0 -374
  253. package/src/orchestrator.ts +0 -2543
  254. package/src/platform-adapter.ts +0 -70
  255. package/src/platform-startup.ts +0 -16
  256. package/src/privacy.ts +0 -118
  257. package/src/progress/reducer.ts +0 -113
  258. package/src/progress/terminal-renderer.ts +0 -294
  259. package/src/progress/view.ts +0 -77
  260. package/src/response-stall.ts +0 -28
  261. package/src/runtime-reload.ts +0 -34
  262. package/src/session-chat-binding.ts +0 -292
  263. package/src/session-name.ts +0 -8
  264. package/src/session.ts +0 -2659
  265. package/src/shared-prefix.ts +0 -29
  266. package/src/shared.ts +0 -552
  267. package/src/sim-agent.ts +0 -167
  268. package/src/sim-platform.ts +0 -177
  269. package/src/sim-store.ts +0 -317
  270. package/src/simplify.ts +0 -120
  271. package/src/startup-lifecycle.ts +0 -250
  272. package/src/stream-state.ts +0 -177
  273. package/src/terminal-error.ts +0 -129
  274. package/src/trace.ts +0 -51
  275. package/src/turn-cards.ts +0 -118
  276. package/src/update-command-guard.ts +0 -165
  277. package/src/wechat-platform.ts +0 -680
@@ -1,682 +1,634 @@
1
- // =============================================================================
2
- // web-ui.ts — Setup Wizard & Management Dashboard HTTP Server
3
- // =============================================================================
4
- // Serves on the same port as the WebSocket relay (18080 default).
5
- // - Setup mode: no config.json → show setup wizard, skip Feishu connection
6
- // - Dashboard mode: config.json exists → serve management page alongside WS relay
7
- // =============================================================================
8
-
9
- import { createServer, IncomingMessage, ServerResponse } from "node:http";
10
- import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
11
- import { readFile, writeFile, stat } from "node:fs/promises";
12
- import { homedir } from "node:os";
13
- import { join, dirname } from "node:path";
14
- import { fileURLToPath } from "node:url";
15
- import { spawn, execSync } from "node:child_process";
16
- import {
17
- buildWebUiUrl,
18
- createInternalRestartEnv,
19
- openWebUiInDefaultBrowser,
20
- } from "./startup-lifecycle.ts";
21
- import {
22
- getClaudeSdkInstalledVersion,
23
- getLastInstallProgress,
24
- installClaudeSdk,
25
- isClaudeSdkInstalled,
26
- isInstallRunning,
27
- } from "./claude-sdk-installer.ts";
28
-
29
- const __dirname = dirname(fileURLToPath(import.meta.url));
30
- const PROJECT_ROOT = join(__dirname, "..");
31
- const USER_DATA_DIR = join(homedir(), ".chatccc");
32
- const CONFIG_FILE = join(USER_DATA_DIR, "config.json");
33
- const CONFIG_SAMPLE_FILE = join(PROJECT_ROOT, "config.sample.json");
34
- const PID_FILE = join(USER_DATA_DIR, "state", "runtime.pid");
35
- const ILINK_AUTH_PATH = join(USER_DATA_DIR, "state", "ilink-auth.json");
36
-
37
- // ---------------------------------------------------------------------------
38
- // Helpers — config.json parsing & generation
39
- // ---------------------------------------------------------------------------
40
-
41
- interface AppConfig {
42
- feishu?: { appId?: string; appSecret?: string };
43
- platforms?: {
44
- feishu?: { enabled?: boolean; platformType?: string };
45
- ilink?: { enabled?: boolean; reuseTokenOnStart?: boolean };
46
- };
47
- webUi?: { openOnStart?: boolean };
48
- chromeDevtools?: { enabled?: boolean; port?: number; chromePath?: string };
49
- port?: number;
50
- gitTimeoutSeconds?: number;
51
- claude?: { enabled?: boolean; defaultAgent?: boolean; model?: string; subagentModel?: string; effort?: string; apiKey?: string; baseUrl?: string; maxTurn?: number };
52
- // `command` 是已废弃的旧字段名,保留只读以兼容升级前的 config.json
53
- cursor?: {
54
- enabled?: boolean;
55
- defaultAgent?: boolean;
56
- path?: string;
57
- command?: string;
58
- model?: string;
59
- alternativeModel?: string;
60
- avatarBatteryMode?: string;
61
- onDemandMonthlyBudget?: number;
62
- };
63
- codex?: { enabled?: boolean; defaultAgent?: boolean; path?: string; command?: string; model?: string; alternativeModel?: string; effort?: string; fastMode?: boolean };
64
- ccc?: {
65
- enabled?: boolean;
66
- defaultAgent?: boolean;
67
- DEEPSEEK_API_KEY?: string;
68
- DEEPSEEK_BASE_URL?: string;
69
- model?: string;
70
- alternativeModel?: string;
71
- effort?: string;
72
- /** 留空("")= 不 override,跟随 DeepCCC 内核配置(~/.deepccc/config.json 或 DEEPCCC_PROVIDER) */
73
- provider?: "" | "openai" | "anthropic";
74
- };
75
- }
76
-
77
- // ---------------------------------------------------------------------------
78
- // /api/start 路径选择(纯函数,便于单测护栏)
79
- // ---------------------------------------------------------------------------
80
- //
81
- // 三种语义:
82
- // - "inplace" : setup 模式,原地启动飞书 service(同进程,不动 PID 文件)
83
- // - "spawn" : dashboard 模式 + service 未运行,spawn 子进程(旧 service 退出后场景)
84
- // - "reload" : dashboard 模式 + service 已经在跑(通常就是当前进程自己)→
85
- // 仅调用 reloadConfigFromDisk() 刷新 export let 常量,**不真正重启**。
86
- // 用户的设计意图:"让新 config 生效就行,不用走 spawn+exit 的真重启"
87
- //
88
- // 关键契约:只要 setup 模式注册了 onActivate 回调,无条件走 inplace —— 因为
89
- // setup 进程**总是**占着 PID 文件、isServiceRunning() 永远为 true,再判 PID
90
- // 会陷入"自己挡自己"的死循环(用户点保存并启动只会得到 already running)。
91
- //
92
- // 历史:曾经在 service 已运行时返回 "reject-already-running",但 dashboard 的
93
- // UI 本身就在跑着的进程内,service 必然在跑——重新跑向导改完配置点"保存并启动"
94
- // 100% 会撞到这条 reject 路径,造成"自己挡自己"的死循环。改 reload 后即可
95
- // 让常量热更新(API 来源、模型、effort、CLI 路径等下次创建会话即生效)。
96
- // 已建立的飞书 WSClient 仍持有旧 APP_ID/APP_SECRET 句柄——如改了飞书凭证,
97
- // 仍需重启 chatccc 进程才能让 WS 长连接换 token;但这是用户少见路径,文档说明即可。
98
-
99
- export type StartPath = "inplace" | "spawn" | "reload";
100
-
101
- export function chooseStartPath(input: {
102
- hasInplaceActivateHook: boolean;
103
- isServiceRunning: boolean;
104
- }): StartPath {
105
- if (input.hasInplaceActivateHook) return "inplace";
106
- if (input.isServiceRunning) return "reload";
107
- return "spawn";
108
- }
109
-
110
- /** 读取 cursor / codex 的 CLI 路径,优先新字段 path,回退旧字段 command */
111
- function readToolPath(tool?: { path?: string; command?: string }): string {
112
- if (!tool) return "";
113
- if (tool.path && tool.path.trim()) return tool.path;
114
- if (tool.command && tool.command.trim()) return tool.command;
115
- return "";
116
- }
117
-
118
- function loadConfig(): AppConfig {
119
- if (!existsSync(CONFIG_FILE)) return {};
120
- try {
121
- let raw = readFileSync(CONFIG_FILE, "utf8");
122
- // 移除可能意外写入的 UTF-8 BOM,避免 JSON.parse 失败导致返回空对象、
123
- // UI 显示所有配置为空。
124
- if (raw.charCodeAt(0) === 0xfeff) raw = raw.slice(1);
125
- return JSON.parse(raw);
126
- } catch {
127
- return {};
128
- }
129
- }
130
-
131
- function saveConfig(cfg: AppConfig): void {
132
- writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2), "utf8");
133
- }
134
-
135
- type ConfigApplyMode = "saved" | "reload" | "restart-required";
136
-
137
- interface ConfigApplyResult {
138
- mode: ConfigApplyMode;
139
- restartRequired: boolean;
140
- restartReasons: string[];
141
- }
142
-
143
- function stringValue(value: unknown): string {
144
- return typeof value === "string" ? value.trim() : "";
145
- }
146
-
147
- function portValue(cfg: AppConfig): number {
148
- return Number.isInteger(cfg.port) && cfg.port! >= 1 && cfg.port! <= 65535 ? cfg.port! : 18080;
149
- }
150
-
151
- function platformEnabled(cfg: AppConfig, platform: "feishu" | "ilink"): boolean {
152
- return cfg.platforms?.[platform]?.enabled !== false;
153
- }
154
-
155
- function feishuPlatformType(cfg: AppConfig): string {
156
- return cfg.platforms?.feishu?.platformType === "lark" ? "lark" : "feishu";
157
- }
158
-
159
- function ilinkReuseTokenOnStart(cfg: AppConfig): boolean {
160
- return cfg.platforms?.ilink?.reuseTokenOnStart !== false;
161
- }
162
-
163
- export function getRestartRequiredReasons(before: AppConfig, after: AppConfig): string[] {
164
- const reasons: string[] = [];
165
-
166
- if (portValue(before) !== portValue(after)) reasons.push("port");
167
- if (stringValue(before.feishu?.appId) !== stringValue(after.feishu?.appId)) reasons.push("feishu.appId");
168
- if (stringValue(before.feishu?.appSecret) !== stringValue(after.feishu?.appSecret)) reasons.push("feishu.appSecret");
169
- if (feishuPlatformType(before) !== feishuPlatformType(after)) reasons.push("platforms.feishu.platformType");
170
- if (platformEnabled(before, "feishu") !== platformEnabled(after, "feishu")) reasons.push("platforms.feishu.enabled");
171
- if (platformEnabled(before, "ilink") !== platformEnabled(after, "ilink")) reasons.push("platforms.ilink.enabled");
172
- if (ilinkReuseTokenOnStart(before) !== ilinkReuseTokenOnStart(after)) {
173
- reasons.push("platforms.ilink.reuseTokenOnStart");
174
- }
175
-
176
- return reasons;
177
- }
178
-
179
- async function applySavedConfigIfPossible(before: AppConfig, after: AppConfig): Promise<ConfigApplyResult> {
180
- const setupMode = Boolean(setupActivateHook && setupHttpServer);
181
- if (setupMode) {
182
- return { mode: "saved", restartRequired: false, restartReasons: [] };
183
- }
184
-
185
- const running = isServiceRunning();
186
- if (!running) {
187
- return { mode: "saved", restartRequired: false, restartReasons: [] };
188
- }
189
-
190
- const restartReasons = getRestartRequiredReasons(before, after);
191
- if (restartReasons.length > 0) {
192
- return { mode: "restart-required", restartRequired: true, restartReasons };
193
- }
194
- if (!reloadConfigHook) {
195
- return { mode: "saved", restartRequired: false, restartReasons: [] };
196
- }
197
-
198
- await reloadConfigHook();
199
- return { mode: "reload", restartRequired: false, restartReasons: [] };
200
- }
201
-
202
- function maskSecret(value: string | undefined): string {
203
- if (!value) return "(未设置)";
204
- if (value.length <= 8) return "***";
205
- return value.slice(0, 4) + "***" + value.slice(-4);
206
- }
207
-
208
- // ---------------------------------------------------------------------------
209
- // Helpers — process management
210
- // ---------------------------------------------------------------------------
211
-
212
- function isServiceRunning(): boolean {
213
- if (!existsSync(PID_FILE)) return false;
214
- try {
215
- const raw = readFileSync(PID_FILE, "utf8").trim();
216
- const pid = parseInt(raw, 10);
217
- if (isNaN(pid)) return false;
218
- process.kill(pid, 0);
219
- return true;
220
- } catch {
221
- return false;
222
- }
223
- }
224
-
225
- function getServicePid(): number | null {
226
- if (!existsSync(PID_FILE)) return null;
227
- try {
228
- const pid = parseInt(readFileSync(PID_FILE, "utf8").trim(), 10);
229
- return isNaN(pid) ? null : pid;
230
- } catch {
231
- return null;
232
- }
233
- }
234
-
235
- function getServiceUptime(): number | null {
236
- try {
237
- const s = statSync(PID_FILE);
238
- return Math.floor((Date.now() - s.mtimeMs) / 1000);
239
- } catch {
240
- return null;
241
- }
242
- }
243
-
244
- import { statSync } from "node:fs";
245
-
246
- function spawnService(): { ok: boolean; pid?: number; error?: string } {
247
- const indexPath = join(PROJECT_ROOT, "src", "index.ts");
248
- if (!existsSync(indexPath)) {
249
- return { ok: false, error: `Entry not found: ${indexPath}` };
250
- }
251
- try {
252
- const child = spawn("npx", ["tsx", "src/index.ts"], {
253
- cwd: PROJECT_ROOT,
254
- detached: true,
255
- stdio: "ignore",
256
- shell: true,
257
- env: createInternalRestartEnv(),
258
- });
259
- child.unref();
260
- return { ok: true, pid: child.pid ?? undefined };
261
- } catch (err) {
262
- return { ok: false, error: (err as Error).message };
263
- }
264
- }
265
-
266
- /**
267
- * 安排一次真正的进程重启:先回复 HTTP 请求,再 spawn 一个延迟启动的子进程,
268
- * 然后退出当前进程。延迟是为了让当前进程完全退出并释放端口。
269
- */
270
- function scheduleRestart(): void {
271
- const indexPath = join(PROJECT_ROOT, "src", "index.ts");
272
- if (!existsSync(indexPath)) return;
273
- if (process.platform === "win32") {
274
- // Windows: ping 作为 sleep(ping 自己 3 次 ≈ 2 秒延迟)
275
- spawn("cmd.exe", ["/c", "ping -n 3 127.0.0.1 > nul && npx tsx src/index.ts"], {
276
- cwd: PROJECT_ROOT,
277
- detached: true,
278
- stdio: "ignore",
279
- windowsHide: true,
280
- env: createInternalRestartEnv(),
281
- }).unref();
282
- } else {
283
- spawn("bash", ["-c", "sleep 2 && npx tsx src/index.ts"], {
284
- cwd: PROJECT_ROOT,
285
- detached: true,
286
- stdio: "ignore",
287
- env: createInternalRestartEnv(),
288
- }).unref();
289
- }
290
- }
291
-
292
- function stopService(): { ok: boolean; error?: string } {
293
- const pid = getServicePid();
294
- if (!pid) return { ok: false, error: "No PID file found" };
295
- try {
296
- if (process.platform === "win32") {
297
- execSync(`taskkill /PID ${pid} /F /T`, { stdio: "pipe", windowsHide: true });
298
- } else {
299
- process.kill(pid, "SIGTERM");
300
- }
301
- return { ok: true };
302
- } catch (err) {
303
- return { ok: false, error: (err as Error).message };
304
- }
305
- }
306
-
307
- function validateCli(tool: string): { ok: boolean; path?: string; error?: string } {
308
- const cfg = loadConfig();
309
- let cmd: string;
310
- if (tool === "cursor") {
311
- cmd = readToolPath(cfg.cursor) || detectCursorAgentPath();
312
- } else {
313
- cmd = readToolPath(cfg.codex) || "codex";
314
- }
315
- try {
316
- const out = execSync(`"${cmd}" --version`, { encoding: "utf8", timeout: 10000, windowsHide: true }).trim();
317
- return { ok: true, path: cmd, error: out.slice(0, 200) };
318
- } catch (err) {
319
- return { ok: false, path: cmd, error: (err as Error).message };
320
- }
321
- }
322
-
323
- function detectCursorAgentPath(): string {
324
- const localAppData = process.env.LOCALAPPDATA;
325
- if (localAppData) {
326
- const defaultPath = join(localAppData, "cursor-agent", "agent.cmd");
327
- if (existsSync(defaultPath)) return defaultPath;
328
- }
329
- return "agent";
330
- }
331
-
332
- // ---------------------------------------------------------------------------
333
- // Helpers — HTTP
334
- // ---------------------------------------------------------------------------
335
-
336
- function jsonReply(res: ServerResponse, code: number, data: unknown): void {
337
- res.writeHead(code, { "Content-Type": "application/json; charset=utf-8" });
338
- res.end(JSON.stringify(data));
339
- }
340
-
341
- function readRequestBody(req: IncomingMessage): Promise<string> {
342
- return new Promise((resolve) => {
343
- let body = "";
344
- req.on("data", (chunk: Buffer) => { body += chunk.toString(); });
345
- req.on("end", () => resolve(body));
346
- });
347
- }
348
-
349
- // ---------------------------------------------------------------------------
350
- // API route handlers
351
- // ---------------------------------------------------------------------------
352
-
353
- async function handleApiCheck(_req: IncomingMessage, res: ServerResponse): Promise<void> {
354
- const hasConfig = existsSync(CONFIG_FILE);
355
- let hasCreds = false;
356
- if (hasConfig) {
357
- const c = loadConfig();
358
- const feishuEnabled = c.platforms?.feishu?.enabled !== false; // 默认 true(向后兼容)
359
- const feishuOk = feishuEnabled && Boolean(c.feishu?.appId?.trim() && c.feishu?.appSecret?.trim());
360
- hasCreds = feishuOk || !feishuEnabled;
361
- }
362
- jsonReply(res, 200, { hasConfig, hasCreds, configPath: CONFIG_FILE });
363
- }
364
-
365
- async function handleGetConfig(_req: IncomingMessage, res: ServerResponse): Promise<void> {
366
- const running = isServiceRunning();
367
- const pid = getServicePid();
368
- const vars = loadConfig();
369
- let ilinkAuthExists = false;
370
- if (existsSync(ILINK_AUTH_PATH)) {
371
- try {
372
- const auth = JSON.parse(readFileSync(ILINK_AUTH_PATH, "utf8"));
373
- ilinkAuthExists = Boolean(auth.token);
374
- } catch { /* ignore parse errors */ }
375
- }
376
- jsonReply(res, 200, { vars, running, pid, ilinkAuthExists });
377
- }
378
-
379
- async function handlePostConfig(req: IncomingMessage, res: ServerResponse): Promise<void> {
380
- const body = await readRequestBody(req);
381
- let updates: Record<string, unknown>;
382
- try {
383
- const parsed = JSON.parse(body);
384
- updates = parsed.vars ?? {};
385
- } catch {
386
- jsonReply(res, 400, { ok: false, error: "Invalid JSON" });
387
- return;
388
- }
389
- const existing = loadConfig();
390
- // AppConfig 是闭合接口(无 index signature),但运行时本质就是 JSON 对象,
391
- // 这里通过 unknown 桥接到 Record 让 deepMerge 能复用;写回前断言回 AppConfig。
392
- const merged = deepMerge(
393
- existing as unknown as Record<string, unknown>,
394
- unflattenConfig(updates),
395
- ) as AppConfig;
396
- try {
397
- saveConfig(merged);
398
- const applyResult = await applySavedConfigIfPossible(existing, merged);
399
- jsonReply(res, 200, { ok: true, ...applyResult });
400
- } catch (err) {
401
- jsonReply(res, 500, { ok: false, saved: true, error: (err as Error).message });
402
- }
403
- }
404
-
405
- function deepMerge(target: Record<string, unknown>, source: Record<string, unknown>): Record<string, unknown> {
406
- const result = { ...target };
407
- for (const [key, val] of Object.entries(source)) {
408
- if (val !== null && typeof val === "object" && !Array.isArray(val) && typeof result[key] === "object" && result[key] !== null && !Array.isArray(result[key])) {
409
- result[key] = deepMerge(result[key] as Record<string, unknown>, val as Record<string, unknown>);
410
- } else {
411
- result[key] = val;
412
- }
413
- }
414
- return result;
415
- }
416
-
417
- // Convert flat key-value pairs to nested config structure
418
- export function unflattenConfig(flat: Record<string, unknown>): Record<string, unknown> {
419
- const result: Record<string, unknown> = {};
420
- for (const [key, val] of Object.entries(flat)) {
421
- if (key === "CHATCCC_APP_ID") {
422
- result.feishu = result.feishu || {};
423
- (result.feishu as Record<string, unknown>).appId = val;
424
- } else if (key === "CHATCCC_APP_SECRET") {
425
- result.feishu = result.feishu || {};
426
- (result.feishu as Record<string, unknown>).appSecret = val;
427
- } else if (key === "CHATCCC_FEISHU_ENABLED") {
428
- result.platforms = result.platforms || {};
429
- (result.platforms as Record<string, unknown>).feishu = (result.platforms as Record<string, unknown>).feishu || {};
430
- ((result.platforms as Record<string, unknown>).feishu as Record<string, unknown>).enabled = val === true || val === "true";
431
- } else if (key === "CHATCCC_FEISHU_PLATFORM_TYPE") {
432
- result.platforms = result.platforms || {};
433
- (result.platforms as Record<string, unknown>).feishu = (result.platforms as Record<string, unknown>).feishu || {};
434
- ((result.platforms as Record<string, unknown>).feishu as Record<string, unknown>).platformType = val;
435
- } else if (key === "CHATCCC_ILINK_ENABLED") {
436
- result.platforms = result.platforms || {};
437
- (result.platforms as Record<string, unknown>).ilink = (result.platforms as Record<string, unknown>).ilink || {};
438
- ((result.platforms as Record<string, unknown>).ilink as Record<string, unknown>).enabled = val === true || val === "true";
439
- } else if (key === "CHATCCC_WEB_UI_OPEN_ON_START") {
440
- result.webUi = result.webUi || {};
441
- (result.webUi as Record<string, unknown>).openOnStart = val === true || val === "true";
442
- } else if (key === "CHATCCC_CHROME_DEVTOOLS_ENABLED") {
443
- result.chromeDevtools = result.chromeDevtools || {};
444
- (result.chromeDevtools as Record<string, unknown>).enabled = val === true || val === "true";
445
- } else if (key === "CHATCCC_CHROME_DEVTOOLS_PORT") {
446
- result.chromeDevtools = result.chromeDevtools || {};
447
- (result.chromeDevtools as Record<string, unknown>).port = parseInt(val as string, 10) || 15166;
448
- } else if (key === "CHATCCC_CHROME_DEVTOOLS_PATH") {
449
- result.chromeDevtools = result.chromeDevtools || {};
450
- (result.chromeDevtools as Record<string, unknown>).chromePath = val;
451
- } else if (key === "CHATCCC_PORT") {
452
- result.port = parseInt(val as string, 10) || 18080;
453
- } else if (key === "CHATCCC_GIT_TIMEOUT_SECONDS") {
454
- result.gitTimeoutSeconds = parseInt(val as string, 10) || 180;
455
- } else if (key === "CHATCCC_ANTHROPIC_MODEL") {
456
- result.claude = result.claude || {};
457
- (result.claude as Record<string, unknown>).model = val;
458
- } else if (key === "CHATCCC_ANTHROPIC_SUBAGENT_MODEL") {
459
- result.claude = result.claude || {};
460
- (result.claude as Record<string, unknown>).subagentModel = val;
461
- } else if (key === "CHATCCC_ANTHROPIC_EFFORT") {
462
- result.claude = result.claude || {};
463
- (result.claude as Record<string, unknown>).effort = val;
464
- } else if (key === "CHATCCC_ANTHROPIC_API_KEY") {
465
- result.claude = result.claude || {};
466
- (result.claude as Record<string, unknown>).apiKey = val;
467
- } else if (key === "CHATCCC_ANTHROPIC_BASE_URL") {
468
- result.claude = result.claude || {};
469
- (result.claude as Record<string, unknown>).baseUrl = val;
470
- } else if (key === "CHATCCC_ANTHROPIC_MAX_TURN") {
471
- result.claude = result.claude || {};
472
- (result.claude as Record<string, unknown>).maxTurn = (function(v){ var n = parseInt(v, 10); return isNaN(n) ? 0 : n; })(val as string);
473
- } else if (key === "CHATCCC_CLAUDE_ENABLED") {
474
- result.claude = result.claude || {};
475
- (result.claude as Record<string, unknown>).enabled = val === true || val === "true";
476
- } else if (key === "CHATCCC_CLAUDE_DEFAULT_AGENT") {
477
- result.claude = result.claude || {};
478
- (result.claude as Record<string, unknown>).defaultAgent = val === true || val === "true";
479
- } else if (key === "CHATCCC_CURSOR_PATH") {
480
- result.cursor = result.cursor || {};
481
- (result.cursor as Record<string, unknown>).path = val;
482
- } else if (key === "CHATCCC_CURSOR_MODEL") {
483
- result.cursor = result.cursor || {};
484
- (result.cursor as Record<string, unknown>).model = val;
485
- } else if (key === "CHATCCC_CURSOR_ALTERNATIVE_MODEL") {
486
- result.cursor = result.cursor || {};
487
- (result.cursor as Record<string, unknown>).alternativeModel = val;
488
- } else if (key === "CHATCCC_CURSOR_AVATAR_BATTERY_MODE") {
489
- result.cursor = result.cursor || {};
490
- (result.cursor as Record<string, unknown>).avatarBatteryMode = val;
491
- } else if (key === "CHATCCC_CURSOR_ON_DEMAND_MONTHLY_BUDGET") {
492
- result.cursor = result.cursor || {};
493
- (result.cursor as Record<string, unknown>).onDemandMonthlyBudget = Number(val);
494
- } else if (key === "CHATCCC_CURSOR_ENABLED") {
495
- result.cursor = result.cursor || {};
496
- (result.cursor as Record<string, unknown>).enabled = val === true || val === "true";
497
- } else if (key === "CHATCCC_CURSOR_DEFAULT_AGENT") {
498
- result.cursor = result.cursor || {};
499
- (result.cursor as Record<string, unknown>).defaultAgent = val === true || val === "true";
500
- } else if (key === "CHATCCC_CODEX_PATH") {
501
- result.codex = result.codex || {};
502
- (result.codex as Record<string, unknown>).path = val;
503
- } else if (key === "CHATCCC_CODEX_MODEL") {
504
- result.codex = result.codex || {};
505
- (result.codex as Record<string, unknown>).model = val;
506
- } else if (key === "CHATCCC_CODEX_ALTERNATIVE_MODEL") {
507
- result.codex = result.codex || {};
508
- (result.codex as Record<string, unknown>).alternativeModel = val;
509
- } else if (key === "CHATCCC_CODEX_EFFORT") {
510
- result.codex = result.codex || {};
511
- (result.codex as Record<string, unknown>).effort = val;
512
- } else if (key === "CHATCCC_CODEX_FAST_MODE") {
513
- result.codex = result.codex || {};
514
- (result.codex as Record<string, unknown>).fastMode = val === true || val === "true";
515
- } else if (key === "CHATCCC_CODEX_ENABLED") {
516
- result.codex = result.codex || {};
517
- (result.codex as Record<string, unknown>).enabled = val === true || val === "true";
518
- } else if (key === "CHATCCC_CODEX_DEFAULT_AGENT") {
519
- result.codex = result.codex || {};
520
- (result.codex as Record<string, unknown>).defaultAgent = val === true || val === "true";
521
- } else if (key === "CHATCCC_CCC_API_KEY") {
522
- result.ccc = result.ccc || {};
523
- (result.ccc as Record<string, unknown>).DEEPSEEK_API_KEY = val;
524
- } else if (key === "CHATCCC_CCC_BASE_URL") {
525
- result.ccc = result.ccc || {};
526
- (result.ccc as Record<string, unknown>).DEEPSEEK_BASE_URL = val;
527
- } else if (key === "CHATCCC_CCC_MODEL") {
528
- result.ccc = result.ccc || {};
529
- (result.ccc as Record<string, unknown>).model = val;
530
- } else if (key === "CHATCCC_CCC_ALTERNATIVE_MODEL") {
531
- result.ccc = result.ccc || {};
532
- (result.ccc as Record<string, unknown>).alternativeModel = val;
533
- } else if (key === "CHATCCC_CCC_EFFORT") {
534
- result.ccc = result.ccc || {};
535
- (result.ccc as Record<string, unknown>).effort = val;
536
- } else if (key === "CHATCCC_CCC_PROVIDER") {
537
- result.ccc = result.ccc || {};
538
- (result.ccc as Record<string, unknown>).provider = val;
539
- } else if (key === "CHATCCC_CCC_ENABLED") {
540
- result.ccc = result.ccc || {};
541
- (result.ccc as Record<string, unknown>).enabled = val === true || val === "true";
542
- } else if (key === "CHATCCC_CCC_DEFAULT_AGENT") {
543
- result.ccc = result.ccc || {};
544
- (result.ccc as Record<string, unknown>).defaultAgent = val === true || val === "true";
545
- }
546
- }
547
- return result;
548
- }
549
-
550
- async function handleGetStatus(_req: IncomingMessage, res: ServerResponse): Promise<void> {
551
- const running = isServiceRunning();
552
- const pid = getServicePid();
553
- const uptime = running ? getServiceUptime() : null;
554
- jsonReply(res, 200, { running, pid, uptime });
555
- }
556
-
557
- async function handleStartService(_req: IncomingMessage, res: ServerResponse): Promise<void> {
558
- const path = chooseStartPath({
559
- hasInplaceActivateHook: Boolean(setupActivateHook && setupHttpServer),
560
- isServiceRunning: isServiceRunning(),
561
- });
562
-
563
- if (path === "inplace") {
564
- // setup 模式:原地启动飞书 service,复用 setup HTTP server,不动 PID 文件。
565
- // 注意 hook setupHttpServer 在 chooseStartPath() 之后**绝不会被并发清空**
566
- // —— init 时一次性赋值,且当前函数是 onActivate 唯一调用点;非 null 断言安全。
567
- const hook = setupActivateHook!;
568
- const server = setupHttpServer!;
569
- const result = await hook(server);
570
- if (result.ok) {
571
- // 切换成功:清掉 hook 防止再次走 inplace 路径(service 已经在跑了)。
572
- // setupHttpServer 不清 —— 它已经接管为 service server,dashboard 仍要用。
573
- setupActivateHook = null;
574
- jsonReply(res, 200, { ok: true, pid: process.pid, mode: "inplace" });
575
- } else {
576
- jsonReply(res, 500, { ok: false, error: result.error });
577
- }
578
- return;
579
- }
580
-
581
- if (path === "reload") {
582
- // service 已经在跑:只刷新进程内配置,让后续消息/新会话使用新值。
583
- if (!reloadConfigHook) {
584
- jsonReply(res, 200, { ok: true, pid: process.pid, mode: "saved" });
585
- return;
586
- }
587
- try {
588
- await reloadConfigHook();
589
- jsonReply(res, 200, { ok: true, pid: process.pid, mode: "reload" });
590
- } catch (err) {
591
- jsonReply(res, 500, { ok: false, error: (err as Error).message });
592
- }
593
- return;
594
- }
595
-
596
- const result = spawnService();
597
- if (result.ok) {
598
- await new Promise((r) => setTimeout(r, 1000));
599
- jsonReply(res, 200, { ok: true, pid: result.pid, mode: "spawn" });
600
- } else {
601
- jsonReply(res, 500, result);
602
- }
603
- }
604
-
605
- async function handleStopService(req: IncomingMessage, res: ServerResponse): Promise<void> {
606
- const running = isServiceRunning();
607
- if (!running) {
608
- jsonReply(res, 200, { ok: false, error: "Service is not running" });
609
- return;
610
- }
611
- jsonReply(res, 200, { ok: true });
612
- setImmediate(() => {
613
- stopService();
614
- });
615
- }
616
-
617
- async function handleRestartService(_req: IncomingMessage, res: ServerResponse): Promise<void> {
618
- jsonReply(res, 200, { ok: true, message: "Restarting..." });
619
- scheduleRestart();
620
- process.exit(0);
621
- }
622
-
623
- async function handleValidate(req: IncomingMessage, res: ServerResponse): Promise<void> {
624
- const body = await readRequestBody(req);
625
- let tool: string;
626
- try { tool = JSON.parse(body).tool; } catch {
627
- jsonReply(res, 400, { ok: false, error: "Missing tool" });
628
- return;
629
- }
630
- const result = validateCli(tool);
631
- jsonReply(res, 200, result);
632
- }
633
-
634
- async function handleForgetIlink(_req: IncomingMessage, res: ServerResponse): Promise<void> {
635
- try {
636
- if (existsSync(ILINK_AUTH_PATH)) {
637
- unlinkSync(ILINK_AUTH_PATH);
638
- }
639
- jsonReply(res, 200, { ok: true });
640
- } catch (err) {
641
- jsonReply(res, 500, { ok: false, error: (err as Error).message });
642
- }
643
- }
644
-
645
- async function handleClaudeSdkStatus(_req: IncomingMessage, res: ServerResponse): Promise<void> {
646
- const progress = getLastInstallProgress();
647
- jsonReply(res, 200, {
648
- installed: isClaudeSdkInstalled(),
649
- version: getClaudeSdkInstalledVersion(),
650
- running: isInstallRunning(),
651
- // 展开到顶层:前端轮询 JS 读顶层 phase/message/percent/error(历史契约),
652
- // 若只放在 progress 嵌套里,前端 s.phase 恒为 undefined → 进度条永不渲染。
653
- ...progress,
654
- // 保留嵌套结构,兼容其它调用方。
655
- progress,
656
- });
657
- }
658
-
659
- async function handleClaudeSdkInstall(_req: IncomingMessage, res: ServerResponse): Promise<void> {
660
- if (isInstallRunning()) {
661
- jsonReply(res, 200, { ok: true, alreadyRunning: true });
662
- return;
663
- }
664
- try {
665
- // 后台执行,不阻塞响应;前端通过 /api/claude-sdk/status 轮询进度
666
- installClaudeSdk().catch((err: unknown) => {
667
- console.error(`[WEB-UI] Claude SDK 安装失败: ${(err as Error).message}`);
668
- });
669
- jsonReply(res, 200, { ok: true });
670
- } catch (err) {
671
- jsonReply(res, 500, { ok: false, error: (err as Error).message });
672
- }
673
- }
674
-
675
- // ---------------------------------------------------------------------------
676
- // HTML page (embedded template)
677
- // ---------------------------------------------------------------------------
678
-
679
- /** Agent Team 的首个占位页面:按产品约定只展示标题,不提前放入功能内容。 */
1
+ // =============================================================================
2
+ // web-ui.ts — Setup Wizard & Management Dashboard HTTP Server
3
+ // =============================================================================
4
+ // Serves on the same port as the WebSocket relay (18080 default).
5
+ // - Setup mode: no config.json → show setup wizard, skip Feishu connection
6
+ // - Dashboard mode: config.json exists → serve management page alongside WS relay
7
+ // =============================================================================
8
+ import { createServer } from "node:http";
9
+ import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { spawn, execSync } from "node:child_process";
13
+ import { CHATCCC_PACKAGE_ROOT } from "./package-root.js";
14
+ import { resolveChatCccRuntimeSpawnSpec } from "./runtime-entry.js";
15
+ import { buildWebUiUrl, createInternalRestartEnv, openWebUiInDefaultBrowser, } from "./startup-lifecycle.js";
16
+ import { getClaudeSdkInstalledVersion, getLastInstallProgress, installClaudeSdk, isClaudeSdkInstalled, isInstallRunning, } from "./claude-sdk-installer.js";
17
+ const PROJECT_ROOT = CHATCCC_PACKAGE_ROOT;
18
+ const USER_DATA_DIR = join(homedir(), ".chatccc");
19
+ const CONFIG_FILE = join(USER_DATA_DIR, "config.json");
20
+ const CONFIG_SAMPLE_FILE = join(PROJECT_ROOT, "config.sample.json");
21
+ const PID_FILE = join(USER_DATA_DIR, "state", "runtime.pid");
22
+ const ILINK_AUTH_PATH = join(USER_DATA_DIR, "state", "ilink-auth.json");
23
+ export function chooseStartPath(input) {
24
+ if (input.hasInplaceActivateHook)
25
+ return "inplace";
26
+ if (input.isServiceRunning)
27
+ return "reload";
28
+ return "spawn";
29
+ }
30
+ /** 读取 cursor / codex 的 CLI 路径,优先新字段 path,回退旧字段 command */
31
+ function readToolPath(tool) {
32
+ if (!tool)
33
+ return "";
34
+ if (tool.path && tool.path.trim())
35
+ return tool.path;
36
+ if (tool.command && tool.command.trim())
37
+ return tool.command;
38
+ return "";
39
+ }
40
+ function loadConfig() {
41
+ if (!existsSync(CONFIG_FILE))
42
+ return {};
43
+ try {
44
+ let raw = readFileSync(CONFIG_FILE, "utf8");
45
+ // 移除可能意外写入的 UTF-8 BOM,避免 JSON.parse 失败导致返回空对象、
46
+ // UI 显示所有配置为空。
47
+ if (raw.charCodeAt(0) === 0xfeff)
48
+ raw = raw.slice(1);
49
+ return JSON.parse(raw);
50
+ }
51
+ catch {
52
+ return {};
53
+ }
54
+ }
55
+ function saveConfig(cfg) {
56
+ writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2), "utf8");
57
+ }
58
+ function stringValue(value) {
59
+ return typeof value === "string" ? value.trim() : "";
60
+ }
61
+ function portValue(cfg) {
62
+ return Number.isInteger(cfg.port) && cfg.port >= 1 && cfg.port <= 65535 ? cfg.port : 18080;
63
+ }
64
+ function platformEnabled(cfg, platform) {
65
+ return cfg.platforms?.[platform]?.enabled !== false;
66
+ }
67
+ function feishuPlatformType(cfg) {
68
+ return cfg.platforms?.feishu?.platformType === "lark" ? "lark" : "feishu";
69
+ }
70
+ function ilinkReuseTokenOnStart(cfg) {
71
+ return cfg.platforms?.ilink?.reuseTokenOnStart !== false;
72
+ }
73
+ export function getRestartRequiredReasons(before, after) {
74
+ const reasons = [];
75
+ if (portValue(before) !== portValue(after))
76
+ reasons.push("port");
77
+ if (stringValue(before.feishu?.appId) !== stringValue(after.feishu?.appId))
78
+ reasons.push("feishu.appId");
79
+ if (stringValue(before.feishu?.appSecret) !== stringValue(after.feishu?.appSecret))
80
+ reasons.push("feishu.appSecret");
81
+ if (feishuPlatformType(before) !== feishuPlatformType(after))
82
+ reasons.push("platforms.feishu.platformType");
83
+ if (platformEnabled(before, "feishu") !== platformEnabled(after, "feishu"))
84
+ reasons.push("platforms.feishu.enabled");
85
+ if (platformEnabled(before, "ilink") !== platformEnabled(after, "ilink"))
86
+ reasons.push("platforms.ilink.enabled");
87
+ if (ilinkReuseTokenOnStart(before) !== ilinkReuseTokenOnStart(after)) {
88
+ reasons.push("platforms.ilink.reuseTokenOnStart");
89
+ }
90
+ return reasons;
91
+ }
92
+ async function applySavedConfigIfPossible(before, after) {
93
+ const setupMode = Boolean(setupActivateHook && setupHttpServer);
94
+ if (setupMode) {
95
+ return { mode: "saved", restartRequired: false, restartReasons: [] };
96
+ }
97
+ const running = isServiceRunning();
98
+ if (!running) {
99
+ return { mode: "saved", restartRequired: false, restartReasons: [] };
100
+ }
101
+ const restartReasons = getRestartRequiredReasons(before, after);
102
+ if (restartReasons.length > 0) {
103
+ return { mode: "restart-required", restartRequired: true, restartReasons };
104
+ }
105
+ if (!reloadConfigHook) {
106
+ return { mode: "saved", restartRequired: false, restartReasons: [] };
107
+ }
108
+ await reloadConfigHook();
109
+ return { mode: "reload", restartRequired: false, restartReasons: [] };
110
+ }
111
+ function maskSecret(value) {
112
+ if (!value)
113
+ return "(未设置)";
114
+ if (value.length <= 8)
115
+ return "***";
116
+ return value.slice(0, 4) + "***" + value.slice(-4);
117
+ }
118
+ // ---------------------------------------------------------------------------
119
+ // Helpers process management
120
+ // ---------------------------------------------------------------------------
121
+ function isServiceRunning() {
122
+ if (!existsSync(PID_FILE))
123
+ return false;
124
+ try {
125
+ const raw = readFileSync(PID_FILE, "utf8").trim();
126
+ const pid = parseInt(raw, 10);
127
+ if (isNaN(pid))
128
+ return false;
129
+ process.kill(pid, 0);
130
+ return true;
131
+ }
132
+ catch {
133
+ return false;
134
+ }
135
+ }
136
+ function getServicePid() {
137
+ if (!existsSync(PID_FILE))
138
+ return null;
139
+ try {
140
+ const pid = parseInt(readFileSync(PID_FILE, "utf8").trim(), 10);
141
+ return isNaN(pid) ? null : pid;
142
+ }
143
+ catch {
144
+ return null;
145
+ }
146
+ }
147
+ function getServiceUptime() {
148
+ try {
149
+ const s = statSync(PID_FILE);
150
+ return Math.floor((Date.now() - s.mtimeMs) / 1000);
151
+ }
152
+ catch {
153
+ return null;
154
+ }
155
+ }
156
+ import { statSync } from "node:fs";
157
+ function spawnService() {
158
+ const spec = resolveChatCccRuntimeSpawnSpec(PROJECT_ROOT);
159
+ if (!existsSync(spec.args[0]))
160
+ return { ok: false, error: `Entry not found: ${spec.args[0]}` };
161
+ try {
162
+ const child = spawn(spec.command, spec.args, {
163
+ cwd: PROJECT_ROOT,
164
+ detached: true,
165
+ stdio: "ignore",
166
+ shell: false,
167
+ env: createInternalRestartEnv(),
168
+ });
169
+ child.unref();
170
+ return { ok: true, pid: child.pid ?? undefined };
171
+ }
172
+ catch (err) {
173
+ return { ok: false, error: err.message };
174
+ }
175
+ }
176
+ /**
177
+ * 安排一次真正的进程重启:先回复 HTTP 请求,再 spawn 一个延迟启动的子进程,
178
+ * 然后退出当前进程。延迟是为了让当前进程完全退出并释放端口。
179
+ */
180
+ function scheduleRestart() {
181
+ const spec = resolveChatCccRuntimeSpawnSpec(PROJECT_ROOT);
182
+ if (!existsSync(spec.args[0]))
183
+ return;
184
+ const delayedLauncher = [
185
+ "const { spawn } = require('node:child_process');",
186
+ `const command = ${JSON.stringify(spec.command)};`,
187
+ `const args = ${JSON.stringify(spec.args)};`,
188
+ `const cwd = ${JSON.stringify(PROJECT_ROOT)};`,
189
+ "setTimeout(() => {",
190
+ " const child = spawn(command, args, { cwd, detached: true, stdio: 'ignore', shell: false, env: process.env });",
191
+ " child.unref();",
192
+ "}, 2000);",
193
+ ].join("\n");
194
+ if (process.platform === "win32") {
195
+ // Windows keeps the helper window hidden; the helper itself provides the delay.
196
+ spawn(process.execPath, ["-e", delayedLauncher], {
197
+ cwd: PROJECT_ROOT,
198
+ detached: true,
199
+ stdio: "ignore",
200
+ windowsHide: true,
201
+ env: createInternalRestartEnv(),
202
+ }).unref();
203
+ }
204
+ else {
205
+ spawn(process.execPath, ["-e", delayedLauncher], {
206
+ cwd: PROJECT_ROOT,
207
+ detached: true,
208
+ stdio: "ignore",
209
+ env: createInternalRestartEnv(),
210
+ }).unref();
211
+ }
212
+ }
213
+ function stopService() {
214
+ const pid = getServicePid();
215
+ if (!pid)
216
+ return { ok: false, error: "No PID file found" };
217
+ try {
218
+ if (process.platform === "win32") {
219
+ execSync(`taskkill /PID ${pid} /F /T`, { stdio: "pipe", windowsHide: true });
220
+ }
221
+ else {
222
+ process.kill(pid, "SIGTERM");
223
+ }
224
+ return { ok: true };
225
+ }
226
+ catch (err) {
227
+ return { ok: false, error: err.message };
228
+ }
229
+ }
230
+ function validateCli(tool) {
231
+ const cfg = loadConfig();
232
+ let cmd;
233
+ if (tool === "cursor") {
234
+ cmd = readToolPath(cfg.cursor) || detectCursorAgentPath();
235
+ }
236
+ else {
237
+ cmd = readToolPath(cfg.codex) || "codex";
238
+ }
239
+ try {
240
+ const out = execSync(`"${cmd}" --version`, { encoding: "utf8", timeout: 10000, windowsHide: true }).trim();
241
+ return { ok: true, path: cmd, error: out.slice(0, 200) };
242
+ }
243
+ catch (err) {
244
+ return { ok: false, path: cmd, error: err.message };
245
+ }
246
+ }
247
+ function detectCursorAgentPath() {
248
+ const localAppData = process.env.LOCALAPPDATA;
249
+ if (localAppData) {
250
+ const defaultPath = join(localAppData, "cursor-agent", "agent.cmd");
251
+ if (existsSync(defaultPath))
252
+ return defaultPath;
253
+ }
254
+ return "agent";
255
+ }
256
+ // ---------------------------------------------------------------------------
257
+ // Helpers — HTTP
258
+ // ---------------------------------------------------------------------------
259
+ function jsonReply(res, code, data) {
260
+ res.writeHead(code, { "Content-Type": "application/json; charset=utf-8" });
261
+ res.end(JSON.stringify(data));
262
+ }
263
+ function readRequestBody(req) {
264
+ return new Promise((resolve) => {
265
+ let body = "";
266
+ req.on("data", (chunk) => { body += chunk.toString(); });
267
+ req.on("end", () => resolve(body));
268
+ });
269
+ }
270
+ // ---------------------------------------------------------------------------
271
+ // API route handlers
272
+ // ---------------------------------------------------------------------------
273
+ async function handleApiCheck(_req, res) {
274
+ const hasConfig = existsSync(CONFIG_FILE);
275
+ let hasCreds = false;
276
+ if (hasConfig) {
277
+ const c = loadConfig();
278
+ const feishuEnabled = c.platforms?.feishu?.enabled !== false; // 默认 true(向后兼容)
279
+ const feishuOk = feishuEnabled && Boolean(c.feishu?.appId?.trim() && c.feishu?.appSecret?.trim());
280
+ hasCreds = feishuOk || !feishuEnabled;
281
+ }
282
+ jsonReply(res, 200, { hasConfig, hasCreds, configPath: CONFIG_FILE });
283
+ }
284
+ async function handleGetConfig(_req, res) {
285
+ const running = isServiceRunning();
286
+ const pid = getServicePid();
287
+ const vars = loadConfig();
288
+ let ilinkAuthExists = false;
289
+ if (existsSync(ILINK_AUTH_PATH)) {
290
+ try {
291
+ const auth = JSON.parse(readFileSync(ILINK_AUTH_PATH, "utf8"));
292
+ ilinkAuthExists = Boolean(auth.token);
293
+ }
294
+ catch { /* ignore parse errors */ }
295
+ }
296
+ jsonReply(res, 200, { vars, running, pid, ilinkAuthExists });
297
+ }
298
+ async function handlePostConfig(req, res) {
299
+ const body = await readRequestBody(req);
300
+ let updates;
301
+ try {
302
+ const parsed = JSON.parse(body);
303
+ updates = parsed.vars ?? {};
304
+ }
305
+ catch {
306
+ jsonReply(res, 400, { ok: false, error: "Invalid JSON" });
307
+ return;
308
+ }
309
+ const existing = loadConfig();
310
+ // AppConfig 是闭合接口(无 index signature),但运行时本质就是 JSON 对象,
311
+ // 这里通过 unknown 桥接到 Record 让 deepMerge 能复用;写回前断言回 AppConfig。
312
+ const merged = deepMerge(existing, unflattenConfig(updates));
313
+ try {
314
+ saveConfig(merged);
315
+ const applyResult = await applySavedConfigIfPossible(existing, merged);
316
+ jsonReply(res, 200, { ok: true, ...applyResult });
317
+ }
318
+ catch (err) {
319
+ jsonReply(res, 500, { ok: false, saved: true, error: err.message });
320
+ }
321
+ }
322
+ function deepMerge(target, source) {
323
+ const result = { ...target };
324
+ for (const [key, val] of Object.entries(source)) {
325
+ if (val !== null && typeof val === "object" && !Array.isArray(val) && typeof result[key] === "object" && result[key] !== null && !Array.isArray(result[key])) {
326
+ result[key] = deepMerge(result[key], val);
327
+ }
328
+ else {
329
+ result[key] = val;
330
+ }
331
+ }
332
+ return result;
333
+ }
334
+ // Convert flat key-value pairs to nested config structure
335
+ export function unflattenConfig(flat) {
336
+ const result = {};
337
+ for (const [key, val] of Object.entries(flat)) {
338
+ if (key === "CHATCCC_APP_ID") {
339
+ result.feishu = result.feishu || {};
340
+ result.feishu.appId = val;
341
+ }
342
+ else if (key === "CHATCCC_APP_SECRET") {
343
+ result.feishu = result.feishu || {};
344
+ result.feishu.appSecret = val;
345
+ }
346
+ else if (key === "CHATCCC_FEISHU_ENABLED") {
347
+ result.platforms = result.platforms || {};
348
+ result.platforms.feishu = result.platforms.feishu || {};
349
+ result.platforms.feishu.enabled = val === true || val === "true";
350
+ }
351
+ else if (key === "CHATCCC_FEISHU_PLATFORM_TYPE") {
352
+ result.platforms = result.platforms || {};
353
+ result.platforms.feishu = result.platforms.feishu || {};
354
+ result.platforms.feishu.platformType = val;
355
+ }
356
+ else if (key === "CHATCCC_ILINK_ENABLED") {
357
+ result.platforms = result.platforms || {};
358
+ result.platforms.ilink = result.platforms.ilink || {};
359
+ result.platforms.ilink.enabled = val === true || val === "true";
360
+ }
361
+ else if (key === "CHATCCC_WEB_UI_OPEN_ON_START") {
362
+ result.webUi = result.webUi || {};
363
+ result.webUi.openOnStart = val === true || val === "true";
364
+ }
365
+ else if (key === "CHATCCC_CHROME_DEVTOOLS_ENABLED") {
366
+ result.chromeDevtools = result.chromeDevtools || {};
367
+ result.chromeDevtools.enabled = val === true || val === "true";
368
+ }
369
+ else if (key === "CHATCCC_CHROME_DEVTOOLS_PORT") {
370
+ result.chromeDevtools = result.chromeDevtools || {};
371
+ result.chromeDevtools.port = parseInt(val, 10) || 15166;
372
+ }
373
+ else if (key === "CHATCCC_CHROME_DEVTOOLS_PATH") {
374
+ result.chromeDevtools = result.chromeDevtools || {};
375
+ result.chromeDevtools.chromePath = val;
376
+ }
377
+ else if (key === "CHATCCC_PORT") {
378
+ result.port = parseInt(val, 10) || 18080;
379
+ }
380
+ else if (key === "CHATCCC_GIT_TIMEOUT_SECONDS") {
381
+ result.gitTimeoutSeconds = parseInt(val, 10) || 180;
382
+ }
383
+ else if (key === "CHATCCC_ANTHROPIC_MODEL") {
384
+ result.claude = result.claude || {};
385
+ result.claude.model = val;
386
+ }
387
+ else if (key === "CHATCCC_ANTHROPIC_SUBAGENT_MODEL") {
388
+ result.claude = result.claude || {};
389
+ result.claude.subagentModel = val;
390
+ }
391
+ else if (key === "CHATCCC_ANTHROPIC_EFFORT") {
392
+ result.claude = result.claude || {};
393
+ result.claude.effort = val;
394
+ }
395
+ else if (key === "CHATCCC_ANTHROPIC_API_KEY") {
396
+ result.claude = result.claude || {};
397
+ result.claude.apiKey = val;
398
+ }
399
+ else if (key === "CHATCCC_ANTHROPIC_BASE_URL") {
400
+ result.claude = result.claude || {};
401
+ result.claude.baseUrl = val;
402
+ }
403
+ else if (key === "CHATCCC_ANTHROPIC_MAX_TURN") {
404
+ result.claude = result.claude || {};
405
+ result.claude.maxTurn = (function (v) { var n = parseInt(v, 10); return isNaN(n) ? 0 : n; })(val);
406
+ }
407
+ else if (key === "CHATCCC_CLAUDE_ENABLED") {
408
+ result.claude = result.claude || {};
409
+ result.claude.enabled = val === true || val === "true";
410
+ }
411
+ else if (key === "CHATCCC_CLAUDE_DEFAULT_AGENT") {
412
+ result.claude = result.claude || {};
413
+ result.claude.defaultAgent = val === true || val === "true";
414
+ }
415
+ else if (key === "CHATCCC_CURSOR_PATH") {
416
+ result.cursor = result.cursor || {};
417
+ result.cursor.path = val;
418
+ }
419
+ else if (key === "CHATCCC_CURSOR_MODEL") {
420
+ result.cursor = result.cursor || {};
421
+ result.cursor.model = val;
422
+ }
423
+ else if (key === "CHATCCC_CURSOR_ALTERNATIVE_MODEL") {
424
+ result.cursor = result.cursor || {};
425
+ result.cursor.alternativeModel = val;
426
+ }
427
+ else if (key === "CHATCCC_CURSOR_AVATAR_BATTERY_MODE") {
428
+ result.cursor = result.cursor || {};
429
+ result.cursor.avatarBatteryMode = val;
430
+ }
431
+ else if (key === "CHATCCC_CURSOR_ON_DEMAND_MONTHLY_BUDGET") {
432
+ result.cursor = result.cursor || {};
433
+ result.cursor.onDemandMonthlyBudget = Number(val);
434
+ }
435
+ else if (key === "CHATCCC_CURSOR_ENABLED") {
436
+ result.cursor = result.cursor || {};
437
+ result.cursor.enabled = val === true || val === "true";
438
+ }
439
+ else if (key === "CHATCCC_CURSOR_DEFAULT_AGENT") {
440
+ result.cursor = result.cursor || {};
441
+ result.cursor.defaultAgent = val === true || val === "true";
442
+ }
443
+ else if (key === "CHATCCC_CODEX_PATH") {
444
+ result.codex = result.codex || {};
445
+ result.codex.path = val;
446
+ }
447
+ else if (key === "CHATCCC_CODEX_MODEL") {
448
+ result.codex = result.codex || {};
449
+ result.codex.model = val;
450
+ }
451
+ else if (key === "CHATCCC_CODEX_ALTERNATIVE_MODEL") {
452
+ result.codex = result.codex || {};
453
+ result.codex.alternativeModel = val;
454
+ }
455
+ else if (key === "CHATCCC_CODEX_EFFORT") {
456
+ result.codex = result.codex || {};
457
+ result.codex.effort = val;
458
+ }
459
+ else if (key === "CHATCCC_CODEX_FAST_MODE") {
460
+ result.codex = result.codex || {};
461
+ result.codex.fastMode = val === true || val === "true";
462
+ }
463
+ else if (key === "CHATCCC_CODEX_ENABLED") {
464
+ result.codex = result.codex || {};
465
+ result.codex.enabled = val === true || val === "true";
466
+ }
467
+ else if (key === "CHATCCC_CODEX_DEFAULT_AGENT") {
468
+ result.codex = result.codex || {};
469
+ result.codex.defaultAgent = val === true || val === "true";
470
+ }
471
+ else if (key === "CHATCCC_CCC_API_KEY") {
472
+ result.ccc = result.ccc || {};
473
+ result.ccc.DEEPSEEK_API_KEY = val;
474
+ }
475
+ else if (key === "CHATCCC_CCC_BASE_URL") {
476
+ result.ccc = result.ccc || {};
477
+ result.ccc.DEEPSEEK_BASE_URL = val;
478
+ }
479
+ else if (key === "CHATCCC_CCC_MODEL") {
480
+ result.ccc = result.ccc || {};
481
+ result.ccc.model = val;
482
+ }
483
+ else if (key === "CHATCCC_CCC_ALTERNATIVE_MODEL") {
484
+ result.ccc = result.ccc || {};
485
+ result.ccc.alternativeModel = val;
486
+ }
487
+ else if (key === "CHATCCC_CCC_EFFORT") {
488
+ result.ccc = result.ccc || {};
489
+ result.ccc.effort = val;
490
+ }
491
+ else if (key === "CHATCCC_CCC_PROVIDER") {
492
+ result.ccc = result.ccc || {};
493
+ result.ccc.provider = val;
494
+ }
495
+ else if (key === "CHATCCC_CCC_ENABLED") {
496
+ result.ccc = result.ccc || {};
497
+ result.ccc.enabled = val === true || val === "true";
498
+ }
499
+ else if (key === "CHATCCC_CCC_DEFAULT_AGENT") {
500
+ result.ccc = result.ccc || {};
501
+ result.ccc.defaultAgent = val === true || val === "true";
502
+ }
503
+ }
504
+ return result;
505
+ }
506
+ async function handleGetStatus(_req, res) {
507
+ const running = isServiceRunning();
508
+ const pid = getServicePid();
509
+ const uptime = running ? getServiceUptime() : null;
510
+ jsonReply(res, 200, { running, pid, uptime });
511
+ }
512
+ async function handleStartService(_req, res) {
513
+ const path = chooseStartPath({
514
+ hasInplaceActivateHook: Boolean(setupActivateHook && setupHttpServer),
515
+ isServiceRunning: isServiceRunning(),
516
+ });
517
+ if (path === "inplace") {
518
+ // setup 模式:原地启动飞书 service,复用 setup HTTP server,不动 PID 文件。
519
+ // 注意 hook setupHttpServer 在 chooseStartPath() 之后**绝不会被并发清空**
520
+ // —— init 时一次性赋值,且当前函数是 onActivate 唯一调用点;非 null 断言安全。
521
+ const hook = setupActivateHook;
522
+ const server = setupHttpServer;
523
+ const result = await hook(server);
524
+ if (result.ok) {
525
+ // 切换成功:清掉 hook 防止再次走 inplace 路径(service 已经在跑了)。
526
+ // setupHttpServer 不清 —— 它已经接管为 service server,dashboard 仍要用。
527
+ setupActivateHook = null;
528
+ jsonReply(res, 200, { ok: true, pid: process.pid, mode: "inplace" });
529
+ }
530
+ else {
531
+ jsonReply(res, 500, { ok: false, error: result.error });
532
+ }
533
+ return;
534
+ }
535
+ if (path === "reload") {
536
+ // service 已经在跑:只刷新进程内配置,让后续消息/新会话使用新值。
537
+ if (!reloadConfigHook) {
538
+ jsonReply(res, 200, { ok: true, pid: process.pid, mode: "saved" });
539
+ return;
540
+ }
541
+ try {
542
+ await reloadConfigHook();
543
+ jsonReply(res, 200, { ok: true, pid: process.pid, mode: "reload" });
544
+ }
545
+ catch (err) {
546
+ jsonReply(res, 500, { ok: false, error: err.message });
547
+ }
548
+ return;
549
+ }
550
+ const result = spawnService();
551
+ if (result.ok) {
552
+ await new Promise((r) => setTimeout(r, 1000));
553
+ jsonReply(res, 200, { ok: true, pid: result.pid, mode: "spawn" });
554
+ }
555
+ else {
556
+ jsonReply(res, 500, result);
557
+ }
558
+ }
559
+ async function handleStopService(req, res) {
560
+ const running = isServiceRunning();
561
+ if (!running) {
562
+ jsonReply(res, 200, { ok: false, error: "Service is not running" });
563
+ return;
564
+ }
565
+ jsonReply(res, 200, { ok: true });
566
+ setImmediate(() => {
567
+ stopService();
568
+ });
569
+ }
570
+ async function handleRestartService(_req, res) {
571
+ jsonReply(res, 200, { ok: true, message: "Restarting..." });
572
+ scheduleRestart();
573
+ process.exit(0);
574
+ }
575
+ async function handleValidate(req, res) {
576
+ const body = await readRequestBody(req);
577
+ let tool;
578
+ try {
579
+ tool = JSON.parse(body).tool;
580
+ }
581
+ catch {
582
+ jsonReply(res, 400, { ok: false, error: "Missing tool" });
583
+ return;
584
+ }
585
+ const result = validateCli(tool);
586
+ jsonReply(res, 200, result);
587
+ }
588
+ async function handleForgetIlink(_req, res) {
589
+ try {
590
+ if (existsSync(ILINK_AUTH_PATH)) {
591
+ unlinkSync(ILINK_AUTH_PATH);
592
+ }
593
+ jsonReply(res, 200, { ok: true });
594
+ }
595
+ catch (err) {
596
+ jsonReply(res, 500, { ok: false, error: err.message });
597
+ }
598
+ }
599
+ async function handleClaudeSdkStatus(_req, res) {
600
+ const progress = getLastInstallProgress();
601
+ jsonReply(res, 200, {
602
+ installed: isClaudeSdkInstalled(),
603
+ version: getClaudeSdkInstalledVersion(),
604
+ running: isInstallRunning(),
605
+ // 展开到顶层:前端轮询 JS 读顶层 phase/message/percent/error(历史契约),
606
+ // 若只放在 progress 嵌套里,前端 s.phase 恒为 undefined → 进度条永不渲染。
607
+ ...progress,
608
+ // 保留嵌套结构,兼容其它调用方。
609
+ progress,
610
+ });
611
+ }
612
+ async function handleClaudeSdkInstall(_req, res) {
613
+ if (isInstallRunning()) {
614
+ jsonReply(res, 200, { ok: true, alreadyRunning: true });
615
+ return;
616
+ }
617
+ try {
618
+ // 后台执行,不阻塞响应;前端通过 /api/claude-sdk/status 轮询进度
619
+ installClaudeSdk().catch((err) => {
620
+ console.error(`[WEB-UI] Claude SDK 安装失败: ${err.message}`);
621
+ });
622
+ jsonReply(res, 200, { ok: true });
623
+ }
624
+ catch (err) {
625
+ jsonReply(res, 500, { ok: false, error: err.message });
626
+ }
627
+ }
628
+ // ---------------------------------------------------------------------------
629
+ // HTML page (embedded template)
630
+ // ---------------------------------------------------------------------------
631
+ /** Agent Team 的首个占位页面:按产品约定只展示标题,不提前放入功能内容。 */
680
632
  export const AGENT_TEAM_PAGE_HTML = `<!DOCTYPE html>
681
633
  <html lang="zh-CN">
682
634
  <head>
@@ -692,8 +644,7 @@ h1{font-size:clamp(40px,8vw,88px);font-weight:750;letter-spacing:-.04em;text-sha
692
644
  <body>
693
645
  <h1>Agent Team</h1>
694
646
  </body>
695
- </html>`;
696
-
647
+ </html>`;
697
648
  export const PAGE_HTML = `<!DOCTYPE html>
698
649
  <html lang="zh-CN">
699
650
  <head>
@@ -2386,145 +2337,120 @@ init();
2386
2337
  setTimeout(claudeEngineRefreshStatus, 300);
2387
2338
  </script>
2388
2339
  </body>
2389
- </html>`;
2390
-
2391
- // ---------------------------------------------------------------------------
2392
- // Router
2393
- // ---------------------------------------------------------------------------
2394
-
2395
- async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
2396
- const url = req.url ?? "/";
2397
- const method = req.method ?? "GET";
2398
- const pathname = url.split("?", 1)[0];
2399
-
2400
- if (extraApiHandler && await extraApiHandler(req, res)) return;
2401
-
2402
- // API routes
2403
- if (url === "/api/check" && method === "GET") return handleApiCheck(req, res);
2404
- if (url === "/api/config" && method === "GET") return handleGetConfig(req, res);
2405
- if (url === "/api/config" && method === "POST") return handlePostConfig(req, res);
2406
- if (url === "/api/status" && method === "GET") return handleGetStatus(req, res);
2407
- if (url === "/api/start" && method === "POST") return handleStartService(req, res);
2408
- if (url === "/api/stop" && method === "POST") return handleStopService(req, res);
2409
- if (url === "/api/restart" && method === "POST") return handleRestartService(req, res);
2410
- if (url === "/api/validate" && method === "POST") return handleValidate(req, res);
2411
- if (url === "/api/ilink/forget" && method === "POST") return handleForgetIlink(req, res);
2412
- if (url === "/api/claude-sdk/status" && method === "GET") return handleClaudeSdkStatus(req, res);
2413
- if (url === "/api/claude-sdk/install" && method === "POST") return handleClaudeSdkInstall(req, res);
2414
-
2415
- if (method === "GET" && (pathname === "/agent-team" || pathname === "/agent-team/")) {
2416
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
2417
- res.end(AGENT_TEAM_PAGE_HTML);
2418
- return;
2419
- }
2420
-
2421
- // Serve HTML page for all other GET requests
2422
- if (method === "GET") {
2423
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
2424
- res.end(PAGE_HTML);
2425
- return;
2426
- }
2427
-
2428
- jsonReply(res, 404, { error: "Not found" });
2429
- }
2430
-
2431
- export function createUiRouter(): (req: IncomingMessage, res: ServerResponse) => void {
2432
- return (req, res) => {
2433
- handleRequest(req, res).catch((err) => {
2434
- console.error(`[WEB-UI] Unhandled error: ${(err as Error).message}`);
2435
- if (!res.headersSent) jsonReply(res, 500, { error: "Internal error" });
2436
- });
2437
- };
2438
- }
2439
-
2440
- // ---------------------------------------------------------------------------
2441
- // Setup mode entry point — called from index.ts when no credentials
2442
- // ---------------------------------------------------------------------------
2443
-
2444
- /**
2445
- * setup service「在线切换」回调签名:
2446
- * - 入参 httpServer:setup 模式当前监听的 HTTP server,会被复用为 service
2447
- * relay server(避免 close + recreate 的端口竞态)。
2448
- * - 返回 { ok: true } 表示原地启动成功,前端会刷新进 dashboard。
2449
- * - 返回 { ok: false, error } 表示启动失败,前端会 toast 错误。
2450
- * setup HTTP server **必须仍然可用**,让用户改完 config 再试一次。
2451
- */
2452
- export type SetupActivateHook = (
2453
- httpServer: ReturnType<typeof createServer>,
2454
- ) => Promise<{ ok: true } | { ok: false; error: string }>;
2455
-
2456
- export interface StartSetupModeOptions {
2457
- onActivate?: SetupActivateHook;
2458
- /** 内部重启进入 setup 模式时设为 false,避免重复弹出系统浏览器。 */
2459
- openBrowser?: boolean;
2460
- }
2461
-
2462
- // setup HTTP server + onActivate 回调通过模块级变量暴露给 handleStartService。
2463
- // 一旦 onActivate 成功执行,setupActivateHook 会被清空——避免 dashboard 模式下
2464
- // 用户再点"启动"时还走 inplace 路径(service 已经在跑了)。
2465
- let setupHttpServer: ReturnType<typeof createServer> | null = null;
2466
- let setupActivateHook: SetupActivateHook | null = null;
2467
-
2468
- // reload-config 回调:dashboard 模式下用户点"保存并启动"时,service 已经在跑,
2469
- // 仅需要把磁盘上刚保存的 config.json 刷进进程内的 export let 常量(live binding)。
2470
- // index.ts 注入,因为 web-ui.ts 自身**不应**直接 import config.ts——后者顶层
2471
- // loadConfig 副作用,被 web-ui.ts 间接 import 会污染所有依赖 web-ui.ts 的单测。
2472
- let reloadConfigHook: (() => void) | null = null;
2473
- type ExtraApiHandler = (req: IncomingMessage, res: ServerResponse) => Promise<boolean> | boolean;
2474
- let extraApiHandler: ExtraApiHandler | null = null;
2475
-
2476
- /**
2477
- * 注册"reload config"回调。约定:
2478
- * - 由 index.ts 在 main() 中一次性调用,传入 () => reloadConfigFromDisk()
2479
- * - handleStartService 在 path="reload" 分支会 await 调用一次;hook 抛错会被
2480
- * 捕获并以 500 回前端,避免 service 死锁。
2481
- */
2482
- export function setReloadConfigHook(hook: () => void | Promise<void>): void {
2483
- reloadConfigHook = hook;
2484
- }
2485
-
2486
- export function setExtraApiHandler(handler: ExtraApiHandler): void {
2487
- extraApiHandler = handler;
2488
- }
2489
-
2490
- export function startSetupMode(
2491
- port: number,
2492
- options: StartSetupModeOptions = {},
2493
- ): ReturnType<typeof createServer> {
2494
- const router = createUiRouter();
2495
- const server = createServer(router);
2496
- setupHttpServer = server;
2497
- setupActivateHook = options.onActivate ?? null;
2498
-
2499
- server.on("error", (err: NodeJS.ErrnoException) => {
2500
- if (err.code === "EADDRINUSE") {
2501
- console.error(`\n[WEB-UI] 端口 ${port} 已被占用。请检查是否有其他 ChatCCC 实例在运行。`);
2502
- console.error(" 可以先停止旧进程,或修改 config.json 中的 port 为其他端口。");
2503
- } else {
2504
- console.error(`\n[WEB-UI] HTTP 服务器错误: ${err.message}`);
2505
- }
2506
- process.exit(1);
2507
- });
2508
-
2509
- server.listen(port, "127.0.0.1", () => {
2510
- const url = buildWebUiUrl(port);
2511
- console.log("");
2512
- console.log("=".repeat(60));
2513
- console.log(" ChatCCC — 首次配置向导");
2514
- console.log("=".repeat(60));
2515
- console.log(" 未检测到已配置的飞书凭证,已启动配置界面。");
2516
- if (options.openBrowser !== false) {
2517
- console.log(` 已启用启动时自动打开浏览器: ${url}`);
2518
- } else {
2519
- console.log(` 内部重启不会自动打开浏览器,请按需访问: ${url}`);
2520
- }
2521
- console.log(" 若浏览器未自动弹出,请手动访问上面的地址。");
2522
- console.log("");
2523
- console.log(" 在向导里填好 App ID / App Secret 后点「保存并启动」,");
2524
- console.log(" 服务会在当前进程内直接激活,不需要重新运行 chatccc。");
2525
- console.log("=".repeat(60));
2526
- console.log("");
2527
- if (options.openBrowser !== false) openWebUiInDefaultBrowser(port);
2528
- });
2529
- return server;
2530
- }
2340
+ </html>`;
2341
+ // ---------------------------------------------------------------------------
2342
+ // Router
2343
+ // ---------------------------------------------------------------------------
2344
+ async function handleRequest(req, res) {
2345
+ const url = req.url ?? "/";
2346
+ const method = req.method ?? "GET";
2347
+ const pathname = url.split("?", 1)[0];
2348
+ if (extraApiHandler && await extraApiHandler(req, res))
2349
+ return;
2350
+ // API routes
2351
+ if (url === "/api/check" && method === "GET")
2352
+ return handleApiCheck(req, res);
2353
+ if (url === "/api/config" && method === "GET")
2354
+ return handleGetConfig(req, res);
2355
+ if (url === "/api/config" && method === "POST")
2356
+ return handlePostConfig(req, res);
2357
+ if (url === "/api/status" && method === "GET")
2358
+ return handleGetStatus(req, res);
2359
+ if (url === "/api/start" && method === "POST")
2360
+ return handleStartService(req, res);
2361
+ if (url === "/api/stop" && method === "POST")
2362
+ return handleStopService(req, res);
2363
+ if (url === "/api/restart" && method === "POST")
2364
+ return handleRestartService(req, res);
2365
+ if (url === "/api/validate" && method === "POST")
2366
+ return handleValidate(req, res);
2367
+ if (url === "/api/ilink/forget" && method === "POST")
2368
+ return handleForgetIlink(req, res);
2369
+ if (url === "/api/claude-sdk/status" && method === "GET")
2370
+ return handleClaudeSdkStatus(req, res);
2371
+ if (url === "/api/claude-sdk/install" && method === "POST")
2372
+ return handleClaudeSdkInstall(req, res);
2373
+ if (method === "GET" && (pathname === "/agent-team" || pathname === "/agent-team/")) {
2374
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
2375
+ res.end(AGENT_TEAM_PAGE_HTML);
2376
+ return;
2377
+ }
2378
+ // Serve HTML page for all other GET requests
2379
+ if (method === "GET") {
2380
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
2381
+ res.end(PAGE_HTML);
2382
+ return;
2383
+ }
2384
+ jsonReply(res, 404, { error: "Not found" });
2385
+ }
2386
+ export function createUiRouter() {
2387
+ return (req, res) => {
2388
+ handleRequest(req, res).catch((err) => {
2389
+ console.error(`[WEB-UI] Unhandled error: ${err.message}`);
2390
+ if (!res.headersSent)
2391
+ jsonReply(res, 500, { error: "Internal error" });
2392
+ });
2393
+ };
2394
+ }
2395
+ // setup HTTP server + onActivate 回调通过模块级变量暴露给 handleStartService。
2396
+ // 一旦 onActivate 成功执行,setupActivateHook 会被清空——避免 dashboard 模式下
2397
+ // 用户再点"启动"时还走 inplace 路径(service 已经在跑了)。
2398
+ let setupHttpServer = null;
2399
+ let setupActivateHook = null;
2400
+ // reload-config 回调:dashboard 模式下用户点"保存并启动"时,service 已经在跑,
2401
+ // 仅需要把磁盘上刚保存的 config.json 刷进进程内的 export let 常量(live binding)。
2402
+ // 由 index.ts 注入,因为 web-ui.ts 自身**不应**直接 import config.ts——后者顶层
2403
+ // loadConfig 副作用,被 web-ui.ts 间接 import 会污染所有依赖 web-ui.ts 的单测。
2404
+ let reloadConfigHook = null;
2405
+ let extraApiHandler = null;
2406
+ /**
2407
+ * 注册"reload config"回调。约定:
2408
+ * - 由 index.ts 在 main() 中一次性调用,传入 () => reloadConfigFromDisk()。
2409
+ * - handleStartService path="reload" 分支会 await 调用一次;hook 抛错会被
2410
+ * 捕获并以 500 回前端,避免 service 死锁。
2411
+ */
2412
+ export function setReloadConfigHook(hook) {
2413
+ reloadConfigHook = hook;
2414
+ }
2415
+ export function setExtraApiHandler(handler) {
2416
+ extraApiHandler = handler;
2417
+ }
2418
+ export function startSetupMode(port, options = {}) {
2419
+ const router = createUiRouter();
2420
+ const server = createServer(router);
2421
+ setupHttpServer = server;
2422
+ setupActivateHook = options.onActivate ?? null;
2423
+ server.on("error", (err) => {
2424
+ if (err.code === "EADDRINUSE") {
2425
+ console.error(`\n[WEB-UI] 端口 ${port} 已被占用。请检查是否有其他 ChatCCC 实例在运行。`);
2426
+ console.error(" 可以先停止旧进程,或修改 config.json 中的 port 为其他端口。");
2427
+ }
2428
+ else {
2429
+ console.error(`\n[WEB-UI] HTTP 服务器错误: ${err.message}`);
2430
+ }
2431
+ process.exit(1);
2432
+ });
2433
+ server.listen(port, "127.0.0.1", () => {
2434
+ const url = buildWebUiUrl(port);
2435
+ console.log("");
2436
+ console.log("=".repeat(60));
2437
+ console.log(" ChatCCC 首次配置向导");
2438
+ console.log("=".repeat(60));
2439
+ console.log(" 未检测到已配置的飞书凭证,已启动配置界面。");
2440
+ if (options.openBrowser !== false) {
2441
+ console.log(` 已启用启动时自动打开浏览器: ${url}`);
2442
+ }
2443
+ else {
2444
+ console.log(` 内部重启不会自动打开浏览器,请按需访问: ${url}`);
2445
+ }
2446
+ console.log(" 若浏览器未自动弹出,请手动访问上面的地址。");
2447
+ console.log("");
2448
+ console.log(" 在向导里填好 App ID / App Secret 后点「保存并启动」,");
2449
+ console.log(" 服务会在当前进程内直接激活,不需要重新运行 chatccc。");
2450
+ console.log("=".repeat(60));
2451
+ console.log("");
2452
+ if (options.openBrowser !== false)
2453
+ openWebUiInDefaultBrowser(port);
2454
+ });
2455
+ return server;
2456
+ }