chatccc 0.2.155 → 0.2.157

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.
@@ -4,7 +4,7 @@
4
4
  "appSecret": ""
5
5
  },
6
6
  "platforms": {
7
- "feishu": { "enabled": true },
7
+ "feishu": { "enabled": true, "platformType": "feishu" },
8
8
  "ilink": { "enabled": true, "reuseTokenOnStart": true }
9
9
  },
10
10
  "port": 18080,
@@ -18,7 +18,7 @@
18
18
  "effort": "",
19
19
  "apiKey": "",
20
20
  "baseUrl": "",
21
- "maxTurn": 0
21
+ "maxTurn": 0
22
22
  },
23
23
  "cursor": {
24
24
  "enabled": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatccc",
3
- "version": "0.2.155",
3
+ "version": "0.2.157",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -459,6 +459,29 @@ describe("buildClaudePromptText", () => {
459
459
  expect(result.endsWith("[User message]\nhello\n[/User message]")).toBe(true);
460
460
  });
461
461
 
462
+ it("prepends the Claude-specific injection prompt on every resumed prompt", () => {
463
+ const first = buildClaudePromptText(
464
+ "[ChatCCC IM skill: feishu-skill]\ncapabilities\n[/ChatCCC IM skill: feishu-skill]\n\n[User message]\nfirst\n[/User message]",
465
+ "Never repeat successful commands for {{session_id}}.",
466
+ "sid-resume",
467
+ );
468
+ const second = buildClaudePromptText(
469
+ "[ChatCCC IM skill: feishu-skill]\ncapabilities\n[/ChatCCC IM skill: feishu-skill]\n\n[User message]\nsecond\n[/User message]",
470
+ "Never repeat successful commands for {{session_id}}.",
471
+ "sid-resume",
472
+ );
473
+
474
+ for (const result of [first, second]) {
475
+ expect(result).toContain("[ChatCCC Claude-specific injection prompt]");
476
+ expect(result).toContain("Never repeat successful commands for sid-resume.");
477
+ expect(result).toContain("[/ChatCCC Claude-specific injection prompt]");
478
+ expect(result).toContain("[ChatCCC IM skill: feishu-skill]");
479
+ expect(result).toContain("[/ChatCCC IM skill: feishu-skill]");
480
+ }
481
+ expect(first).toContain("[User message]\nfirst\n[/User message]");
482
+ expect(second).toContain("[User message]\nsecond\n[/User message]");
483
+ });
484
+
462
485
  it("leaves user text unchanged when the injection prompt is empty", () => {
463
486
  expect(buildClaudePromptText("hello", " ")).toBe("hello");
464
487
  expect(buildClaudePromptText("hello", null)).toBe("hello");
@@ -9,9 +9,9 @@ const mockStreamStates = new Map<string, {
9
9
  finalReply: string;
10
10
  status?: "running" | "done" | "stopped" | "error";
11
11
  turnCount?: number;
12
- finalReplySentTurn?: number;
13
- finalReplySentAt?: number;
14
- }>();
12
+ finalReplySentTurn?: number;
13
+ finalReplySentAt?: number;
14
+ }>();
15
15
  vi.mock("../stream-state.ts", () => ({
16
16
  readStreamState: async (sid: string) => {
17
17
  const state = mockStreamStates.get(sid);
@@ -20,9 +20,9 @@ vi.mock("../stream-state.ts", () => ({
20
20
  sessionId: sid,
21
21
  accumulatedContent: state.accumulatedContent,
22
22
  finalReply: state.finalReply,
23
- finalReplySentTurn: state.finalReplySentTurn,
24
- finalReplySentAt: state.finalReplySentAt,
25
- status: state.status ?? "running",
23
+ finalReplySentTurn: state.finalReplySentTurn,
24
+ finalReplySentAt: state.finalReplySentAt,
25
+ status: state.status ?? "running",
26
26
  chunkCount: 0,
27
27
  turnCount: state.turnCount ?? 0,
28
28
  contextTokens: 0,
@@ -37,18 +37,18 @@ vi.mock("../stream-state.ts", () => ({
37
37
  finalReply: string;
38
38
  status?: "running" | "done" | "stopped" | "error";
39
39
  turnCount?: number;
40
- finalReplySentTurn?: number;
41
- finalReplySentAt?: number;
42
- }) => {
40
+ finalReplySentTurn?: number;
41
+ finalReplySentAt?: number;
42
+ }) => {
43
43
  mockStreamStates.set(state.sessionId, {
44
44
  accumulatedContent: state.accumulatedContent,
45
45
  finalReply: state.finalReply,
46
46
  status: state.status,
47
47
  turnCount: state.turnCount,
48
- finalReplySentTurn: state.finalReplySentTurn,
49
- finalReplySentAt: state.finalReplySentAt,
50
- });
51
- },
48
+ finalReplySentTurn: state.finalReplySentTurn,
49
+ finalReplySentAt: state.finalReplySentAt,
50
+ });
51
+ },
52
52
  createEmptyStreamState: (sid: string, cwd: string, tool: string, turnCount: number) => ({
53
53
  sessionId: sid, status: "running" as const, accumulatedContent: "", finalReply: "", chunkCount: 0, turnCount, contextTokens: 0, updatedAt: Date.now(), cwd, tool,
54
54
  }),
@@ -396,8 +396,44 @@ describe("runAgentSession process monitor", () => {
396
396
  expect.stringContaining("进程异常结束"),
397
397
  );
398
398
  });
399
- });
400
-
399
+ it("re-injects IM skill capabilities for each resumed Claude prompt", async () => {
400
+ const platform = mockPlatform("feishu");
401
+ setSessionPlatform(platform);
402
+ bindChatToSession("sid-resume", "chat-resume");
403
+ recordLastActiveChat("sid-resume", "chat-resume");
404
+
405
+ const sentTexts: string[] = [];
406
+ const adapter: ToolAdapter = {
407
+ displayName: "Claude Code",
408
+ sessionDescPrefix: "Claude Code Session:",
409
+ createSession: async () => ({ sessionId: "sid-resume" }),
410
+ getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
411
+ closeSession: async () => {},
412
+ prompt: async function* (_sid: string, text: string) {
413
+ sentTexts.push(text);
414
+ yield { type: "assistant", blocks: [{ type: "text", text: "done" }] };
415
+ },
416
+ };
417
+ _setAdapterForToolForTest("claude", adapter);
418
+
419
+ await runAgentSession("sid-resume", "first prompt", platform, "chat-resume", Date.now(), "claude");
420
+ await runAgentSession("sid-resume", "second prompt", platform, "chat-resume", Date.now(), "claude");
421
+
422
+ expect(sentTexts).toHaveLength(2);
423
+ for (const text of sentTexts) {
424
+ expect(text).toContain("[ChatCCC IM skill: feishu-skill]");
425
+ expect(text).toContain("[/ChatCCC IM skill: feishu-skill]");
426
+ expect(text).toContain('"session_id":"sid-resume"');
427
+ expect(text).toContain("http://127.0.0.1:");
428
+ expect(text).toContain("/api/agent/send-image");
429
+ expect(text).toContain("[User message]");
430
+ expect(text).toContain("[/User message]");
431
+ }
432
+ expect(sentTexts[0]).toContain("first prompt");
433
+ expect(sentTexts[1]).toContain("second prompt");
434
+ });
435
+ });
436
+
401
437
  describe("unified display loop WeChat delta", () => {
402
438
  beforeEach(() => {
403
439
  vi.useFakeTimers();
@@ -114,4 +114,4 @@ export async function handleAgentStopStuckRequest(
114
114
 
115
115
  jsonReply(res, 200, { ok: true });
116
116
  return true;
117
- }
117
+ }
package/src/config.ts CHANGED
@@ -102,6 +102,8 @@ export interface FeishuConfig {
102
102
  export interface PlatformConfig {
103
103
  enabled: boolean;
104
104
  reuseTokenOnStart?: boolean;
105
+ /** 飞书平台类型:feishu(国内飞书)或 lark(国际版);缺省按 feishu */
106
+ platformType?: "feishu" | "lark";
105
107
  }
106
108
 
107
109
  export interface PlatformsConfig {
@@ -319,6 +321,11 @@ function autofillToolPathsAfterSampleCopy(configFile: string): void {
319
321
  }
320
322
  }
321
323
 
324
+ function normalizePlatformType(raw: string): "feishu" | "lark" {
325
+ if (raw === "lark") return "lark";
326
+ return "feishu";
327
+ }
328
+
322
329
  function loadConfig(): AppConfig {
323
330
  const defaults: AppConfig = {
324
331
  feishu: { appId: "", appSecret: "" },
@@ -449,6 +456,11 @@ function loadConfig(): AppConfig {
449
456
  enabled: typeof (parsed.platforms as unknown as Record<string, unknown> | undefined)?.feishu === "object"
450
457
  ? Boolean(((parsed.platforms as unknown as Record<string, unknown>).feishu as Record<string, unknown>).enabled ?? true)
451
458
  : true,
459
+ platformType: normalizePlatformType(
460
+ typeof (parsed.platforms as unknown as Record<string, unknown> | undefined)?.feishu === "object"
461
+ ? String(((parsed.platforms as unknown as Record<string, unknown>).feishu as Record<string, unknown>).platformType ?? "feishu")
462
+ : "feishu",
463
+ ),
452
464
  },
453
465
  ilink: {
454
466
  enabled: typeof (parsed.platforms as unknown as Record<string, unknown> | undefined)?.ilink === "object"
@@ -517,7 +529,14 @@ export let APP_SECRET = config.feishu.appSecret;
517
529
  export let FEISHU_ENABLED = config.platforms.feishu.enabled;
518
530
  export let ILINK_ENABLED = config.platforms.ilink.enabled;
519
531
  export let ILINK_REUSE_TOKEN_ON_START = config.platforms.ilink.reuseTokenOnStart ?? true;
520
- export const BASE_URL = "https://open.feishu.cn/open-apis";
532
+
533
+ function computeBaseUrl(platformType?: string): string {
534
+ if (platformType === "lark") return "https://open.larksuite.com/open-apis";
535
+ return "https://open.feishu.cn/open-apis";
536
+ }
537
+
538
+ export let BASE_URL = computeBaseUrl(config.platforms.feishu.platformType);
539
+ export let FEISHU_PLATFORM_TYPE: "feishu" | "lark" = config.platforms.feishu.platformType === "lark" ? "lark" : "feishu";
521
540
  export const CHATCCC_PORT = config.port;
522
541
 
523
542
  /** 与 CHATCCC_PORT 一致,供 --local 连接本机中继 */
@@ -603,11 +622,13 @@ export function applyLoadedConfig(next: AppConfig): void {
603
622
  FEISHU_ENABLED = next.platforms.feishu.enabled;
604
623
  ILINK_ENABLED = next.platforms.ilink.enabled;
605
624
  ILINK_REUSE_TOKEN_ON_START = next.platforms.ilink.reuseTokenOnStart ?? true;
625
+ FEISHU_PLATFORM_TYPE = next.platforms.feishu.platformType === "lark" ? "lark" : "feishu";
626
+ BASE_URL = computeBaseUrl(FEISHU_PLATFORM_TYPE);
606
627
  CLAUDE_MODEL = next.claude.model;
607
628
  CLAUDE_SUBAGENT_MODEL = next.claude.subagentModel;
608
- CLAUDE_EFFORT = next.claude.effort;
609
- CLAUDE_MAX_TURN = next.claude.maxTurn;
610
- CLAUDE_API_KEY = next.claude.apiKey;
629
+ CLAUDE_EFFORT = next.claude.effort;
630
+ CLAUDE_MAX_TURN = next.claude.maxTurn;
631
+ CLAUDE_API_KEY = next.claude.apiKey;
611
632
  CLAUDE_BASE_URL = next.claude.baseUrl;
612
633
  GIT_TIMEOUT_SECONDS = next.gitTimeoutSeconds;
613
634
  GIT_TIMEOUT_MS = GIT_TIMEOUT_SECONDS * 1000;
@@ -738,6 +759,9 @@ export function reportEnvironmentVariableReadout(): void {
738
759
  APP_SECRET.trim() ? "已读入(内容不在日志中显示)" : "未读入或为空"
739
760
  );
740
761
 
762
+ console.log(` [默认] [可选] platforms.feishu.platformType`);
763
+ console.log(` 平台类型: ${FEISHU_PLATFORM_TYPE === "lark" ? "Lark (open.larksuite.com)" : "飞书 (open.feishu.cn)"}`);
764
+
741
765
  console.log(` [默认] [可选] port`);
742
766
  console.log(` 监听端口: ${CHATCCC_PORT}`);
743
767
 
package/src/index.ts CHANGED
@@ -24,7 +24,7 @@
24
24
 
25
25
  import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
26
26
 
27
- import { WSClient, EventDispatcher } from "@larksuiteoapi/node-sdk";
27
+ import { WSClient, EventDispatcher, Domain } from "@larksuiteoapi/node-sdk";
28
28
  import WebSocket from "ws";
29
29
 
30
30
  import { appendStartupTrace, attachRelayWebSocket, ensureSingleInstance, freeRelayListenPort, installCrashLogging, waitForPortFree } from "./shared.ts";
@@ -36,6 +36,7 @@ import {
36
36
  APP_ID,
37
37
  APP_SECRET,
38
38
  FEISHU_ENABLED,
39
+ FEISHU_PLATFORM_TYPE,
39
40
  ILINK_ENABLED,
40
41
  ILINK_REUSE_TOKEN_ON_START,
41
42
  BASE_URL,
@@ -212,7 +213,7 @@ function parseCardAction(data: unknown): CardActionResult | null {
212
213
  }
213
214
  if (!cmd) return null;
214
215
 
215
- const CMD_MAP: Record<string, string> = { stop: "/stop", cancel: "/cancel", new: "/new", "new claude": "/new claude", "new cursor": "/new cursor", "new codex": "/new codex", restart: "/restart", state: "/state", cd: "/cd", sessions: "/sessions", newh: "/newh" };
216
+ const CMD_MAP: Record<string, string> = { stop: "/stop", cancel: "/cancel", new: "/new", "new claude": "/new claude", "new cursor": "/new cursor", "new codex": "/new codex", restart: "/restart", state: "/state", cd: "/cd", sessions: "/sessions", newh: "/newh" };
216
217
  let text = CMD_MAP[cmd] ?? "";
217
218
  if (cmd === "cd" && typeof action.value === "object" && action.value !== null) {
218
219
  const path = (action.value as Record<string, string>).path;
@@ -348,9 +349,10 @@ async function startBotServiceCore(): Promise<void> {
348
349
  const msg = err instanceof Error ? err.message : String(err);
349
350
  console.error(" 失败:无法获取 tenant_access_token。");
350
351
  console.error(` 接口: POST ${BASE_URL}/auth/v3/tenant_access_token/internal`);
352
+ const apiHost = BASE_URL.replace("https://", "").replace("/open-apis", "");
351
353
  console.error(" 常见原因:");
352
354
  console.error(
353
- " - 本机网络无法访问 open.feishu.cn(可尝试:关闭系统/终端代理、检查防火墙;Windows 可管理员运行 netsh winsock reset 后重启)",
355
+ ` - 本机网络无法访问 ${apiHost}(可尝试:关闭系统/终端代理、检查防火墙;Windows 可管理员运行 netsh winsock reset 后重启)`,
354
356
  );
355
357
  console.error(" - App ID / App Secret 与开放平台「凭证与基础信息」不一致");
356
358
  console.error(" - 自建应用尚未创建/发布可用版本");
@@ -542,6 +544,7 @@ async function startBotServiceCore(): Promise<void> {
542
544
  const wsClient = new WSClient({
543
545
  appId: APP_ID,
544
546
  appSecret: APP_SECRET,
547
+ domain: FEISHU_PLATFORM_TYPE === "lark" ? Domain.Lark : Domain.Feishu,
545
548
  onReady: async () => {
546
549
  await rebuildBindingsFromRegistry().catch((err) =>
547
550
  console.error(`[${ts()}] [SDK READY] rebuild bindings failed: ${(err as Error).message}`)
@@ -71,11 +71,11 @@ export interface ActivePrompt {
71
71
  /** Set when the watchdog detects that the CLI process disappeared before stream finalization. */
72
72
  abnormalExit?: boolean;
73
73
  abnormalExitNotified?: boolean;
74
- /** Set when the resource monitor detects CPU + memory unchanged for 3 minutes. */
75
- resourceStuck?: boolean;
76
- /** Adapter-provided callback to close the underlying SDK session / subprocess.
77
- * Called by stop-stuck-loop before controller.abort() to terminate the CLI
78
- * process immediately, rather than waiting for the async generator to unblock. */
74
+ /** Set when the resource monitor detects CPU + memory unchanged for 3 minutes. */
75
+ resourceStuck?: boolean;
76
+ /** Adapter-provided callback to close the underlying SDK session / subprocess.
77
+ * Called by stop-stuck-loop before controller.abort() to terminate the CLI
78
+ * process immediately, rather than waiting for the async generator to unblock. */
79
79
  closeSession?: () => void;
80
80
  }
81
81
 
@@ -228,4 +228,4 @@ export function resetBindingState(): void {
228
228
  clearInterval(unifiedDisplayLoopHandle);
229
229
  unifiedDisplayLoopHandle = null;
230
230
  }
231
- }
231
+ }
package/src/session.ts CHANGED
@@ -1161,11 +1161,11 @@ export async function runAgentSession(
1161
1161
  // 标记 prompt 结束
1162
1162
  resourceMonitor.off("stuck", onResourceStuck);
1163
1163
  const prompt = activePrompts.get(sessionId);
1164
- const wasStopped = prompt?.stopped ?? false;
1165
- const wasAbnormalExit = prompt?.abnormalExit ?? false;
1166
- const wasResourceStuck = prompt?.resourceStuck ?? false;
1167
- clearPromptProcessMonitor(sessionId);
1168
- activePrompts.delete(sessionId);
1164
+ const wasStopped = prompt?.stopped ?? false;
1165
+ const wasAbnormalExit = prompt?.abnormalExit ?? false;
1166
+ const wasResourceStuck = prompt?.resourceStuck ?? false;
1167
+ clearPromptProcessMonitor(sessionId);
1168
+ activePrompts.delete(sessionId);
1169
1169
 
1170
1170
  // 先写最终状态(done/stopped),确保 display loop 在下一轮消费前
1171
1171
  // 读到新状态并终结旧卡片。否则 setImmediate 在 CHECK 阶段先于
@@ -1176,13 +1176,13 @@ export async function runAgentSession(
1176
1176
 
1177
1177
  // stop-stuck-loop 接口可能在 fire-and-forget 中已写入带 final_reply 的
1178
1178
  // stream state,finally 不应覆盖它。
1179
- let finalReplyToWrite = finalReply;
1180
- try {
1181
- const existing = await readStreamState(sessionId);
1182
- if (existing && existing.finalReply.length > finalReply.length) {
1183
- finalReplyToWrite = existing.finalReply;
1184
- }
1185
- } catch {}
1179
+ let finalReplyToWrite = finalReply;
1180
+ try {
1181
+ const existing = await readStreamState(sessionId);
1182
+ if (existing && existing.finalReply.length > finalReply.length) {
1183
+ finalReplyToWrite = existing.finalReply;
1184
+ }
1185
+ } catch {}
1186
1186
 
1187
1187
  await writeStreamState({
1188
1188
  sessionId,
@@ -1191,11 +1191,11 @@ export async function runAgentSession(
1191
1191
  finalReply: finalReplyToWrite,
1192
1192
  chunkCount: state.chunkCount,
1193
1193
  turnCount: nextTurnCount,
1194
- contextTokens: existingInfo?.lastContextTokens ?? 0,
1195
- updatedAt: Date.now(),
1196
- cwd,
1197
- tool,
1198
- });
1194
+ contextTokens: existingInfo?.lastContextTokens ?? 0,
1195
+ updatedAt: Date.now(),
1196
+ cwd,
1197
+ tool,
1198
+ });
1199
1199
 
1200
1200
  // 消费队列中的缓存消息(异步,不阻塞后续清理)
1201
1201
  // 用户 /stop 后应丢弃队列消息,避免用户停止后又自动开始新轮
package/src/web-ui.ts CHANGED
@@ -158,6 +158,30 @@ function spawnService(): { ok: boolean; pid?: number; error?: string } {
158
158
  }
159
159
  }
160
160
 
161
+ /**
162
+ * 安排一次真正的进程重启:先回复 HTTP 请求,再 spawn 一个延迟启动的子进程,
163
+ * 然后退出当前进程。延迟是为了让当前进程完全退出并释放端口。
164
+ */
165
+ function scheduleRestart(): void {
166
+ const indexPath = join(PROJECT_ROOT, "src", "index.ts");
167
+ if (!existsSync(indexPath)) return;
168
+ if (process.platform === "win32") {
169
+ // Windows: ping 作为 sleep(ping 自己 3 次 ≈ 2 秒延迟)
170
+ spawn("cmd.exe", ["/c", "ping -n 3 127.0.0.1 > nul && npx tsx src/index.ts"], {
171
+ cwd: PROJECT_ROOT,
172
+ detached: true,
173
+ stdio: "ignore",
174
+ windowsHide: true,
175
+ }).unref();
176
+ } else {
177
+ spawn("bash", ["-c", "sleep 2 && npx tsx src/index.ts"], {
178
+ cwd: PROJECT_ROOT,
179
+ detached: true,
180
+ stdio: "ignore",
181
+ }).unref();
182
+ }
183
+ }
184
+
161
185
  function stopService(): { ok: boolean; error?: string } {
162
186
  const pid = getServicePid();
163
187
  if (!pid) return { ok: false, error: "No PID file found" };
@@ -296,6 +320,10 @@ export function unflattenConfig(flat: Record<string, unknown>): Record<string, u
296
320
  result.platforms = result.platforms || {};
297
321
  (result.platforms as Record<string, unknown>).feishu = (result.platforms as Record<string, unknown>).feishu || {};
298
322
  ((result.platforms as Record<string, unknown>).feishu as Record<string, unknown>).enabled = val === true || val === "true";
323
+ } else if (key === "CHATCCC_FEISHU_PLATFORM_TYPE") {
324
+ result.platforms = result.platforms || {};
325
+ (result.platforms as Record<string, unknown>).feishu = (result.platforms as Record<string, unknown>).feishu || {};
326
+ ((result.platforms as Record<string, unknown>).feishu as Record<string, unknown>).platformType = val;
299
327
  } else if (key === "CHATCCC_ILINK_ENABLED") {
300
328
  result.platforms = result.platforms || {};
301
329
  (result.platforms as Record<string, unknown>).ilink = (result.platforms as Record<string, unknown>).ilink || {};
@@ -392,23 +420,10 @@ async function handleStartService(_req: IncomingMessage, res: ServerResponse): P
392
420
  }
393
421
 
394
422
  if (path === "reload") {
395
- // service 已经在跑(通常就是当前进程自己)。仅把磁盘上刚保存的 config.json
396
- // 刷进进程内的 export let 常量,不走真重启。下次创建会话时新值即生效。
397
- if (!reloadConfigHook) {
398
- // 没注册 reload hook 视为编程错误:index.ts 必须在 main() 里调用
399
- // setReloadConfigHook();返回 500 让前端有提示,避免静默"看似生效但没生效"。
400
- jsonReply(res, 500, {
401
- ok: false,
402
- error: "reload hook 未注册(应在 main() 调用 setReloadConfigHook)",
403
- });
404
- return;
405
- }
406
- try {
407
- await reloadConfigHook();
408
- jsonReply(res, 200, { ok: true, pid: process.pid, mode: "reload" });
409
- } catch (err) {
410
- jsonReply(res, 500, { ok: false, error: (err as Error).message });
411
- }
423
+ // service 已经在跑,执行真正的进程重启让所有配置(含 WSClient domain)生效
424
+ jsonReply(res, 200, { ok: true, pid: process.pid, mode: "restart" });
425
+ scheduleRestart();
426
+ process.exit(0);
412
427
  return;
413
428
  }
414
429
 
@@ -433,14 +448,10 @@ async function handleStopService(req: IncomingMessage, res: ServerResponse): Pro
433
448
  });
434
449
  }
435
450
 
436
- async function handleRestartService(req: IncomingMessage, res: ServerResponse): Promise<void> {
451
+ async function handleRestartService(_req: IncomingMessage, res: ServerResponse): Promise<void> {
437
452
  jsonReply(res, 200, { ok: true, message: "Restarting..." });
438
- setImmediate(() => {
439
- stopService();
440
- setTimeout(() => {
441
- spawnService();
442
- }, 1000);
443
- });
453
+ scheduleRestart();
454
+ process.exit(0);
444
455
  }
445
456
 
446
457
  async function handleValidate(req: IncomingMessage, res: ServerResponse): Promise<void> {
@@ -582,6 +593,14 @@ header .badge{font-size:13px;padding:4px 12px;border-radius:12px;font-weight:500
582
593
  <input type="checkbox" class="agent-toggle" id="platform-enable-feishu" checked onchange="onWizardPlatformToggle('feishu', this.checked)">
583
594
  </div>
584
595
  <div id="feishu-cred-fields" style="margin-top:12px;padding-top:12px;border-top:1px solid #e2e8f0">
596
+ <div class="form-group" style="margin-bottom:10px">
597
+ <label>平台类型</label>
598
+ <select id="field-CHATCCC_FEISHU_PLATFORM_TYPE" style="width:100%;padding:8px 12px;border:1px solid #cbd5e1;border-radius:8px;font-size:14px;outline:none">
599
+ <option value="feishu">飞书 (open.feishu.cn)</option>
600
+ <option value="lark">Lark (open.larksuite.com)</option>
601
+ </select>
602
+ <div class="hint">飞书或 Lark 国际版,决定 API 服务器地址</div>
603
+ </div>
585
604
  <div class="form-group" style="margin-bottom:10px">
586
605
  <label>CHATCCC_APP_ID *</label>
587
606
  <input type="text" id="field-CHATCCC_APP_ID" placeholder="cli_xxxxxxxxxxxx">
@@ -759,6 +778,7 @@ header .badge{font-size:13px;padding:4px 12px;border-radius:12px;font-weight:500
759
778
  </div>
760
779
  <div class="config-row"><span class="key">App ID</span><span class="val" id="cfg-APP_ID">-</span></div>
761
780
  <div class="config-row"><span class="key">App Secret</span><span class="val" id="cfg-APP_SECRET">-</span></div>
781
+ <div class="config-row"><span class="key">平台类型</span><span class="val" id="cfg-FEISHU_PLATFORM_TYPE">-</span></div>
762
782
  <button class="btn btn-outline" style="margin-top:8px" onclick="editSection('feishu')">编辑</button>
763
783
  </div>
764
784
  </details>
@@ -1058,6 +1078,8 @@ function renderStep1() {
1058
1078
  var f = c.feishu || {};
1059
1079
  prefillNested('field-CHATCCC_APP_ID', f.appId);
1060
1080
  prefillNested('field-CHATCCC_APP_SECRET', f.appSecret);
1081
+ var pf = (c.platforms && c.platforms.feishu) || {};
1082
+ prefillNested('field-CHATCCC_FEISHU_PLATFORM_TYPE', pf.platformType || 'feishu');
1061
1083
  // 平台开关:按已有 config 回填;首次配置(无飞书凭证)时默认关闭飞书、开启微信
1062
1084
  var hasExistingCreds = Boolean(c.feishu?.appId?.trim() && c.feishu?.appSecret?.trim());
1063
1085
  var feishuEnabled = hasExistingCreds
@@ -1156,6 +1178,9 @@ function collectAllFields() {
1156
1178
  var el = document.getElementById('field-' + key);
1157
1179
  if (el && el.value.trim()) vars[key] = el.value.trim();
1158
1180
  });
1181
+ // 平台类型(下拉选择框,始终发送以保持与服务端同步)
1182
+ var ptEl = document.getElementById('field-CHATCCC_FEISHU_PLATFORM_TYPE');
1183
+ if (ptEl && ptEl.value.trim()) vars['CHATCCC_FEISHU_PLATFORM_TYPE'] = ptEl.value.trim();
1159
1184
  vars.CHATCCC_FEISHU_ENABLED = !!state.platformsEnabled.feishu;
1160
1185
  vars.CHATCCC_ILINK_ENABLED = !!state.platformsEnabled.ilink;
1161
1186
  vars.CHATCCC_CLAUDE_ENABLED = !!state.agentsEnabled.claude;
@@ -1197,6 +1222,8 @@ function renderStep3() {
1197
1222
  if (state.platformsEnabled.feishu) {
1198
1223
  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>');
1199
1224
  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>');
1225
+ var ptLabel = vars.CHATCCC_FEISHU_PLATFORM_TYPE === 'lark' ? 'Lark (open.larksuite.com)' : '飞书 (open.feishu.cn)';
1226
+ lines.push('<div class="config-row"><span class="key">平台类型</span><span class="val">' + ptLabel + '</span></div>');
1200
1227
  }
1201
1228
 
1202
1229
  lines.push('<h3 style="margin:16px 0 8px">微信 iLink</h3>');
@@ -1289,20 +1316,20 @@ async function saveAndStart() {
1289
1316
  document.getElementById('btn-save-start').innerHTML = '<span class="spinner"></span> 应用中...';
1290
1317
  var result = await api('/api/start', 'POST');
1291
1318
  if (result.ok) {
1292
- // 后端按 mode 区分场景,前端给出更贴切的 toast:
1293
- // - inplace:setup → service 首次激活,进程内启动飞书 service
1294
- // - reload :service 已经在跑,仅刷新进程内 config(不真重启)
1295
- // - spawn :旧 service 已退出,spawn 新子进程
1296
1319
  var msg;
1297
- if (result.mode === 'reload') {
1298
- msg = '配置已保存并生效(无须重启)';
1320
+ if (result.mode === 'restart') {
1321
+ msg = '配置已保存,服务正在重启…';
1299
1322
  } else if (result.mode === 'inplace') {
1300
1323
  msg = '服务已启动! PID: ' + result.pid;
1301
1324
  } else {
1302
1325
  msg = '服务已启动! PID: ' + (result.pid || '?');
1303
1326
  }
1304
1327
  toast(msg);
1305
- setTimeout(function(){ location.reload(); }, 1500);
1328
+ if (result.mode === 'restart') {
1329
+ pollUntilRunning();
1330
+ } else {
1331
+ setTimeout(function(){ location.reload(); }, 1500);
1332
+ }
1306
1333
  } else {
1307
1334
  toast('保存失败: ' + (result.error || '未知错误'), 'error');
1308
1335
  document.getElementById('btn-save-start').disabled = false;
@@ -1368,6 +1395,8 @@ function updateDashboardUI() {
1368
1395
  state.platformsEnabled.feishu = feishuEnabled;
1369
1396
  document.getElementById('cfg-APP_ID').textContent = c.feishu && c.feishu.appId ? c.feishu.appId.slice(0,8) + '...' + c.feishu.appId.slice(-4) : '-';
1370
1397
  document.getElementById('cfg-APP_SECRET').textContent = c.feishu && c.feishu.appSecret ? '***已设置***' : '-';
1398
+ var pt = (c.platforms && c.platforms.feishu && c.platforms.feishu.platformType === 'lark') ? 'Lark (open.larksuite.com)' : '飞书 (open.feishu.cn)';
1399
+ document.getElementById('cfg-FEISHU_PLATFORM_TYPE').textContent = pt;
1371
1400
 
1372
1401
  // 只显示已启用的 Agent 卡片(按 enabled 字段;缺省时退回到"任一字段非空"兼容旧 config)
1373
1402
  var claudeOn = isAgentEnabled(c.claude, CLAUDE_FALLBACK_KEYS);
@@ -1436,16 +1465,21 @@ async function restartService() {
1436
1465
  if (!confirm('确定要重启服务吗?')) return;
1437
1466
  document.getElementById('btn-restart').disabled = true;
1438
1467
  document.getElementById('btn-restart').textContent = '重启中...';
1439
- await api('/api/start', 'POST');
1440
- // Wait and refresh
1441
- setTimeout(async function(){
1442
- var s = await api('/api/status');
1443
- state.running = s.running;
1444
- state.pid = s.pid;
1445
- updateDashboardUI();
1446
- document.getElementById('btn-restart').disabled = false;
1447
- document.getElementById('btn-restart').textContent = '重启';
1448
- }, 2000);
1468
+ await api('/api/restart', 'POST');
1469
+ pollUntilRunning();
1470
+ }
1471
+
1472
+ async function pollUntilRunning() {
1473
+ for (var i = 0; i < 30; i++) {
1474
+ await new Promise(function(r) { setTimeout(r, 1000); });
1475
+ try {
1476
+ var s = await api('/api/status');
1477
+ if (s.running) { location.reload(); return; }
1478
+ } catch(e) {}
1479
+ }
1480
+ toast('重启超时,请在终端手动运行 chatccc', 'error');
1481
+ document.getElementById('btn-restart').disabled = false;
1482
+ document.getElementById('btn-restart').textContent = '重启';
1449
1483
  }
1450
1484
 
1451
1485
  // ---- Edit Modal ----
@@ -1497,6 +1531,16 @@ function editSection(section) {
1497
1531
  html += '<input type="' + (isSecret ? 'password' : 'text') + '" id="edit-' + key + '" value="' + String(val).replace(/"/g,'&quot;') + '">';
1498
1532
  html += '</div>';
1499
1533
  });
1534
+ // 平台类型:feishu 编辑时额外渲染下拉选择框
1535
+ if (section === 'feishu') {
1536
+ var ptVal = (state.config.platforms && state.config.platforms.feishu && state.config.platforms.feishu.platformType) || 'feishu';
1537
+ html += '<div class="form-group"><label>平台类型</label>';
1538
+ html += '<select id="edit-CHATCCC_FEISHU_PLATFORM_TYPE" style="width:100%;padding:8px 12px;border:1px solid #cbd5e1;border-radius:8px;font-size:14px;outline:none">';
1539
+ html += '<option value="feishu"' + (ptVal === 'feishu' ? ' selected' : '') + '>飞书 (open.feishu.cn)</option>';
1540
+ html += '<option value="lark"' + (ptVal === 'lark' ? ' selected' : '') + '>Lark (open.larksuite.com)</option>';
1541
+ html += '</select></div>';
1542
+ }
1543
+ document.getElementById('edit-modal-fields').innerHTML = html;
1500
1544
  }
1501
1545
 
1502
1546
  function closeEditModal() {
@@ -1515,6 +1559,11 @@ async function saveEdit() {
1515
1559
  var el = document.getElementById('edit-' + key);
1516
1560
  if (el) vars[key] = el.value.trim();
1517
1561
  });
1562
+ // 平台类型:feishu 编辑时额外采集下拉选择框的值
1563
+ if (editSectionType === 'feishu') {
1564
+ var ptEl = document.getElementById('edit-CHATCCC_FEISHU_PLATFORM_TYPE');
1565
+ if (ptEl && ptEl.value.trim()) vars['CHATCCC_FEISHU_PLATFORM_TYPE'] = ptEl.value.trim();
1566
+ }
1518
1567
  await saveConfig(vars);
1519
1568
  closeEditModal();
1520
1569
  updateDashboardUI();
@@ -1565,6 +1614,7 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise
1565
1614
  if (url === "/api/status" && method === "GET") return handleGetStatus(req, res);
1566
1615
  if (url === "/api/start" && method === "POST") return handleStartService(req, res);
1567
1616
  if (url === "/api/stop" && method === "POST") return handleStopService(req, res);
1617
+ if (url === "/api/restart" && method === "POST") return handleRestartService(req, res);
1568
1618
  if (url === "/api/validate" && method === "POST") return handleValidate(req, res);
1569
1619
  if (url === "/api/ilink/forget" && method === "POST") return handleForgetIlink(req, res);
1570
1620