chatccc 0.2.49 → 0.2.51
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/package.json +1 -1
- package/src/__tests__/cards.test.ts +9 -1
- package/src/__tests__/session.test.ts +426 -2
- package/src/__tests__/stream-state.test.ts +134 -0
- package/src/cards.ts +2 -2
- package/src/index.ts +125 -73
- package/src/session-chat-binding.ts +23 -0
- package/src/session.ts +208 -9
- package/src/shared.ts +95 -68
- package/src/stream-state.ts +140 -93
package/src/index.ts
CHANGED
|
@@ -30,7 +30,7 @@ import { resolve, dirname } from "node:path";
|
|
|
30
30
|
import { WSClient, EventDispatcher } from "@larksuiteoapi/node-sdk";
|
|
31
31
|
import WebSocket from "ws";
|
|
32
32
|
|
|
33
|
-
import { appendStartupTrace, attachRelayWebSocket, ensureSingleInstance, freeRelayListenPort, installCrashLogging } from "./shared.ts";
|
|
33
|
+
import { appendStartupTrace, attachRelayWebSocket, ensureSingleInstance, freeRelayListenPort, installCrashLogging, waitForPortFree } from "./shared.ts";
|
|
34
34
|
import { createUiRouter, setExtraApiHandler, setReloadConfigHook, startSetupMode } from "./web-ui.ts";
|
|
35
35
|
import { makeTraceId, logTrace } from "./trace.ts";
|
|
36
36
|
import {
|
|
@@ -97,14 +97,17 @@ import {
|
|
|
97
97
|
initClaudeSession,
|
|
98
98
|
lastMsgTimestamps,
|
|
99
99
|
processedMessages,
|
|
100
|
+
rebuildBindingsFromRegistry,
|
|
100
101
|
resetState,
|
|
101
102
|
resumeAndPrompt,
|
|
102
103
|
sessionInfoMap,
|
|
104
|
+
switchChatBinding,
|
|
103
105
|
recordSessionRegistry,
|
|
104
106
|
getAdapterForTool,
|
|
105
107
|
stopSession,
|
|
106
108
|
loadSessionRegistryForBinding,
|
|
107
109
|
removeSessionRegistryRecord,
|
|
110
|
+
saveSessionTool,
|
|
108
111
|
} from "./session.ts";
|
|
109
112
|
import {
|
|
110
113
|
bindChatToSession,
|
|
@@ -493,6 +496,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
493
496
|
startTime: Date.now(),
|
|
494
497
|
running: false,
|
|
495
498
|
});
|
|
499
|
+
await saveSessionTool(sessionId, tool, initialName);
|
|
496
500
|
await sendCardReply(
|
|
497
501
|
freshToken, chatId, `${toolLabel} Session Ready`,
|
|
498
502
|
`这是你的 **${toolLabel}** 私聊会话。\n\n` +
|
|
@@ -542,6 +546,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
542
546
|
startTime: Date.now(),
|
|
543
547
|
running: false,
|
|
544
548
|
});
|
|
549
|
+
await saveSessionTool(sessionId, tool, initialName);
|
|
545
550
|
|
|
546
551
|
const adapter = getAdapterForTool(tool);
|
|
547
552
|
await sendCardReply(
|
|
@@ -628,6 +633,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
628
633
|
await updateChatInfo(freshToken, chatId, newName, description!);
|
|
629
634
|
console.log(`[${ts()}] [RENAME] First message → group renamed to "${newName}"`);
|
|
630
635
|
await recordSessionRegistry({ chatId, sessionId, tool: descriptionTool, chatName: newName }).catch(() => {});
|
|
636
|
+
await saveSessionTool(sessionId, descriptionTool, newName).catch(() => {});
|
|
631
637
|
} catch (err) {
|
|
632
638
|
console.error(`[${ts()}] [RENAME] Failed: ${(err as Error).message}`);
|
|
633
639
|
}
|
|
@@ -713,11 +719,10 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
713
719
|
cwd = await getDefaultCwd(chatId);
|
|
714
720
|
}
|
|
715
721
|
|
|
716
|
-
//
|
|
717
|
-
// 旧 session
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
722
|
+
// 第一步:创建新 session(此时尚未碰任何内存绑定,失败可直接返回,
|
|
723
|
+
// 旧 session 状态完全保留)。
|
|
724
|
+
// 注意不 abort 旧 session:旧 session 若仍在跑,display loop 会继续
|
|
725
|
+
// 把卡片推到原群直到 prompt 自然结束(或被 /stop)。
|
|
721
726
|
let newSessionId: string;
|
|
722
727
|
try {
|
|
723
728
|
const init = await initClaudeSession(descriptionTool, cwd);
|
|
@@ -728,36 +733,46 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
728
733
|
return;
|
|
729
734
|
}
|
|
730
735
|
|
|
731
|
-
//
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
736
|
+
// 第二步:事务式切换 chat 绑定 —— 把 chat 从 oldSessionId 改嫁到
|
|
737
|
+
// newSessionId。switchChatBinding 内部保证:
|
|
738
|
+
// - 群聊先调 updateChatInfo 改群描述,失败则内存绑定完全不动
|
|
739
|
+
// - 私聊跳过 updateChatInfo(私聊 chatId 调群 API 必然失败)
|
|
740
|
+
// - API 成功后统一切换内存:unbind 旧 / clear displayCards / bind 新 /
|
|
741
|
+
// recordLastActiveChat / sessionInfoMap.set + 持久化 registry/sessions
|
|
742
|
+
// 详见 session.ts::switchChatBinding 注释。
|
|
735
743
|
const descPrefix = sessionPrefixForTool(descriptionTool);
|
|
736
744
|
const newName = sessionChatName("新会话", cwd);
|
|
737
|
-
|
|
738
|
-
console.log(`[${ts()}] [NEWH] Group updated: name="${newName}" desc="${descPrefix} ${newSessionId}"`);
|
|
739
|
-
|
|
740
|
-
sessionInfoMap.set(chatId, {
|
|
741
|
-
sessionId: newSessionId,
|
|
742
|
-
turnCount: 0,
|
|
743
|
-
lastContextTokens: 0,
|
|
744
|
-
startTime: Date.now(),
|
|
745
|
-
tool: descriptionTool,
|
|
746
|
-
});
|
|
747
|
-
await recordSessionRegistry({
|
|
745
|
+
const switchResult = await switchChatBinding({
|
|
748
746
|
chatId,
|
|
749
|
-
|
|
747
|
+
chatType,
|
|
748
|
+
oldSessionId: sessionId,
|
|
749
|
+
newSessionId,
|
|
750
750
|
tool: descriptionTool,
|
|
751
751
|
chatName: newName,
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
startTime: Date.now(),
|
|
755
|
-
running: false,
|
|
752
|
+
newDescription: `${descPrefix} ${newSessionId}`,
|
|
753
|
+
updateChatInfoFn: (cid, name, desc) => updateChatInfo(freshToken, cid, name, desc),
|
|
756
754
|
});
|
|
755
|
+
if (!switchResult.ok) {
|
|
756
|
+
// 飞书 API 失败:内存仍指向旧 session,新 session 已创建但无人使用,
|
|
757
|
+
// 留在 sessions.json(loadSessionTools)成为 orphan,可由 /sessions
|
|
758
|
+
// 列表展示供人工清理或下次 /newh 覆盖。代价小于让群 description
|
|
759
|
+
// 与内存绑定不一致。
|
|
760
|
+
logTrace(tid, "DONE", { outcome: "newh_update_chat_fail", error: switchResult.error?.message });
|
|
761
|
+
await sendCardReply(
|
|
762
|
+
freshToken, chatId, "Error",
|
|
763
|
+
`更新群描述失败,会话未切换(新 session 已创建但未启用):\n${switchResult.error?.message}`,
|
|
764
|
+
"red"
|
|
765
|
+
);
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
if (chatType !== "p2p") {
|
|
769
|
+
console.log(`[${ts()}] [NEWH] Group updated: name="${newName}" desc="${descPrefix} ${newSessionId}"`);
|
|
770
|
+
}
|
|
757
771
|
|
|
758
772
|
setChatAvatar(freshToken, chatId, descriptionTool, "new").catch(() => {});
|
|
759
773
|
|
|
760
|
-
// 如果新 session 有活跃 prompt
|
|
774
|
+
// 如果新 session 有活跃 prompt,启动 display loop 让本群也能看到。
|
|
775
|
+
// /newh 后通常是空 session,这里几乎走不到,但留作防御。
|
|
761
776
|
if (isSessionRunning(newSessionId)) {
|
|
762
777
|
const { ensureDisplayLoop } = await import("./session.ts");
|
|
763
778
|
ensureDisplayLoop(newSessionId);
|
|
@@ -829,12 +844,8 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
829
844
|
}
|
|
830
845
|
const target = ordered[index];
|
|
831
846
|
|
|
832
|
-
//
|
|
833
|
-
|
|
834
|
-
unbindChatFromSession(sessionId, chatId);
|
|
835
|
-
displayCards.delete(chatId);
|
|
836
|
-
}
|
|
837
|
-
|
|
847
|
+
// 第一步:解析目标 session 的 cwd(adapter.getSessionInfo 失败则 fallback,
|
|
848
|
+
// 这一步不动任何内存绑定,失败也不脏)。
|
|
838
849
|
const targetAdapter = getAdapterForTool(target.tool);
|
|
839
850
|
let cwd2: string;
|
|
840
851
|
try {
|
|
@@ -844,33 +855,43 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
844
855
|
cwd2 = await getDefaultCwd(chatId);
|
|
845
856
|
}
|
|
846
857
|
|
|
847
|
-
//
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
858
|
+
// 第二步:事务式切换 chat 绑定到 target.sessionId。
|
|
859
|
+
// switchChatBinding 内部行为同 /newh,详见 session.ts::switchChatBinding。
|
|
860
|
+
// 注意:这里把 turnCount 沿用 target.turnCount(从 registry 读到的目标 chat
|
|
861
|
+
// 历史轮数),而不是从 0 开始,这样 /status 显示的轮数延续历史。
|
|
851
862
|
const descPrefix2 = sessionPrefixForTool(target.tool);
|
|
852
863
|
const newName2 = target.chatName || sessionChatName("新会话", cwd2);
|
|
853
|
-
|
|
854
|
-
console.log(`[${ts()}] [SESSION] Switched to session ${target.sessionId} (#${index + 1}), name="${newName2}"`);
|
|
855
|
-
|
|
856
|
-
sessionInfoMap.set(chatId, {
|
|
857
|
-
sessionId: target.sessionId,
|
|
858
|
-
turnCount: target.turnCount,
|
|
859
|
-
lastContextTokens: 0,
|
|
860
|
-
startTime: Date.now(),
|
|
861
|
-
tool: target.tool,
|
|
862
|
-
});
|
|
863
|
-
await recordSessionRegistry({
|
|
864
|
+
const switchResult = await switchChatBinding({
|
|
864
865
|
chatId,
|
|
865
|
-
|
|
866
|
+
chatType,
|
|
867
|
+
oldSessionId: sessionId,
|
|
868
|
+
newSessionId: target.sessionId,
|
|
866
869
|
tool: target.tool,
|
|
867
870
|
chatName: newName2,
|
|
868
|
-
|
|
871
|
+
newDescription: `${descPrefix2} ${target.sessionId}`,
|
|
872
|
+
initialTurnCount: target.turnCount,
|
|
873
|
+
initialContextTokens: 0,
|
|
874
|
+
updateChatInfoFn: (cid, name, desc) => updateChatInfo(freshToken, cid, name, desc),
|
|
869
875
|
});
|
|
876
|
+
if (!switchResult.ok) {
|
|
877
|
+
logTrace(tid, "DONE", { outcome: "session_update_chat_fail", error: switchResult.error?.message });
|
|
878
|
+
await sendCardReply(
|
|
879
|
+
freshToken, chatId, "Error",
|
|
880
|
+
`更新群描述失败,会话未切换:\n${switchResult.error?.message}`,
|
|
881
|
+
"red"
|
|
882
|
+
);
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
if (chatType !== "p2p") {
|
|
886
|
+
console.log(`[${ts()}] [SESSION] Switched to session ${target.sessionId} (#${index + 1}), name="${newName2}"`);
|
|
887
|
+
}
|
|
870
888
|
|
|
871
889
|
setChatAvatar(freshToken, chatId, target.tool, "new").catch(() => {});
|
|
872
890
|
|
|
873
|
-
// 如果新 session 有活跃 prompt
|
|
891
|
+
// 如果新 session 有活跃 prompt,加上 display loop。注意:此时
|
|
892
|
+
// recordLastActiveChat 已经把 lastActive 改成当前 chat,会"抢走"
|
|
893
|
+
// 原有群的 display 推送(见 review 中 corner case 5)。这是已知
|
|
894
|
+
// 设计权衡:用户主动切换就是想"接管"该 session 的显示。
|
|
874
895
|
if (isSessionRunning(target.sessionId)) {
|
|
875
896
|
const { ensureDisplayLoop } = await import("./session.ts");
|
|
876
897
|
ensureDisplayLoop(target.sessionId);
|
|
@@ -1225,25 +1246,34 @@ async function startBotServiceCore(): Promise<void> {
|
|
|
1225
1246
|
console.error(`[WS] Local relay error: ${err.message}`);
|
|
1226
1247
|
});
|
|
1227
1248
|
} else {
|
|
1249
|
+
// 进程首次启动:此时所有 Map 都是空的,resetState 主要是打个 LOG 标识"开始
|
|
1250
|
+
// 干净状态"。修正残留的 running stream-state 并重建 session→chat 映射。
|
|
1228
1251
|
resetState();
|
|
1229
|
-
// 启动时修正残留的 running 状态并重建 session→chat 映射
|
|
1230
1252
|
fixStaleStreamStates().then(async () => {
|
|
1231
1253
|
const registry = await loadSessionRegistryForBinding();
|
|
1232
1254
|
rebuildSessionChatsFromRegistry(registry);
|
|
1233
1255
|
}).catch((err) => console.error(`[${ts()}] Init bindings failed: ${(err as Error).message}`));
|
|
1234
1256
|
|
|
1257
|
+
// ⚠️ 关键设计:onReady / onReconnected 只重建只读映射,**绝不**清空运行时
|
|
1258
|
+
// 状态(activePrompts、displayLoops、sessionInfoMap、processedMessages)。
|
|
1259
|
+
// SDK 重连只是底层 WebSocket 抖动,业务层不受影响:
|
|
1260
|
+
// - 后台 generator 仍在跑、stream-state 仍在被写
|
|
1261
|
+
// - display loop 仍在向群推送
|
|
1262
|
+
// - 已处理消息的去重 set 必须保留,避免 SDK 重推老消息时 prompt 跑两遍
|
|
1263
|
+
// 历史 bug:此处曾误调 resetState() 导致重连即让所有后台任务变孤儿,
|
|
1264
|
+
// 同一 session 还可能双开 prompt(详见 session.ts::resetState 注释)。
|
|
1235
1265
|
const wsClient = new WSClient({
|
|
1236
1266
|
appId: APP_ID,
|
|
1237
1267
|
appSecret: APP_SECRET,
|
|
1238
1268
|
onReady: async () => {
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1269
|
+
await rebuildBindingsFromRegistry().catch((err) =>
|
|
1270
|
+
console.error(`[${ts()}] [SDK READY] rebuild bindings failed: ${(err as Error).message}`)
|
|
1271
|
+
);
|
|
1242
1272
|
},
|
|
1243
1273
|
onReconnected: async () => {
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1274
|
+
await rebuildBindingsFromRegistry().catch((err) =>
|
|
1275
|
+
console.error(`[${ts()}] [SDK RECONNECT] rebuild bindings failed: ${(err as Error).message}`)
|
|
1276
|
+
);
|
|
1247
1277
|
},
|
|
1248
1278
|
});
|
|
1249
1279
|
|
|
@@ -1405,22 +1435,14 @@ async function main(): Promise<void> {
|
|
|
1405
1435
|
// 凭证齐全:自己起 HTTP server(同时挂 UI router),再调 startBotService
|
|
1406
1436
|
// 把 WS 中继和飞书 SDK 挂上去。
|
|
1407
1437
|
appendStartupTrace("main: before freeRelayListenPort", { CHATCCC_PORT });
|
|
1408
|
-
freeRelayListenPort(CHATCCC_PORT);
|
|
1409
|
-
appendStartupTrace("main: after freeRelayListenPort", { CHATCCC_PORT });
|
|
1438
|
+
const killed = freeRelayListenPort(CHATCCC_PORT);
|
|
1439
|
+
appendStartupTrace("main: after freeRelayListenPort", { CHATCCC_PORT, killed });
|
|
1440
|
+
if (killed > 0) {
|
|
1441
|
+
await waitForPortFree(CHATCCC_PORT);
|
|
1442
|
+
appendStartupTrace("main: port free confirmed", { CHATCCC_PORT });
|
|
1443
|
+
}
|
|
1410
1444
|
const httpServer = createServer(createUiRouter());
|
|
1411
|
-
await
|
|
1412
|
-
const onError = (err: NodeJS.ErrnoException): void => {
|
|
1413
|
-
httpServer.removeListener("listening", onListening);
|
|
1414
|
-
rejectListen(err);
|
|
1415
|
-
};
|
|
1416
|
-
const onListening = (): void => {
|
|
1417
|
-
httpServer.removeListener("error", onError);
|
|
1418
|
-
resolveListen();
|
|
1419
|
-
};
|
|
1420
|
-
httpServer.once("error", onError);
|
|
1421
|
-
httpServer.once("listening", onListening);
|
|
1422
|
-
httpServer.listen(CHATCCC_PORT, "127.0.0.1");
|
|
1423
|
-
}).catch((err: NodeJS.ErrnoException) => {
|
|
1445
|
+
await listenWithRetry(httpServer, CHATCCC_PORT, "127.0.0.1").catch((err: NodeJS.ErrnoException) => {
|
|
1424
1446
|
console.error(`\n[启动] 本地中继 WebSocket 监听失败:端口 ${CHATCCC_PORT}(${err.code ?? "?"} — ${err.message})`);
|
|
1425
1447
|
console.error(
|
|
1426
1448
|
" 处理建议: 关闭占用该端口的其它程序,或在 config.json 的 port 字段里改成其它未占用端口(如 18081)。"
|
|
@@ -1439,6 +1461,36 @@ async function main(): Promise<void> {
|
|
|
1439
1461
|
installShutdownHandlers(httpServer);
|
|
1440
1462
|
}
|
|
1441
1463
|
|
|
1464
|
+
/**
|
|
1465
|
+
* 带重试的 server.listen,Windows 端口释放有延迟时自动重试。
|
|
1466
|
+
*/
|
|
1467
|
+
async function listenWithRetry(
|
|
1468
|
+
server: ReturnType<typeof createServer>,
|
|
1469
|
+
port: number,
|
|
1470
|
+
host: string,
|
|
1471
|
+
maxRetries = 3,
|
|
1472
|
+
): Promise<void> {
|
|
1473
|
+
for (let i = 0; i < maxRetries; i++) {
|
|
1474
|
+
try {
|
|
1475
|
+
await new Promise<void>((resolve, reject) => {
|
|
1476
|
+
server.once("error", reject);
|
|
1477
|
+
server.once("listening", resolve);
|
|
1478
|
+
server.listen(port, host);
|
|
1479
|
+
});
|
|
1480
|
+
return;
|
|
1481
|
+
} catch (err: unknown) {
|
|
1482
|
+
const e = err as NodeJS.ErrnoException;
|
|
1483
|
+
server.removeAllListeners("listening");
|
|
1484
|
+
server.removeAllListeners("error");
|
|
1485
|
+
if (e.code === "EADDRINUSE" && i < maxRetries - 1) {
|
|
1486
|
+
await new Promise((r) => setTimeout(r, 500 * (i + 1)));
|
|
1487
|
+
continue;
|
|
1488
|
+
}
|
|
1489
|
+
throw err;
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1442
1494
|
/**
|
|
1443
1495
|
* 注册 SIGINT / SIGTERM 清理:把 relay/setup 共用的 httpServer 关掉再退出。
|
|
1444
1496
|
*
|
|
@@ -31,6 +31,13 @@ export function unbindChatFromSession(sessionId: string, chatId: string): void {
|
|
|
31
31
|
chats.delete(chatId);
|
|
32
32
|
if (chats.size === 0) sessionChatsMap.delete(sessionId);
|
|
33
33
|
}
|
|
34
|
+
// 双保险:lastActiveChatMap 是 sessionId → 最后活跃 chatId 的快照,
|
|
35
|
+
// 若被解绑的 chatId 正好是该 session 的 lastActive(典型场景:/newh
|
|
36
|
+
// 把当前群从旧 session 改嫁到新 session),不清理会让旧 session 的
|
|
37
|
+
// display loop 继续向已离开的群推送卡片。
|
|
38
|
+
if (lastActiveChatMap.get(sessionId)?.chatId === chatId) {
|
|
39
|
+
lastActiveChatMap.delete(sessionId);
|
|
40
|
+
}
|
|
34
41
|
}
|
|
35
42
|
|
|
36
43
|
export function getChatsForSession(sessionId: string): string[] {
|
|
@@ -75,6 +82,22 @@ export function getLastActiveChat(sessionId: string): string | undefined {
|
|
|
75
82
|
return lastActiveChatMap.get(sessionId)?.chatId;
|
|
76
83
|
}
|
|
77
84
|
|
|
85
|
+
/**
|
|
86
|
+
* display loop 决定推送目标 chat 的纯函数:
|
|
87
|
+
* 仅当 lastActiveChat 仍然绑定到该 session 时才返回,否则返回 undefined。
|
|
88
|
+
*
|
|
89
|
+
* 修复 bug:用户 `/newh` 把当前群从旧 session 改嫁到新 session 后,旧
|
|
90
|
+
* session 的 lastActiveChatMap 残留旧 chatId 快照,display loop 会继续
|
|
91
|
+
* 在该群创建/更新卡片。这里通过交叉验证 sessionChatsMap 把这种悬挂记录
|
|
92
|
+
* 排除掉——loop 拿到 undefined 就走"无活跃群"分支自然停推。
|
|
93
|
+
*/
|
|
94
|
+
export function pickDisplayChat(sessionId: string): string | undefined {
|
|
95
|
+
const candidate = lastActiveChatMap.get(sessionId)?.chatId;
|
|
96
|
+
if (!candidate) return undefined;
|
|
97
|
+
const chats = sessionChatsMap.get(sessionId);
|
|
98
|
+
return chats?.has(candidate) ? candidate : undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
78
101
|
// ---------------------------------------------------------------------------
|
|
79
102
|
// displayCards: chatId → 展示卡片状态(display loop 用)
|
|
80
103
|
// ---------------------------------------------------------------------------
|
package/src/session.ts
CHANGED
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
rebuildSessionChatsFromRegistry,
|
|
46
46
|
recordLastActiveChat,
|
|
47
47
|
getLastActiveChat,
|
|
48
|
+
pickDisplayChat,
|
|
48
49
|
} from "./session-chat-binding.ts";
|
|
49
50
|
|
|
50
51
|
// ---------------------------------------------------------------------------
|
|
@@ -84,6 +85,29 @@ export const sessionInfoMap = new Map<string, {
|
|
|
84
85
|
tool: string;
|
|
85
86
|
}>();
|
|
86
87
|
|
|
88
|
+
/**
|
|
89
|
+
* 清空所有进程内运行时状态。
|
|
90
|
+
*
|
|
91
|
+
* ⚠️ 红线:**绝对不要**在飞书 SDK 的 onReady / onReconnected 回调里调用本函数。
|
|
92
|
+
* SDK 的 WebSocket 重连只是底层连接抖动,业务层(活跃 prompt、display loop、
|
|
93
|
+
* stream-state 文件、轮数计数)完全不受影响。在重连里调 resetState 会:
|
|
94
|
+
* 1) `activePrompts.clear()` 只是删 Map,**不会** abort 后台 generator。
|
|
95
|
+
* generator 继续跑、继续写 stream-state.json,但 display loop 已被
|
|
96
|
+
* stop,用户群里再也看不到任何更新;最终回复永远不发到群。
|
|
97
|
+
* 2) 该 sessionId 在内存里"看似空闲",下一条用户消息进来会**第二次进入**
|
|
98
|
+
* `runAgentSession`,同一个 cursor/claude session 同时跑两条 prompt,
|
|
99
|
+
* 输出互相串扰、token 计费翻倍。
|
|
100
|
+
* 3) `processedMessages` / `lastMsgTimestamps` 被清,SDK 重连后若服务端
|
|
101
|
+
* 重推已 ack 的消息,去重失效会让同一 prompt 被处理两次。
|
|
102
|
+
* 4) `sessionInfoMap` 清空后,群再发消息时 nextTurnCount 从 1 重新计数。
|
|
103
|
+
*
|
|
104
|
+
* 合法调用点:
|
|
105
|
+
* - 单元测试 setup(清测试间状态)
|
|
106
|
+
* - 进程首次启动(此时 Map 都是空的,调用纯粹是为了打 LOG)
|
|
107
|
+
*
|
|
108
|
+
* SDK 重连场景请改用 `rebuildBindingsFromRegistry()`,它只重建 sessionId →
|
|
109
|
+
* chatId 映射,不动任何运行时状态。
|
|
110
|
+
*/
|
|
87
111
|
export function resetState(): void {
|
|
88
112
|
for (const entry of chatSessionMap.values()) {
|
|
89
113
|
if (entry.spinnerTimer) clearInterval(entry.spinnerTimer);
|
|
@@ -93,7 +117,6 @@ export function resetState(): void {
|
|
|
93
117
|
sessionInfoMap.clear();
|
|
94
118
|
processedMessages.clear();
|
|
95
119
|
lastMsgTimestamps.clear();
|
|
96
|
-
// 清理新的全局状态
|
|
97
120
|
activePrompts.clear();
|
|
98
121
|
displayCards.clear();
|
|
99
122
|
for (const stop of displayLoops.values()) stop();
|
|
@@ -101,6 +124,9 @@ export function resetState(): void {
|
|
|
101
124
|
console.log(`[${ts()}] [RESET] State cleared (dedup + active sessions + bindings)`);
|
|
102
125
|
}
|
|
103
126
|
|
|
127
|
+
// 注:`rebuildBindingsFromRegistry` 定义在下方与 loadSessionRegistry 同区域,
|
|
128
|
+
// 是 onReady/onReconnected 取代 resetState 的正确入口。
|
|
129
|
+
|
|
104
130
|
// ---------------------------------------------------------------------------
|
|
105
131
|
// Adapter: 按 tool 创建并缓存
|
|
106
132
|
// ---------------------------------------------------------------------------
|
|
@@ -136,11 +162,14 @@ export function getAdapterForTool(tool: string): ToolAdapter {
|
|
|
136
162
|
interface SessionToolRecord {
|
|
137
163
|
tool: string;
|
|
138
164
|
createdAt: number;
|
|
165
|
+
chatName?: string;
|
|
139
166
|
}
|
|
140
167
|
|
|
168
|
+
let sessionToolsFile = SESSIONS_FILE;
|
|
169
|
+
|
|
141
170
|
async function loadSessionTools(): Promise<Record<string, SessionToolRecord>> {
|
|
142
171
|
try {
|
|
143
|
-
const raw = await readFile(
|
|
172
|
+
const raw = await readFile(sessionToolsFile, "utf-8");
|
|
144
173
|
return JSON.parse(raw);
|
|
145
174
|
} catch {
|
|
146
175
|
return {};
|
|
@@ -149,17 +178,23 @@ async function loadSessionTools(): Promise<Record<string, SessionToolRecord>> {
|
|
|
149
178
|
|
|
150
179
|
async function saveSessionTools(data: Record<string, SessionToolRecord>): Promise<void> {
|
|
151
180
|
try {
|
|
152
|
-
await mkdir(dirname(
|
|
153
|
-
await writeFile(
|
|
181
|
+
await mkdir(dirname(sessionToolsFile), { recursive: true });
|
|
182
|
+
await writeFile(sessionToolsFile, JSON.stringify(data, null, 2), "utf-8");
|
|
154
183
|
} catch (err) {
|
|
155
184
|
console.error(`[${ts()}] Failed to save sessions.json: ${(err as Error).message}`);
|
|
156
185
|
fileLog.flush();
|
|
157
186
|
}
|
|
158
187
|
}
|
|
159
188
|
|
|
160
|
-
export async function saveSessionTool(sessionId: string, tool: string): Promise<void> {
|
|
189
|
+
export async function saveSessionTool(sessionId: string, tool: string, chatName?: string): Promise<void> {
|
|
161
190
|
const data = await loadSessionTools();
|
|
162
|
-
data[sessionId]
|
|
191
|
+
const existing = data[sessionId];
|
|
192
|
+
const mergedChatName = chatName ?? existing?.chatName;
|
|
193
|
+
data[sessionId] = {
|
|
194
|
+
tool,
|
|
195
|
+
createdAt: existing?.createdAt ?? Date.now(),
|
|
196
|
+
...(mergedChatName ? { chatName: mergedChatName } : {}),
|
|
197
|
+
};
|
|
163
198
|
await saveSessionTools(data);
|
|
164
199
|
}
|
|
165
200
|
|
|
@@ -169,6 +204,14 @@ export async function getSessionTool(sessionId: string): Promise<string | null>
|
|
|
169
204
|
return record?.tool ?? null;
|
|
170
205
|
}
|
|
171
206
|
|
|
207
|
+
export function _setSessionToolsFileForTest(filePath: string): void {
|
|
208
|
+
sessionToolsFile = filePath;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function _resetSessionToolsFileForTest(): void {
|
|
212
|
+
sessionToolsFile = SESSIONS_FILE;
|
|
213
|
+
}
|
|
214
|
+
|
|
172
215
|
// ---------------------------------------------------------------------------
|
|
173
216
|
// Conversation session registry for /sessions
|
|
174
217
|
// ---------------------------------------------------------------------------
|
|
@@ -217,6 +260,26 @@ export async function loadSessionRegistryForBinding(): Promise<SessionRegistryDa
|
|
|
217
260
|
return loadSessionRegistry();
|
|
218
261
|
}
|
|
219
262
|
|
|
263
|
+
/**
|
|
264
|
+
* 从持久化的 registry 重建 sessionId → chatId 映射。
|
|
265
|
+
*
|
|
266
|
+
* 设计契约(替代之前 onReady/onReconnected 误用的 resetState):
|
|
267
|
+
* - **不动** activePrompts:后台 prompt 在 SDK 重连后必须继续被识别为活跃,
|
|
268
|
+
* 否则下条用户消息会绕过 isSessionRunning 检查再开一条 prompt,
|
|
269
|
+
* 导致同一 sessionId 双开 generator
|
|
270
|
+
* - **不动** sessionInfoMap:内存里的轮数/contextTokens 比 registry 更新
|
|
271
|
+
* - **不动** displayCards / displayLoops:正在跑的 prompt 还需要它们继续推卡片
|
|
272
|
+
* - **不动** processedMessages / lastMsgTimestamps:SDK 重连若重推已 ack 消息,
|
|
273
|
+
* 去重 set 还在才能避免同一 prompt 跑两遍
|
|
274
|
+
*
|
|
275
|
+
* 唯一被重建的是 sessionChatsMap(通过调用 rebuildSessionChatsFromRegistry)——
|
|
276
|
+
* 该 Map 是从 registry 派生的纯只读映射,重建是幂等且廉价的。
|
|
277
|
+
*/
|
|
278
|
+
export async function rebuildBindingsFromRegistry(): Promise<void> {
|
|
279
|
+
const registry = await loadSessionRegistry();
|
|
280
|
+
rebuildSessionChatsFromRegistry(registry);
|
|
281
|
+
}
|
|
282
|
+
|
|
220
283
|
async function saveSessionRegistry(data: SessionRegistryData): Promise<void> {
|
|
221
284
|
try {
|
|
222
285
|
await mkdir(dirname(sessionRegistryFile), { recursive: true });
|
|
@@ -361,6 +424,118 @@ export function accumulateBlockContent(
|
|
|
361
424
|
}
|
|
362
425
|
}
|
|
363
426
|
|
|
427
|
+
// ---------------------------------------------------------------------------
|
|
428
|
+
// switchChatBinding — /newh、/session N 共用的事务式"切换 chat 绑定"
|
|
429
|
+
// ---------------------------------------------------------------------------
|
|
430
|
+
//
|
|
431
|
+
// 设计契约(解决三类历史 bug):
|
|
432
|
+
//
|
|
433
|
+
// 1. 私聊不能调 updateChatInfo(飞书 API 在 p2p chatId 上会返回非 0 → throw)。
|
|
434
|
+
// 之前的实现没判断 chatType,私聊 /newh、/session N 走到 updateChatInfo
|
|
435
|
+
// 就直接抛错,留下"内存已切换、registry 没更新"的脏状态。
|
|
436
|
+
//
|
|
437
|
+
// 2. updateChatInfo 群聊也可能因为网络/频控失败。之前的代码顺序是
|
|
438
|
+
// 先 unbind 旧 → bind 新 → 再调 updateChatInfo
|
|
439
|
+
// API 失败后内存绑定已经切走,但群 description 还是旧 sessionId。
|
|
440
|
+
// 下次用户在群里发消息时,extractSessionInfo 拿到旧 sessionId,而内存绑定
|
|
441
|
+
// 指向新 sessionId,路由完全错乱(参考 /newh 的 corner case 7)。
|
|
442
|
+
//
|
|
443
|
+
// 3. 改成"先 API 后内存"的顺序后,API 失败就完全不切换内存,下次消息按
|
|
444
|
+
// 旧 description 正常路由到旧 session,新创建的 session 留在 sessions.json
|
|
445
|
+
// 里成为可清理的 orphan。
|
|
446
|
+
//
|
|
447
|
+
// 调用方约定:
|
|
448
|
+
// - newSessionId 必须是已经 createSession 完成的真实 session(本函数不创建)
|
|
449
|
+
// - oldSessionId 为 null 表示当前 chat 没有任何旧绑定(比如私聊首次绑)
|
|
450
|
+
// - 私聊跳过 updateChatInfo,直接做内存切换 + registry 持久化
|
|
451
|
+
// - API 失败时:
|
|
452
|
+
// * 不动内存绑定 / sessionInfoMap / displayCards
|
|
453
|
+
// * 返回 { ok: false, error }
|
|
454
|
+
// * 调用方负责把错误反馈给用户
|
|
455
|
+
// ---------------------------------------------------------------------------
|
|
456
|
+
|
|
457
|
+
export interface SwitchChatBindingArgs {
|
|
458
|
+
chatId: string;
|
|
459
|
+
chatType: string;
|
|
460
|
+
oldSessionId: string | null;
|
|
461
|
+
newSessionId: string;
|
|
462
|
+
tool: string;
|
|
463
|
+
/** 群名(私聊忽略) */
|
|
464
|
+
chatName: string;
|
|
465
|
+
/** 群描述(私聊忽略),通常为 `${sessionPrefixForTool(tool)} ${newSessionId}` */
|
|
466
|
+
newDescription: string;
|
|
467
|
+
/** 切换后 sessionInfoMap 的初始 turnCount/lastContextTokens(如沿用历史) */
|
|
468
|
+
initialTurnCount?: number;
|
|
469
|
+
initialContextTokens?: number;
|
|
470
|
+
/** 飞书 updateChatInfo 实现,依赖注入便于测试 mock */
|
|
471
|
+
updateChatInfoFn: (chatId: string, name: string, description: string) => Promise<void>;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
export interface SwitchChatBindingResult {
|
|
475
|
+
ok: boolean;
|
|
476
|
+
error?: Error;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export async function switchChatBinding(args: SwitchChatBindingArgs): Promise<SwitchChatBindingResult> {
|
|
480
|
+
const {
|
|
481
|
+
chatId,
|
|
482
|
+
chatType,
|
|
483
|
+
oldSessionId,
|
|
484
|
+
newSessionId,
|
|
485
|
+
tool,
|
|
486
|
+
chatName,
|
|
487
|
+
newDescription,
|
|
488
|
+
initialTurnCount = 0,
|
|
489
|
+
initialContextTokens = 0,
|
|
490
|
+
updateChatInfoFn,
|
|
491
|
+
} = args;
|
|
492
|
+
|
|
493
|
+
// Step 1: 群聊场景先调用飞书 API(不可逆操作放最前)。
|
|
494
|
+
// 私聊跳过——p2p chatId 调 updateChatInfo 必然失败。
|
|
495
|
+
if (chatType !== "p2p") {
|
|
496
|
+
try {
|
|
497
|
+
await updateChatInfoFn(chatId, chatName, newDescription);
|
|
498
|
+
} catch (err) {
|
|
499
|
+
// API 失败:完全不动内存,调用方负责回报用户。
|
|
500
|
+
return { ok: false, error: err as Error };
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Step 2: API 成功(或私聊跳过)后,原子地切换内存绑定。
|
|
505
|
+
// 这一段全是同步 Map 操作,不会失败。
|
|
506
|
+
if (oldSessionId) {
|
|
507
|
+
unbindChatFromSession(oldSessionId, chatId);
|
|
508
|
+
displayCards.delete(chatId);
|
|
509
|
+
}
|
|
510
|
+
bindChatToSession(newSessionId, chatId);
|
|
511
|
+
recordLastActiveChat(newSessionId, chatId);
|
|
512
|
+
|
|
513
|
+
const now = Date.now();
|
|
514
|
+
sessionInfoMap.set(chatId, {
|
|
515
|
+
sessionId: newSessionId,
|
|
516
|
+
turnCount: initialTurnCount,
|
|
517
|
+
lastContextTokens: initialContextTokens,
|
|
518
|
+
startTime: now,
|
|
519
|
+
tool,
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
// Step 3: 持久化(registry + sessions.json)。
|
|
523
|
+
// 这两步即使失败也不影响内存正确性,下次 prompt 会再写一次。
|
|
524
|
+
await recordSessionRegistry({
|
|
525
|
+
chatId,
|
|
526
|
+
sessionId: newSessionId,
|
|
527
|
+
tool,
|
|
528
|
+
chatName,
|
|
529
|
+
turnCount: initialTurnCount,
|
|
530
|
+
lastContextTokens: initialContextTokens,
|
|
531
|
+
startTime: now,
|
|
532
|
+
running: false,
|
|
533
|
+
});
|
|
534
|
+
await saveSessionTool(newSessionId, tool, chatName);
|
|
535
|
+
|
|
536
|
+
return { ok: true };
|
|
537
|
+
}
|
|
538
|
+
|
|
364
539
|
// ---------------------------------------------------------------------------
|
|
365
540
|
// AI tool session management
|
|
366
541
|
// ---------------------------------------------------------------------------
|
|
@@ -658,7 +833,9 @@ export function ensureDisplayLoop(sessionId: string): void {
|
|
|
658
833
|
const state = await readStreamState(sessionId);
|
|
659
834
|
if (!state) return;
|
|
660
835
|
|
|
661
|
-
|
|
836
|
+
// pickDisplayChat 在 lastActiveChat 已与本 session 解绑时返回 undefined,
|
|
837
|
+
// 避免 /newh 等改嫁场景下旧 session 仍向已离开群推卡片。
|
|
838
|
+
const chatId = pickDisplayChat(sessionId);
|
|
662
839
|
if (!chatId) {
|
|
663
840
|
// 无活跃群,若 session 已结束则停止 loop
|
|
664
841
|
if (state.status !== "running") {
|
|
@@ -901,9 +1078,31 @@ export interface SessionsListEntry {
|
|
|
901
1078
|
|
|
902
1079
|
export async function getAllSessionsStatus(): Promise<SessionsListEntry[]> {
|
|
903
1080
|
const registry = await loadSessionRegistry();
|
|
904
|
-
const
|
|
1081
|
+
const registryEntries = Object.values(registry)
|
|
905
1082
|
.filter((record) => record.chatId && record.sessionId && record.tool)
|
|
906
|
-
.
|
|
1083
|
+
.map((record) => ({ ...record, sortTime: record.updatedAt }));
|
|
1084
|
+
const registeredSessionIds = new Set(registryEntries.map((record) => record.sessionId));
|
|
1085
|
+
const sessionTools = await loadSessionTools();
|
|
1086
|
+
const orphanEntries = Object.entries(sessionTools)
|
|
1087
|
+
.filter(([sessionId, record]) => sessionId && record?.tool && !registeredSessionIds.has(sessionId))
|
|
1088
|
+
.map(([sessionId, record]) => {
|
|
1089
|
+
const createdAt = Number.isFinite(record.createdAt) ? record.createdAt : 0;
|
|
1090
|
+
const active = activePrompts.get(sessionId);
|
|
1091
|
+
return {
|
|
1092
|
+
chatId: "",
|
|
1093
|
+
sessionId,
|
|
1094
|
+
tool: record.tool,
|
|
1095
|
+
chatName: record.chatName ?? "",
|
|
1096
|
+
turnCount: 0,
|
|
1097
|
+
lastContextTokens: 0,
|
|
1098
|
+
startTime: active?.startTime ?? createdAt,
|
|
1099
|
+
updatedAt: createdAt,
|
|
1100
|
+
running: false,
|
|
1101
|
+
sortTime: active?.startTime ?? createdAt,
|
|
1102
|
+
};
|
|
1103
|
+
});
|
|
1104
|
+
const entries = [...registryEntries, ...orphanEntries]
|
|
1105
|
+
.sort((a, b) => b.sortTime - a.sortTime)
|
|
907
1106
|
.slice(0, 20);
|
|
908
1107
|
// 并行解析每个 session 的 model/effort(cursor 涉及异步 store IO)
|
|
909
1108
|
return Promise.all(
|