chatccc 0.2.252 → 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 (280) 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/os-prompts/darwin.md +8 -8
  5. package/deepccc-agent/os-prompts/linux.md +8 -8
  6. package/deepccc-agent/os-prompts/win32.md +11 -11
  7. package/deepccc-agent/package.json +65 -65
  8. package/dist/deepccc-agent/src/cli.js +634 -0
  9. package/dist/deepccc-agent/src/config.js +76 -0
  10. package/dist/deepccc-agent/src/context.js +348 -0
  11. package/dist/deepccc-agent/src/file-log.js +34 -0
  12. package/dist/deepccc-agent/src/file-tools.js +1208 -0
  13. package/dist/deepccc-agent/src/index.js +571 -0
  14. package/dist/deepccc-agent/src/permissions.js +170 -0
  15. package/dist/deepccc-agent/src/privacy.js +124 -0
  16. package/dist/deepccc-agent/src/proc-tree-kill.js +60 -0
  17. package/dist/deepccc-agent/src/progress/cards-helpers.js +70 -0
  18. package/dist/deepccc-agent/src/progress/reducer.js +102 -0
  19. package/dist/deepccc-agent/src/progress/terminal-renderer.js +264 -0
  20. package/dist/deepccc-agent/src/progress/view.js +30 -0
  21. package/dist/deepccc-agent/src/raw-stream-log.js +106 -0
  22. package/dist/deepccc-agent/src/session-search.js +276 -0
  23. package/dist/deepccc-agent/src/session-select.js +23 -0
  24. package/dist/deepccc-agent/src/sigint.js +26 -0
  25. package/dist/deepccc-agent/src/skills.js +178 -0
  26. package/dist/deepccc-agent/src/web-tools.js +246 -0
  27. package/dist/src/adapters/adapter-interface.js +19 -0
  28. package/dist/src/adapters/ccc-adapter.js +112 -0
  29. package/dist/src/adapters/claude-adapter.js +497 -0
  30. package/dist/src/adapters/claude-session-meta-store.js +92 -0
  31. package/dist/src/adapters/codex-adapter.js +279 -0
  32. package/dist/src/adapters/codex-session-meta-store.js +94 -0
  33. package/dist/src/adapters/cursor-adapter.js +491 -0
  34. package/dist/src/adapters/cursor-session-meta-store.js +116 -0
  35. package/dist/src/adapters/jsonl-stream.js +104 -0
  36. package/{src/adapters/proc-tree-kill.ts → dist/src/adapters/proc-tree-kill.js} +94 -97
  37. package/dist/src/adapters/raw-stream-log.js +106 -0
  38. package/dist/src/adapters/resource-monitor.js +113 -0
  39. package/dist/src/agent-activity.js +133 -0
  40. package/dist/src/agent-delegate-task-rpc.js +129 -0
  41. package/dist/src/agent-delegate-task.js +48 -0
  42. package/dist/src/agent-file-rpc.js +152 -0
  43. package/dist/src/agent-image-rpc.js +148 -0
  44. package/dist/src/agent-platform-routing.js +13 -0
  45. package/dist/src/agent-reload-config-rpc.js +23 -0
  46. package/dist/src/agent-rpc-body.js +87 -0
  47. package/dist/src/agent-stop-stuck.js +110 -0
  48. package/dist/src/card-action-routing.js +7 -0
  49. package/dist/src/card-plain-text.js +101 -0
  50. package/dist/src/cardkit.js +158 -0
  51. package/dist/src/cards.js +573 -0
  52. package/dist/src/chatgpt-subscription-rpc.js +18 -0
  53. package/dist/src/chatgpt-subscription.js +199 -0
  54. package/dist/src/chrome-devtools-guard.js +238 -0
  55. package/dist/src/claude-sdk-installer.js +249 -0
  56. package/dist/src/codex-reset-actions.js +143 -0
  57. package/dist/src/config-utils.js +149 -0
  58. package/dist/src/config.js +804 -0
  59. package/dist/src/cursor-usage.js +77 -0
  60. package/dist/src/exit-banner.js +28 -0
  61. package/dist/src/feishu-api.js +1404 -0
  62. package/dist/src/feishu-message-ingress.js +137 -0
  63. package/dist/src/feishu-platform.js +97 -0
  64. package/dist/src/format-message.js +252 -0
  65. package/dist/src/git-command.js +155 -0
  66. package/dist/src/im-skills.js +121 -0
  67. package/dist/src/index.js +833 -0
  68. package/dist/src/litellm-proxy.js +300 -0
  69. package/dist/src/orchestrator.js +2078 -0
  70. package/dist/src/package-root.js +26 -0
  71. package/dist/src/platform-adapter.js +7 -0
  72. package/dist/src/platform-startup.js +6 -0
  73. package/dist/src/privacy.js +100 -0
  74. package/dist/src/progress/reducer.js +102 -0
  75. package/dist/src/progress/terminal-renderer.js +264 -0
  76. package/dist/src/progress/view.js +30 -0
  77. package/dist/src/response-stall.js +14 -0
  78. package/dist/src/runtime-entry.js +13 -0
  79. package/dist/src/runtime-reload.js +19 -0
  80. package/dist/src/session-chat-binding.js +183 -0
  81. package/dist/src/session-name.js +7 -0
  82. package/dist/src/session.js +2144 -0
  83. package/dist/src/shared-prefix.js +16 -0
  84. package/dist/src/shared.js +493 -0
  85. package/dist/src/sim-agent.js +105 -0
  86. package/dist/src/sim-platform.js +142 -0
  87. package/dist/src/sim-store.js +231 -0
  88. package/dist/src/simplify.js +99 -0
  89. package/dist/src/startup-lifecycle.js +209 -0
  90. package/dist/src/stream-state.js +141 -0
  91. package/dist/src/terminal-error.js +100 -0
  92. package/dist/src/trace.js +50 -0
  93. package/dist/src/turn-cards.js +92 -0
  94. package/dist/src/update-command-guard.js +114 -0
  95. package/{src/web-ui.ts → dist/src/web-ui.js} +749 -823
  96. package/dist/src/wechat-platform.js +545 -0
  97. package/package.json +76 -74
  98. package/deepccc-agent/LICENSE +0 -201
  99. package/deepccc-agent/bin/deepccc.mjs +0 -26
  100. package/deepccc-agent/docs/cache-hit-rate-1.jpg +0 -0
  101. package/deepccc-agent/docs/cache-hit-rate-2.jpg +0 -0
  102. package/deepccc-agent/package-lock.json +0 -2027
  103. package/deepccc-agent/src/__tests__/chat-session.test.ts +0 -877
  104. package/deepccc-agent/src/__tests__/cli-json.test.ts +0 -49
  105. package/deepccc-agent/src/__tests__/config.test.ts +0 -34
  106. package/deepccc-agent/src/__tests__/context.test.ts +0 -341
  107. package/deepccc-agent/src/__tests__/file-tools.test.ts +0 -240
  108. package/deepccc-agent/src/__tests__/permissions.test.ts +0 -199
  109. package/deepccc-agent/src/__tests__/privacy.test.ts +0 -318
  110. package/deepccc-agent/src/__tests__/progress-reducer.test.ts +0 -121
  111. package/deepccc-agent/src/__tests__/session-search.test.ts +0 -262
  112. package/deepccc-agent/src/__tests__/session-select.test.ts +0 -116
  113. package/deepccc-agent/src/__tests__/sigint.test.ts +0 -56
  114. package/deepccc-agent/src/__tests__/skills.test.ts +0 -284
  115. package/deepccc-agent/src/__tests__/terminal-renderer.test.ts +0 -247
  116. package/deepccc-agent/src/__tests__/web-tools.test.ts +0 -220
  117. package/deepccc-agent/src/cli.ts +0 -682
  118. package/deepccc-agent/src/config.ts +0 -101
  119. package/deepccc-agent/src/context.ts +0 -465
  120. package/deepccc-agent/src/file-log.ts +0 -38
  121. package/deepccc-agent/src/file-tools.ts +0 -1493
  122. package/deepccc-agent/src/index.ts +0 -710
  123. package/deepccc-agent/src/permissions.ts +0 -226
  124. package/deepccc-agent/src/privacy.ts +0 -141
  125. package/deepccc-agent/src/proc-tree-kill.ts +0 -61
  126. package/deepccc-agent/src/progress/cards-helpers.ts +0 -76
  127. package/deepccc-agent/src/progress/reducer.ts +0 -113
  128. package/deepccc-agent/src/progress/terminal-renderer.ts +0 -294
  129. package/deepccc-agent/src/progress/view.ts +0 -77
  130. package/deepccc-agent/src/raw-stream-log.ts +0 -124
  131. package/deepccc-agent/src/session-search.ts +0 -370
  132. package/deepccc-agent/src/session-select.ts +0 -48
  133. package/deepccc-agent/src/sigint.ts +0 -50
  134. package/deepccc-agent/src/skills.ts +0 -205
  135. package/deepccc-agent/src/web-tools.ts +0 -313
  136. package/deepccc-agent/tsconfig.build.json +0 -13
  137. package/deepccc-agent/tsconfig.json +0 -13
  138. package/deepccc-agent/vitest.config.ts +0 -7
  139. package/src/__tests__/adapter-interface.test.ts +0 -152
  140. package/src/__tests__/agent-activity.test.ts +0 -86
  141. package/src/__tests__/agent-delegate-task-rpc.test.ts +0 -165
  142. package/src/__tests__/agent-image-rpc.test.ts +0 -34
  143. package/src/__tests__/agent-platform-routing.test.ts +0 -26
  144. package/src/__tests__/agent-reload-config-rpc.test.ts +0 -99
  145. package/src/__tests__/agent-rpc-body.test.ts +0 -42
  146. package/src/__tests__/builtin-chat-session.test.ts +0 -532
  147. package/src/__tests__/builtin-cli-json.test.ts +0 -39
  148. package/src/__tests__/builtin-config.test.ts +0 -26
  149. package/src/__tests__/builtin-context.test.ts +0 -319
  150. package/src/__tests__/builtin-file-tools.test.ts +0 -240
  151. package/src/__tests__/builtin-permissions.test.ts +0 -219
  152. package/src/__tests__/builtin-session-search.test.ts +0 -262
  153. package/src/__tests__/builtin-session-select.test.ts +0 -116
  154. package/src/__tests__/builtin-sigint.test.ts +0 -56
  155. package/src/__tests__/builtin-skills.test.ts +0 -284
  156. package/src/__tests__/builtin-web-tools.test.ts +0 -220
  157. package/src/__tests__/card-action-routing.test.ts +0 -18
  158. package/src/__tests__/card-plain-text.test.ts +0 -45
  159. package/src/__tests__/cardkit.test.ts +0 -60
  160. package/src/__tests__/cards.test.ts +0 -607
  161. package/src/__tests__/ccc-adapter.test.ts +0 -194
  162. package/src/__tests__/chatgpt-subscription-rpc.test.ts +0 -89
  163. package/src/__tests__/chatgpt-subscription.test.ts +0 -135
  164. package/src/__tests__/chrome-devtools-guard.test.ts +0 -165
  165. package/src/__tests__/claude-adapter.test.ts +0 -614
  166. package/src/__tests__/claude-raw-stream-log.test.ts +0 -96
  167. package/src/__tests__/claude-sdk-installer.test.ts +0 -285
  168. package/src/__tests__/codex-adapter.test.ts +0 -331
  169. package/src/__tests__/codex-raw-stream-log.test.ts +0 -170
  170. package/src/__tests__/codex-reset-actions.test.ts +0 -146
  171. package/src/__tests__/config-reload.test.ts +0 -284
  172. package/src/__tests__/config-sample.test.ts +0 -97
  173. package/src/__tests__/config-utils.test.ts +0 -40
  174. package/src/__tests__/config.test.ts +0 -395
  175. package/src/__tests__/crash-logging.test.ts +0 -360
  176. package/src/__tests__/cursor-adapter.test.ts +0 -890
  177. package/src/__tests__/cursor-session-meta-store.test.ts +0 -212
  178. package/src/__tests__/feishu-api.test.ts +0 -60
  179. package/src/__tests__/feishu-avatar.test.ts +0 -504
  180. package/src/__tests__/feishu-message-ingress.test.ts +0 -138
  181. package/src/__tests__/feishu-platform.test.ts +0 -75
  182. package/src/__tests__/fixtures/codex_simple_text.jsonl +0 -4
  183. package/src/__tests__/fixtures/codex_with_tool.jsonl +0 -6
  184. package/src/__tests__/fixtures/cursor_partial_only.jsonl +0 -5
  185. package/src/__tests__/fixtures/cursor_partial_with_final.jsonl +0 -13
  186. package/src/__tests__/fixtures/cursor_with_tool_call.jsonl +0 -12
  187. package/src/__tests__/format-message.test.ts +0 -316
  188. package/src/__tests__/git-command.test.ts +0 -288
  189. package/src/__tests__/im-skills.test.ts +0 -125
  190. package/src/__tests__/jsonl-stream.test.ts +0 -79
  191. package/src/__tests__/orchestrator.test.ts +0 -1268
  192. package/src/__tests__/package-files.test.ts +0 -24
  193. package/src/__tests__/platform-startup.test.ts +0 -19
  194. package/src/__tests__/privacy.test.ts +0 -198
  195. package/src/__tests__/proc-tree-kill.test.ts +0 -108
  196. package/src/__tests__/progress-reducer.test.ts +0 -121
  197. package/src/__tests__/raw-stream-log.test.ts +0 -106
  198. package/src/__tests__/response-stall.test.ts +0 -49
  199. package/src/__tests__/restart.test.ts +0 -232
  200. package/src/__tests__/session-ccc-config.test.ts +0 -66
  201. package/src/__tests__/session.test.ts +0 -3004
  202. package/src/__tests__/shared-prefix.test.ts +0 -36
  203. package/src/__tests__/sim-agent.test.ts +0 -174
  204. package/src/__tests__/sim-platform.test.ts +0 -93
  205. package/src/__tests__/sim-store.test.ts +0 -214
  206. package/src/__tests__/simplify.test.ts +0 -283
  207. package/src/__tests__/startup-lifecycle.test.ts +0 -231
  208. package/src/__tests__/stop-session.test.ts +0 -162
  209. package/src/__tests__/stream-state.test.ts +0 -164
  210. package/src/__tests__/terminal-error.test.ts +0 -54
  211. package/src/__tests__/terminal-renderer.test.ts +0 -247
  212. package/src/__tests__/update-command-guard.test.ts +0 -144
  213. package/src/__tests__/web-ui.test.ts +0 -438
  214. package/src/__tests__/wechat-platform.test.ts +0 -111
  215. package/src/adapters/adapter-interface.ts +0 -217
  216. package/src/adapters/ccc-adapter.ts +0 -150
  217. package/src/adapters/claude-adapter.ts +0 -673
  218. package/src/adapters/claude-session-meta-store.ts +0 -120
  219. package/src/adapters/codex-adapter.ts +0 -426
  220. package/src/adapters/codex-session-meta-store.ts +0 -131
  221. package/src/adapters/cursor-adapter.ts +0 -681
  222. package/src/adapters/cursor-session-meta-store.ts +0 -154
  223. package/src/adapters/jsonl-stream.ts +0 -157
  224. package/src/adapters/raw-stream-log.ts +0 -124
  225. package/src/adapters/resource-monitor.ts +0 -141
  226. package/src/agent-activity.ts +0 -175
  227. package/src/agent-delegate-task-rpc.ts +0 -153
  228. package/src/agent-delegate-task.ts +0 -91
  229. package/src/agent-file-rpc.ts +0 -172
  230. package/src/agent-image-rpc.ts +0 -168
  231. package/src/agent-platform-routing.ts +0 -28
  232. package/src/agent-reload-config-rpc.ts +0 -34
  233. package/src/agent-rpc-body.ts +0 -92
  234. package/src/agent-stop-stuck.ts +0 -129
  235. package/src/card-action-routing.ts +0 -14
  236. package/src/card-plain-text.ts +0 -108
  237. package/src/cardkit.ts +0 -179
  238. package/src/cards.ts +0 -684
  239. package/src/chatgpt-subscription-rpc.ts +0 -27
  240. package/src/chatgpt-subscription.ts +0 -299
  241. package/src/chrome-devtools-guard.ts +0 -318
  242. package/src/claude-sdk-installer.ts +0 -324
  243. package/src/codex-reset-actions.ts +0 -184
  244. package/src/config-utils.ts +0 -211
  245. package/src/config.ts +0 -1063
  246. package/src/cursor-usage.ts +0 -128
  247. package/src/exit-banner.ts +0 -33
  248. package/src/feishu-api.ts +0 -1616
  249. package/src/feishu-message-ingress.ts +0 -195
  250. package/src/feishu-platform.ts +0 -159
  251. package/src/format-message.ts +0 -293
  252. package/src/git-command.ts +0 -202
  253. package/src/im-skills.ts +0 -149
  254. package/src/index.ts +0 -1089
  255. package/src/litellm-proxy.ts +0 -374
  256. package/src/orchestrator.ts +0 -2543
  257. package/src/platform-adapter.ts +0 -70
  258. package/src/platform-startup.ts +0 -16
  259. package/src/privacy.ts +0 -118
  260. package/src/progress/reducer.ts +0 -113
  261. package/src/progress/terminal-renderer.ts +0 -294
  262. package/src/progress/view.ts +0 -77
  263. package/src/response-stall.ts +0 -28
  264. package/src/runtime-reload.ts +0 -34
  265. package/src/session-chat-binding.ts +0 -292
  266. package/src/session-name.ts +0 -8
  267. package/src/session.ts +0 -2659
  268. package/src/shared-prefix.ts +0 -29
  269. package/src/shared.ts +0 -552
  270. package/src/sim-agent.ts +0 -167
  271. package/src/sim-platform.ts +0 -177
  272. package/src/sim-store.ts +0 -317
  273. package/src/simplify.ts +0 -120
  274. package/src/startup-lifecycle.ts +0 -250
  275. package/src/stream-state.ts +0 -177
  276. package/src/terminal-error.ts +0 -129
  277. package/src/trace.ts +0 -51
  278. package/src/turn-cards.ts +0 -118
  279. package/src/update-command-guard.ts +0 -165
  280. 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
+ }