chatccc 0.2.15 → 0.2.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +111 -111
- package/bin/chatccc.mjs +24 -36
- package/config.sample.json +26 -0
- package/images/avatars/status_busy.png +0 -0
- package/images/avatars/status_idle.png +0 -0
- package/images/avatars/status_new.png +0 -0
- package/images/img_readme_0.jpg +0 -0
- package/images/img_readme_1.jpg +0 -0
- package/images/img_readme_callback.png +0 -0
- package/images/img_readme_event.png +0 -0
- package/images/img_readme_permission.png +0 -0
- package/package.json +13 -12
- package/src/__tests__/cards.test.ts +3 -3
- package/src/__tests__/claude-adapter.test.ts +301 -10
- package/src/__tests__/config-reload.test.ts +171 -0
- package/src/__tests__/config.test.ts +259 -2
- package/src/__tests__/crash-logging.test.ts +210 -0
- package/src/__tests__/session.test.ts +2 -2
- package/src/__tests__/web-ui.test.ts +178 -0
- package/src/adapters/claude-adapter.ts +57 -8
- package/src/adapters/codex-adapter.ts +9 -8
- package/src/adapters/cursor-adapter.ts +1 -1
- package/src/config-utils.ts +177 -0
- package/src/config.ts +616 -382
- package/src/exit-banner.ts +12 -2
- package/src/feishu-api.ts +4 -15
- package/src/index.ts +178 -64
- package/src/session.ts +27 -12
- package/src/shared.ts +189 -16
- package/src/web-ui.ts +1591 -0
- package/.env.example +0 -44
package/src/web-ui.ts
ADDED
|
@@ -0,0 +1,1591 @@
|
|
|
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, writeFileSync } from "node:fs";
|
|
11
|
+
import { readFile, writeFile, stat } from "node:fs/promises";
|
|
12
|
+
import { join, dirname } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import { spawn, execSync } from "node:child_process";
|
|
15
|
+
|
|
16
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const PROJECT_ROOT = join(__dirname, "..");
|
|
18
|
+
const CONFIG_FILE = join(PROJECT_ROOT, "config.json");
|
|
19
|
+
const CONFIG_SAMPLE_FILE = join(PROJECT_ROOT, "config.sample.json");
|
|
20
|
+
const PID_FILE = join(PROJECT_ROOT, "state", "runtime.pid");
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Helpers — config.json parsing & generation
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
interface AppConfig {
|
|
27
|
+
feishu?: { appId?: string; appSecret?: string };
|
|
28
|
+
port?: number;
|
|
29
|
+
gitTimeoutSeconds?: number;
|
|
30
|
+
claude?: { enabled?: boolean; model?: string; effort?: string; apiKey?: string; baseUrl?: string };
|
|
31
|
+
// `command` 是已废弃的旧字段名,保留只读以兼容升级前的 config.json
|
|
32
|
+
cursor?: { enabled?: boolean; path?: string; command?: string; model?: string };
|
|
33
|
+
codex?: { enabled?: boolean; path?: string; command?: string; model?: string; effort?: string };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Claude API 模式("official" / "thirdparty")
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// 用户在 Wizard / Dashboard 上选择的"官方 API(Anthropic 直连)"或"第三方 API
|
|
40
|
+
// (自定义网关)"模式:
|
|
41
|
+
// - "official" → 强制把 claude.apiKey / claude.baseUrl 视为空,永远写入 ""
|
|
42
|
+
// (即使前端漏传也按此处理,作为服务端兌底);
|
|
43
|
+
// - "thirdparty" → 保留前端提交的 apiKey / baseUrl 原值,由用户填写。
|
|
44
|
+
//
|
|
45
|
+
// 这两个纯函数被前端 JS 与服务端 handlePostConfig 同时使用,单测在
|
|
46
|
+
// src/__tests__/web-ui.test.ts 里锁定它们的契约。
|
|
47
|
+
|
|
48
|
+
export type ClaudeApiMode = "official" | "thirdparty";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 加载已有 config.json 时,决定 UI 应初始展现哪种模式:
|
|
52
|
+
* 只要 apiKey 或 baseUrl 任一非空(trim 后),即视为第三方模式;否则官方。
|
|
53
|
+
*/
|
|
54
|
+
export function detectClaudeApiMode(
|
|
55
|
+
claude: { apiKey?: string; baseUrl?: string } | undefined,
|
|
56
|
+
): ClaudeApiMode {
|
|
57
|
+
const apiKey = (claude?.apiKey ?? "").trim();
|
|
58
|
+
const baseUrl = (claude?.baseUrl ?? "").trim();
|
|
59
|
+
if (apiKey || baseUrl) return "thirdparty";
|
|
60
|
+
return "official";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 服务端在写入 config.json 前按用户选择的模式归一化扁平 vars:
|
|
65
|
+
* - mode = "thirdparty":保留 vars 中的 CLAUDE_API_KEY / CLAUDE_BASE_URL 原值
|
|
66
|
+
* (包括 ""——用户也可能主动清空一项)。
|
|
67
|
+
* - mode = "official" 或未传 mode:强制把 CLAUDE_API_KEY / CLAUDE_BASE_URL
|
|
68
|
+
* 设为 "",即使前端漏传这两个键也要主动加上 "",覆盖 config.json 里旧值。
|
|
69
|
+
*
|
|
70
|
+
* 这里"未传 mode 默认按 official 处理"是有意为之的兌底:旧版前端可能不传
|
|
71
|
+
* mode 字段,按更安全的方向(不保留可能误填的密钥)落地。
|
|
72
|
+
*/
|
|
73
|
+
export function applyClaudeApiMode(
|
|
74
|
+
vars: Record<string, unknown>,
|
|
75
|
+
mode: string | undefined,
|
|
76
|
+
): Record<string, unknown> {
|
|
77
|
+
if (mode === "thirdparty") return vars;
|
|
78
|
+
return { ...vars, CLAUDE_API_KEY: "", CLAUDE_BASE_URL: "" };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// /api/start 路径选择(纯函数,便于单测护栏)
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
//
|
|
85
|
+
// 三种语义:
|
|
86
|
+
// - "inplace" : setup 模式,原地启动飞书 service(同进程,不动 PID 文件)
|
|
87
|
+
// - "spawn" : dashboard 模式 + service 未运行,spawn 子进程(旧 service 退出后场景)
|
|
88
|
+
// - "reload" : dashboard 模式 + service 已经在跑(通常就是当前进程自己)→
|
|
89
|
+
// 仅调用 reloadConfigFromDisk() 刷新 export let 常量,**不真正重启**。
|
|
90
|
+
// 用户的设计意图:"让新 config 生效就行,不用走 spawn+exit 的真重启"。
|
|
91
|
+
//
|
|
92
|
+
// 关键契约:只要 setup 模式注册了 onActivate 回调,无条件走 inplace —— 因为
|
|
93
|
+
// setup 进程**总是**占着 PID 文件、isServiceRunning() 永远为 true,再判 PID
|
|
94
|
+
// 会陷入"自己挡自己"的死循环(用户点保存并启动只会得到 already running)。
|
|
95
|
+
//
|
|
96
|
+
// 历史:曾经在 service 已运行时返回 "reject-already-running",但 dashboard 的
|
|
97
|
+
// UI 本身就在跑着的进程内,service 必然在跑——重新跑向导改完配置点"保存并启动"
|
|
98
|
+
// 100% 会撞到这条 reject 路径,造成"自己挡自己"的死循环。改 reload 后即可
|
|
99
|
+
// 让常量热更新(API 来源、模型、effort、CLI 路径等下次创建会话即生效)。
|
|
100
|
+
// 已建立的飞书 WSClient 仍持有旧 APP_ID/APP_SECRET 句柄——如改了飞书凭证,
|
|
101
|
+
// 仍需重启 chatccc 进程才能让 WS 长连接换 token;但这是用户少见路径,文档说明即可。
|
|
102
|
+
|
|
103
|
+
export type StartPath = "inplace" | "spawn" | "reload";
|
|
104
|
+
|
|
105
|
+
export function chooseStartPath(input: {
|
|
106
|
+
hasInplaceActivateHook: boolean;
|
|
107
|
+
isServiceRunning: boolean;
|
|
108
|
+
}): StartPath {
|
|
109
|
+
if (input.hasInplaceActivateHook) return "inplace";
|
|
110
|
+
if (input.isServiceRunning) return "reload";
|
|
111
|
+
return "spawn";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 读取 cursor / codex 的 CLI 路径,优先新字段 path,回退旧字段 command */
|
|
115
|
+
function readToolPath(tool?: { path?: string; command?: string }): string {
|
|
116
|
+
if (!tool) return "";
|
|
117
|
+
if (tool.path && tool.path.trim()) return tool.path;
|
|
118
|
+
if (tool.command && tool.command.trim()) return tool.command;
|
|
119
|
+
return "";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function loadConfig(): AppConfig {
|
|
123
|
+
if (!existsSync(CONFIG_FILE)) return {};
|
|
124
|
+
try {
|
|
125
|
+
return JSON.parse(readFileSync(CONFIG_FILE, "utf8"));
|
|
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
|
+
function maskSecret(value: string | undefined): string {
|
|
136
|
+
if (!value) return "(未设置)";
|
|
137
|
+
if (value.length <= 8) return "***";
|
|
138
|
+
return value.slice(0, 4) + "***" + value.slice(-4);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// Helpers — process management
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
function isServiceRunning(): boolean {
|
|
146
|
+
if (!existsSync(PID_FILE)) return false;
|
|
147
|
+
try {
|
|
148
|
+
const raw = readFileSync(PID_FILE, "utf8").trim();
|
|
149
|
+
const pid = parseInt(raw, 10);
|
|
150
|
+
if (isNaN(pid)) return false;
|
|
151
|
+
process.kill(pid, 0);
|
|
152
|
+
return true;
|
|
153
|
+
} catch {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function getServicePid(): number | null {
|
|
159
|
+
if (!existsSync(PID_FILE)) return null;
|
|
160
|
+
try {
|
|
161
|
+
const pid = parseInt(readFileSync(PID_FILE, "utf8").trim(), 10);
|
|
162
|
+
return isNaN(pid) ? null : pid;
|
|
163
|
+
} catch {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function getServiceUptime(): number | null {
|
|
169
|
+
try {
|
|
170
|
+
const s = statSync(PID_FILE);
|
|
171
|
+
return Math.floor((Date.now() - s.mtimeMs) / 1000);
|
|
172
|
+
} catch {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
import { statSync } from "node:fs";
|
|
178
|
+
|
|
179
|
+
function spawnService(): { ok: boolean; pid?: number; error?: string } {
|
|
180
|
+
const indexPath = join(PROJECT_ROOT, "src", "index.ts");
|
|
181
|
+
if (!existsSync(indexPath)) {
|
|
182
|
+
return { ok: false, error: `Entry not found: ${indexPath}` };
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
const child = spawn("npx", ["tsx", "src/index.ts"], {
|
|
186
|
+
cwd: PROJECT_ROOT,
|
|
187
|
+
detached: true,
|
|
188
|
+
stdio: "ignore",
|
|
189
|
+
shell: true,
|
|
190
|
+
});
|
|
191
|
+
child.unref();
|
|
192
|
+
return { ok: true, pid: child.pid ?? undefined };
|
|
193
|
+
} catch (err) {
|
|
194
|
+
return { ok: false, error: (err as Error).message };
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function stopService(): { ok: boolean; error?: string } {
|
|
199
|
+
const pid = getServicePid();
|
|
200
|
+
if (!pid) return { ok: false, error: "No PID file found" };
|
|
201
|
+
try {
|
|
202
|
+
if (process.platform === "win32") {
|
|
203
|
+
execSync(`taskkill /PID ${pid} /F /T`, { stdio: "pipe", windowsHide: true });
|
|
204
|
+
} else {
|
|
205
|
+
process.kill(pid, "SIGTERM");
|
|
206
|
+
}
|
|
207
|
+
return { ok: true };
|
|
208
|
+
} catch (err) {
|
|
209
|
+
return { ok: false, error: (err as Error).message };
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function validateCli(tool: string): { ok: boolean; path?: string; error?: string } {
|
|
214
|
+
const cfg = loadConfig();
|
|
215
|
+
let cmd: string;
|
|
216
|
+
if (tool === "cursor") {
|
|
217
|
+
cmd = readToolPath(cfg.cursor) || detectCursorAgentPath();
|
|
218
|
+
} else {
|
|
219
|
+
cmd = readToolPath(cfg.codex) || "codex";
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
const out = execSync(`"${cmd}" --version`, { encoding: "utf8", timeout: 10000, windowsHide: true }).trim();
|
|
223
|
+
return { ok: true, path: cmd, error: out.slice(0, 200) };
|
|
224
|
+
} catch (err) {
|
|
225
|
+
return { ok: false, path: cmd, error: (err as Error).message };
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function detectCursorAgentPath(): string {
|
|
230
|
+
const localAppData = process.env.LOCALAPPDATA;
|
|
231
|
+
if (localAppData) {
|
|
232
|
+
const defaultPath = join(localAppData, "cursor-agent", "agent.cmd");
|
|
233
|
+
if (existsSync(defaultPath)) return defaultPath;
|
|
234
|
+
}
|
|
235
|
+
return "agent";
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// Helpers — HTTP
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
function jsonReply(res: ServerResponse, code: number, data: unknown): void {
|
|
243
|
+
res.writeHead(code, { "Content-Type": "application/json; charset=utf-8" });
|
|
244
|
+
res.end(JSON.stringify(data));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function readRequestBody(req: IncomingMessage): Promise<string> {
|
|
248
|
+
return new Promise((resolve) => {
|
|
249
|
+
let body = "";
|
|
250
|
+
req.on("data", (chunk: Buffer) => { body += chunk.toString(); });
|
|
251
|
+
req.on("end", () => resolve(body));
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
// API route handlers
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
async function handleApiCheck(_req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
260
|
+
const hasConfig = existsSync(CONFIG_FILE);
|
|
261
|
+
const hasCreds = hasConfig && (() => {
|
|
262
|
+
const c = loadConfig();
|
|
263
|
+
return Boolean(c.feishu?.appId?.trim() && c.feishu?.appSecret?.trim());
|
|
264
|
+
})();
|
|
265
|
+
jsonReply(res, 200, { hasConfig, hasCreds, configPath: CONFIG_FILE });
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function handleGetConfig(_req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
269
|
+
const running = isServiceRunning();
|
|
270
|
+
const pid = getServicePid();
|
|
271
|
+
const vars = loadConfig();
|
|
272
|
+
jsonReply(res, 200, { vars, running, pid });
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function handlePostConfig(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
276
|
+
const body = await readRequestBody(req);
|
|
277
|
+
let updates: Record<string, unknown>;
|
|
278
|
+
let claudeApiMode: string | undefined;
|
|
279
|
+
try {
|
|
280
|
+
const parsed = JSON.parse(body);
|
|
281
|
+
updates = parsed.vars ?? {};
|
|
282
|
+
// 前端可选传 claudeApiMode:"official" / "thirdparty";未传时 applyClaudeApiMode
|
|
283
|
+
// 会按 official 兌底清空 apiKey/baseUrl,避免把误填的密钥落到 config.json。
|
|
284
|
+
claudeApiMode = typeof parsed.claudeApiMode === "string" ? parsed.claudeApiMode : undefined;
|
|
285
|
+
} catch {
|
|
286
|
+
jsonReply(res, 400, { ok: false, error: "Invalid JSON" });
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
const normalized = applyClaudeApiMode(updates, claudeApiMode);
|
|
290
|
+
const existing = loadConfig();
|
|
291
|
+
// AppConfig 是闭合接口(无 index signature),但运行时本质就是 JSON 对象,
|
|
292
|
+
// 这里通过 unknown 桥接到 Record 让 deepMerge 能复用;写回前断言回 AppConfig。
|
|
293
|
+
const merged = deepMerge(
|
|
294
|
+
existing as unknown as Record<string, unknown>,
|
|
295
|
+
unflattenConfig(normalized),
|
|
296
|
+
) as AppConfig;
|
|
297
|
+
try {
|
|
298
|
+
saveConfig(merged);
|
|
299
|
+
jsonReply(res, 200, { ok: true });
|
|
300
|
+
} catch (err) {
|
|
301
|
+
jsonReply(res, 500, { ok: false, error: (err as Error).message });
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function deepMerge(target: Record<string, unknown>, source: Record<string, unknown>): Record<string, unknown> {
|
|
306
|
+
const result = { ...target };
|
|
307
|
+
for (const [key, val] of Object.entries(source)) {
|
|
308
|
+
if (val !== null && typeof val === "object" && !Array.isArray(val) && typeof result[key] === "object" && result[key] !== null && !Array.isArray(result[key])) {
|
|
309
|
+
result[key] = deepMerge(result[key] as Record<string, unknown>, val as Record<string, unknown>);
|
|
310
|
+
} else {
|
|
311
|
+
result[key] = val;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return result;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Convert flat key-value pairs to nested config structure
|
|
318
|
+
function unflattenConfig(flat: Record<string, unknown>): Record<string, unknown> {
|
|
319
|
+
const result: Record<string, unknown> = {};
|
|
320
|
+
for (const [key, val] of Object.entries(flat)) {
|
|
321
|
+
if (key === "CHATCCC_APP_ID") {
|
|
322
|
+
result.feishu = result.feishu || {};
|
|
323
|
+
(result.feishu as Record<string, unknown>).appId = val;
|
|
324
|
+
} else if (key === "CHATCCC_APP_SECRET") {
|
|
325
|
+
result.feishu = result.feishu || {};
|
|
326
|
+
(result.feishu as Record<string, unknown>).appSecret = val;
|
|
327
|
+
} else if (key === "CHATCCC_PORT") {
|
|
328
|
+
result.port = parseInt(val as string, 10) || 18080;
|
|
329
|
+
} else if (key === "CHATCCC_GIT_TIMEOUT_SECONDS") {
|
|
330
|
+
result.gitTimeoutSeconds = parseInt(val as string, 10) || 180;
|
|
331
|
+
} else if (key === "CLAUDE_API_KEY") {
|
|
332
|
+
result.claude = result.claude || {};
|
|
333
|
+
(result.claude as Record<string, unknown>).apiKey = val;
|
|
334
|
+
} else if (key === "CLAUDE_BASE_URL") {
|
|
335
|
+
result.claude = result.claude || {};
|
|
336
|
+
(result.claude as Record<string, unknown>).baseUrl = val;
|
|
337
|
+
} else if (key === "CHATCCC_ANTHROPIC_MODEL") {
|
|
338
|
+
result.claude = result.claude || {};
|
|
339
|
+
(result.claude as Record<string, unknown>).model = val;
|
|
340
|
+
} else if (key === "CHATCCC_ANTHROPIC_EFFORT") {
|
|
341
|
+
result.claude = result.claude || {};
|
|
342
|
+
(result.claude as Record<string, unknown>).effort = val;
|
|
343
|
+
} else if (key === "CHATCCC_CLAUDE_ENABLED") {
|
|
344
|
+
result.claude = result.claude || {};
|
|
345
|
+
(result.claude as Record<string, unknown>).enabled = val === true || val === "true";
|
|
346
|
+
} else if (key === "CHATCCC_CURSOR_PATH") {
|
|
347
|
+
result.cursor = result.cursor || {};
|
|
348
|
+
(result.cursor as Record<string, unknown>).path = val;
|
|
349
|
+
} else if (key === "CHATCCC_CURSOR_MODEL") {
|
|
350
|
+
result.cursor = result.cursor || {};
|
|
351
|
+
(result.cursor as Record<string, unknown>).model = val;
|
|
352
|
+
} else if (key === "CHATCCC_CURSOR_ENABLED") {
|
|
353
|
+
result.cursor = result.cursor || {};
|
|
354
|
+
(result.cursor as Record<string, unknown>).enabled = val === true || val === "true";
|
|
355
|
+
} else if (key === "CHATCCC_CODEX_PATH") {
|
|
356
|
+
result.codex = result.codex || {};
|
|
357
|
+
(result.codex as Record<string, unknown>).path = val;
|
|
358
|
+
} else if (key === "CHATCCC_CODEX_MODEL") {
|
|
359
|
+
result.codex = result.codex || {};
|
|
360
|
+
(result.codex as Record<string, unknown>).model = val;
|
|
361
|
+
} else if (key === "CHATCCC_CODEX_EFFORT") {
|
|
362
|
+
result.codex = result.codex || {};
|
|
363
|
+
(result.codex as Record<string, unknown>).effort = val;
|
|
364
|
+
} else if (key === "CHATCCC_CODEX_ENABLED") {
|
|
365
|
+
result.codex = result.codex || {};
|
|
366
|
+
(result.codex as Record<string, unknown>).enabled = val === true || val === "true";
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return result;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async function handleGetStatus(_req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
373
|
+
const running = isServiceRunning();
|
|
374
|
+
const pid = getServicePid();
|
|
375
|
+
const uptime = running ? getServiceUptime() : null;
|
|
376
|
+
jsonReply(res, 200, { running, pid, uptime });
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function handleStartService(_req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
380
|
+
const path = chooseStartPath({
|
|
381
|
+
hasInplaceActivateHook: Boolean(setupActivateHook && setupHttpServer),
|
|
382
|
+
isServiceRunning: isServiceRunning(),
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
if (path === "inplace") {
|
|
386
|
+
// setup 模式:原地启动飞书 service,复用 setup HTTP server,不动 PID 文件。
|
|
387
|
+
// 注意 hook 和 setupHttpServer 在 chooseStartPath() 之后**绝不会被并发清空**
|
|
388
|
+
// —— init 时一次性赋值,且当前函数是 onActivate 唯一调用点;非 null 断言安全。
|
|
389
|
+
const hook = setupActivateHook!;
|
|
390
|
+
const server = setupHttpServer!;
|
|
391
|
+
const result = await hook(server);
|
|
392
|
+
if (result.ok) {
|
|
393
|
+
// 切换成功:清掉 hook 防止再次走 inplace 路径(service 已经在跑了)。
|
|
394
|
+
// setupHttpServer 不清 —— 它已经接管为 service server,dashboard 仍要用。
|
|
395
|
+
setupActivateHook = null;
|
|
396
|
+
jsonReply(res, 200, { ok: true, pid: process.pid, mode: "inplace" });
|
|
397
|
+
} else {
|
|
398
|
+
jsonReply(res, 500, { ok: false, error: result.error });
|
|
399
|
+
}
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (path === "reload") {
|
|
404
|
+
// service 已经在跑(通常就是当前进程自己)。仅把磁盘上刚保存的 config.json
|
|
405
|
+
// 刷进进程内的 export let 常量,不走真重启。下次创建会话时新值即生效。
|
|
406
|
+
if (!reloadConfigHook) {
|
|
407
|
+
// 没注册 reload hook 视为编程错误:index.ts 必须在 main() 里调用
|
|
408
|
+
// setReloadConfigHook();返回 500 让前端有提示,避免静默"看似生效但没生效"。
|
|
409
|
+
jsonReply(res, 500, {
|
|
410
|
+
ok: false,
|
|
411
|
+
error: "reload hook 未注册(应在 main() 调用 setReloadConfigHook)",
|
|
412
|
+
});
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
try {
|
|
416
|
+
await reloadConfigHook();
|
|
417
|
+
jsonReply(res, 200, { ok: true, pid: process.pid, mode: "reload" });
|
|
418
|
+
} catch (err) {
|
|
419
|
+
jsonReply(res, 500, { ok: false, error: (err as Error).message });
|
|
420
|
+
}
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const result = spawnService();
|
|
425
|
+
if (result.ok) {
|
|
426
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
427
|
+
jsonReply(res, 200, { ok: true, pid: result.pid, mode: "spawn" });
|
|
428
|
+
} else {
|
|
429
|
+
jsonReply(res, 500, result);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async function handleStopService(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
434
|
+
const running = isServiceRunning();
|
|
435
|
+
if (!running) {
|
|
436
|
+
jsonReply(res, 200, { ok: false, error: "Service is not running" });
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
jsonReply(res, 200, { ok: true });
|
|
440
|
+
setImmediate(() => {
|
|
441
|
+
stopService();
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async function handleRestartService(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
446
|
+
jsonReply(res, 200, { ok: true, message: "Restarting..." });
|
|
447
|
+
setImmediate(() => {
|
|
448
|
+
stopService();
|
|
449
|
+
setTimeout(() => {
|
|
450
|
+
spawnService();
|
|
451
|
+
}, 1000);
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async function handleValidate(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
456
|
+
const body = await readRequestBody(req);
|
|
457
|
+
let tool: string;
|
|
458
|
+
try { tool = JSON.parse(body).tool; } catch {
|
|
459
|
+
jsonReply(res, 400, { ok: false, error: "Missing tool" });
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
const result = validateCli(tool);
|
|
463
|
+
jsonReply(res, 200, result);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// ---------------------------------------------------------------------------
|
|
467
|
+
// HTML page (embedded template)
|
|
468
|
+
// ---------------------------------------------------------------------------
|
|
469
|
+
|
|
470
|
+
const PAGE_HTML = `<!DOCTYPE html>
|
|
471
|
+
<html lang="zh-CN">
|
|
472
|
+
<head>
|
|
473
|
+
<meta charset="UTF-8">
|
|
474
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
475
|
+
<title>ChatCCC</title>
|
|
476
|
+
<style>
|
|
477
|
+
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
|
478
|
+
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#f1f5f9;color:#1e293b;line-height:1.6}
|
|
479
|
+
header{background:#0f172a;color:#fff;padding:16px 24px;display:flex;align-items:center;justify-content:space-between}
|
|
480
|
+
header h1{font-size:20px;font-weight:600}
|
|
481
|
+
header .badge{font-size:13px;padding:4px 12px;border-radius:12px;font-weight:500}
|
|
482
|
+
.badge-running{background:#16a34a;color:#fff}
|
|
483
|
+
.badge-stopped{background:#94a3b8;color:#fff}
|
|
484
|
+
/* container 完全不限宽、不留左右内边距 —— step-2 是三列卡片需要尽量利用屏幕;
|
|
485
|
+
单列内容(step-1/3、steps-bar、dashboard-view 子元素)由下面 720 规则收口居中。 */
|
|
486
|
+
.container{margin:0 auto;padding:24px 0}
|
|
487
|
+
.card{background:#fff;border-radius:12px;padding:24px;margin-bottom:16px;box-shadow:0 1px 3px rgba(0,0,0,.08)}
|
|
488
|
+
/* 单列内容(飞书表单 / 确认 / dashboard 卡片 / 进度条)保留 720 居中,不受 container 加宽影响 */
|
|
489
|
+
#step-1,#step-3,#steps-bar,#step-label-bar,#dashboard-view > *{max-width:720px;margin-left:auto;margin-right:auto}
|
|
490
|
+
.card h2{font-size:18px;font-weight:600;margin-bottom:12px}
|
|
491
|
+
.card h3{font-size:15px;font-weight:600;margin-bottom:8px;color:#334155}
|
|
492
|
+
.form-group{margin-bottom:16px}
|
|
493
|
+
.form-group label{display:block;font-size:14px;font-weight:500;margin-bottom:4px;color:#475569}
|
|
494
|
+
/* 只针对文本/下拉这类输入控件加边框 + focus 高亮;radio/checkbox 保留浏览器默认渲染,
|
|
495
|
+
否则 box-shadow:0 0 0 3px 会在 radio 的矩形包围盒外画出一个蓝色矩形框,看上去像另一个输入控件。 */
|
|
496
|
+
.form-group input:not([type=radio]):not([type=checkbox]),.form-group select{width:100%;padding:8px 12px;border:1px solid #cbd5e1;border-radius:8px;font-size:14px;outline:none;transition:border-color .15s}
|
|
497
|
+
.form-group input:not([type=radio]):not([type=checkbox]):focus,.form-group select:focus{border-color:#3b82f6;box-shadow:0 0 0 3px rgba(59,130,246,.15)}
|
|
498
|
+
.form-group .hint{font-size:12px;color:#94a3b8;margin-top:2px}
|
|
499
|
+
.btn{display:inline-flex;align-items:center;gap:6px;padding:8px 20px;border:none;border-radius:8px;font-size:14px;font-weight:500;cursor:pointer;transition:all .15s}
|
|
500
|
+
.btn-primary{background:#3b82f6;color:#fff}
|
|
501
|
+
.btn-primary:hover{background:#2563eb}
|
|
502
|
+
.btn-outline{background:#fff;color:#475569;border:1px solid #cbd5e1}
|
|
503
|
+
.btn-outline:hover{background:#f1f5f9}
|
|
504
|
+
.btn-danger{background:#ef4444;color:#fff}
|
|
505
|
+
.btn-danger:hover{background:#dc2626}
|
|
506
|
+
.btn-success{background:#16a34a;color:#fff}
|
|
507
|
+
.btn-success:hover{background:#15803d}
|
|
508
|
+
.btn:disabled{opacity:.5;cursor:not-allowed}
|
|
509
|
+
.btn-group{display:flex;gap:8px;flex-wrap:wrap}
|
|
510
|
+
.agent-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px;margin-bottom:16px}
|
|
511
|
+
.agent-card{min-width:0}
|
|
512
|
+
.agent-card{border:2px solid #e2e8f0;border-radius:12px;padding:16px;background:#fff;display:flex;flex-direction:column;transition:border-color .15s,background .15s}
|
|
513
|
+
.agent-card.enabled{border-color:#3b82f6;background:#eff6ff}
|
|
514
|
+
.agent-card-header{display:flex;align-items:flex-start;gap:10px;margin-bottom:12px}
|
|
515
|
+
.agent-card-header .meta{flex:1;min-width:0}
|
|
516
|
+
.agent-card-header .name{font-size:15px;font-weight:600;margin-bottom:2px}
|
|
517
|
+
.agent-card-header .desc{font-size:12px;color:#64748b;line-height:1.4}
|
|
518
|
+
.agent-toggle{appearance:none;-webkit-appearance:none;width:38px;height:22px;background:#cbd5e1;border-radius:11px;position:relative;cursor:pointer;flex-shrink:0;transition:background .2s;outline:none;border:none;margin:0}
|
|
519
|
+
.agent-toggle:checked{background:#3b82f6}
|
|
520
|
+
.agent-toggle::before{content:"";position:absolute;width:18px;height:18px;border-radius:50%;background:#fff;top:2px;left:2px;transition:left .2s;box-shadow:0 1px 2px rgba(0,0,0,.2)}
|
|
521
|
+
.agent-toggle:checked::before{left:18px}
|
|
522
|
+
.agent-body{flex:1}
|
|
523
|
+
.agent-body fieldset{border:none;padding:0;margin:0}
|
|
524
|
+
.agent-body fieldset[disabled]{opacity:.45;pointer-events:none}
|
|
525
|
+
.steps{display:flex;gap:4px;margin-bottom:8px}
|
|
526
|
+
.step{flex:1;height:4px;background:#e2e8f0;border-radius:2px;transition:background .2s}
|
|
527
|
+
.step.active{background:#3b82f6}
|
|
528
|
+
.step.done{background:#93c5fd}
|
|
529
|
+
.step-label-bar{text-align:right;font-size:13px;color:#64748b;margin-bottom:16px;font-weight:500}
|
|
530
|
+
.status-bar{display:flex;align-items:center;gap:12px;padding:16px 20px;background:#fff;border-radius:12px;margin-bottom:16px;box-shadow:0 1px 3px rgba(0,0,0,.08)}
|
|
531
|
+
.status-dot{width:10px;height:10px;border-radius:50%}
|
|
532
|
+
.status-dot.running{background:#16a34a;box-shadow:0 0 6px rgba(22,163,74,.4)}
|
|
533
|
+
.status-dot.stopped{background:#94a3b8}
|
|
534
|
+
.config-row{display:flex;justify-content:space-between;align-items:center;padding:10px 0;border-bottom:1px solid #f1f5f9}
|
|
535
|
+
.config-row:last-child{border-bottom:none}
|
|
536
|
+
.config-row .key{font-size:13px;font-weight:500;color:#64748b}
|
|
537
|
+
.config-row .val{font-size:14px;color:#1e293b;text-align:right}
|
|
538
|
+
.config-section{margin-bottom:8px}
|
|
539
|
+
.config-section summary{font-weight:600;font-size:15px;cursor:pointer;padding:8px 0;color:#334155}
|
|
540
|
+
.section-detail{padding:8px 0 16px 8px}
|
|
541
|
+
.hidden{display:none !important}
|
|
542
|
+
.toast{position:fixed;top:16px;right:16px;padding:12px 20px;border-radius:8px;color:#fff;font-size:14px;font-weight:500;z-index:100;animation:slideIn .3s ease}
|
|
543
|
+
.toast-success{background:#16a34a}
|
|
544
|
+
.toast-error{background:#ef4444}
|
|
545
|
+
@keyframes slideIn{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}}
|
|
546
|
+
.spinner{display:inline-block;width:14px;height:14px;border:2px solid rgba(255,255,255,.3);border-top-color:#fff;border-radius:50%;animation:spin .6s linear infinite}
|
|
547
|
+
@keyframes spin{to{transform:rotate(360deg)}}
|
|
548
|
+
</style>
|
|
549
|
+
</head>
|
|
550
|
+
<body>
|
|
551
|
+
<header>
|
|
552
|
+
<h1>ChatCCC</h1>
|
|
553
|
+
<span id="header-badge" class="badge badge-stopped">未启动</span>
|
|
554
|
+
</header>
|
|
555
|
+
<div class="container">
|
|
556
|
+
|
|
557
|
+
<!-- ===== Setup Wizard ===== -->
|
|
558
|
+
<div id="wizard-view">
|
|
559
|
+
<div class="steps" id="steps-bar">
|
|
560
|
+
<div class="step active" data-step="1"></div>
|
|
561
|
+
<div class="step" data-step="2"></div>
|
|
562
|
+
<div class="step" data-step="3"></div>
|
|
563
|
+
</div>
|
|
564
|
+
<div class="step-label-bar" id="step-label-bar">第 1 步 / 共 3 步</div>
|
|
565
|
+
|
|
566
|
+
<!-- Step 1: 飞书应用(首次必填) -->
|
|
567
|
+
<div id="step-1" class="card">
|
|
568
|
+
<h2>飞书应用</h2>
|
|
569
|
+
<p style="color:#64748b;font-size:14px;margin-bottom:16px">先填写飞书自建应用凭证。从飞书开放平台「凭证与基础信息」复制 App ID 与 App Secret。</p>
|
|
570
|
+
|
|
571
|
+
<div class="form-group">
|
|
572
|
+
<label>CHATCCC_APP_ID *</label>
|
|
573
|
+
<input type="text" id="field-CHATCCC_APP_ID" placeholder="cli_xxxxxxxxxxxx">
|
|
574
|
+
<div class="hint">必填</div>
|
|
575
|
+
</div>
|
|
576
|
+
<div class="form-group">
|
|
577
|
+
<label>CHATCCC_APP_SECRET *</label>
|
|
578
|
+
<input type="password" id="field-CHATCCC_APP_SECRET" placeholder="...">
|
|
579
|
+
<div class="hint">必填</div>
|
|
580
|
+
</div>
|
|
581
|
+
|
|
582
|
+
<div class="btn-group" style="justify-content:flex-end">
|
|
583
|
+
<button class="btn btn-primary" id="btn-step1-next" onclick="goStep1Next()">下一步</button>
|
|
584
|
+
</div>
|
|
585
|
+
</div>
|
|
586
|
+
|
|
587
|
+
<!-- Step 2: 启用 AI Agent 并配置 -->
|
|
588
|
+
<div id="step-2" class="card hidden">
|
|
589
|
+
<h2>启用 AI Agent</h2>
|
|
590
|
+
<p style="color:#64748b;font-size:14px;margin-bottom:16px">在飞书中可同时启用多个 AI 编程工具。打开对应卡片的开关后填写配置,至少需要启用一个并填写正确才能进入下一步。</p>
|
|
591
|
+
|
|
592
|
+
<div class="agent-cards">
|
|
593
|
+
|
|
594
|
+
<!-- Claude 卡片 -->
|
|
595
|
+
<div class="agent-card" id="agent-card-claude">
|
|
596
|
+
<div class="agent-card-header">
|
|
597
|
+
<input type="checkbox" class="agent-toggle" id="agent-enable-claude" onchange="onAgentToggle('claude', this.checked)">
|
|
598
|
+
<div class="meta">
|
|
599
|
+
<div class="name">Claude Code</div>
|
|
600
|
+
<div class="desc">Anthropic Claude Agent SDK<br>官方/第三方 API 均可</div>
|
|
601
|
+
</div>
|
|
602
|
+
</div>
|
|
603
|
+
<fieldset class="agent-body" id="agent-body-claude" disabled>
|
|
604
|
+
<div class="form-group" style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:12px 14px">
|
|
605
|
+
<label style="margin-bottom:8px;font-weight:600;color:#334155">API 来源</label>
|
|
606
|
+
<div style="display:flex;flex-direction:column;gap:6px">
|
|
607
|
+
<label style="display:flex;align-items:center;gap:6px;font-weight:500;cursor:pointer">
|
|
608
|
+
<input type="radio" name="claude-api-mode" value="official" id="claude-api-mode-official" checked onchange="onClaudeApiModeChange('official')">
|
|
609
|
+
官方 API(Anthropic 直连)
|
|
610
|
+
</label>
|
|
611
|
+
<label style="display:flex;align-items:center;gap:6px;font-weight:500;cursor:pointer">
|
|
612
|
+
<input type="radio" name="claude-api-mode" value="thirdparty" id="claude-api-mode-thirdparty" onchange="onClaudeApiModeChange('thirdparty')">
|
|
613
|
+
第三方 API(自定义网关)
|
|
614
|
+
</label>
|
|
615
|
+
</div>
|
|
616
|
+
<div class="hint" style="margin-top:6px">官方模式不需 API Key / Base URL;第三方模式需填写。<strong>切回官方后保存会清空已填的密钥。</strong></div>
|
|
617
|
+
</div>
|
|
618
|
+
|
|
619
|
+
<div id="claude-thirdparty-fields" class="hidden">
|
|
620
|
+
<div class="form-group">
|
|
621
|
+
<label>API Key</label>
|
|
622
|
+
<input type="password" id="field-CLAUDE_API_KEY" placeholder="sk-...">
|
|
623
|
+
<div class="hint">第三方网关 API 密钥,对应 <code>claude.apiKey</code></div>
|
|
624
|
+
</div>
|
|
625
|
+
<div class="form-group">
|
|
626
|
+
<label>Base URL</label>
|
|
627
|
+
<input type="text" id="field-CLAUDE_BASE_URL" placeholder="https://api.deepseek.com/anthropic">
|
|
628
|
+
<div class="hint">Anthropic 兼容端点,对应 <code>claude.baseUrl</code></div>
|
|
629
|
+
</div>
|
|
630
|
+
</div>
|
|
631
|
+
|
|
632
|
+
<div class="form-group">
|
|
633
|
+
<label>模型</label>
|
|
634
|
+
<input type="text" id="field-CHATCCC_ANTHROPIC_MODEL" placeholder="留空表示不向 SDK 传 model">
|
|
635
|
+
</div>
|
|
636
|
+
<div class="form-group">
|
|
637
|
+
<label>思考深度 (Effort)</label>
|
|
638
|
+
<input type="text" id="field-CHATCCC_ANTHROPIC_EFFORT" placeholder="留空表示不向 SDK 传 effort">
|
|
639
|
+
</div>
|
|
640
|
+
</fieldset>
|
|
641
|
+
</div>
|
|
642
|
+
|
|
643
|
+
<!-- Cursor 卡片 -->
|
|
644
|
+
<div class="agent-card" id="agent-card-cursor">
|
|
645
|
+
<div class="agent-card-header">
|
|
646
|
+
<input type="checkbox" class="agent-toggle" id="agent-enable-cursor" onchange="onAgentToggle('cursor', this.checked)">
|
|
647
|
+
<div class="meta">
|
|
648
|
+
<div class="name">Cursor</div>
|
|
649
|
+
<div class="desc">Cursor Agent CLI<br>需安装 Cursor</div>
|
|
650
|
+
</div>
|
|
651
|
+
</div>
|
|
652
|
+
<fieldset class="agent-body" id="agent-body-cursor" disabled>
|
|
653
|
+
<div class="form-group">
|
|
654
|
+
<label>CLI 路径</label>
|
|
655
|
+
<input type="text" id="field-CHATCCC_CURSOR_PATH" placeholder="自动探测...">
|
|
656
|
+
<div class="hint" id="cursor-path-hint"></div>
|
|
657
|
+
</div>
|
|
658
|
+
<div class="form-group">
|
|
659
|
+
<label>模型</label>
|
|
660
|
+
<input type="text" id="field-CHATCCC_CURSOR_MODEL" placeholder="留空表示不传 --model">
|
|
661
|
+
</div>
|
|
662
|
+
<button class="btn btn-outline" onclick="validateCli('cursor')" style="margin-bottom:12px">检测 Cursor CLI</button>
|
|
663
|
+
<div id="cursor-validate-result"></div>
|
|
664
|
+
</fieldset>
|
|
665
|
+
</div>
|
|
666
|
+
|
|
667
|
+
<!-- Codex 卡片 -->
|
|
668
|
+
<div class="agent-card" id="agent-card-codex">
|
|
669
|
+
<div class="agent-card-header">
|
|
670
|
+
<input type="checkbox" class="agent-toggle" id="agent-enable-codex" onchange="onAgentToggle('codex', this.checked)">
|
|
671
|
+
<div class="meta">
|
|
672
|
+
<div class="name">Codex</div>
|
|
673
|
+
<div class="desc">OpenAI Codex CLI<br>需安装并登录</div>
|
|
674
|
+
</div>
|
|
675
|
+
</div>
|
|
676
|
+
<fieldset class="agent-body" id="agent-body-codex" disabled>
|
|
677
|
+
<div class="form-group">
|
|
678
|
+
<label>CLI 路径</label>
|
|
679
|
+
<input type="text" id="field-CHATCCC_CODEX_PATH" placeholder="codex">
|
|
680
|
+
</div>
|
|
681
|
+
<div class="form-group">
|
|
682
|
+
<label>模型</label>
|
|
683
|
+
<input type="text" id="field-CHATCCC_CODEX_MODEL" placeholder="留空由 codex config.toml 决定">
|
|
684
|
+
</div>
|
|
685
|
+
<div class="form-group">
|
|
686
|
+
<label>努力程度 (Effort)</label>
|
|
687
|
+
<input type="text" id="field-CHATCCC_CODEX_EFFORT" placeholder="留空由 codex config.toml 决定">
|
|
688
|
+
</div>
|
|
689
|
+
<button class="btn btn-outline" onclick="validateCli('codex')" style="margin-bottom:12px">检测 Codex CLI</button>
|
|
690
|
+
<div id="codex-validate-result"></div>
|
|
691
|
+
</fieldset>
|
|
692
|
+
</div>
|
|
693
|
+
|
|
694
|
+
</div>
|
|
695
|
+
|
|
696
|
+
<div class="btn-group" style="justify-content:space-between">
|
|
697
|
+
<button class="btn btn-outline" onclick="goStep(1)">返回</button>
|
|
698
|
+
<button class="btn btn-primary" id="btn-step2-next" disabled onclick="goStep(3)">下一步</button>
|
|
699
|
+
</div>
|
|
700
|
+
</div>
|
|
701
|
+
|
|
702
|
+
<!-- Step 3: Review -->
|
|
703
|
+
<div id="step-3" class="card hidden">
|
|
704
|
+
<h2>确认配置</h2>
|
|
705
|
+
<div id="review-content"></div>
|
|
706
|
+
<div class="btn-group" style="justify-content:space-between;margin-top:16px">
|
|
707
|
+
<button class="btn btn-outline" onclick="goStep(2)">返回修改</button>
|
|
708
|
+
<button class="btn btn-success" id="btn-save-start" onclick="saveAndStart()">保存并启动</button>
|
|
709
|
+
</div>
|
|
710
|
+
</div>
|
|
711
|
+
</div>
|
|
712
|
+
|
|
713
|
+
<!-- ===== Dashboard ===== -->
|
|
714
|
+
<div id="dashboard-view" class="hidden">
|
|
715
|
+
<div class="status-bar" id="status-bar">
|
|
716
|
+
<div class="status-dot stopped" id="status-dot"></div>
|
|
717
|
+
<div style="flex:1">
|
|
718
|
+
<div style="font-weight:600" id="status-text">服务未启动</div>
|
|
719
|
+
<div style="font-size:13px;color:#64748b" id="status-detail"></div>
|
|
720
|
+
</div>
|
|
721
|
+
<div class="btn-group">
|
|
722
|
+
<button class="btn btn-danger" id="btn-stop" onclick="stopService()">停止</button>
|
|
723
|
+
<button class="btn btn-outline" id="btn-restart" onclick="restartService()">重启</button>
|
|
724
|
+
</div>
|
|
725
|
+
</div>
|
|
726
|
+
|
|
727
|
+
<details class="card config-section">
|
|
728
|
+
<summary>飞书应用</summary>
|
|
729
|
+
<div class="section-detail">
|
|
730
|
+
<div class="config-row"><span class="key">App ID</span><span class="val" id="cfg-APP_ID">-</span></div>
|
|
731
|
+
<div class="config-row"><span class="key">App Secret</span><span class="val" id="cfg-APP_SECRET">-</span></div>
|
|
732
|
+
<button class="btn btn-outline" style="margin-top:8px" onclick="editSection('feishu')">编辑</button>
|
|
733
|
+
</div>
|
|
734
|
+
</details>
|
|
735
|
+
|
|
736
|
+
<details class="card config-section" id="dash-claude">
|
|
737
|
+
<summary>Claude Agent</summary>
|
|
738
|
+
<div class="section-detail">
|
|
739
|
+
<div class="config-row"><span class="key">API 来源</span><span class="val" id="cfg-CLAUDE_API_MODE">-</span></div>
|
|
740
|
+
<div class="config-row" id="cfg-row-CLAUDE_API_KEY"><span class="key">API Key</span><span class="val" id="cfg-CLAUDE_API_KEY">-</span></div>
|
|
741
|
+
<div class="config-row" id="cfg-row-CLAUDE_BASE_URL"><span class="key">Base URL</span><span class="val" id="cfg-CLAUDE_BASE_URL">-</span></div>
|
|
742
|
+
<div class="config-row"><span class="key">模型</span><span class="val" id="cfg-ANTHROPIC_MODEL">-</span></div>
|
|
743
|
+
<div class="config-row"><span class="key">Effort</span><span class="val" id="cfg-ANTHROPIC_EFFORT">-</span></div>
|
|
744
|
+
<button class="btn btn-outline" style="margin-top:8px" onclick="editSection('claude')">编辑</button>
|
|
745
|
+
</div>
|
|
746
|
+
</details>
|
|
747
|
+
|
|
748
|
+
<details class="card config-section" id="dash-cursor">
|
|
749
|
+
<summary>Cursor Agent</summary>
|
|
750
|
+
<div class="section-detail">
|
|
751
|
+
<div class="config-row"><span class="key">CLI 路径</span><span class="val" id="cfg-CURSOR_PATH">-</span></div>
|
|
752
|
+
<div class="config-row"><span class="key">模型</span><span class="val" id="cfg-CURSOR_MODEL">-</span></div>
|
|
753
|
+
<button class="btn btn-outline" style="margin-top:8px" onclick="editSection('cursor')">编辑</button>
|
|
754
|
+
</div>
|
|
755
|
+
</details>
|
|
756
|
+
|
|
757
|
+
<details class="card config-section" id="dash-codex">
|
|
758
|
+
<summary>Codex Agent</summary>
|
|
759
|
+
<div class="section-detail">
|
|
760
|
+
<div class="config-row"><span class="key">CLI 路径</span><span class="val" id="cfg-CODEX_PATH">-</span></div>
|
|
761
|
+
<div class="config-row"><span class="key">模型</span><span class="val" id="cfg-CODEX_MODEL">-</span></div>
|
|
762
|
+
<div class="config-row"><span class="key">Effort</span><span class="val" id="cfg-CODEX_EFFORT">-</span></div>
|
|
763
|
+
<button class="btn btn-outline" style="margin-top:8px" onclick="editSection('codex')">编辑</button>
|
|
764
|
+
</div>
|
|
765
|
+
</details>
|
|
766
|
+
|
|
767
|
+
<div class="card" id="dash-no-agent-hint" style="text-align:center;color:#94a3b8;display:none">
|
|
768
|
+
未启用任何 AI Agent。点击下方按钮重新运行配置向导启用。
|
|
769
|
+
</div>
|
|
770
|
+
|
|
771
|
+
<div class="card" style="text-align:center">
|
|
772
|
+
<button class="btn btn-outline" onclick="reconfigure()">重新运行配置向导</button>
|
|
773
|
+
</div>
|
|
774
|
+
</div>
|
|
775
|
+
|
|
776
|
+
<!-- Edit Modal -->
|
|
777
|
+
<div id="edit-modal" class="card hidden" style="position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);width:90%;max-width:480px;z-index:200;max-height:80vh;overflow-y:auto">
|
|
778
|
+
<h2 id="edit-modal-title">编辑配置</h2>
|
|
779
|
+
<div id="edit-modal-fields"></div>
|
|
780
|
+
<div class="btn-group" style="justify-content:flex-end;margin-top:16px">
|
|
781
|
+
<button class="btn btn-outline" onclick="closeEditModal()">取消</button>
|
|
782
|
+
<button class="btn btn-primary" onclick="saveEdit()">保存</button>
|
|
783
|
+
</div>
|
|
784
|
+
</div>
|
|
785
|
+
<div id="edit-overlay" class="hidden" style="position:fixed;inset:0;background:rgba(0,0,0,.3);z-index:199" onclick="closeEditModal()"></div>
|
|
786
|
+
|
|
787
|
+
</div>
|
|
788
|
+
|
|
789
|
+
<script>
|
|
790
|
+
let state = {
|
|
791
|
+
view: 'loading',
|
|
792
|
+
// 三个 Agent 各自的启用开关;初始全 false,renderStep2() 会按已存在 config 自动打开
|
|
793
|
+
agentsEnabled: { claude: false, cursor: false, codex: false },
|
|
794
|
+
wizardStep: 1,
|
|
795
|
+
config: {},
|
|
796
|
+
running: false,
|
|
797
|
+
pid: null
|
|
798
|
+
};
|
|
799
|
+
|
|
800
|
+
// Step 2 输入事件是否已绑(避免每次 goStep(2) 重复绑定)
|
|
801
|
+
var step2InputBound = false;
|
|
802
|
+
|
|
803
|
+
const AGENT_FIELDS = {
|
|
804
|
+
claude: ['CLAUDE_API_KEY','CLAUDE_BASE_URL','CHATCCC_ANTHROPIC_MODEL','CHATCCC_ANTHROPIC_EFFORT'],
|
|
805
|
+
cursor: ['CHATCCC_CURSOR_PATH','CHATCCC_CURSOR_MODEL'],
|
|
806
|
+
codex: ['CHATCCC_CODEX_PATH','CHATCCC_CODEX_MODEL','CHATCCC_CODEX_EFFORT']
|
|
807
|
+
};
|
|
808
|
+
const FEISHU_FIELDS = ['CHATCCC_APP_ID','CHATCCC_APP_SECRET'];
|
|
809
|
+
|
|
810
|
+
// 当前选中的 Claude API 模式("official" / "thirdparty")
|
|
811
|
+
// Wizard / Dashboard 都通过这个变量驱动 UI 显隐和提交时的 mode 字段
|
|
812
|
+
var claudeApiMode = 'official';
|
|
813
|
+
|
|
814
|
+
// model / effort 输入框 placeholder:仅第三方模式下举例典型取值(网关五花八门,
|
|
815
|
+
// 示例只是引导);官方模式不列举任何具体值,避免给出过期/不支持的取值产生误导。
|
|
816
|
+
var CLAUDE_MODEL_PLACEHOLDER = {
|
|
817
|
+
official: '留空表示不向 SDK 传 model',
|
|
818
|
+
thirdparty: 'deepseek-v4-pro'
|
|
819
|
+
};
|
|
820
|
+
var CLAUDE_EFFORT_PLACEHOLDER = {
|
|
821
|
+
official: '留空表示不向 SDK 传 effort',
|
|
822
|
+
thirdparty: 'low / medium / high / max(留空不向 SDK 传 effort)'
|
|
823
|
+
};
|
|
824
|
+
|
|
825
|
+
function onClaudeApiModeChange(mode) {
|
|
826
|
+
claudeApiMode = mode;
|
|
827
|
+
var thirdPartyEl = document.getElementById('claude-thirdparty-fields');
|
|
828
|
+
if (thirdPartyEl) {
|
|
829
|
+
thirdPartyEl.classList.toggle('hidden', mode !== 'thirdparty');
|
|
830
|
+
}
|
|
831
|
+
// Edit Modal 内的同名容器(如果当前打开的是 Claude 编辑模态框)
|
|
832
|
+
var editThirdPartyEl = document.getElementById('edit-claude-thirdparty-fields');
|
|
833
|
+
if (editThirdPartyEl) {
|
|
834
|
+
editThirdPartyEl.classList.toggle('hidden', mode !== 'thirdparty');
|
|
835
|
+
}
|
|
836
|
+
// 同步 model / effort 输入框的 placeholder(Wizard + Edit Modal 两处都更新)
|
|
837
|
+
var modelPlaceholder = CLAUDE_MODEL_PLACEHOLDER[mode] || CLAUDE_MODEL_PLACEHOLDER.official;
|
|
838
|
+
var effortPlaceholder = CLAUDE_EFFORT_PLACEHOLDER[mode] || CLAUDE_EFFORT_PLACEHOLDER.official;
|
|
839
|
+
var wizardModelEl = document.getElementById('field-CHATCCC_ANTHROPIC_MODEL');
|
|
840
|
+
if (wizardModelEl) wizardModelEl.placeholder = modelPlaceholder;
|
|
841
|
+
var editModelEl = document.getElementById('edit-CHATCCC_ANTHROPIC_MODEL');
|
|
842
|
+
if (editModelEl) editModelEl.placeholder = modelPlaceholder;
|
|
843
|
+
var wizardEffortEl = document.getElementById('field-CHATCCC_ANTHROPIC_EFFORT');
|
|
844
|
+
if (wizardEffortEl) wizardEffortEl.placeholder = effortPlaceholder;
|
|
845
|
+
var editEffortEl = document.getElementById('edit-CHATCCC_ANTHROPIC_EFFORT');
|
|
846
|
+
if (editEffortEl) editEffortEl.placeholder = effortPlaceholder;
|
|
847
|
+
// Claude 切换 API 模式可能改变"是否填对"的判定(第三方需要 apiKey+baseUrl)
|
|
848
|
+
updateStep2NextBtn();
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/** 切换某个 Agent 的启用状态:联动卡片高亮 + fieldset 禁用 + 下一步按钮校验 */
|
|
852
|
+
function onAgentToggle(agent, enabled) {
|
|
853
|
+
state.agentsEnabled[agent] = enabled;
|
|
854
|
+
var card = document.getElementById('agent-card-' + agent);
|
|
855
|
+
if (card) card.classList.toggle('enabled', enabled);
|
|
856
|
+
var body = document.getElementById('agent-body-' + agent);
|
|
857
|
+
if (body) body.disabled = !enabled;
|
|
858
|
+
updateStep2NextBtn();
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/** Claude 启用时"填对"的判定:第三方模式需 apiKey + baseUrl 都非空,其他情况一律算填对 */
|
|
862
|
+
function isClaudeFieldsValid() {
|
|
863
|
+
if (claudeApiMode !== 'thirdparty') return true;
|
|
864
|
+
var apiKey = (document.getElementById('field-CLAUDE_API_KEY') || {}).value || '';
|
|
865
|
+
var baseUrl = (document.getElementById('field-CLAUDE_BASE_URL') || {}).value || '';
|
|
866
|
+
return Boolean(apiKey.trim() && baseUrl.trim());
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/**
|
|
870
|
+
* "下一步"按钮启用条件:至少一个 Agent 开关打开且本身填写满足要求
|
|
871
|
+
* - claude:第三方模式必须 apiKey + baseUrl 都填;官方模式视为已填对
|
|
872
|
+
* - cursor / codex:开关打开即视为填对(path 留空时运行时会自动探测/退回 PATH)
|
|
873
|
+
*/
|
|
874
|
+
function updateStep2NextBtn() {
|
|
875
|
+
var btn = document.getElementById('btn-step2-next');
|
|
876
|
+
if (!btn) return;
|
|
877
|
+
var validCount = 0;
|
|
878
|
+
if (state.agentsEnabled.claude && isClaudeFieldsValid()) validCount++;
|
|
879
|
+
if (state.agentsEnabled.cursor) validCount++;
|
|
880
|
+
if (state.agentsEnabled.codex) validCount++;
|
|
881
|
+
btn.disabled = validCount === 0;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/** 加载已有 config 时按 detectClaudeApiMode 契约判定初始模式 */
|
|
885
|
+
function detectClaudeApiModeFromConfig(claude) {
|
|
886
|
+
if (!claude) return 'official';
|
|
887
|
+
var apiKey = (claude.apiKey || '').toString().trim();
|
|
888
|
+
var baseUrl = (claude.baseUrl || '').toString().trim();
|
|
889
|
+
return (apiKey || baseUrl) ? 'thirdparty' : 'official';
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// ---- API helpers ----
|
|
893
|
+
async function api(path, method, body) {
|
|
894
|
+
const opts = { method, headers: {} };
|
|
895
|
+
if (body) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); }
|
|
896
|
+
const r = await fetch(path, opts);
|
|
897
|
+
return r.json();
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// ---- Toast ----
|
|
901
|
+
function toast(msg, type) {
|
|
902
|
+
const el = document.createElement('div');
|
|
903
|
+
el.className = 'toast toast-' + (type || 'success');
|
|
904
|
+
el.textContent = msg;
|
|
905
|
+
document.body.appendChild(el);
|
|
906
|
+
setTimeout(function(){ el.remove(); }, 3000);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// ---- Init ----
|
|
910
|
+
async function init() {
|
|
911
|
+
const check = await api('/api/check');
|
|
912
|
+
if (check.hasCreds) {
|
|
913
|
+
await loadDashboard();
|
|
914
|
+
} else {
|
|
915
|
+
await showWizard();
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// ---- Wizard ----
|
|
920
|
+
const TOTAL_STEPS = 3;
|
|
921
|
+
|
|
922
|
+
async function showWizard() {
|
|
923
|
+
state.view = 'wizard';
|
|
924
|
+
document.getElementById('wizard-view').classList.remove('hidden');
|
|
925
|
+
document.getElementById('dashboard-view').classList.add('hidden');
|
|
926
|
+
document.getElementById('header-badge').textContent = '首次配置';
|
|
927
|
+
document.getElementById('header-badge').className = 'badge badge-stopped';
|
|
928
|
+
// 预加载已有 config,便于在 wizard 各步骤回填
|
|
929
|
+
try {
|
|
930
|
+
var d = await api('/api/config');
|
|
931
|
+
state.config = d.vars || {};
|
|
932
|
+
} catch (e) { state.config = state.config || {}; }
|
|
933
|
+
goStep(1);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function goStep1Next() {
|
|
937
|
+
var appId = (document.getElementById('field-CHATCCC_APP_ID').value || '').trim();
|
|
938
|
+
var appSecret = (document.getElementById('field-CHATCCC_APP_SECRET').value || '').trim();
|
|
939
|
+
if (!appId || !appSecret) {
|
|
940
|
+
toast('请先填写飞书 App ID 和 App Secret', 'error');
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
goStep(2);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
function goStep(n) {
|
|
947
|
+
state.wizardStep = n;
|
|
948
|
+
document.querySelectorAll('#wizard-view > .card').forEach(function(c){ c.classList.add('hidden'); });
|
|
949
|
+
document.getElementById('step-' + n).classList.remove('hidden');
|
|
950
|
+
document.querySelectorAll('#steps-bar .step').forEach(function(s, i){
|
|
951
|
+
s.classList.remove('active','done');
|
|
952
|
+
if (i + 1 < n) s.classList.add('done');
|
|
953
|
+
if (i + 1 === n) s.classList.add('active');
|
|
954
|
+
});
|
|
955
|
+
document.getElementById('step-label-bar').textContent = '第 ' + n + ' 步 / 共 ' + TOTAL_STEPS + ' 步';
|
|
956
|
+
if (n === 1) renderStep1();
|
|
957
|
+
if (n === 2) renderStep2();
|
|
958
|
+
if (n === 3) renderStep3();
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
function prefillNested(elId, val) {
|
|
962
|
+
var el = document.getElementById(elId);
|
|
963
|
+
if (el && !el.value && val !== undefined && val !== null && val !== '') el.value = val;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
function renderStep1() {
|
|
967
|
+
// 从嵌套 config 预填飞书字段(state.config.feishu.appId 等)
|
|
968
|
+
// 端口与 /git 超时不在前端页面配置,仅作为高级配置保留在 config.json 中。
|
|
969
|
+
var c = state.config || {};
|
|
970
|
+
var f = c.feishu || {};
|
|
971
|
+
prefillNested('field-CHATCCC_APP_ID', f.appId);
|
|
972
|
+
prefillNested('field-CHATCCC_APP_SECRET', f.appSecret);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/**
|
|
976
|
+
* 判定某个 agent 是否启用,优先级:
|
|
977
|
+
* 1) config 中显式 boolean enabled 字段
|
|
978
|
+
* 2) 任一配置字段非空(向后兼容旧 config.json,未升级到带 enabled 字段时仍可工作)
|
|
979
|
+
*/
|
|
980
|
+
function isAgentEnabled(node, keys) {
|
|
981
|
+
if (!node) return false;
|
|
982
|
+
if (typeof node.enabled === 'boolean') return node.enabled;
|
|
983
|
+
for (var i = 0; i < keys.length; i++) {
|
|
984
|
+
var v = node[keys[i]];
|
|
985
|
+
if (v !== undefined && v !== null && String(v).trim() !== '') return true;
|
|
986
|
+
}
|
|
987
|
+
return false;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
var CLAUDE_FALLBACK_KEYS = ['apiKey','baseUrl','model','effort'];
|
|
991
|
+
var CURSOR_FALLBACK_KEYS = ['path','command','model'];
|
|
992
|
+
var CODEX_FALLBACK_KEYS = ['path','command','model','effort'];
|
|
993
|
+
|
|
994
|
+
function renderStep2() {
|
|
995
|
+
var c = state.config || {};
|
|
996
|
+
if (c.claude) {
|
|
997
|
+
prefillNested('field-CLAUDE_API_KEY', c.claude.apiKey);
|
|
998
|
+
prefillNested('field-CLAUDE_BASE_URL', c.claude.baseUrl);
|
|
999
|
+
prefillNested('field-CHATCCC_ANTHROPIC_MODEL', c.claude.model);
|
|
1000
|
+
prefillNested('field-CHATCCC_ANTHROPIC_EFFORT', c.claude.effort);
|
|
1001
|
+
}
|
|
1002
|
+
// 按已有 apiKey/baseUrl 判定初始 API 模式,并相应显示/隐藏字段
|
|
1003
|
+
var initialMode = detectClaudeApiModeFromConfig(c.claude);
|
|
1004
|
+
var radio = document.getElementById('claude-api-mode-' + initialMode);
|
|
1005
|
+
if (radio) radio.checked = true;
|
|
1006
|
+
onClaudeApiModeChange(initialMode);
|
|
1007
|
+
if (c.cursor) {
|
|
1008
|
+
prefillNested('field-CHATCCC_CURSOR_PATH', c.cursor.path || c.cursor.command);
|
|
1009
|
+
prefillNested('field-CHATCCC_CURSOR_MODEL', c.cursor.model);
|
|
1010
|
+
}
|
|
1011
|
+
if (c.codex) {
|
|
1012
|
+
prefillNested('field-CHATCCC_CODEX_PATH', c.codex.path || c.codex.command);
|
|
1013
|
+
prefillNested('field-CHATCCC_CODEX_MODEL', c.codex.model);
|
|
1014
|
+
prefillNested('field-CHATCCC_CODEX_EFFORT', c.codex.effort);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// 按已有 config 决定每个 Agent 默认是否开启:优先 enabled 字段,缺省时按"任一字段非空"
|
|
1018
|
+
var claudeOn = isAgentEnabled(c.claude, CLAUDE_FALLBACK_KEYS);
|
|
1019
|
+
var cursorOn = isAgentEnabled(c.cursor, CURSOR_FALLBACK_KEYS);
|
|
1020
|
+
var codexOn = isAgentEnabled(c.codex, CODEX_FALLBACK_KEYS);
|
|
1021
|
+
document.getElementById('agent-enable-claude').checked = claudeOn;
|
|
1022
|
+
document.getElementById('agent-enable-cursor').checked = cursorOn;
|
|
1023
|
+
document.getElementById('agent-enable-codex').checked = codexOn;
|
|
1024
|
+
onAgentToggle('claude', claudeOn);
|
|
1025
|
+
onAgentToggle('cursor', cursorOn);
|
|
1026
|
+
onAgentToggle('codex', codexOn);
|
|
1027
|
+
|
|
1028
|
+
// Cursor path placeholder/hint:把已探测到的路径显示为占位
|
|
1029
|
+
var hint = document.getElementById('cursor-path-hint');
|
|
1030
|
+
var detected = c.cursor && (c.cursor.path || c.cursor.command);
|
|
1031
|
+
if (detected) {
|
|
1032
|
+
var inp = document.getElementById('field-CHATCCC_CURSOR_PATH');
|
|
1033
|
+
if (inp && !inp.value) inp.placeholder = detected;
|
|
1034
|
+
if (hint) hint.textContent = '已自动探测到';
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// 字段输入时实时刷新"下一步"按钮(Claude 第三方模式 apiKey/baseUrl 必填)
|
|
1038
|
+
if (!step2InputBound) {
|
|
1039
|
+
document.getElementById('step-2').addEventListener('input', updateStep2NextBtn);
|
|
1040
|
+
step2InputBound = true;
|
|
1041
|
+
}
|
|
1042
|
+
updateStep2NextBtn();
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
/**
|
|
1046
|
+
* 收集"待落地到 config.json 的扁平 vars"。
|
|
1047
|
+
*
|
|
1048
|
+
* - 飞书字段始终收集
|
|
1049
|
+
* - 三个 Agent 的 enabled 状态都显式下发,让 config.json 持久化用户的最新开关偏好
|
|
1050
|
+
* - Agent 字段仅在该 Agent 开关启用时收集;未启用的 Agent 不下发其它字段,
|
|
1051
|
+
* 服务端 deepMerge 会保留 config.json 中已有值(避免关闭开关时误清空旧配置)
|
|
1052
|
+
* - Claude 启用且当前 mode=official 时显式置空 apiKey/baseUrl,覆盖 config.json 旧值
|
|
1053
|
+
*/
|
|
1054
|
+
function collectAllFields() {
|
|
1055
|
+
var vars = {};
|
|
1056
|
+
FEISHU_FIELDS.forEach(function(key){
|
|
1057
|
+
var el = document.getElementById('field-' + key);
|
|
1058
|
+
if (el && el.value.trim()) vars[key] = el.value.trim();
|
|
1059
|
+
});
|
|
1060
|
+
vars.CHATCCC_CLAUDE_ENABLED = !!state.agentsEnabled.claude;
|
|
1061
|
+
vars.CHATCCC_CURSOR_ENABLED = !!state.agentsEnabled.cursor;
|
|
1062
|
+
vars.CHATCCC_CODEX_ENABLED = !!state.agentsEnabled.codex;
|
|
1063
|
+
if (state.agentsEnabled.claude) {
|
|
1064
|
+
AGENT_FIELDS.claude.forEach(function(key){
|
|
1065
|
+
var el = document.getElementById('field-' + key);
|
|
1066
|
+
if (el && el.value.trim()) vars[key] = el.value.trim();
|
|
1067
|
+
});
|
|
1068
|
+
if (claudeApiMode !== 'thirdparty') {
|
|
1069
|
+
vars.CLAUDE_API_KEY = '';
|
|
1070
|
+
vars.CLAUDE_BASE_URL = '';
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
if (state.agentsEnabled.cursor) {
|
|
1074
|
+
AGENT_FIELDS.cursor.forEach(function(key){
|
|
1075
|
+
var el = document.getElementById('field-' + key);
|
|
1076
|
+
if (el && el.value.trim()) vars[key] = el.value.trim();
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
if (state.agentsEnabled.codex) {
|
|
1080
|
+
AGENT_FIELDS.codex.forEach(function(key){
|
|
1081
|
+
var el = document.getElementById('field-' + key);
|
|
1082
|
+
if (el && el.value.trim()) vars[key] = el.value.trim();
|
|
1083
|
+
});
|
|
1084
|
+
}
|
|
1085
|
+
return vars;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
function renderStep3() {
|
|
1089
|
+
var vars = collectAllFields();
|
|
1090
|
+
var lines = [];
|
|
1091
|
+
lines.push('<h3 style="margin-bottom:8px">飞书应用</h3>');
|
|
1092
|
+
lines.push('<div class="config-row"><span class="key">CHATCCC_APP_ID</span><span class="val">' + (vars.CHATCCC_APP_ID || '<span style="color:#ef4444">未填写</span>') + '</span></div>');
|
|
1093
|
+
lines.push('<div class="config-row"><span class="key">CHATCCC_APP_SECRET</span><span class="val">' + (vars.CHATCCC_APP_SECRET ? '***已设置***' : '<span style="color:#ef4444">未填写</span>') + '</span></div>');
|
|
1094
|
+
|
|
1095
|
+
lines.push('<h3 style="margin:16px 0 8px">已启用的 AI Agent</h3>');
|
|
1096
|
+
var enabledList = [];
|
|
1097
|
+
if (state.agentsEnabled.claude) enabledList.push('claude');
|
|
1098
|
+
if (state.agentsEnabled.cursor) enabledList.push('cursor');
|
|
1099
|
+
if (state.agentsEnabled.codex) enabledList.push('codex');
|
|
1100
|
+
if (enabledList.length === 0) {
|
|
1101
|
+
lines.push('<div style="color:#ef4444">未启用任何 AI Agent</div>');
|
|
1102
|
+
}
|
|
1103
|
+
enabledList.forEach(function(t){
|
|
1104
|
+
if (t === 'claude') {
|
|
1105
|
+
lines.push('<h4 style="margin:10px 0 4px;color:#334155">Claude Code</h4>');
|
|
1106
|
+
var modeLabel = claudeApiMode === 'thirdparty' ? '第三方 API(自定义网关)' : '官方 API(Anthropic 直连)';
|
|
1107
|
+
lines.push('<div class="config-row"><span class="key">API 来源</span><span class="val">' + modeLabel + '</span></div>');
|
|
1108
|
+
if (claudeApiMode === 'thirdparty') {
|
|
1109
|
+
lines.push('<div class="config-row"><span class="key">API Key</span><span class="val">' + (vars.CLAUDE_API_KEY ? '***已设置***' : '(未设置)') + '</span></div>');
|
|
1110
|
+
if (vars.CLAUDE_BASE_URL) lines.push('<div class="config-row"><span class="key">Base URL</span><span class="val">' + vars.CLAUDE_BASE_URL + '</span></div>');
|
|
1111
|
+
}
|
|
1112
|
+
lines.push('<div class="config-row"><span class="key">模型</span><span class="val">' + (vars.CHATCCC_ANTHROPIC_MODEL || '(留空)') + '</span></div>');
|
|
1113
|
+
lines.push('<div class="config-row"><span class="key">Effort</span><span class="val">' + (vars.CHATCCC_ANTHROPIC_EFFORT || '(留空)') + '</span></div>');
|
|
1114
|
+
} else if (t === 'cursor') {
|
|
1115
|
+
lines.push('<h4 style="margin:10px 0 4px;color:#334155">Cursor</h4>');
|
|
1116
|
+
if (vars.CHATCCC_CURSOR_PATH) lines.push('<div class="config-row"><span class="key">CLI 路径</span><span class="val">' + vars.CHATCCC_CURSOR_PATH + '</span></div>');
|
|
1117
|
+
lines.push('<div class="config-row"><span class="key">模型</span><span class="val">' + (vars.CHATCCC_CURSOR_MODEL || '(留空)') + '</span></div>');
|
|
1118
|
+
} else if (t === 'codex') {
|
|
1119
|
+
lines.push('<h4 style="margin:10px 0 4px;color:#334155">Codex</h4>');
|
|
1120
|
+
if (vars.CHATCCC_CODEX_PATH) lines.push('<div class="config-row"><span class="key">CLI 路径</span><span class="val">' + vars.CHATCCC_CODEX_PATH + '</span></div>');
|
|
1121
|
+
lines.push('<div class="config-row"><span class="key">模型</span><span class="val">' + (vars.CHATCCC_CODEX_MODEL || '(留空)') + '</span></div>');
|
|
1122
|
+
lines.push('<div class="config-row"><span class="key">Effort</span><span class="val">' + (vars.CHATCCC_CODEX_EFFORT || '(留空)') + '</span></div>');
|
|
1123
|
+
}
|
|
1124
|
+
});
|
|
1125
|
+
document.getElementById('review-content').innerHTML = lines.join('');
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
async function saveConfig(vars) {
|
|
1129
|
+
// 同时把当前 claudeApiMode 传给服务端:服务端 applyClaudeApiMode() 会按
|
|
1130
|
+
// mode 强制清空 apiKey/baseUrl(即使前端 vars 没传也会兌底,详见 web-ui.test.ts)。
|
|
1131
|
+
//
|
|
1132
|
+
// 当 wizard 中 claude 开关未启用时,前端不打算修改 claude 的任何字段——但服务端
|
|
1133
|
+
// 默认按 official 兌底会清空 apiKey/baseUrl。这里按"当前 config 的模式"上送,
|
|
1134
|
+
// 让已存在的 thirdparty 凭证保留不被清空。
|
|
1135
|
+
var modeToSend = claudeApiMode;
|
|
1136
|
+
if (state.view === 'wizard' && state.agentsEnabled && state.agentsEnabled.claude === false) {
|
|
1137
|
+
modeToSend = detectClaudeApiModeFromConfig(state.config && state.config.claude);
|
|
1138
|
+
}
|
|
1139
|
+
var result = await api('/api/config', 'POST', { vars: vars, claudeApiMode: modeToSend });
|
|
1140
|
+
if (result.ok) {
|
|
1141
|
+
state.config = Object.assign({}, state.config, vars);
|
|
1142
|
+
toast('配置已保存');
|
|
1143
|
+
} else {
|
|
1144
|
+
toast('保存失败: ' + (result.error || '未知错误'), 'error');
|
|
1145
|
+
}
|
|
1146
|
+
return result.ok;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
async function saveAndStart() {
|
|
1150
|
+
var vars = collectAllFields();
|
|
1151
|
+
if (!vars.CHATCCC_APP_ID || !vars.CHATCCC_APP_SECRET) {
|
|
1152
|
+
toast('请先填写飞书 App ID 和 App Secret', 'error');
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
var ok = await saveConfig(vars);
|
|
1156
|
+
if (!ok) return;
|
|
1157
|
+
document.getElementById('btn-save-start').disabled = true;
|
|
1158
|
+
document.getElementById('btn-save-start').innerHTML = '<span class="spinner"></span> 应用中...';
|
|
1159
|
+
var result = await api('/api/start', 'POST');
|
|
1160
|
+
if (result.ok) {
|
|
1161
|
+
// 后端按 mode 区分场景,前端给出更贴切的 toast:
|
|
1162
|
+
// - inplace:setup → service 首次激活,进程内启动飞书 service
|
|
1163
|
+
// - reload :service 已经在跑,仅刷新进程内 config(不真重启)
|
|
1164
|
+
// - spawn :旧 service 已退出,spawn 新子进程
|
|
1165
|
+
var msg;
|
|
1166
|
+
if (result.mode === 'reload') {
|
|
1167
|
+
msg = '配置已保存并生效(无须重启)';
|
|
1168
|
+
} else if (result.mode === 'inplace') {
|
|
1169
|
+
msg = '服务已启动! PID: ' + result.pid;
|
|
1170
|
+
} else {
|
|
1171
|
+
msg = '服务已启动! PID: ' + (result.pid || '?');
|
|
1172
|
+
}
|
|
1173
|
+
toast(msg);
|
|
1174
|
+
setTimeout(function(){ location.reload(); }, 1500);
|
|
1175
|
+
} else {
|
|
1176
|
+
toast('保存失败: ' + (result.error || '未知错误'), 'error');
|
|
1177
|
+
document.getElementById('btn-save-start').disabled = false;
|
|
1178
|
+
document.getElementById('btn-save-start').textContent = '保存并启动';
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
// ---- Dashboard ----
|
|
1183
|
+
async function loadDashboard() {
|
|
1184
|
+
state.view = 'dashboard';
|
|
1185
|
+
document.getElementById('wizard-view').classList.add('hidden');
|
|
1186
|
+
document.getElementById('dashboard-view').classList.remove('hidden');
|
|
1187
|
+
|
|
1188
|
+
var configData = await api('/api/config');
|
|
1189
|
+
state.config = configData.vars || {};
|
|
1190
|
+
state.running = configData.running;
|
|
1191
|
+
state.pid = configData.pid;
|
|
1192
|
+
|
|
1193
|
+
updateDashboardUI();
|
|
1194
|
+
if (state.running) { pollStatus(); }
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
function updateDashboardUI() {
|
|
1198
|
+
var running = state.running;
|
|
1199
|
+
var dot = document.getElementById('status-dot');
|
|
1200
|
+
var text = document.getElementById('status-text');
|
|
1201
|
+
var detail = document.getElementById('status-detail');
|
|
1202
|
+
var badge = document.getElementById('header-badge');
|
|
1203
|
+
var btnStop = document.getElementById('btn-stop');
|
|
1204
|
+
var btnRestart = document.getElementById('btn-restart');
|
|
1205
|
+
|
|
1206
|
+
// dashboard 顶部不再提供"启动"按钮:dashboard 本身跑在 chatccc 进程内,
|
|
1207
|
+
// 用户能看到此页面时 service 必然在跑;停止后页面随进程退出无法再点启动,
|
|
1208
|
+
// 必须回到终端 chatccc 重新启动。这里只保留"停止 / 重启"。
|
|
1209
|
+
if (running) {
|
|
1210
|
+
dot.className = 'status-dot running';
|
|
1211
|
+
text.textContent = '服务运行中';
|
|
1212
|
+
detail.textContent = 'PID: ' + (state.pid || '?') + ' | 端口: ' + (state.config.port || '18080');
|
|
1213
|
+
badge.textContent = '运行中';
|
|
1214
|
+
badge.className = 'badge badge-running';
|
|
1215
|
+
btnStop.disabled = false;
|
|
1216
|
+
btnRestart.disabled = false;
|
|
1217
|
+
} else {
|
|
1218
|
+
dot.className = 'status-dot stopped';
|
|
1219
|
+
text.textContent = '服务未启动(请在终端运行 chatccc 重新启动)';
|
|
1220
|
+
detail.textContent = '';
|
|
1221
|
+
badge.textContent = '已停止';
|
|
1222
|
+
badge.className = 'badge badge-stopped';
|
|
1223
|
+
btnStop.disabled = true;
|
|
1224
|
+
btnRestart.disabled = true;
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
// Config summary
|
|
1228
|
+
var c = state.config;
|
|
1229
|
+
document.getElementById('cfg-APP_ID').textContent = c.feishu && c.feishu.appId ? c.feishu.appId.slice(0,8) + '...' + c.feishu.appId.slice(-4) : '-';
|
|
1230
|
+
document.getElementById('cfg-APP_SECRET').textContent = c.feishu && c.feishu.appSecret ? '***已设置***' : '-';
|
|
1231
|
+
|
|
1232
|
+
// 只显示已启用的 Agent 卡片(按 enabled 字段;缺省时退回到"任一字段非空"兼容旧 config)
|
|
1233
|
+
var claudeOn = isAgentEnabled(c.claude, CLAUDE_FALLBACK_KEYS);
|
|
1234
|
+
var cursorOn = isAgentEnabled(c.cursor, CURSOR_FALLBACK_KEYS);
|
|
1235
|
+
var codexOn = isAgentEnabled(c.codex, CODEX_FALLBACK_KEYS);
|
|
1236
|
+
document.getElementById('dash-claude').style.display = claudeOn ? '' : 'none';
|
|
1237
|
+
document.getElementById('dash-cursor').style.display = cursorOn ? '' : 'none';
|
|
1238
|
+
document.getElementById('dash-codex').style.display = codexOn ? '' : 'none';
|
|
1239
|
+
// 三个都未启用时给一个空态提示,引导用户去配置向导启用
|
|
1240
|
+
var emptyHint = document.getElementById('dash-no-agent-hint');
|
|
1241
|
+
if (emptyHint) emptyHint.style.display = (!claudeOn && !cursorOn && !codexOn) ? '' : 'none';
|
|
1242
|
+
|
|
1243
|
+
// 按 detectClaudeApiMode 的契约判定 API 来源(apiKey/baseUrl 任一非空 → 第三方)
|
|
1244
|
+
var claudeApiKey = (c.claude && c.claude.apiKey ? String(c.claude.apiKey) : '').trim();
|
|
1245
|
+
var claudeBaseUrl = (c.claude && c.claude.baseUrl ? String(c.claude.baseUrl) : '').trim();
|
|
1246
|
+
var isThirdPartyClaude = !!(claudeApiKey || claudeBaseUrl);
|
|
1247
|
+
document.getElementById('cfg-CLAUDE_API_MODE').textContent = isThirdPartyClaude ? '第三方 API(自定义网关)' : '官方 API(Anthropic 直连)';
|
|
1248
|
+
// 官方模式下 API Key / Base URL 行整体隐藏,避免显示无意义的 "-"
|
|
1249
|
+
document.getElementById('cfg-row-CLAUDE_API_KEY').style.display = isThirdPartyClaude ? '' : 'none';
|
|
1250
|
+
document.getElementById('cfg-row-CLAUDE_BASE_URL').style.display = isThirdPartyClaude ? '' : 'none';
|
|
1251
|
+
document.getElementById('cfg-CLAUDE_API_KEY').textContent = claudeApiKey ? '***已设置***' : '-';
|
|
1252
|
+
document.getElementById('cfg-CLAUDE_BASE_URL').textContent = claudeBaseUrl || '-';
|
|
1253
|
+
document.getElementById('cfg-ANTHROPIC_MODEL').textContent = (c.claude && c.claude.model) || '(留空)';
|
|
1254
|
+
document.getElementById('cfg-ANTHROPIC_EFFORT').textContent = (c.claude && c.claude.effort) || '(留空)';
|
|
1255
|
+
document.getElementById('cfg-CURSOR_PATH').textContent = (c.cursor && (c.cursor.path || c.cursor.command)) || '-';
|
|
1256
|
+
document.getElementById('cfg-CURSOR_MODEL').textContent = (c.cursor && c.cursor.model) || '(留空)';
|
|
1257
|
+
document.getElementById('cfg-CODEX_PATH').textContent = (c.codex && (c.codex.path || c.codex.command)) || 'codex';
|
|
1258
|
+
document.getElementById('cfg-CODEX_MODEL').textContent = (c.codex && c.codex.model) || '(留空)';
|
|
1259
|
+
document.getElementById('cfg-CODEX_EFFORT').textContent = (c.codex && c.codex.effort) || '(留空)';
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
function pollStatus() {
|
|
1263
|
+
setInterval(async function(){
|
|
1264
|
+
if (state.view !== 'dashboard') return;
|
|
1265
|
+
var s = await api('/api/status');
|
|
1266
|
+
state.running = s.running;
|
|
1267
|
+
state.pid = s.pid;
|
|
1268
|
+
updateDashboardUI();
|
|
1269
|
+
}, 5000);
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
async function stopService() {
|
|
1273
|
+
if (!confirm('确定要停止服务吗?停止后需要在终端重新运行 chatccc 来启动。')) return;
|
|
1274
|
+
document.getElementById('btn-stop').disabled = true;
|
|
1275
|
+
document.getElementById('btn-stop').textContent = '停止中...';
|
|
1276
|
+
await api('/api/stop', 'POST');
|
|
1277
|
+
state.running = false;
|
|
1278
|
+
state.pid = null;
|
|
1279
|
+
toast('服务已停止。请在终端运行 chatccc 重新启动。');
|
|
1280
|
+
updateDashboardUI();
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
async function restartService() {
|
|
1284
|
+
if (!confirm('确定要重启服务吗?')) return;
|
|
1285
|
+
document.getElementById('btn-restart').disabled = true;
|
|
1286
|
+
document.getElementById('btn-restart').textContent = '重启中...';
|
|
1287
|
+
await api('/api/start', 'POST');
|
|
1288
|
+
// Wait and refresh
|
|
1289
|
+
setTimeout(async function(){
|
|
1290
|
+
var s = await api('/api/status');
|
|
1291
|
+
state.running = s.running;
|
|
1292
|
+
state.pid = s.pid;
|
|
1293
|
+
updateDashboardUI();
|
|
1294
|
+
document.getElementById('btn-restart').disabled = false;
|
|
1295
|
+
document.getElementById('btn-restart').textContent = '重启';
|
|
1296
|
+
}, 2000);
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
// ---- Edit Modal ----
|
|
1300
|
+
var editSectionType = null;
|
|
1301
|
+
|
|
1302
|
+
function editSection(section) {
|
|
1303
|
+
editSectionType = section;
|
|
1304
|
+
var fields;
|
|
1305
|
+
if (section === 'feishu') fields = FEISHU_FIELDS;
|
|
1306
|
+
else fields = AGENT_FIELDS[section] || [];
|
|
1307
|
+
|
|
1308
|
+
var titleMap = { feishu: '飞书应用', claude: 'Claude Agent', cursor: 'Cursor Agent', codex: 'Codex Agent' };
|
|
1309
|
+
document.getElementById('edit-modal-title').textContent = '编辑 ' + (titleMap[section] || section);
|
|
1310
|
+
|
|
1311
|
+
var html = '';
|
|
1312
|
+
var labelMap = {
|
|
1313
|
+
'CHATCCC_APP_ID': 'App ID', 'CHATCCC_APP_SECRET': 'App Secret',
|
|
1314
|
+
'CLAUDE_API_KEY': 'API Key', 'CLAUDE_BASE_URL': 'Base URL',
|
|
1315
|
+
'CHATCCC_ANTHROPIC_MODEL': '模型', 'CHATCCC_ANTHROPIC_EFFORT': 'Effort',
|
|
1316
|
+
'CHATCCC_CURSOR_PATH': 'CLI 路径', 'CHATCCC_CURSOR_MODEL': '模型',
|
|
1317
|
+
'CHATCCC_CODEX_PATH': 'CLI 路径', 'CHATCCC_CODEX_MODEL': '模型', 'CHATCCC_CODEX_EFFORT': 'Effort'
|
|
1318
|
+
};
|
|
1319
|
+
|
|
1320
|
+
// Claude Agent 编辑:先按当前 config 决定初始 mode,并在 modal 顶部插入切换器;
|
|
1321
|
+
// 把 API Key / Base URL 的输入框装进同一个 .hidden 容器,按 mode 显隐与 Wizard 一致。
|
|
1322
|
+
if (section === 'claude') {
|
|
1323
|
+
var initialMode = detectClaudeApiModeFromConfig(state.config && state.config.claude);
|
|
1324
|
+
claudeApiMode = initialMode;
|
|
1325
|
+
html += '<div class="form-group" style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:12px 14px">';
|
|
1326
|
+
html += ' <label style="margin-bottom:8px;font-weight:600;color:#334155">API 来源</label>';
|
|
1327
|
+
html += ' <div style="display:flex;gap:18px;flex-wrap:wrap">';
|
|
1328
|
+
html += ' <label style="display:flex;align-items:center;gap:6px;font-weight:500;cursor:pointer">';
|
|
1329
|
+
html += ' <input type="radio" name="edit-claude-api-mode" value="official"' + (initialMode === 'official' ? ' checked' : '') + ' onchange="onClaudeApiModeChange(\\'official\\')">';
|
|
1330
|
+
html += ' 官方 API(Anthropic 直连)';
|
|
1331
|
+
html += ' </label>';
|
|
1332
|
+
html += ' <label style="display:flex;align-items:center;gap:6px;font-weight:500;cursor:pointer">';
|
|
1333
|
+
html += ' <input type="radio" name="edit-claude-api-mode" value="thirdparty"' + (initialMode === 'thirdparty' ? ' checked' : '') + ' onchange="onClaudeApiModeChange(\\'thirdparty\\')">';
|
|
1334
|
+
html += ' 第三方 API(自定义网关)';
|
|
1335
|
+
html += ' </label>';
|
|
1336
|
+
html += ' </div>';
|
|
1337
|
+
html += ' <div class="hint" style="margin-top:6px">切回官方模式后保存,已填的密钥会被清空。</div>';
|
|
1338
|
+
html += '</div>';
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
var thirdPartyOpened = false;
|
|
1342
|
+
fields.forEach(function(key){
|
|
1343
|
+
var val = state.config[key] || '';
|
|
1344
|
+
// Also check nested config
|
|
1345
|
+
if (!val) {
|
|
1346
|
+
if (section === 'feishu') {
|
|
1347
|
+
if (key === 'CHATCCC_APP_ID' && state.config.feishu) val = state.config.feishu.appId || '';
|
|
1348
|
+
else if (key === 'CHATCCC_APP_SECRET' && state.config.feishu) val = state.config.feishu.appSecret || '';
|
|
1349
|
+
} else if (section === 'claude' && state.config.claude) {
|
|
1350
|
+
if (key === 'CLAUDE_API_KEY') val = state.config.claude.apiKey || '';
|
|
1351
|
+
else if (key === 'CLAUDE_BASE_URL') val = state.config.claude.baseUrl || '';
|
|
1352
|
+
else if (key === 'CHATCCC_ANTHROPIC_MODEL') val = state.config.claude.model || '';
|
|
1353
|
+
else if (key === 'CHATCCC_ANTHROPIC_EFFORT') val = state.config.claude.effort || '';
|
|
1354
|
+
} else if (section === 'cursor' && state.config.cursor) {
|
|
1355
|
+
if (key === 'CHATCCC_CURSOR_PATH') val = state.config.cursor.path || state.config.cursor.command || '';
|
|
1356
|
+
else if (key === 'CHATCCC_CURSOR_MODEL') val = state.config.cursor.model || '';
|
|
1357
|
+
} else if (section === 'codex' && state.config.codex) {
|
|
1358
|
+
if (key === 'CHATCCC_CODEX_PATH') val = state.config.codex.path || state.config.codex.command || '';
|
|
1359
|
+
else if (key === 'CHATCCC_CODEX_MODEL') val = state.config.codex.model || '';
|
|
1360
|
+
else if (key === 'CHATCCC_CODEX_EFFORT') val = state.config.codex.effort || '';
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
var isSecret = key.includes('SECRET') || key.includes('API_KEY');
|
|
1364
|
+
var isClaudeThirdPartyField = section === 'claude' && (key === 'CLAUDE_API_KEY' || key === 'CLAUDE_BASE_URL');
|
|
1365
|
+
|
|
1366
|
+
if (isClaudeThirdPartyField && !thirdPartyOpened) {
|
|
1367
|
+
html += '<div id="edit-claude-thirdparty-fields"' + (claudeApiMode === 'thirdparty' ? '' : ' class="hidden"') + '>';
|
|
1368
|
+
thirdPartyOpened = true;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
html += '<div class="form-group"><label>' + (labelMap[key] || key) + '</label>';
|
|
1372
|
+
html += '<input type="' + (isSecret ? 'password' : 'text') + '" id="edit-' + key + '" value="' + String(val).replace(/"/g,'"') + '">';
|
|
1373
|
+
html += '</div>';
|
|
1374
|
+
});
|
|
1375
|
+
|
|
1376
|
+
if (thirdPartyOpened) html += '</div>';
|
|
1377
|
+
|
|
1378
|
+
document.getElementById('edit-modal-fields').innerHTML = html;
|
|
1379
|
+
document.getElementById('edit-modal').classList.remove('hidden');
|
|
1380
|
+
document.getElementById('edit-overlay').classList.remove('hidden');
|
|
1381
|
+
|
|
1382
|
+
// Edit Modal 的 input 是 innerHTML 现写入的,需要在 DOM 就绪后再触发一次同步:
|
|
1383
|
+
// 让 model 输入框的 placeholder / 第三方字段容器的显隐状态,跟当前 claudeApiMode 对齐。
|
|
1384
|
+
if (section === 'claude') onClaudeApiModeChange(claudeApiMode);
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
function closeEditModal() {
|
|
1388
|
+
document.getElementById('edit-modal').classList.add('hidden');
|
|
1389
|
+
document.getElementById('edit-overlay').classList.add('hidden');
|
|
1390
|
+
editSectionType = null;
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
async function saveEdit() {
|
|
1394
|
+
var fields;
|
|
1395
|
+
if (editSectionType === 'feishu') fields = FEISHU_FIELDS;
|
|
1396
|
+
else fields = AGENT_FIELDS[editSectionType] || [];
|
|
1397
|
+
|
|
1398
|
+
var vars = {};
|
|
1399
|
+
fields.forEach(function(key){
|
|
1400
|
+
var el = document.getElementById('edit-' + key);
|
|
1401
|
+
if (el) vars[key] = el.value.trim();
|
|
1402
|
+
});
|
|
1403
|
+
// 编辑 Claude 时按当前 mode 强制清空 apiKey/baseUrl(与 collectAllFields 保持一致);
|
|
1404
|
+
// 服务端 applyClaudeApiMode 也会兌底,但这里前端先做能让 state.config 立即同步。
|
|
1405
|
+
if (editSectionType === 'claude' && claudeApiMode !== 'thirdparty') {
|
|
1406
|
+
vars.CLAUDE_API_KEY = '';
|
|
1407
|
+
vars.CLAUDE_BASE_URL = '';
|
|
1408
|
+
}
|
|
1409
|
+
await saveConfig(vars);
|
|
1410
|
+
closeEditModal();
|
|
1411
|
+
// 本地 state.config.claude 也按 mode 同步清空,避免 dashboard UI 残留旧值
|
|
1412
|
+
if (editSectionType === 'claude' && claudeApiMode !== 'thirdparty') {
|
|
1413
|
+
state.config.claude = state.config.claude || {};
|
|
1414
|
+
state.config.claude.apiKey = '';
|
|
1415
|
+
state.config.claude.baseUrl = '';
|
|
1416
|
+
}
|
|
1417
|
+
updateDashboardUI();
|
|
1418
|
+
toast('修改已保存。若服务正在运行,需重启生效。');
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
// ---- Other actions ----
|
|
1422
|
+
function reconfigure() {
|
|
1423
|
+
if (!confirm('这将重新打开配置向导。现有配置不会丢失。')) return;
|
|
1424
|
+
state.view = 'wizard';
|
|
1425
|
+
state.wizardStep = 1;
|
|
1426
|
+
// agentsEnabled / claudeApiMode 都留给 renderStep2() 按已有 config 重新判定
|
|
1427
|
+
showWizard().catch(function(){});
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
function validateCli(tool) {
|
|
1431
|
+
var resultEl = document.getElementById(tool + '-validate-result');
|
|
1432
|
+
resultEl.innerHTML = '<span style="color:#94a3b8">检测中...</span>';
|
|
1433
|
+
api('/api/validate', 'POST', { tool: tool }).then(function(r){
|
|
1434
|
+
if (r.ok) {
|
|
1435
|
+
resultEl.innerHTML = '<span style="color:#16a34a">已找到: ' + r.path + ' | ' + r.error + '</span>';
|
|
1436
|
+
} else {
|
|
1437
|
+
resultEl.innerHTML = '<span style="color:#ef4444">未找到: ' + r.path + ' — ' + r.error + '</span>';
|
|
1438
|
+
}
|
|
1439
|
+
});
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
// ---- Start ----
|
|
1443
|
+
init();
|
|
1444
|
+
</script>
|
|
1445
|
+
</body>
|
|
1446
|
+
</html>`;
|
|
1447
|
+
|
|
1448
|
+
// ---------------------------------------------------------------------------
|
|
1449
|
+
// Router
|
|
1450
|
+
// ---------------------------------------------------------------------------
|
|
1451
|
+
|
|
1452
|
+
async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
1453
|
+
const url = req.url ?? "/";
|
|
1454
|
+
const method = req.method ?? "GET";
|
|
1455
|
+
|
|
1456
|
+
// API routes
|
|
1457
|
+
if (url === "/api/check" && method === "GET") return handleApiCheck(req, res);
|
|
1458
|
+
if (url === "/api/config" && method === "GET") return handleGetConfig(req, res);
|
|
1459
|
+
if (url === "/api/config" && method === "POST") return handlePostConfig(req, res);
|
|
1460
|
+
if (url === "/api/status" && method === "GET") return handleGetStatus(req, res);
|
|
1461
|
+
if (url === "/api/start" && method === "POST") return handleStartService(req, res);
|
|
1462
|
+
if (url === "/api/stop" && method === "POST") return handleStopService(req, res);
|
|
1463
|
+
if (url === "/api/validate" && method === "POST") return handleValidate(req, res);
|
|
1464
|
+
|
|
1465
|
+
// Serve HTML page for all other GET requests
|
|
1466
|
+
if (method === "GET") {
|
|
1467
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
1468
|
+
res.end(PAGE_HTML);
|
|
1469
|
+
return;
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
jsonReply(res, 404, { error: "Not found" });
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
export function createUiRouter(): (req: IncomingMessage, res: ServerResponse) => void {
|
|
1476
|
+
return (req, res) => {
|
|
1477
|
+
handleRequest(req, res).catch((err) => {
|
|
1478
|
+
console.error(`[WEB-UI] Unhandled error: ${(err as Error).message}`);
|
|
1479
|
+
if (!res.headersSent) jsonReply(res, 500, { error: "Internal error" });
|
|
1480
|
+
});
|
|
1481
|
+
};
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
// ---------------------------------------------------------------------------
|
|
1485
|
+
// Setup mode entry point — called from index.ts when no credentials
|
|
1486
|
+
// ---------------------------------------------------------------------------
|
|
1487
|
+
|
|
1488
|
+
/**
|
|
1489
|
+
* 跨平台调起本地浏览器。
|
|
1490
|
+
* - Windows:cmd /c start "" <url>。注意 start 把第一个被引号包裹的参数当成"窗口标题",
|
|
1491
|
+
* 所以这里需要传一个空标题占位,否则当 url 被引号包住时它会被错误地当成标题。
|
|
1492
|
+
* - macOS:open <url>
|
|
1493
|
+
* - Linux:xdg-open <url>
|
|
1494
|
+
*
|
|
1495
|
+
* 任何失败都不抛——主流程不依赖浏览器是否真的弹起来,console 已经打印了 url。
|
|
1496
|
+
*/
|
|
1497
|
+
function openInBrowser(url: string): void {
|
|
1498
|
+
try {
|
|
1499
|
+
if (process.platform === "win32") {
|
|
1500
|
+
const child = spawn("cmd.exe", ["/c", "start", "", url], {
|
|
1501
|
+
detached: true,
|
|
1502
|
+
stdio: "ignore",
|
|
1503
|
+
windowsHide: true,
|
|
1504
|
+
});
|
|
1505
|
+
child.on("error", () => { /* 浏览器未弹起来不影响主流程 */ });
|
|
1506
|
+
child.unref();
|
|
1507
|
+
} else if (process.platform === "darwin") {
|
|
1508
|
+
const child = spawn("open", [url], { detached: true, stdio: "ignore" });
|
|
1509
|
+
child.on("error", () => {});
|
|
1510
|
+
child.unref();
|
|
1511
|
+
} else {
|
|
1512
|
+
const child = spawn("xdg-open", [url], { detached: true, stdio: "ignore" });
|
|
1513
|
+
child.on("error", () => {});
|
|
1514
|
+
child.unref();
|
|
1515
|
+
}
|
|
1516
|
+
} catch (err) {
|
|
1517
|
+
console.error(`[WEB-UI] 自动打开浏览器失败: ${(err as Error).message}`);
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
/**
|
|
1522
|
+
* setup → service「在线切换」回调签名:
|
|
1523
|
+
* - 入参 httpServer:setup 模式当前监听的 HTTP server,会被复用为 service 的
|
|
1524
|
+
* relay server(避免 close + recreate 的端口竞态)。
|
|
1525
|
+
* - 返回 { ok: true } 表示原地启动成功,前端会刷新进 dashboard。
|
|
1526
|
+
* - 返回 { ok: false, error } 表示启动失败,前端会 toast 错误。
|
|
1527
|
+
* setup HTTP server **必须仍然可用**,让用户改完 config 再试一次。
|
|
1528
|
+
*/
|
|
1529
|
+
export type SetupActivateHook = (
|
|
1530
|
+
httpServer: ReturnType<typeof createServer>,
|
|
1531
|
+
) => Promise<{ ok: true } | { ok: false; error: string }>;
|
|
1532
|
+
|
|
1533
|
+
export interface StartSetupModeOptions {
|
|
1534
|
+
onActivate?: SetupActivateHook;
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
// setup HTTP server + onActivate 回调通过模块级变量暴露给 handleStartService。
|
|
1538
|
+
// 一旦 onActivate 成功执行,setupActivateHook 会被清空——避免 dashboard 模式下
|
|
1539
|
+
// 用户再点"启动"时还走 inplace 路径(service 已经在跑了)。
|
|
1540
|
+
let setupHttpServer: ReturnType<typeof createServer> | null = null;
|
|
1541
|
+
let setupActivateHook: SetupActivateHook | null = null;
|
|
1542
|
+
|
|
1543
|
+
// reload-config 回调:dashboard 模式下用户点"保存并启动"时,service 已经在跑,
|
|
1544
|
+
// 仅需要把磁盘上刚保存的 config.json 刷进进程内的 export let 常量(live binding)。
|
|
1545
|
+
// 由 index.ts 注入,因为 web-ui.ts 自身**不应**直接 import config.ts——后者顶层
|
|
1546
|
+
// 有 loadConfig 副作用,被 web-ui.ts 间接 import 会污染所有依赖 web-ui.ts 的单测。
|
|
1547
|
+
let reloadConfigHook: (() => void) | null = null;
|
|
1548
|
+
|
|
1549
|
+
/**
|
|
1550
|
+
* 注册"reload config"回调。约定:
|
|
1551
|
+
* - 由 index.ts 在 main() 中一次性调用,传入 () => reloadConfigFromDisk()。
|
|
1552
|
+
* - handleStartService 在 path="reload" 分支会 await 调用一次;hook 抛错会被
|
|
1553
|
+
* 捕获并以 500 回前端,避免 service 死锁。
|
|
1554
|
+
*/
|
|
1555
|
+
export function setReloadConfigHook(hook: () => void | Promise<void>): void {
|
|
1556
|
+
reloadConfigHook = hook;
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
export function startSetupMode(port: number, options: StartSetupModeOptions = {}): void {
|
|
1560
|
+
const router = createUiRouter();
|
|
1561
|
+
const server = createServer(router);
|
|
1562
|
+
setupHttpServer = server;
|
|
1563
|
+
setupActivateHook = options.onActivate ?? null;
|
|
1564
|
+
|
|
1565
|
+
server.on("error", (err: NodeJS.ErrnoException) => {
|
|
1566
|
+
if (err.code === "EADDRINUSE") {
|
|
1567
|
+
console.error(`\n[WEB-UI] 端口 ${port} 已被占用。请检查是否有其他 ChatCCC 实例在运行。`);
|
|
1568
|
+
console.error(" 可以先停止旧进程,或修改 config.json 中的 port 为其他端口。");
|
|
1569
|
+
} else {
|
|
1570
|
+
console.error(`\n[WEB-UI] HTTP 服务器错误: ${err.message}`);
|
|
1571
|
+
}
|
|
1572
|
+
process.exit(1);
|
|
1573
|
+
});
|
|
1574
|
+
|
|
1575
|
+
server.listen(port, "127.0.0.1", () => {
|
|
1576
|
+
const url = `http://127.0.0.1:${port}`;
|
|
1577
|
+
console.log("");
|
|
1578
|
+
console.log("=".repeat(60));
|
|
1579
|
+
console.log(" ChatCCC — 首次配置向导");
|
|
1580
|
+
console.log("=".repeat(60));
|
|
1581
|
+
console.log(" 未检测到已配置的飞书凭证,已启动配置界面。");
|
|
1582
|
+
console.log(` 正在自动打开浏览器: ${url}`);
|
|
1583
|
+
console.log(" 若浏览器未自动弹出,请手动访问上面的地址。");
|
|
1584
|
+
console.log("");
|
|
1585
|
+
console.log(" 在向导里填好 App ID / App Secret 后点「保存并启动」,");
|
|
1586
|
+
console.log(" 服务会在当前进程内直接激活,不需要重新运行 chatccc。");
|
|
1587
|
+
console.log("=".repeat(60));
|
|
1588
|
+
console.log("");
|
|
1589
|
+
openInBrowser(url);
|
|
1590
|
+
});
|
|
1591
|
+
}
|