chatccc 0.2.92 → 0.2.94
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/im-skills/wechat-file-skill/receive-send-file.md +38 -38
- package/im-skills/wechat-file-skill/send-file.mjs +83 -83
- package/im-skills/wechat-file-skill/skill.md +10 -10
- package/im-skills/wechat-image-skill/skill.md +10 -10
- package/im-skills/wechat-video-skill/receive-send-video.md +38 -38
- package/im-skills/wechat-video-skill/send-video.mjs +79 -79
- package/im-skills/wechat-video-skill/skill.md +10 -10
- package/package.json +1 -1
- package/scripts/postinstall-sharp-check.mjs +58 -58
- package/src/__tests__/session.test.ts +148 -6
- package/src/__tests__/stream-state.test.ts +30 -0
- package/src/builtin/cli.ts +196 -196
- package/src/builtin/index.ts +166 -166
- package/src/index.ts +3 -1
- package/src/orchestrator.ts +0 -10
- package/src/session-chat-binding.ts +14 -4
- package/src/session.ts +212 -209
- package/src/stream-state.ts +20 -1
- package/src/turn-cards.ts +117 -117
package/src/session.ts
CHANGED
|
@@ -37,7 +37,14 @@ function compressWechatDisplayText(text: string): string {
|
|
|
37
37
|
if (lines.length <= 10) return text;
|
|
38
38
|
return [...lines.slice(0, 5), "...", ...lines.slice(-5)].join("\n");
|
|
39
39
|
}
|
|
40
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
readStreamState,
|
|
42
|
+
writeStreamState,
|
|
43
|
+
createEmptyStreamState,
|
|
44
|
+
fixStaleStreamStates,
|
|
45
|
+
isFinalReplySentForTurn,
|
|
46
|
+
markFinalReplySent,
|
|
47
|
+
} from "./stream-state.ts";
|
|
41
48
|
import { addCardToTurn, finalizeTurnCards, markCardDone } from "./turn-cards.ts";
|
|
42
49
|
import {
|
|
43
50
|
bindChatToSession,
|
|
@@ -46,7 +53,8 @@ import {
|
|
|
46
53
|
isSessionRunning,
|
|
47
54
|
activePrompts,
|
|
48
55
|
displayCards,
|
|
49
|
-
|
|
56
|
+
unifiedDisplayLoopHandle,
|
|
57
|
+
setUnifiedDisplayLoopHandle,
|
|
50
58
|
rebuildSessionChatsFromRegistry,
|
|
51
59
|
recordLastActiveChat,
|
|
52
60
|
getLastActiveChat,
|
|
@@ -58,6 +66,18 @@ import {
|
|
|
58
66
|
consumeQueuePreservedChat,
|
|
59
67
|
} from "./session-chat-binding.ts";
|
|
60
68
|
|
|
69
|
+
async function sendFinalReplyTextOnce(
|
|
70
|
+
platform: PlatformAdapter,
|
|
71
|
+
chatId: string,
|
|
72
|
+
sessionId: string,
|
|
73
|
+
turnCount: number,
|
|
74
|
+
text: string,
|
|
75
|
+
): Promise<boolean> {
|
|
76
|
+
const sent = await platform.sendText(chatId, text).then((ok) => ok !== false).catch(() => false);
|
|
77
|
+
if (sent) await markFinalReplySent(sessionId, turnCount);
|
|
78
|
+
return sent;
|
|
79
|
+
}
|
|
80
|
+
|
|
61
81
|
// ---------------------------------------------------------------------------
|
|
62
82
|
// Shared state (imported by index.ts)
|
|
63
83
|
// ---------------------------------------------------------------------------
|
|
@@ -171,8 +191,7 @@ export function resetState(): void {
|
|
|
171
191
|
chatPlatformMap.clear();
|
|
172
192
|
activePrompts.clear();
|
|
173
193
|
displayCards.clear();
|
|
174
|
-
|
|
175
|
-
displayLoops.clear();
|
|
194
|
+
stopUnifiedDisplayLoop();
|
|
176
195
|
console.log(`[${ts()}] [RESET] State cleared (dedup + active sessions + bindings)`);
|
|
177
196
|
}
|
|
178
197
|
|
|
@@ -321,7 +340,7 @@ export async function loadSessionRegistryForBinding(): Promise<SessionRegistryDa
|
|
|
321
340
|
* 否则下条用户消息会绕过 isSessionRunning 检查再开一条 prompt,
|
|
322
341
|
* 导致同一 sessionId 双开 generator
|
|
323
342
|
* - **不动** sessionInfoMap:内存里的轮数/contextTokens 比 registry 更新
|
|
324
|
-
* - **不动** displayCards
|
|
343
|
+
* - **不动** displayCards:正在跑的 prompt 还需要它们继续推卡片
|
|
325
344
|
* - **不动** processedMessages / lastMsgTimestamps:SDK 重连若重推已 ack 消息,
|
|
326
345
|
* 去重 set 还在才能避免同一 prompt 跑两遍
|
|
327
346
|
*
|
|
@@ -805,34 +824,45 @@ export async function runAgentSession(
|
|
|
805
824
|
if (displayChatId) {
|
|
806
825
|
const pp = platformForChat(displayChatId);
|
|
807
826
|
const display = displayCards.get(displayChatId);
|
|
808
|
-
const oldLoopExisted = displayLoops.has(sessionId);
|
|
809
827
|
|
|
810
828
|
if (display && pp) {
|
|
811
|
-
//
|
|
829
|
+
// 统一 display loop 被 cardBusy 挡住或尚未 tick → 现在终结卡片
|
|
812
830
|
while (display.cardBusy) await new Promise(r => setTimeout(r, 20));
|
|
813
|
-
const nextSeq = display.sequence + 1;
|
|
814
|
-
const headerTitle = prevState.status === "stopped" ? "已停止" : "完成";
|
|
815
|
-
const headerTemplate = prevState.status === "stopped" ? "red" : undefined;
|
|
816
|
-
const cardContent = truncateContent(prevState.accumulatedContent + prevState.finalReply) || " ";
|
|
817
|
-
const doneCard = buildProgressCard(cardContent, { showStop: false, headerTitle, headerTemplate });
|
|
818
|
-
await pp.cardUpdate(display.cardId, doneCard, nextSeq).catch(() => {});
|
|
819
|
-
displayCards.delete(displayChatId);
|
|
820
|
-
|
|
821
|
-
// 持久化:标记上一轮所有卡片为终态
|
|
822
|
-
const finalStatus = prevState.status === "stopped" ? "stopped" : "done";
|
|
823
|
-
finalizeTurnCards(sessionId, prevState.turnCount, finalStatus).catch(() => {});
|
|
824
831
|
|
|
825
|
-
|
|
826
|
-
|
|
832
|
+
// 竞态防护:等待期间统一 display loop 的 tick 可能也已读到同一个
|
|
833
|
+
// terminal state,发送了 finalReply 并删除/替换了 displayCards 条目。
|
|
834
|
+
// 通过引用比较检测——若统一 loop 已处理则只补持久化和头像,不重复发。
|
|
835
|
+
if (displayCards.get(displayChatId) !== display) {
|
|
836
|
+
const finalStatus = prevState.status === "stopped" ? "stopped" : "done";
|
|
837
|
+
finalizeTurnCards(sessionId, prevState.turnCount, finalStatus).catch(() => {});
|
|
838
|
+
pp.setChatAvatar(displayChatId, prevState.tool, "idle").catch(() => {});
|
|
839
|
+
} else {
|
|
840
|
+
const nextSeq = display.sequence + 1;
|
|
841
|
+
const headerTitle = prevState.status === "stopped" ? "已停止" : "完成";
|
|
842
|
+
const headerTemplate = prevState.status === "stopped" ? "red" : undefined;
|
|
843
|
+
const cardContent = truncateContent(prevState.accumulatedContent + prevState.finalReply) || " ";
|
|
844
|
+
const doneCard = buildProgressCard(cardContent, { showStop: false, headerTitle, headerTemplate });
|
|
845
|
+
await pp.cardUpdate(display.cardId, doneCard, nextSeq).catch(() => {});
|
|
846
|
+
// cardUpdate IO 期间统一 loop 可能也已处理此 display → 删前检查引用
|
|
847
|
+
const stillOursAfterUpdate = displayCards.get(displayChatId) === display;
|
|
848
|
+
displayCards.delete(displayChatId);
|
|
849
|
+
|
|
850
|
+
// 持久化:标记上一轮所有卡片为终态
|
|
851
|
+
const finalStatus = prevState.status === "stopped" ? "stopped" : "done";
|
|
852
|
+
finalizeTurnCards(sessionId, prevState.turnCount, finalStatus).catch(() => {});
|
|
853
|
+
|
|
854
|
+
if (prevState.finalReply && stillOursAfterUpdate && !isFinalReplySentForTurn(prevState)) {
|
|
855
|
+
await sendFinalReplyTextOnce(pp, displayChatId, sessionId, prevState.turnCount, prevState.finalReply);
|
|
856
|
+
}
|
|
857
|
+
pp.setChatAvatar(displayChatId, prevState.tool, "idle").catch(() => {});
|
|
827
858
|
}
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
// 同时持久化终结旧 turn 卡片:旧 loop 的 mismatch 守卫可能已删 display entry
|
|
859
|
+
} else if (pp && prevState.finalReply && !isFinalReplySentForTurn(prevState)) {
|
|
860
|
+
// 无 display 记录但上一轮有 finalReply(极快轮次),至少发送
|
|
861
|
+
const finalStatus = prevState.status === "stopped" ? "stopped" : "done";
|
|
832
862
|
finalizeTurnCards(sessionId, prevState.turnCount, finalStatus).catch(() => {});
|
|
833
|
-
await pp
|
|
863
|
+
await sendFinalReplyTextOnce(pp, displayChatId, sessionId, prevState.turnCount, prevState.finalReply);
|
|
834
864
|
}
|
|
835
|
-
// else:
|
|
865
|
+
// else: displayCards 无记录且无 finalReply → 无需处理
|
|
836
866
|
}
|
|
837
867
|
}
|
|
838
868
|
|
|
@@ -841,7 +871,7 @@ export async function runAgentSession(
|
|
|
841
871
|
await writeStreamState(initialState);
|
|
842
872
|
|
|
843
873
|
// 为新 turn 创建第一张展示卡片,同时注册到 turn-cards 持久化。
|
|
844
|
-
//
|
|
874
|
+
// 统一 display loop 始终运行,卡片创建后下一个 tick 即自动开始更新。
|
|
845
875
|
const displayChatIdForNew = pickDisplayChat(sessionId);
|
|
846
876
|
if (displayChatIdForNew) {
|
|
847
877
|
const ppNew = platformForChat(displayChatIdForNew);
|
|
@@ -858,16 +888,28 @@ export async function runAgentSession(
|
|
|
858
888
|
cardCreatedAt: Date.now(),
|
|
859
889
|
lastSentContent: "",
|
|
860
890
|
streamErrorNotified: false,
|
|
891
|
+
sessionId,
|
|
861
892
|
turnCount: nextTurnCount,
|
|
893
|
+
dotCount: 0,
|
|
862
894
|
});
|
|
863
895
|
addCardToTurn(sessionId, nextTurnCount, cardId).catch(() => {});
|
|
864
896
|
}
|
|
897
|
+
} else if (ppNew && ppNew.kind === "wechat") {
|
|
898
|
+
// WeChat: 无卡片,但需要 display entry 追踪已发送内容
|
|
899
|
+
displayCards.set(displayChatIdForNew, {
|
|
900
|
+
cardId: "",
|
|
901
|
+
sequence: 0,
|
|
902
|
+
cardBusy: false,
|
|
903
|
+
cardCreatedAt: Date.now(),
|
|
904
|
+
lastSentContent: "",
|
|
905
|
+
streamErrorNotified: false,
|
|
906
|
+
sessionId,
|
|
907
|
+
turnCount: nextTurnCount,
|
|
908
|
+
dotCount: 0,
|
|
909
|
+
});
|
|
865
910
|
}
|
|
866
911
|
}
|
|
867
912
|
|
|
868
|
-
// 启动 display loop
|
|
869
|
-
ensureDisplayLoop(sessionId);
|
|
870
|
-
|
|
871
913
|
// 设置最后活跃群头像为 busy
|
|
872
914
|
const activeCid = getLastActiveChat(sessionId) ?? getChatsForSession(sessionId)[0];
|
|
873
915
|
if (activeCid) {
|
|
@@ -1019,90 +1061,83 @@ export async function runAgentSession(
|
|
|
1019
1061
|
}
|
|
1020
1062
|
|
|
1021
1063
|
// ---------------------------------------------------------------------------
|
|
1022
|
-
//
|
|
1064
|
+
// startUnifiedDisplayLoop — 全局统一 display 循环,遍历 displayCards 更新卡片
|
|
1065
|
+
// ---------------------------------------------------------------------------
|
|
1066
|
+
// 替代旧的 per-session ensureDisplayLoop,消除 kill/restart 竞态条件。
|
|
1067
|
+
// 单一定时器遍历所有 displayCards 条目,通过条目内的 sessionId 查找 stream state。
|
|
1023
1068
|
// ---------------------------------------------------------------------------
|
|
1024
1069
|
|
|
1025
1070
|
const CARD_ROTATE_MS = 9 * 60 * 1000;
|
|
1026
1071
|
|
|
1027
|
-
export function
|
|
1028
|
-
|
|
1029
|
-
// (发送完成卡片、清理 display)尚未 delete 自己,新 turn 的
|
|
1030
|
-
// runAgentSession 调用 ensureDisplayLoop 时因 guard 直接返回,
|
|
1031
|
-
// 导致新 turn 没有 display loop → 卡片永久不更新。
|
|
1032
|
-
const oldStop = displayLoops.get(sessionId);
|
|
1033
|
-
if (oldStop) {
|
|
1034
|
-
console.log(`[${ts()}] [DISPLAY] ensureDisplayLoop restarting loop for ${sessionId} (old loop existed)`);
|
|
1035
|
-
oldStop();
|
|
1036
|
-
displayLoops.delete(sessionId);
|
|
1037
|
-
}
|
|
1038
|
-
|
|
1039
|
-
let dotCount = 0;
|
|
1072
|
+
export function startUnifiedDisplayLoop(): void {
|
|
1073
|
+
if (unifiedDisplayLoopHandle !== null) return;
|
|
1040
1074
|
|
|
1041
1075
|
const interval = setInterval(() => {
|
|
1042
1076
|
void (async () => {
|
|
1043
|
-
const
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
if (
|
|
1058
|
-
|
|
1077
|
+
for (const [chatId, display] of displayCards) {
|
|
1078
|
+
if (display.cardBusy) continue;
|
|
1079
|
+
|
|
1080
|
+
const sessionId = display.sessionId;
|
|
1081
|
+
const state = await readStreamState(sessionId);
|
|
1082
|
+
if (!state) {
|
|
1083
|
+
displayCards.delete(chatId);
|
|
1084
|
+
continue;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// 交叉验证:chat 当前绑定的 session 是否仍是 display 记录的 session。
|
|
1088
|
+
// 若 chat 已被切换到其他 session(如 /newh),旧 display 必须停推。
|
|
1089
|
+
const currentSessionForChat = sessionInfoMap.get(chatId)?.sessionId;
|
|
1090
|
+
if (currentSessionForChat && currentSessionForChat !== sessionId) {
|
|
1091
|
+
if (state.status !== "running") {
|
|
1092
|
+
displayCards.delete(chatId);
|
|
1059
1093
|
}
|
|
1094
|
+
continue;
|
|
1060
1095
|
}
|
|
1061
|
-
return;
|
|
1062
|
-
}
|
|
1063
1096
|
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
displayLoops.delete(sessionId);
|
|
1097
|
+
// 验证 chat 仍是该 session 的最后活跃群
|
|
1098
|
+
const lastActive = getLastActiveChat(sessionId);
|
|
1099
|
+
if (lastActive !== chatId) {
|
|
1100
|
+
if (state.status !== "running") {
|
|
1101
|
+
displayCards.delete(chatId);
|
|
1102
|
+
}
|
|
1103
|
+
continue;
|
|
1072
1104
|
}
|
|
1073
|
-
return;
|
|
1074
|
-
}
|
|
1075
1105
|
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1106
|
+
const isTerminal = state.status !== "running";
|
|
1107
|
+
|
|
1108
|
+
try {
|
|
1109
|
+
const p = platformForChat(chatId);
|
|
1110
|
+
if (!p) continue;
|
|
1111
|
+
|
|
1112
|
+
const isWechat = p.kind === "wechat";
|
|
1113
|
+
|
|
1114
|
+
if (isTerminal) {
|
|
1115
|
+
if (isWechat) {
|
|
1116
|
+
const prevAccLen = display.lastSentAccLen ?? 0;
|
|
1117
|
+
const prevFinalReply = display.lastSentFinalReply ?? "";
|
|
1118
|
+
const accDelta = state.accumulatedContent.slice(prevAccLen);
|
|
1119
|
+
let replyDelta: string;
|
|
1120
|
+
if (prevFinalReply && state.finalReply.startsWith(prevFinalReply)) {
|
|
1121
|
+
replyDelta = state.finalReply.slice(prevFinalReply.length);
|
|
1122
|
+
} else {
|
|
1123
|
+
replyDelta = state.finalReply;
|
|
1124
|
+
}
|
|
1125
|
+
const remaining = (accDelta + replyDelta).trim();
|
|
1126
|
+
|
|
1127
|
+
// 若 session 仍在 activePrompts 中,说明 runAgentSession 的 finally
|
|
1128
|
+
// 还没执行,当前 stream state 可能是 stopSession fire-and-forget
|
|
1129
|
+
// 写入的,finalReply 滞后于内存态。跳过发送,等 finally 落盘后
|
|
1130
|
+
// 下一次 tick 再处理,避免发送过期内容或与后续发送重复。
|
|
1131
|
+
if (activePrompts.has(sessionId)) continue;
|
|
1132
|
+
|
|
1133
|
+
const tail = "━━━ 回答结束 ━━━";
|
|
1134
|
+
const finalMsg = remaining ? remaining + "\n" + tail : tail;
|
|
1135
|
+
if (!isFinalReplySentForTurn(state)) {
|
|
1136
|
+
await sendFinalReplyTextOnce(p, chatId, sessionId, state.turnCount, finalMsg);
|
|
1137
|
+
}
|
|
1138
|
+
displayCards.delete(chatId);
|
|
1095
1139
|
} else {
|
|
1096
|
-
|
|
1097
|
-
}
|
|
1098
|
-
const remaining = (accDelta + replyDelta).trim();
|
|
1099
|
-
const tail = "━━━ 回答结束 ━━━";
|
|
1100
|
-
const finalMsg = remaining ? remaining + "\n" + tail : tail;
|
|
1101
|
-
await p.sendText(chatId, finalMsg).catch(() => {});
|
|
1102
|
-
if (display) displayCards.delete(chatId);
|
|
1103
|
-
} else {
|
|
1104
|
-
// 发送最终结果(卡片平台)
|
|
1105
|
-
if (display) {
|
|
1140
|
+
// 发送最终结果(卡片平台)
|
|
1106
1141
|
while (display.cardBusy) await new Promise(r => setTimeout(r, 20));
|
|
1107
1142
|
const nextSeq = display.sequence + 1;
|
|
1108
1143
|
const headerTitle = state.status === "stopped" ? "已停止" : "完成";
|
|
@@ -1110,102 +1145,71 @@ export function ensureDisplayLoop(sessionId: string): void {
|
|
|
1110
1145
|
const cardContent = truncateContent(state.accumulatedContent + state.finalReply) || " ";
|
|
1111
1146
|
const doneCard = buildProgressCard(cardContent, { showStop: false, headerTitle, headerTemplate });
|
|
1112
1147
|
await p.cardUpdate(display.cardId, doneCard, nextSeq).catch(() => {});
|
|
1148
|
+
|
|
1149
|
+
// 若 session 仍在 activePrompts 中,说明 runAgentSession 的 finally
|
|
1150
|
+
// 还没执行,当前 stream state 可能是 stopSession fire-and-forget
|
|
1151
|
+
// 写入的,finalReply 滞后于内存态。卡片已更新为终态外观,但不发送
|
|
1152
|
+
// 文本、不删除 display 条目,留给 finally 落盘后的下一次 tick 处理。
|
|
1153
|
+
if (activePrompts.has(sessionId)) {
|
|
1154
|
+
display.lastSentAccLen = state.accumulatedContent.length;
|
|
1155
|
+
display.lastSentFinalReply = state.finalReply;
|
|
1156
|
+
continue;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1113
1159
|
const finalSt = state.status === "stopped" ? "stopped" : "done";
|
|
1114
1160
|
finalizeTurnCards(sessionId, state.turnCount, finalSt).catch(() => {});
|
|
1115
1161
|
displayCards.delete(chatId);
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
}
|
|
1124
|
-
p.setChatAvatar(chatId, state.tool, "idle").catch(() => {});
|
|
1125
|
-
} else {
|
|
1126
|
-
// running: 创建或更新展示
|
|
1127
|
-
if (isWechat) {
|
|
1128
|
-
// WeChat: 不使用卡片,基于 agent 真实 delta 推送 raw content
|
|
1129
|
-
if (!display) {
|
|
1130
|
-
displayCards.set(chatId, {
|
|
1131
|
-
cardId: "",
|
|
1132
|
-
sequence: 0,
|
|
1133
|
-
cardBusy: false,
|
|
1134
|
-
cardCreatedAt: Date.now(),
|
|
1135
|
-
lastSentContent: "",
|
|
1136
|
-
streamErrorNotified: false,
|
|
1137
|
-
});
|
|
1138
|
-
}
|
|
1139
|
-
const d = displayCards.get(chatId);
|
|
1140
|
-
if (!d || d.cardBusy) return;
|
|
1141
|
-
|
|
1142
|
-
// 分开追踪 accumulatedContent 和 finalReply 的已发送位置。
|
|
1143
|
-
// 如果只用 rawFull.startsWith(prevRaw),当新的 tool_use/tool_result
|
|
1144
|
-
// 插入到 accumulatedContent 中间时,rawFull 不再以 prevRaw 开头,
|
|
1145
|
-
// 会回退到发送完整内容 → 产生大量重复。
|
|
1146
|
-
const prevAccLen = d.lastSentAccLen ?? 0;
|
|
1147
|
-
const prevFinalReply = d.lastSentFinalReply ?? "";
|
|
1148
|
-
const accDelta = state.accumulatedContent.slice(prevAccLen);
|
|
1149
|
-
let replyDelta: string;
|
|
1150
|
-
if (prevFinalReply && state.finalReply.startsWith(prevFinalReply)) {
|
|
1151
|
-
replyDelta = state.finalReply.slice(prevFinalReply.length);
|
|
1152
|
-
} else {
|
|
1153
|
-
replyDelta = state.finalReply;
|
|
1154
|
-
}
|
|
1155
|
-
const delta = (accDelta + replyDelta).trim();
|
|
1156
|
-
if (!delta) return;
|
|
1157
|
-
|
|
1158
|
-
d.cardBusy = true;
|
|
1159
|
-
try {
|
|
1160
|
-
const ok = await p.sendText(chatId, compressWechatDisplayText(delta));
|
|
1161
|
-
if (ok) {
|
|
1162
|
-
d.lastSentAccLen = state.accumulatedContent.length;
|
|
1163
|
-
d.lastSentFinalReply = state.finalReply;
|
|
1164
|
-
d.lastSentContent = delta;
|
|
1165
|
-
} else {
|
|
1166
|
-
// 发送失败(限流等),不更新光标,下次合并重试
|
|
1167
|
-
return;
|
|
1168
|
-
}
|
|
1169
|
-
} catch (err) {
|
|
1170
|
-
console.error(`[${ts()}] WeChat sendText error: chatId=${chatId} ${(err as Error).message}`);
|
|
1171
|
-
if (!d.streamErrorNotified) {
|
|
1172
|
-
d.streamErrorNotified = true;
|
|
1173
|
-
p.sendText(chatId, "文本发送失败,请稍后查看结果。").catch(() => {});
|
|
1162
|
+
if (state.finalReply) {
|
|
1163
|
+
if (!isFinalReplySentForTurn(state)) {
|
|
1164
|
+
await sendFinalReplyTextOnce(p, chatId, sessionId, state.turnCount, state.finalReply);
|
|
1165
|
+
}
|
|
1166
|
+
} else if (state.accumulatedContent.trim()) {
|
|
1167
|
+
const short = truncateContent(state.accumulatedContent, 30, 4000);
|
|
1168
|
+
await p.sendText(chatId, `[生成过程]\n${short}`).catch(() => {});
|
|
1174
1169
|
}
|
|
1175
|
-
} finally {
|
|
1176
|
-
d.cardBusy = false;
|
|
1177
1170
|
}
|
|
1171
|
+
p.setChatAvatar(chatId, state.tool, "idle").catch(() => {});
|
|
1172
|
+
console.log(`[${ts()}] [DISPLAY] unified loop deleted display for ${chatId} (terminal: ${state.status})`);
|
|
1178
1173
|
} else {
|
|
1179
|
-
//
|
|
1180
|
-
if (
|
|
1181
|
-
//
|
|
1182
|
-
const
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
lastSentContent: "",
|
|
1191
|
-
streamErrorNotified: false,
|
|
1192
|
-
turnCount: state.turnCount,
|
|
1193
|
-
});
|
|
1194
|
-
addCardToTurn(sessionId, state.turnCount, cardId).catch(() => {});
|
|
1174
|
+
// running: 创建或更新展示
|
|
1175
|
+
if (isWechat) {
|
|
1176
|
+
// WeChat: 不使用卡片,基于 agent 真实 delta 推送 raw content
|
|
1177
|
+
const prevAccLen = display.lastSentAccLen ?? 0;
|
|
1178
|
+
const prevFinalReply = display.lastSentFinalReply ?? "";
|
|
1179
|
+
const accDelta = state.accumulatedContent.slice(prevAccLen);
|
|
1180
|
+
let replyDelta: string;
|
|
1181
|
+
if (prevFinalReply && state.finalReply.startsWith(prevFinalReply)) {
|
|
1182
|
+
replyDelta = state.finalReply.slice(prevFinalReply.length);
|
|
1183
|
+
} else {
|
|
1184
|
+
replyDelta = state.finalReply;
|
|
1195
1185
|
}
|
|
1196
|
-
|
|
1197
|
-
if (
|
|
1186
|
+
const delta = (accDelta + replyDelta).trim();
|
|
1187
|
+
if (!delta) continue;
|
|
1198
1188
|
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1189
|
+
display.cardBusy = true;
|
|
1190
|
+
try {
|
|
1191
|
+
const ok = await p.sendText(chatId, compressWechatDisplayText(delta));
|
|
1192
|
+
if (ok) {
|
|
1193
|
+
display.lastSentAccLen = state.accumulatedContent.length;
|
|
1194
|
+
display.lastSentFinalReply = state.finalReply;
|
|
1195
|
+
display.lastSentContent = delta;
|
|
1196
|
+
}
|
|
1197
|
+
} catch (err) {
|
|
1198
|
+
console.error(`[${ts()}] WeChat sendText error: chatId=${chatId} ${(err as Error).message}`);
|
|
1199
|
+
if (!display.streamErrorNotified) {
|
|
1200
|
+
display.streamErrorNotified = true;
|
|
1201
|
+
p.sendText(chatId, "文本发送失败,请稍后查看结果。").catch(() => {});
|
|
1202
|
+
}
|
|
1203
|
+
} finally {
|
|
1204
|
+
display.cardBusy = false;
|
|
1205
|
+
}
|
|
1206
|
+
} else {
|
|
1207
|
+
// 非 WeChat: 卡片流程
|
|
1202
1208
|
if (display.turnCount !== state.turnCount) {
|
|
1203
1209
|
console.log(`[${ts()}] [DISPLAY] turn mismatch for ${chatId}: display.turnCount=${display.turnCount} state.turnCount=${state.turnCount}, resetting`);
|
|
1204
|
-
// 兜底:turn mismatch 意味着旧 turn 已结束但卡片未在飞书端终结,
|
|
1205
|
-
// 持久化标记为 done,避免 turn-cards.json 残留 "active"
|
|
1206
1210
|
finalizeTurnCards(sessionId, display.turnCount, "done").catch(() => {});
|
|
1207
1211
|
displayCards.delete(chatId);
|
|
1208
|
-
|
|
1212
|
+
continue;
|
|
1209
1213
|
}
|
|
1210
1214
|
|
|
1211
1215
|
// 卡片轮转
|
|
@@ -1214,7 +1218,7 @@ export function ensureDisplayLoop(sessionId: string): void {
|
|
|
1214
1218
|
try {
|
|
1215
1219
|
const oldSeqBase = display.sequence;
|
|
1216
1220
|
const oldContent = state.accumulatedContent + state.finalReply;
|
|
1217
|
-
const oldCard = buildProgressCard(truncateContent(oldContent) || " ", { showStop: false, headerTitle: "
|
|
1221
|
+
const oldCard = buildProgressCard(truncateContent(oldContent) || " ", { showStop: false, headerTitle: "生成中(上轮)" });
|
|
1218
1222
|
await p.cardUpdate(display.cardId, oldCard, oldSeqBase + 1).catch(() => {});
|
|
1219
1223
|
markCardDone(sessionId, display.turnCount, display.cardId).catch(() => {});
|
|
1220
1224
|
const newCardId = await p.cardCreate(buildProgressCard(
|
|
@@ -1227,8 +1231,6 @@ export function ensureDisplayLoop(sessionId: string): void {
|
|
|
1227
1231
|
display.cardId = newCardId;
|
|
1228
1232
|
display.sequence = 1;
|
|
1229
1233
|
display.cardCreatedAt = Date.now();
|
|
1230
|
-
// 记录基线:分开追踪 accumulatedContent 长度和 finalReply,
|
|
1231
|
-
// 避免 tool 内容中间插入时前缀匹配失败 → 回退发完整内容
|
|
1232
1234
|
display.rotationAccLen = state.accumulatedContent.length;
|
|
1233
1235
|
display.rotationFinalReply = state.finalReply;
|
|
1234
1236
|
display.lastSentContent = "";
|
|
@@ -1239,11 +1241,10 @@ export function ensureDisplayLoop(sessionId: string): void {
|
|
|
1239
1241
|
} finally {
|
|
1240
1242
|
display.cardBusy = false;
|
|
1241
1243
|
}
|
|
1242
|
-
|
|
1244
|
+
continue;
|
|
1243
1245
|
}
|
|
1244
1246
|
|
|
1245
|
-
// 轮转后:分开追踪 accumulatedContent 和 finalReply
|
|
1246
|
-
// 避免 tool 内容插入中间时前缀匹配失败
|
|
1247
|
+
// 轮转后:分开追踪 accumulatedContent 和 finalReply 增量
|
|
1247
1248
|
if (display.rotationAccLen !== undefined) {
|
|
1248
1249
|
const accDelta = state.accumulatedContent.slice(display.rotationAccLen);
|
|
1249
1250
|
const rotReply = display.rotationFinalReply ?? "";
|
|
@@ -1255,10 +1256,9 @@ export function ensureDisplayLoop(sessionId: string): void {
|
|
|
1255
1256
|
}
|
|
1256
1257
|
const delta = (accDelta + replyDelta).trim();
|
|
1257
1258
|
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
if (displayContent === display.lastSentContent) return;
|
|
1259
|
+
display.dotCount = (display.dotCount % 9) + 1;
|
|
1260
|
+
const displayContent = (delta || "") + "\n" + "。" .repeat(display.dotCount);
|
|
1261
|
+
if (displayContent === display.lastSentContent) continue;
|
|
1262
1262
|
|
|
1263
1263
|
display.lastSentContent = displayContent;
|
|
1264
1264
|
const deltaCard = buildProgressCard(truncateContent(displayContent) || "处理中...", { showStop: true, headerTitle: "生成中..." });
|
|
@@ -1276,12 +1276,12 @@ export function ensureDisplayLoop(sessionId: string): void {
|
|
|
1276
1276
|
} finally {
|
|
1277
1277
|
display.cardBusy = false;
|
|
1278
1278
|
}
|
|
1279
|
-
|
|
1279
|
+
continue;
|
|
1280
1280
|
}
|
|
1281
1281
|
|
|
1282
|
-
dotCount = (dotCount % 9) + 1;
|
|
1283
|
-
const fullContent = state.accumulatedContent + state.finalReply + "\n" + "。".repeat(dotCount);
|
|
1284
|
-
if (fullContent === display.lastSentContent)
|
|
1282
|
+
display.dotCount = (display.dotCount % 9) + 1;
|
|
1283
|
+
const fullContent = state.accumulatedContent + state.finalReply + "\n" + "。".repeat(display.dotCount);
|
|
1284
|
+
if (fullContent === display.lastSentContent) continue;
|
|
1285
1285
|
|
|
1286
1286
|
display.lastSentContent = fullContent;
|
|
1287
1287
|
const cardContent = truncateContent(fullContent);
|
|
@@ -1302,23 +1302,26 @@ export function ensureDisplayLoop(sessionId: string): void {
|
|
|
1302
1302
|
}
|
|
1303
1303
|
}
|
|
1304
1304
|
}
|
|
1305
|
+
} catch (err) {
|
|
1306
|
+
console.error(`[${ts()}] Display loop error for ${chatId}: ${(err as Error).message}`);
|
|
1305
1307
|
}
|
|
1306
|
-
} catch (err) {
|
|
1307
|
-
console.error(`[${ts()}] Display loop error for ${chatId}: ${(err as Error).message}`);
|
|
1308
|
-
}
|
|
1309
|
-
|
|
1310
|
-
if (isTerminal) {
|
|
1311
|
-
console.log(`[${ts()}] [DISPLAY] loop stopping for ${sessionId} (terminal state: ${state.status})`);
|
|
1312
|
-
clearInterval(interval);
|
|
1313
|
-
displayLoops.delete(sessionId);
|
|
1314
1308
|
}
|
|
1315
1309
|
})().catch((err: unknown) => {
|
|
1316
1310
|
const e = err instanceof Error ? err : new Error(String(err));
|
|
1317
|
-
console.error(`[${ts()}]
|
|
1311
|
+
console.error(`[${ts()}] Unified display loop uncaught: ${e.message}`);
|
|
1318
1312
|
});
|
|
1319
1313
|
}, 3000);
|
|
1320
1314
|
|
|
1321
|
-
|
|
1315
|
+
setUnifiedDisplayLoopHandle(interval);
|
|
1316
|
+
console.log(`[${ts()}] [DISPLAY] Unified display loop started`);
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
export function stopUnifiedDisplayLoop(): void {
|
|
1320
|
+
if (unifiedDisplayLoopHandle !== null) {
|
|
1321
|
+
clearInterval(unifiedDisplayLoopHandle);
|
|
1322
|
+
setUnifiedDisplayLoopHandle(null);
|
|
1323
|
+
console.log(`[${ts()}] [DISPLAY] Unified display loop stopped`);
|
|
1324
|
+
}
|
|
1322
1325
|
}
|
|
1323
1326
|
|
|
1324
1327
|
// ---------------------------------------------------------------------------
|
package/src/stream-state.ts
CHANGED
|
@@ -14,6 +14,9 @@ export interface StreamState {
|
|
|
14
14
|
status: "running" | "done" | "stopped" | "error";
|
|
15
15
|
accumulatedContent: string;
|
|
16
16
|
finalReply: string;
|
|
17
|
+
/** The turn whose terminal text reply has already been delivered to IM. */
|
|
18
|
+
finalReplySentTurn?: number;
|
|
19
|
+
finalReplySentAt?: number;
|
|
17
20
|
chunkCount: number;
|
|
18
21
|
turnCount: number;
|
|
19
22
|
contextTokens: number;
|
|
@@ -97,6 +100,22 @@ export async function writeStreamState(state: StreamState): Promise<void> {
|
|
|
97
100
|
}
|
|
98
101
|
}
|
|
99
102
|
|
|
103
|
+
export function isFinalReplySentForTurn(state: Pick<StreamState, "turnCount" | "finalReplySentTurn">): boolean {
|
|
104
|
+
return state.finalReplySentTurn === state.turnCount;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function markFinalReplySent(sessionId: string, turnCount: number, sentAt = Date.now()): Promise<void> {
|
|
108
|
+
const state = await readStreamState(sessionId);
|
|
109
|
+
if (!state) return;
|
|
110
|
+
if (state.turnCount !== turnCount) return;
|
|
111
|
+
if (state.status === "running") return;
|
|
112
|
+
|
|
113
|
+
state.finalReplySentTurn = turnCount;
|
|
114
|
+
state.finalReplySentAt = sentAt;
|
|
115
|
+
state.updatedAt = Date.now();
|
|
116
|
+
await writeStreamState(state);
|
|
117
|
+
}
|
|
118
|
+
|
|
100
119
|
export function createEmptyStreamState(sessionId: string, cwd: string, tool: string, turnCount: number): StreamState {
|
|
101
120
|
return {
|
|
102
121
|
sessionId,
|
|
@@ -138,4 +157,4 @@ export async function fixStaleStreamStates(): Promise<void> {
|
|
|
138
157
|
} catch (err) {
|
|
139
158
|
console.error(`[${ts()}] [STREAM-STATE] fixStaleStreamStates failed: ${(err as Error).message}`);
|
|
140
159
|
}
|
|
141
|
-
}
|
|
160
|
+
}
|