chatccc 0.2.215 → 0.2.217
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__/feishu-avatar.test.ts +74 -11
- package/src/__tests__/feishu-message-ingress.test.ts +138 -0
- package/src/__tests__/orchestrator.test.ts +12 -0
- package/src/__tests__/sim-platform.test.ts +4 -3
- package/src/agent-delegate-task.ts +12 -2
- package/src/feishu-api.ts +77 -47
- package/src/feishu-message-ingress.ts +195 -0
- package/src/index.ts +112 -81
- package/src/orchestrator.ts +45 -23
- package/src/platform-adapter.ts +6 -5
- package/src/session.ts +28 -12
package/package.json
CHANGED
|
@@ -2,7 +2,8 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
|
|
5
|
-
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import sharp from "sharp";
|
|
6
7
|
|
|
7
8
|
const mockConfig = {
|
|
8
9
|
cursor: {
|
|
@@ -68,14 +69,18 @@ function mockAvatarFetch(uploadedNames: string[], usageResponse: Response): void
|
|
|
68
69
|
}));
|
|
69
70
|
}
|
|
70
71
|
|
|
71
|
-
function mockAvatarUploadOnlyFetch(
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
72
|
+
function mockAvatarUploadOnlyFetch(
|
|
73
|
+
uploadedNames: string[],
|
|
74
|
+
uploadedImages: Buffer[] = [],
|
|
75
|
+
): ReturnType<typeof vi.fn> {
|
|
76
|
+
const fetchMock = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
|
|
77
|
+
const urlText = String(url);
|
|
78
|
+
if (urlText === "https://open.feishu.test/im/v1/images") {
|
|
79
|
+
const form = init?.body as FormData;
|
|
80
|
+
const image = form.get("image") as File;
|
|
81
|
+
uploadedNames.push(image.name);
|
|
82
|
+
uploadedImages.push(Buffer.from(await image.arrayBuffer()));
|
|
83
|
+
return new Response(JSON.stringify({ code: 0, data: { image_key: "img_test" } }), { status: 200 });
|
|
79
84
|
}
|
|
80
85
|
if (urlText === "https://open.feishu.test/im/v1/chats/chat_1") {
|
|
81
86
|
return new Response(JSON.stringify({ code: 0 }), { status: 200 });
|
|
@@ -197,7 +202,7 @@ describe("Codex avatar usage battery", () => {
|
|
|
197
202
|
}
|
|
198
203
|
});
|
|
199
204
|
|
|
200
|
-
it("uses provided Codex usage without fetching usage again", async () => {
|
|
205
|
+
it("uses provided Codex usage without fetching usage again", async () => {
|
|
201
206
|
const homeDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-home-"));
|
|
202
207
|
const userDataDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-data-"));
|
|
203
208
|
const uploadedNames: string[] = [];
|
|
@@ -220,7 +225,65 @@ describe("Codex avatar usage battery", () => {
|
|
|
220
225
|
await rm(homeDir, { recursive: true, force: true });
|
|
221
226
|
await rm(userDataDir, { recursive: true, force: true });
|
|
222
227
|
}
|
|
223
|
-
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("isolates Fast and standard Codex avatars in the upload cache", async () => {
|
|
231
|
+
const homeDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-home-"));
|
|
232
|
+
const userDataDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-data-"));
|
|
233
|
+
const uploadedNames: string[] = [];
|
|
234
|
+
mockAvatarUploadOnlyFetch(uploadedNames);
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
const { setChatAvatar } = await loadFeishuApiWithHome(homeDir, userDataDir);
|
|
238
|
+
await setChatAvatar("tenant-token", "chat_1", "codex", "idle", {
|
|
239
|
+
codexUsage: null,
|
|
240
|
+
fastMode: false,
|
|
241
|
+
});
|
|
242
|
+
await setChatAvatar("tenant-token", "chat_1", "codex", "idle", {
|
|
243
|
+
codexUsage: null,
|
|
244
|
+
fastMode: true,
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
expect(uploadedNames).toEqual([
|
|
248
|
+
"avatar_codex_idle.jpg",
|
|
249
|
+
"avatar_codex_idle_fast.jpg",
|
|
250
|
+
]);
|
|
251
|
+
const cacheRaw = await readFile(join(userDataDir, "state", "avatar-image-keys.json"), "utf-8");
|
|
252
|
+
const cache = JSON.parse(cacheRaw) as Record<string, string>;
|
|
253
|
+
expect(cache["codex:idle:plain"]).toBe("img_test");
|
|
254
|
+
expect(cache["codex:idle:plain:fast-champagne-frame-v1"]).toBe("img_test");
|
|
255
|
+
} finally {
|
|
256
|
+
await rm(homeDir, { recursive: true, force: true });
|
|
257
|
+
await rm(userDataDir, { recursive: true, force: true });
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("renders a thick champagne frame above the Codex badge in Fast mode", async () => {
|
|
262
|
+
const homeDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-home-"));
|
|
263
|
+
const userDataDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-data-"));
|
|
264
|
+
const uploadedNames: string[] = [];
|
|
265
|
+
const uploadedImages: Buffer[] = [];
|
|
266
|
+
mockAvatarUploadOnlyFetch(uploadedNames, uploadedImages);
|
|
267
|
+
|
|
268
|
+
try {
|
|
269
|
+
const { setChatAvatar } = await loadFeishuApiWithHome(homeDir, userDataDir);
|
|
270
|
+
await setChatAvatar("tenant-token", "chat_1", "codex", "idle", {
|
|
271
|
+
codexUsage: null,
|
|
272
|
+
fastMode: true,
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
expect(uploadedNames).toEqual(["avatar_codex_idle_fast.jpg"]);
|
|
276
|
+
const { data, info } = await sharp(uploadedImages[0]).raw().toBuffer({ resolveWithObject: true });
|
|
277
|
+
const pixelOffset = (149 * info.width + 200) * info.channels;
|
|
278
|
+
const [red, green, blue] = data.subarray(pixelOffset, pixelOffset + 3);
|
|
279
|
+
expect(red).toBeGreaterThan(220);
|
|
280
|
+
expect(green).toBeGreaterThan(150);
|
|
281
|
+
expect(blue).toBeLessThan(180);
|
|
282
|
+
} finally {
|
|
283
|
+
await rm(homeDir, { recursive: true, force: true });
|
|
284
|
+
await rm(userDataDir, { recursive: true, force: true });
|
|
285
|
+
}
|
|
286
|
+
});
|
|
224
287
|
|
|
225
288
|
it("returns both usage windows and available reset credit expiries", async () => {
|
|
226
289
|
const homeDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-home-"));
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
FeishuMessageLedger,
|
|
9
|
+
createAckFirstEventHandler,
|
|
10
|
+
} from "../feishu-message-ingress.ts";
|
|
11
|
+
|
|
12
|
+
const tempDirs: string[] = [];
|
|
13
|
+
|
|
14
|
+
async function createLedger(maxEntries = 5): Promise<FeishuMessageLedger> {
|
|
15
|
+
const dir = await mkdtemp(join(tmpdir(), "chatccc-feishu-ingress-"));
|
|
16
|
+
tempDirs.push(dir);
|
|
17
|
+
return new FeishuMessageLedger(join(dir, "messages.json"), maxEntries);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
afterEach(async () => {
|
|
21
|
+
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("createAckFirstEventHandler", () => {
|
|
25
|
+
it("returns before starting the asynchronous event worker", async () => {
|
|
26
|
+
let scheduled: (() => void) | undefined;
|
|
27
|
+
const schedule = vi.fn((task: () => void) => {
|
|
28
|
+
scheduled = task;
|
|
29
|
+
});
|
|
30
|
+
const worker = vi.fn(async () => {});
|
|
31
|
+
const onError = vi.fn();
|
|
32
|
+
const handler = createAckFirstEventHandler(worker, onError, schedule);
|
|
33
|
+
|
|
34
|
+
await expect(handler({ message: "test" })).resolves.toBeUndefined();
|
|
35
|
+
expect(schedule).toHaveBeenCalledOnce();
|
|
36
|
+
expect(worker).not.toHaveBeenCalled();
|
|
37
|
+
|
|
38
|
+
scheduled?.();
|
|
39
|
+
await vi.waitFor(() => expect(worker).toHaveBeenCalledWith({ message: "test" }));
|
|
40
|
+
expect(onError).not.toHaveBeenCalled();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("reports detached worker failures without rejecting the SDK callback", async () => {
|
|
44
|
+
const error = new Error("worker failed");
|
|
45
|
+
const onError = vi.fn();
|
|
46
|
+
const handler = createAckFirstEventHandler(
|
|
47
|
+
async () => {
|
|
48
|
+
throw error;
|
|
49
|
+
},
|
|
50
|
+
onError,
|
|
51
|
+
(task) => task(),
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
await expect(handler({})).resolves.toBeUndefined();
|
|
55
|
+
await vi.waitFor(() => expect(onError).toHaveBeenCalledWith(error));
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("FeishuMessageLedger", () => {
|
|
60
|
+
it("rejects an already accepted message after a process restart", async () => {
|
|
61
|
+
const first = await createLedger();
|
|
62
|
+
await first.load();
|
|
63
|
+
|
|
64
|
+
expect(await first.accept({
|
|
65
|
+
messageId: "om_001",
|
|
66
|
+
chatId: "oc_chat",
|
|
67
|
+
createTime: 100,
|
|
68
|
+
})).toBe("accepted");
|
|
69
|
+
|
|
70
|
+
const restarted = new FeishuMessageLedger(first.filePath, 5);
|
|
71
|
+
await restarted.load();
|
|
72
|
+
|
|
73
|
+
expect(await restarted.accept({
|
|
74
|
+
messageId: "om_001",
|
|
75
|
+
chatId: "oc_chat",
|
|
76
|
+
createTime: 100,
|
|
77
|
+
})).toBe("duplicate");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("rejects a unique old event after restoring the chat high-water mark", async () => {
|
|
81
|
+
const first = await createLedger();
|
|
82
|
+
await first.load();
|
|
83
|
+
|
|
84
|
+
expect(await first.accept({
|
|
85
|
+
messageId: "om_new",
|
|
86
|
+
chatId: "oc_chat",
|
|
87
|
+
createTime: 200,
|
|
88
|
+
})).toBe("accepted");
|
|
89
|
+
|
|
90
|
+
const restarted = new FeishuMessageLedger(first.filePath, 5);
|
|
91
|
+
await restarted.load();
|
|
92
|
+
|
|
93
|
+
expect(await restarted.accept({
|
|
94
|
+
messageId: "om_old",
|
|
95
|
+
chatId: "oc_chat",
|
|
96
|
+
createTime: 100,
|
|
97
|
+
})).toBe("stale");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("accepts distinct messages with the same create timestamp", async () => {
|
|
101
|
+
const ledger = await createLedger();
|
|
102
|
+
await ledger.load();
|
|
103
|
+
|
|
104
|
+
expect(await ledger.accept({
|
|
105
|
+
messageId: "om_001",
|
|
106
|
+
chatId: "oc_chat",
|
|
107
|
+
createTime: 100,
|
|
108
|
+
})).toBe("accepted");
|
|
109
|
+
expect(await ledger.accept({
|
|
110
|
+
messageId: "om_002",
|
|
111
|
+
chatId: "oc_chat",
|
|
112
|
+
createTime: 100,
|
|
113
|
+
})).toBe("accepted");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("keeps only the configured number of recent message IDs", async () => {
|
|
117
|
+
const ledger = await createLedger(2);
|
|
118
|
+
await ledger.load();
|
|
119
|
+
|
|
120
|
+
await ledger.accept({ messageId: "om_001", chatId: "oc_chat", createTime: 100 });
|
|
121
|
+
await ledger.accept({ messageId: "om_002", chatId: "oc_chat", createTime: 200 });
|
|
122
|
+
await ledger.accept({ messageId: "om_003", chatId: "oc_chat", createTime: 300 });
|
|
123
|
+
|
|
124
|
+
const restarted = new FeishuMessageLedger(ledger.filePath, 2);
|
|
125
|
+
await restarted.load();
|
|
126
|
+
|
|
127
|
+
expect(await restarted.accept({
|
|
128
|
+
messageId: "om_001",
|
|
129
|
+
chatId: "oc_other",
|
|
130
|
+
createTime: 100,
|
|
131
|
+
})).toBe("accepted");
|
|
132
|
+
expect(await restarted.accept({
|
|
133
|
+
messageId: "om_003",
|
|
134
|
+
chatId: "oc_chat",
|
|
135
|
+
createTime: 300,
|
|
136
|
+
})).toBe("duplicate");
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -681,9 +681,21 @@ describe("handleCommand WeChat processing ack", () => {
|
|
|
681
681
|
|
|
682
682
|
await handleCommand(platform, "/fast on", "feishu-codex", "ou-user", Date.now(), "p2p");
|
|
683
683
|
expect(getEffectiveFastModeForTool("codex", "sid-codex-fast")).toBe(true);
|
|
684
|
+
expect(platform.setChatAvatar).toHaveBeenLastCalledWith(
|
|
685
|
+
"feishu-codex",
|
|
686
|
+
"codex",
|
|
687
|
+
"idle",
|
|
688
|
+
{ fastMode: true },
|
|
689
|
+
);
|
|
684
690
|
|
|
685
691
|
await handleCommand(platform, "/fast off", "feishu-codex", "ou-user", Date.now(), "p2p");
|
|
686
692
|
expect(getEffectiveFastModeForTool("codex", "sid-codex-fast")).toBe(false);
|
|
693
|
+
expect(platform.setChatAvatar).toHaveBeenLastCalledWith(
|
|
694
|
+
"feishu-codex",
|
|
695
|
+
"codex",
|
|
696
|
+
"idle",
|
|
697
|
+
{ fastMode: false },
|
|
698
|
+
);
|
|
687
699
|
card = JSON.parse(
|
|
688
700
|
vi.mocked(platform.sendRawCard).mock.calls.at(-1)?.[1] ?? "{}",
|
|
689
701
|
) as { elements?: Array<{ tag: string; text?: { content: string } }> };
|
|
@@ -73,9 +73,10 @@ describe("SimulatedPlatform", () => {
|
|
|
73
73
|
});
|
|
74
74
|
|
|
75
75
|
it("纯函数 formatDelayNotice 正常工作", () => {
|
|
76
|
-
const notice = SimulatedPlatform.formatDelayNotice(Date.now() - 20 * 60 * 1000, "测试消息");
|
|
77
|
-
expect(notice).toBeDefined();
|
|
78
|
-
expect(notice).toContain("延迟送达");
|
|
76
|
+
const notice = SimulatedPlatform.formatDelayNotice(Date.now() - 20 * 60 * 1000, "测试消息");
|
|
77
|
+
expect(notice).toBeDefined();
|
|
78
|
+
expect(notice).toContain("延迟送达");
|
|
79
|
+
expect(notice).not.toContain("因服务离线");
|
|
79
80
|
// 近期消息不触发
|
|
80
81
|
expect(SimulatedPlatform.formatDelayNotice(Date.now(), "test")).toBeNull();
|
|
81
82
|
});
|
|
@@ -3,7 +3,13 @@ import { resolve } from "node:path";
|
|
|
3
3
|
import { sessionPrefixForTool, toolDisplayName, ts } from "./config.ts";
|
|
4
4
|
import { setDefaultCwd } from "./config.ts";
|
|
5
5
|
import type { PlatformAdapter } from "./platform-adapter.ts";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
getEffectiveFastModeForTool,
|
|
8
|
+
initClaudeSession,
|
|
9
|
+
recordSessionRegistry,
|
|
10
|
+
resumeAndPrompt,
|
|
11
|
+
saveSessionTool,
|
|
12
|
+
} from "./session.ts";
|
|
7
13
|
import { bindChatToSession } from "./session-chat-binding.ts";
|
|
8
14
|
import { sessionChatName } from "./session-name.ts";
|
|
9
15
|
|
|
@@ -64,7 +70,11 @@ export async function delegateAgentTask(input: DelegateAgentTaskInput): Promise<
|
|
|
64
70
|
`下面会自动把任务作为第一句话发送给 ${toolLabel}。`,
|
|
65
71
|
"green",
|
|
66
72
|
).catch(() => {});
|
|
67
|
-
|
|
73
|
+
const fastMode = getEffectiveFastModeForTool(input.tool, sessionId);
|
|
74
|
+
const avatarUpdate = fastMode
|
|
75
|
+
? input.platform.setChatAvatar(chatId, input.tool, "new", { fastMode: true })
|
|
76
|
+
: input.platform.setChatAvatar(chatId, input.tool, "new");
|
|
77
|
+
avatarUpdate.catch(() => {});
|
|
68
78
|
|
|
69
79
|
await resumeAndPrompt(
|
|
70
80
|
sessionId,
|
package/src/feishu-api.ts
CHANGED
|
@@ -324,6 +324,7 @@ const AVATAR_BADGE_SIZE = 92;
|
|
|
324
324
|
const AVATAR_BADGE_MARGIN = 10;
|
|
325
325
|
const PLAIN_AVATAR_TOOL = "plain";
|
|
326
326
|
const CODEX_AVATAR_USAGE_STYLE_VERSION = "usage-window-aware-v14";
|
|
327
|
+
const CODEX_FAST_FRAME_STYLE_VERSION = "fast-champagne-frame-v1";
|
|
327
328
|
const CURSOR_AVATAR_USAGE_STYLE_VERSION = "usage-battery-v1";
|
|
328
329
|
|
|
329
330
|
export interface CodexUsageBalance {
|
|
@@ -371,18 +372,20 @@ function avatarCombinationPath(tool: string, status: string): string {
|
|
|
371
372
|
return resolvePath(AVATAR_COMBINATIONS_DIR, `avatar_${normalizeAvatarTool(tool)}_${normalizeAvatarStatus(status)}.png`);
|
|
372
373
|
}
|
|
373
374
|
|
|
374
|
-
function avatarCacheKey(
|
|
375
|
-
tool: string,
|
|
376
|
-
status: string,
|
|
377
|
-
codexUsage: CodexUsageSummary | null = null,
|
|
378
|
-
cursorBatteryPercent: number | null = null,
|
|
379
|
-
|
|
375
|
+
function avatarCacheKey(
|
|
376
|
+
tool: string,
|
|
377
|
+
status: string,
|
|
378
|
+
codexUsage: CodexUsageSummary | null = null,
|
|
379
|
+
cursorBatteryPercent: number | null = null,
|
|
380
|
+
fastMode = false,
|
|
381
|
+
): string {
|
|
380
382
|
const normalizedTool = normalizeAvatarTool(tool);
|
|
381
383
|
const normalizedStatus = normalizeAvatarStatus(status);
|
|
382
384
|
if (normalizedTool === "codex") {
|
|
383
|
-
|
|
385
|
+
const fastKey = fastMode ? `:${CODEX_FAST_FRAME_STYLE_VERSION}` : "";
|
|
386
|
+
if (!codexUsage?.weekly) return `${normalizedTool}:${normalizedStatus}:plain${fastKey}`;
|
|
384
387
|
const ringKey = codexUsage.fiveHour ? `:5h-ring:${codexUsage.fiveHour.remainingPercent}` : "";
|
|
385
|
-
return `${normalizedTool}:${normalizedStatus}:${CODEX_AVATAR_USAGE_STYLE_VERSION}:7d-battery:${codexUsage.weekly.remainingPercent}${ringKey}`;
|
|
388
|
+
return `${normalizedTool}:${normalizedStatus}:${CODEX_AVATAR_USAGE_STYLE_VERSION}:7d-battery:${codexUsage.weekly.remainingPercent}${ringKey}${fastKey}`;
|
|
386
389
|
}
|
|
387
390
|
if (normalizedTool === "cursor") {
|
|
388
391
|
return cursorBatteryPercent !== null
|
|
@@ -757,7 +760,7 @@ function buildCodexUsageRingSvg(remainingPercent: number): Buffer {
|
|
|
757
760
|
</svg>`);
|
|
758
761
|
}
|
|
759
762
|
|
|
760
|
-
async function buildAgentBadgeOverlay(tool: string): Promise<sharp.OverlayOptions> {
|
|
763
|
+
async function buildAgentBadgeOverlay(tool: string): Promise<sharp.OverlayOptions> {
|
|
761
764
|
const badge = await sharp(AVATAR_BADGES[tool])
|
|
762
765
|
.resize(AVATAR_BADGE_SIZE, AVATAR_BADGE_SIZE, {
|
|
763
766
|
fit: "contain",
|
|
@@ -770,24 +773,50 @@ async function buildAgentBadgeOverlay(tool: string): Promise<sharp.OverlayOption
|
|
|
770
773
|
input: badge,
|
|
771
774
|
left: AVATAR_SIZE - AVATAR_BADGE_SIZE - AVATAR_BADGE_MARGIN,
|
|
772
775
|
top: AVATAR_SIZE - AVATAR_BADGE_SIZE - AVATAR_BADGE_MARGIN,
|
|
773
|
-
};
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function buildCodexFastFrameOverlay(): sharp.OverlayOptions {
|
|
780
|
+
const size = AVATAR_BADGE_SIZE + 16;
|
|
781
|
+
const innerOffset = 7;
|
|
782
|
+
const innerSize = size - innerOffset * 2;
|
|
783
|
+
const frame = Buffer.from(`
|
|
784
|
+
<svg width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg">
|
|
785
|
+
<defs>
|
|
786
|
+
<linearGradient id="champagne" x1="0" y1="0" x2="${size}" y2="${size}" gradientUnits="userSpaceOnUse">
|
|
787
|
+
<stop offset="0" stop-color="#FFF0BE"/>
|
|
788
|
+
<stop offset="0.55" stop-color="#FFD56A"/>
|
|
789
|
+
<stop offset="1" stop-color="#EFA923"/>
|
|
790
|
+
</linearGradient>
|
|
791
|
+
</defs>
|
|
792
|
+
<rect x="0.5" y="0.5" width="${size - 1}" height="${size - 1}" rx="25" fill="url(#champagne)"/>
|
|
793
|
+
<rect x="${innerOffset}" y="${innerOffset}" width="${innerSize}" height="${innerSize}" rx="21" fill="#fffdf4"/>
|
|
794
|
+
</svg>`);
|
|
795
|
+
return {
|
|
796
|
+
input: frame,
|
|
797
|
+
left: AVATAR_SIZE - size - 2,
|
|
798
|
+
top: AVATAR_SIZE - size - 2,
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
async function renderAvatar(
|
|
803
|
+
tool: string,
|
|
804
|
+
status: string,
|
|
805
|
+
codexUsage: CodexUsageSummary | null = null,
|
|
806
|
+
cursorBatteryPercent: number | null = null,
|
|
807
|
+
fastMode = false,
|
|
808
|
+
): Promise<{ buffer: Buffer; contentType: string; filename: string }> {
|
|
782
809
|
const normalizedTool = normalizeAvatarTool(tool);
|
|
783
810
|
const normalizedStatus = normalizeAvatarStatus(status);
|
|
784
811
|
const composites: sharp.OverlayOptions[] = [];
|
|
785
812
|
const hasAgentBadge = normalizedTool !== PLAIN_AVATAR_TOOL;
|
|
813
|
+
const useFastCodexAvatar = normalizedTool === "codex" && fastMode;
|
|
786
814
|
|
|
787
815
|
const codexWeeklyUsage = normalizedTool === "codex" ? codexUsage?.weekly ?? null : null;
|
|
788
816
|
const useDynamicCodexAvatar = normalizedTool === "codex" && codexUsage !== null && codexWeeklyUsage !== null;
|
|
789
817
|
const useDynamicCursorAvatar = normalizedTool === "cursor" && cursorBatteryPercent !== null;
|
|
790
|
-
const
|
|
818
|
+
const useDynamicBadgeAvatar = useDynamicCodexAvatar || useDynamicCursorAvatar || useFastCodexAvatar;
|
|
819
|
+
const basePath = useDynamicBadgeAvatar
|
|
791
820
|
? AVATAR_SOURCES[normalizedStatus]
|
|
792
821
|
: hasAgentBadge
|
|
793
822
|
? avatarCombinationPath(normalizedTool, normalizedStatus)
|
|
@@ -797,16 +826,15 @@ async function renderAvatar(
|
|
|
797
826
|
if (codexUsage.fiveHour) {
|
|
798
827
|
composites.push({ input: buildCodexUsageRingSvg(codexUsage.fiveHour.remainingPercent), left: 0, top: 0 });
|
|
799
828
|
}
|
|
800
|
-
composites.push(
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
}
|
|
829
|
+
composites.push({ input: buildCodexUsageBatterySvg(codexWeeklyUsage.remainingPercent), left: 0, top: 0 });
|
|
830
|
+
} else if (useDynamicCursorAvatar) {
|
|
831
|
+
composites.push({ input: buildCodexUsageBatterySvg(cursorBatteryPercent), left: 0, top: 0 });
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
if (useDynamicBadgeAvatar) {
|
|
835
|
+
if (useFastCodexAvatar) composites.push(buildCodexFastFrameOverlay());
|
|
836
|
+
composites.push(await buildAgentBadgeOverlay(normalizedTool));
|
|
837
|
+
}
|
|
810
838
|
|
|
811
839
|
let pipeline = sharp(await readFile(basePath))
|
|
812
840
|
.resize(AVATAR_SIZE, AVATAR_SIZE, { fit: "cover", position: "center" });
|
|
@@ -824,21 +852,22 @@ async function renderAvatar(
|
|
|
824
852
|
buffer: jpeg,
|
|
825
853
|
contentType: "image/jpeg",
|
|
826
854
|
filename: normalizedTool === "codex" && codexUsage?.weekly
|
|
827
|
-
? `avatar_${normalizedTool}_${normalizedStatus}_7d_${codexUsage.weekly.remainingPercent}${codexUsage.fiveHour ? `_5h_${codexUsage.fiveHour.remainingPercent}` : ""}.jpg`
|
|
855
|
+
? `avatar_${normalizedTool}_${normalizedStatus}${useFastCodexAvatar ? "_fast" : ""}_7d_${codexUsage.weekly.remainingPercent}${codexUsage.fiveHour ? `_5h_${codexUsage.fiveHour.remainingPercent}` : ""}.jpg`
|
|
828
856
|
: normalizedTool === "cursor" && cursorBatteryPercent !== null
|
|
829
857
|
? `avatar_${normalizedTool}_${normalizedStatus}_battery_${cursorBatteryPercent}.jpg`
|
|
830
|
-
: `avatar_${normalizedTool}_${normalizedStatus}.jpg`,
|
|
858
|
+
: `avatar_${normalizedTool}_${normalizedStatus}${useFastCodexAvatar ? "_fast" : ""}.jpg`,
|
|
831
859
|
};
|
|
832
860
|
}
|
|
833
861
|
|
|
834
|
-
async function uploadImage(
|
|
835
|
-
token: string,
|
|
836
|
-
tool: string,
|
|
837
|
-
status: string,
|
|
838
|
-
codexUsage: CodexUsageSummary | null = null,
|
|
839
|
-
cursorBatteryPercent: number | null = null,
|
|
840
|
-
|
|
841
|
-
|
|
862
|
+
async function uploadImage(
|
|
863
|
+
token: string,
|
|
864
|
+
tool: string,
|
|
865
|
+
status: string,
|
|
866
|
+
codexUsage: CodexUsageSummary | null = null,
|
|
867
|
+
cursorBatteryPercent: number | null = null,
|
|
868
|
+
fastMode = false,
|
|
869
|
+
): Promise<string> {
|
|
870
|
+
const image = await renderAvatar(tool, status, codexUsage, cursorBatteryPercent, fastMode);
|
|
842
871
|
const blob = new Blob([new Uint8Array(image.buffer)], { type: image.contentType });
|
|
843
872
|
const form = new FormData();
|
|
844
873
|
form.append("image_type", "avatar");
|
|
@@ -869,13 +898,14 @@ async function getOrUploadAvatarKey(
|
|
|
869
898
|
const normalizedTool = normalizeAvatarTool(tool);
|
|
870
899
|
const normalizedStatus = normalizeAvatarStatus(status);
|
|
871
900
|
const codexUsage = normalizedTool === "codex" ? await resolveCodexAvatarUsage(usageHints.codexUsage) : null;
|
|
872
|
-
const cursorBatteryPercent = normalizedTool === "cursor"
|
|
873
|
-
? await resolveCursorAvatarBatteryPercent(usageHints.cursorUsage)
|
|
874
|
-
: null;
|
|
875
|
-
const
|
|
876
|
-
const
|
|
877
|
-
|
|
878
|
-
|
|
901
|
+
const cursorBatteryPercent = normalizedTool === "cursor"
|
|
902
|
+
? await resolveCursorAvatarBatteryPercent(usageHints.cursorUsage)
|
|
903
|
+
: null;
|
|
904
|
+
const fastMode = normalizedTool === "codex" && usageHints.fastMode === true;
|
|
905
|
+
const keyName = avatarCacheKey(normalizedTool, normalizedStatus, codexUsage, cursorBatteryPercent, fastMode);
|
|
906
|
+
const cached = avatarKeyCache.get(keyName);
|
|
907
|
+
if (cached) return cached;
|
|
908
|
+
const key = await uploadImage(token, normalizedTool, normalizedStatus, codexUsage, cursorBatteryPercent, fastMode);
|
|
879
909
|
avatarKeyCache.set(keyName, key);
|
|
880
910
|
await persistAvatarKeyCache().catch((err) => {
|
|
881
911
|
console.error(`[${ts()}] [AVATAR] persist cache FAIL: ${(err as Error).message}`);
|
|
@@ -1023,7 +1053,7 @@ export function formatDelayNotice(createTimeMs: number, messageText?: string, no
|
|
|
1023
1053
|
}
|
|
1024
1054
|
|
|
1025
1055
|
const contentLine = messageText ? `\n> 原始内容:${messageText.slice(0, 200)}` : "";
|
|
1026
|
-
return `> ⚠️ 延迟送达提醒:此消息于 ${sendTimeStr}
|
|
1056
|
+
return `> ⚠️ 延迟送达提醒:此消息于 ${sendTimeStr} 发送,现延迟约 ${delayStr}后送达${contentLine}`;
|
|
1027
1057
|
}
|
|
1028
1058
|
|
|
1029
1059
|
/**
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
|
|
5
|
+
export const MAX_PROCESSED = 5000;
|
|
6
|
+
|
|
7
|
+
export type FeishuMessageDisposition = "accepted" | "duplicate" | "stale";
|
|
8
|
+
|
|
9
|
+
export interface FeishuMessageIdentity {
|
|
10
|
+
messageId?: string;
|
|
11
|
+
chatId: string;
|
|
12
|
+
createTime: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface PersistedMessageEntry {
|
|
16
|
+
messageId: string;
|
|
17
|
+
chatId: string;
|
|
18
|
+
createTime: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface PersistedMessageLedger {
|
|
22
|
+
version: 1;
|
|
23
|
+
entries: PersistedMessageEntry[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type ScheduleTask = (task: () => void) => void;
|
|
27
|
+
|
|
28
|
+
const defaultSchedule: ScheduleTask = (task) => {
|
|
29
|
+
setImmediate(task);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The Feishu SDK waits for an event handler's return value before sending its
|
|
34
|
+
* WebSocket response. Schedule the real work for the next event-loop turn so
|
|
35
|
+
* the SDK callback can return and acknowledge the event immediately.
|
|
36
|
+
*/
|
|
37
|
+
export function createAckFirstEventHandler<T>(
|
|
38
|
+
worker: (data: T) => Promise<void>,
|
|
39
|
+
onError: (error: unknown) => void,
|
|
40
|
+
schedule: ScheduleTask = defaultSchedule,
|
|
41
|
+
): (data: T) => Promise<void> {
|
|
42
|
+
return async (data) => {
|
|
43
|
+
schedule(() => {
|
|
44
|
+
void worker(data).catch(onError);
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isPersistedEntry(value: unknown): value is PersistedMessageEntry {
|
|
50
|
+
if (!value || typeof value !== "object") return false;
|
|
51
|
+
const entry = value as Record<string, unknown>;
|
|
52
|
+
return typeof entry.messageId === "string"
|
|
53
|
+
&& entry.messageId.length > 0
|
|
54
|
+
&& typeof entry.chatId === "string"
|
|
55
|
+
&& typeof entry.createTime === "number"
|
|
56
|
+
&& Number.isFinite(entry.createTime);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export class FeishuMessageLedger {
|
|
60
|
+
private readonly messageIds: Set<string>;
|
|
61
|
+
private entries: PersistedMessageEntry[] = [];
|
|
62
|
+
private latestCreateTimeByChat = new Map<string, number>();
|
|
63
|
+
private persistTail: Promise<void> = Promise.resolve();
|
|
64
|
+
|
|
65
|
+
constructor(
|
|
66
|
+
public readonly filePath: string,
|
|
67
|
+
private readonly maxEntries = MAX_PROCESSED,
|
|
68
|
+
messageIds?: Set<string>,
|
|
69
|
+
) {
|
|
70
|
+
this.messageIds = messageIds ?? new Set<string>();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async load(): Promise<void> {
|
|
74
|
+
this.clearMemory();
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const raw = await readFile(this.filePath, "utf-8");
|
|
78
|
+
const parsed = JSON.parse(raw) as Partial<PersistedMessageLedger>;
|
|
79
|
+
const entries = Array.isArray(parsed.entries)
|
|
80
|
+
? parsed.entries.filter(isPersistedEntry)
|
|
81
|
+
: [];
|
|
82
|
+
this.entries = entries.slice(-this.maxEntries);
|
|
83
|
+
this.rebuildIndexes();
|
|
84
|
+
|
|
85
|
+
if (entries.length > this.maxEntries) {
|
|
86
|
+
await this.persist();
|
|
87
|
+
}
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
90
|
+
console.error(
|
|
91
|
+
`[FEISHU-DEDUP] Failed to load ${this.filePath}: ${(error as Error).message}`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async accept(identity: FeishuMessageIdentity): Promise<FeishuMessageDisposition> {
|
|
98
|
+
const { messageId, chatId, createTime } = identity;
|
|
99
|
+
|
|
100
|
+
if (messageId && this.messageIds.has(messageId)) {
|
|
101
|
+
return "duplicate";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const latestCreateTime = this.latestCreateTimeByChat.get(chatId);
|
|
105
|
+
if (latestCreateTime !== undefined && createTime < latestCreateTime) {
|
|
106
|
+
return "stale";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (!messageId) {
|
|
110
|
+
this.recordLatestCreateTime(chatId, createTime);
|
|
111
|
+
return "accepted";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
this.entries.push({ messageId, chatId, createTime });
|
|
115
|
+
this.messageIds.add(messageId);
|
|
116
|
+
this.recordLatestCreateTime(chatId, createTime);
|
|
117
|
+
|
|
118
|
+
if (this.entries.length > this.maxEntries) {
|
|
119
|
+
this.entries = this.entries.slice(-this.maxEntries);
|
|
120
|
+
this.rebuildIndexes();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
await this.persist();
|
|
125
|
+
} catch (error) {
|
|
126
|
+
console.error(
|
|
127
|
+
`[FEISHU-DEDUP] Failed to persist ${this.filePath}: ${(error as Error).message}`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
return "accepted";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
clearMemory(): void {
|
|
134
|
+
this.entries = [];
|
|
135
|
+
this.messageIds.clear();
|
|
136
|
+
this.latestCreateTimeByChat.clear();
|
|
137
|
+
this.persistTail = Promise.resolve();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private recordLatestCreateTime(chatId: string, createTime: number): void {
|
|
141
|
+
const current = this.latestCreateTimeByChat.get(chatId);
|
|
142
|
+
if (current === undefined || createTime > current) {
|
|
143
|
+
this.latestCreateTimeByChat.set(chatId, createTime);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private rebuildIndexes(): void {
|
|
148
|
+
this.messageIds.clear();
|
|
149
|
+
this.latestCreateTimeByChat.clear();
|
|
150
|
+
for (const entry of this.entries) {
|
|
151
|
+
this.messageIds.add(entry.messageId);
|
|
152
|
+
this.recordLatestCreateTime(entry.chatId, entry.createTime);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private persist(): Promise<void> {
|
|
157
|
+
const snapshot: PersistedMessageLedger = {
|
|
158
|
+
version: 1,
|
|
159
|
+
entries: this.entries.map((entry) => ({ ...entry })),
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const nextPersist = this.persistTail
|
|
163
|
+
.catch(() => {})
|
|
164
|
+
.then(async () => {
|
|
165
|
+
await mkdir(dirname(this.filePath), { recursive: true });
|
|
166
|
+
const tempPath = `${this.filePath}.${process.pid}.tmp`;
|
|
167
|
+
try {
|
|
168
|
+
await writeFile(tempPath, JSON.stringify(snapshot), "utf-8");
|
|
169
|
+
await rename(tempPath, this.filePath);
|
|
170
|
+
} finally {
|
|
171
|
+
await rm(tempPath, { force: true }).catch(() => {});
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
this.persistTail = nextPersist;
|
|
175
|
+
return nextPersist;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const defaultLedgerPath = join(
|
|
180
|
+
homedir(),
|
|
181
|
+
".chatccc",
|
|
182
|
+
"state",
|
|
183
|
+
"feishu-message-ledger.json",
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
export const processedMessages = new Set<string>();
|
|
187
|
+
export const feishuMessageLedger = new FeishuMessageLedger(
|
|
188
|
+
defaultLedgerPath,
|
|
189
|
+
MAX_PROCESSED,
|
|
190
|
+
processedMessages,
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
export function clearFeishuMessageLedgerMemory(): void {
|
|
194
|
+
feishuMessageLedger.clearMemory();
|
|
195
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -96,11 +96,9 @@ import {
|
|
|
96
96
|
sendCardKitMessage,
|
|
97
97
|
updateCardKitCard,
|
|
98
98
|
} from "./cardkit.ts";
|
|
99
|
-
import {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
processedMessages,
|
|
103
|
-
rebuildBindingsFromRegistry,
|
|
99
|
+
import {
|
|
100
|
+
loadSessionRegistryForBinding,
|
|
101
|
+
rebuildBindingsFromRegistry,
|
|
104
102
|
resetState,
|
|
105
103
|
setSessionPlatform,
|
|
106
104
|
startUnifiedDisplayLoop,
|
|
@@ -115,7 +113,11 @@ import { handleCommand, type PlatformAdapter } from "./orchestrator.ts";
|
|
|
115
113
|
import { createWechatAdapter, startWechatPlatform } from "./wechat-platform.ts";
|
|
116
114
|
import { handleCodexResetCardAction } from "./codex-reset-actions.ts";
|
|
117
115
|
import { resolveFeishuCardActionChatType } from "./card-action-routing.ts";
|
|
118
|
-
import { reloadRuntimeConfig } from "./runtime-reload.ts";
|
|
116
|
+
import { reloadRuntimeConfig } from "./runtime-reload.ts";
|
|
117
|
+
import {
|
|
118
|
+
createAckFirstEventHandler,
|
|
119
|
+
feishuMessageLedger,
|
|
120
|
+
} from "./feishu-message-ingress.ts";
|
|
119
121
|
|
|
120
122
|
// ---------------------------------------------------------------------------
|
|
121
123
|
// Feishu 平台适配器
|
|
@@ -262,12 +264,105 @@ function parseCardAction(data: unknown): CardActionResult | null {
|
|
|
262
264
|
// WebSocket relay broadcast
|
|
263
265
|
// ---------------------------------------------------------------------------
|
|
264
266
|
|
|
265
|
-
let broadcastToRelay: (data: unknown) => void = () => {};
|
|
266
|
-
const wechatSignal = { stopped: false };
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
267
|
+
let broadcastToRelay: (data: unknown) => void = () => {};
|
|
268
|
+
const wechatSignal = { stopped: false };
|
|
269
|
+
|
|
270
|
+
async function processFeishuMessageEvent(data: Evt): Promise<void> {
|
|
271
|
+
const traceId = makeTraceId();
|
|
272
|
+
try {
|
|
273
|
+
broadcastToRelay(data);
|
|
274
|
+
|
|
275
|
+
const event = getInnerEvent(data);
|
|
276
|
+
const message = event.message;
|
|
277
|
+
if (!message) return;
|
|
278
|
+
|
|
279
|
+
const messageId = message.message_id;
|
|
280
|
+
const chatId = message.chat_id ?? "";
|
|
281
|
+
const chatType = message.chat_type ?? "group";
|
|
282
|
+
const msgTimestamp = parseInt(message.create_time ?? "0", 10) || Date.now();
|
|
283
|
+
const disposition = await feishuMessageLedger.accept({
|
|
284
|
+
messageId,
|
|
285
|
+
chatId,
|
|
286
|
+
createTime: msgTimestamp,
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
if (disposition === "duplicate") {
|
|
290
|
+
console.log(`[MSG] Duplicate message ignored: ${messageId ?? "(missing id)"}`);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if (disposition === "stale") {
|
|
294
|
+
logTrace(traceId, "DONE", {
|
|
295
|
+
outcome: "skip_stale_feishu_message",
|
|
296
|
+
messageId,
|
|
297
|
+
chatId,
|
|
298
|
+
msgTimestamp,
|
|
299
|
+
});
|
|
300
|
+
console.log(
|
|
301
|
+
`[${ts()}] [SKIP] Stale Feishu message ignored: messageId=${messageId ?? "(missing id)"} chatId=${chatId} createTime=${msgTimestamp}`,
|
|
302
|
+
);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const text = await formatMessageContent(message);
|
|
307
|
+
const openId = event.sender?.sender_id?.open_id ?? "";
|
|
308
|
+
|
|
309
|
+
console.log(
|
|
310
|
+
`[MSG] id=${messageId ?? "(missing)"} sender=${openId} chat=${chatId} type=${chatType} text="${text}"`,
|
|
311
|
+
);
|
|
312
|
+
appendChatLog(chatId, openId, text);
|
|
313
|
+
|
|
314
|
+
if (messageId) {
|
|
315
|
+
getTenantAccessToken().then((freshToken) =>
|
|
316
|
+
addReaction(freshToken, messageId).catch((err) =>
|
|
317
|
+
console.error(`[${ts()}] Reaction failed: ${(err as Error).message}`)
|
|
318
|
+
)
|
|
319
|
+
).catch((err) =>
|
|
320
|
+
console.error(`[${ts()}] Reaction token failed: ${(err as Error).message}`)
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (!text) return;
|
|
325
|
+
logTrace(traceId, "RECV", {
|
|
326
|
+
messageId,
|
|
327
|
+
chatId,
|
|
328
|
+
chatType,
|
|
329
|
+
text: text.slice(0, 100),
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
const delayNotice = formatDelayNotice(msgTimestamp, text);
|
|
333
|
+
if (delayNotice) {
|
|
334
|
+
const delayToken = await getTenantAccessToken();
|
|
335
|
+
await sendCardReply(delayToken, chatId, "延迟送达", delayNotice, "yellow").catch(() => {});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
await handleCommand(
|
|
339
|
+
feishuPlatform,
|
|
340
|
+
text,
|
|
341
|
+
chatId,
|
|
342
|
+
openId,
|
|
343
|
+
msgTimestamp,
|
|
344
|
+
chatType,
|
|
345
|
+
traceId,
|
|
346
|
+
buildUpdateCommandId("message", messageId),
|
|
347
|
+
);
|
|
348
|
+
} catch (err) {
|
|
349
|
+
logTrace(traceId, "ERROR", { message: (err as Error).message });
|
|
350
|
+
console.error(
|
|
351
|
+
`[${ts()}] [FATAL] im.message.receive_v1 worker crashed: ${(err as Error).message}`,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const handleFeishuMessageEventAckFirst = createAckFirstEventHandler(
|
|
357
|
+
processFeishuMessageEvent,
|
|
358
|
+
(err) => console.error(
|
|
359
|
+
`[${ts()}] [FATAL] Detached Feishu event worker failed: ${(err as Error).message}`,
|
|
360
|
+
),
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
// ---------------------------------------------------------------------------
|
|
364
|
+
// Simulate mode: inject message via HTTP
|
|
365
|
+
// ---------------------------------------------------------------------------
|
|
271
366
|
|
|
272
367
|
async function handleSimInjectMessage(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
|
273
368
|
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
@@ -399,74 +494,9 @@ async function startBotServiceCore(): Promise<void> {
|
|
|
399
494
|
|
|
400
495
|
console.log(`[${ts()}] [AUTH] Token obtained`);
|
|
401
496
|
|
|
402
|
-
const eventDispatcher = new EventDispatcher({});
|
|
403
|
-
eventDispatcher.register({
|
|
404
|
-
"im.message.receive_v1":
|
|
405
|
-
const traceId = makeTraceId();
|
|
406
|
-
try {
|
|
407
|
-
broadcastToRelay(data);
|
|
408
|
-
|
|
409
|
-
const event = getInnerEvent(data);
|
|
410
|
-
const message = event.message;
|
|
411
|
-
if (!message) return;
|
|
412
|
-
|
|
413
|
-
const messageId = message.message_id;
|
|
414
|
-
if (messageId) {
|
|
415
|
-
if (processedMessages.has(messageId)) {
|
|
416
|
-
console.log(`[MSG] Duplicate message ignored: ${messageId}`);
|
|
417
|
-
return;
|
|
418
|
-
}
|
|
419
|
-
processedMessages.add(messageId);
|
|
420
|
-
if (processedMessages.size > MAX_PROCESSED) {
|
|
421
|
-
const it = processedMessages.values();
|
|
422
|
-
for (let i = 0; i < 1000; i++) processedMessages.delete(it.next().value as string);
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
const text = await formatMessageContent(message);
|
|
427
|
-
const sender = event.sender;
|
|
428
|
-
const openId = sender?.sender_id?.open_id ?? "";
|
|
429
|
-
const chatId = message.chat_id ?? "";
|
|
430
|
-
const chatType = message.chat_type ?? "group";
|
|
431
|
-
|
|
432
|
-
console.log(`[MSG] sender=${openId} chat=${chatId} type=${chatType} text="${text}"`);
|
|
433
|
-
appendChatLog(chatId, openId, text);
|
|
434
|
-
|
|
435
|
-
if (messageId) {
|
|
436
|
-
getTenantAccessToken().then((freshToken) =>
|
|
437
|
-
addReaction(freshToken, messageId).catch((err) =>
|
|
438
|
-
console.error(`[${ts()}] Reaction failed: ${(err as Error).message}`)
|
|
439
|
-
)
|
|
440
|
-
).catch((err) =>
|
|
441
|
-
console.error(`[${ts()}] Reaction token failed: ${(err as Error).message}`)
|
|
442
|
-
);
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
if (!text) return;
|
|
446
|
-
const msgTimestamp = parseInt(message.create_time ?? "0", 10) || Date.now();
|
|
447
|
-
logTrace(traceId, "RECV", { chatId, chatType, text: text.slice(0, 100) });
|
|
448
|
-
const delayNotice = formatDelayNotice(msgTimestamp, text);
|
|
449
|
-
if (delayNotice) {
|
|
450
|
-
const delayToken = await getTenantAccessToken();
|
|
451
|
-
await sendCardReply(delayToken, chatId, "延迟送达", delayNotice, "yellow").catch(() => {});
|
|
452
|
-
}
|
|
453
|
-
// 仅 `/update` 会使用这个稳定 ID 做跨重启幂等;其他命令仍沿用
|
|
454
|
-
// processedMessages 的进程内去重。
|
|
455
|
-
await handleCommand(
|
|
456
|
-
feishuPlatform,
|
|
457
|
-
text,
|
|
458
|
-
chatId,
|
|
459
|
-
openId,
|
|
460
|
-
msgTimestamp,
|
|
461
|
-
chatType,
|
|
462
|
-
traceId,
|
|
463
|
-
buildUpdateCommandId("message", messageId),
|
|
464
|
-
);
|
|
465
|
-
} catch (err) {
|
|
466
|
-
logTrace(traceId, "ERROR", { message: (err as Error).message });
|
|
467
|
-
console.error(`[${ts()}] [FATAL] im.message.receive_v1 handler crashed: ${(err as Error).message}`);
|
|
468
|
-
}
|
|
469
|
-
},
|
|
497
|
+
const eventDispatcher = new EventDispatcher({});
|
|
498
|
+
eventDispatcher.register({
|
|
499
|
+
"im.message.receive_v1": handleFeishuMessageEventAckFirst,
|
|
470
500
|
|
|
471
501
|
"card.action.trigger": async (data: Evt) => {
|
|
472
502
|
try {
|
|
@@ -593,7 +623,8 @@ async function startBotServiceCore(): Promise<void> {
|
|
|
593
623
|
} else {
|
|
594
624
|
// 进程首次启动:此时所有 Map 都是空的,resetState 主要是打个 LOG 标识"开始
|
|
595
625
|
// 干净状态"。修正残留的 running stream-state 并重建 session→chat 映射。
|
|
596
|
-
resetState();
|
|
626
|
+
resetState();
|
|
627
|
+
await feishuMessageLedger.load();
|
|
597
628
|
startUnifiedDisplayLoop();
|
|
598
629
|
fixStaleStreamStates().then(async () => {
|
|
599
630
|
const registry = await loadSessionRegistryForBinding();
|
package/src/orchestrator.ts
CHANGED
|
@@ -340,6 +340,22 @@ function fastHelpAfterModel(tool: string): string {
|
|
|
340
340
|
: "";
|
|
341
341
|
}
|
|
342
342
|
|
|
343
|
+
function setChatAvatarForSession(
|
|
344
|
+
platform: PlatformAdapter,
|
|
345
|
+
chatId: string,
|
|
346
|
+
tool: string,
|
|
347
|
+
status: string,
|
|
348
|
+
sessionId?: string,
|
|
349
|
+
usageHints?: ChatAvatarUsageHints,
|
|
350
|
+
): Promise<void> {
|
|
351
|
+
const fastMode = getEffectiveFastModeForTool(tool, sessionId);
|
|
352
|
+
if (!usageHints && !fastMode) return platform.setChatAvatar(chatId, tool, status);
|
|
353
|
+
return platform.setChatAvatar(chatId, tool, status, {
|
|
354
|
+
...usageHints,
|
|
355
|
+
...(fastMode ? { fastMode: true } : {}),
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
343
359
|
async function sendFastModeStatus(
|
|
344
360
|
platform: PlatformAdapter,
|
|
345
361
|
chatId: string,
|
|
@@ -369,24 +385,26 @@ async function resolveUsageTarget(chatId: string): Promise<{ tool: "codex" | "cu
|
|
|
369
385
|
}
|
|
370
386
|
}
|
|
371
387
|
|
|
372
|
-
function refreshUsageAvatar(
|
|
373
|
-
platform: PlatformAdapter,
|
|
374
|
-
chatId: string,
|
|
375
|
-
tool: "codex" | "cursor",
|
|
376
|
-
status: "busy" | "idle",
|
|
377
|
-
usageHints: ChatAvatarUsageHints,
|
|
378
|
-
|
|
379
|
-
|
|
388
|
+
function refreshUsageAvatar(
|
|
389
|
+
platform: PlatformAdapter,
|
|
390
|
+
chatId: string,
|
|
391
|
+
tool: "codex" | "cursor",
|
|
392
|
+
status: "busy" | "idle",
|
|
393
|
+
usageHints: ChatAvatarUsageHints,
|
|
394
|
+
sessionId?: string,
|
|
395
|
+
): void {
|
|
396
|
+
setChatAvatarForSession(platform, chatId, tool, status, sessionId, usageHints).catch((err) => {
|
|
380
397
|
console.warn(`[${ts()}] [AVATAR] usage refresh failed: chatId=${chatId} tool=${tool} ${(err as Error).message}`);
|
|
381
398
|
});
|
|
382
399
|
}
|
|
383
400
|
|
|
384
|
-
async function sendUsageSummary(
|
|
385
|
-
platform: PlatformAdapter,
|
|
386
|
-
chatId: string,
|
|
387
|
-
tool: "codex" | "cursor",
|
|
388
|
-
avatarStatus: "busy" | "idle" = "idle",
|
|
389
|
-
|
|
401
|
+
async function sendUsageSummary(
|
|
402
|
+
platform: PlatformAdapter,
|
|
403
|
+
chatId: string,
|
|
404
|
+
tool: "codex" | "cursor",
|
|
405
|
+
avatarStatus: "busy" | "idle" = "idle",
|
|
406
|
+
sessionId?: string,
|
|
407
|
+
): Promise<void> {
|
|
390
408
|
if (tool === "cursor") {
|
|
391
409
|
const usage = await getCursorUsageSummary();
|
|
392
410
|
const content = formatCursorUsageSummary(usage);
|
|
@@ -395,7 +413,7 @@ async function sendUsageSummary(
|
|
|
395
413
|
} else {
|
|
396
414
|
await platform.sendCard(chatId, "Cursor Usage", content, "blue");
|
|
397
415
|
}
|
|
398
|
-
refreshUsageAvatar(platform, chatId, tool, avatarStatus, { cursorUsage: usage });
|
|
416
|
+
refreshUsageAvatar(platform, chatId, tool, avatarStatus, { cursorUsage: usage }, sessionId);
|
|
399
417
|
return;
|
|
400
418
|
}
|
|
401
419
|
|
|
@@ -411,7 +429,7 @@ async function sendUsageSummary(
|
|
|
411
429
|
} else {
|
|
412
430
|
await platform.sendCard(chatId, "Codex Usage", content, "blue");
|
|
413
431
|
}
|
|
414
|
-
refreshUsageAvatar(platform, chatId, tool, avatarStatus, { codexUsage: usage });
|
|
432
|
+
refreshUsageAvatar(platform, chatId, tool, avatarStatus, { codexUsage: usage }, sessionId);
|
|
415
433
|
}
|
|
416
434
|
|
|
417
435
|
async function sendUsageError(platform: PlatformAdapter, chatId: string, tool: "codex" | "cursor", err: unknown): Promise<void> {
|
|
@@ -585,7 +603,7 @@ async function resolveFeishuP2pAgent(
|
|
|
585
603
|
`检测到默认 Agent 已变化:**${previousLabel} → ${desiredLabel}**。\n\n已创建新的空白 ${desiredLabel} 私聊会话,并从本条消息开始使用。`,
|
|
586
604
|
"green",
|
|
587
605
|
).catch(() => {});
|
|
588
|
-
platform
|
|
606
|
+
setChatAvatarForSession(platform, chatId, desiredTool, "new", init.sessionId).catch(() => {});
|
|
589
607
|
return { kind: "ready", sessionId: init.sessionId, tool: desiredTool };
|
|
590
608
|
} catch (err) {
|
|
591
609
|
return {
|
|
@@ -817,7 +835,7 @@ export async function handleCommand(
|
|
|
817
835
|
const avatarStatus = usageTarget.sessionId && isSessionRunning(usageTarget.sessionId) ? "busy" : "idle";
|
|
818
836
|
logTrace(tid, "BRANCH", { cmd: "/usage", tool: usageTool });
|
|
819
837
|
try {
|
|
820
|
-
await sendUsageSummary(platform, chatId, usageTool, avatarStatus);
|
|
838
|
+
await sendUsageSummary(platform, chatId, usageTool, avatarStatus, usageTarget.sessionId);
|
|
821
839
|
logTrace(tid, "DONE", { outcome: "usage", tool: usageTool });
|
|
822
840
|
} catch (err) {
|
|
823
841
|
await sendUsageError(platform, chatId, usageTool, err);
|
|
@@ -1149,7 +1167,7 @@ export async function handleCommand(
|
|
|
1149
1167
|
sessionId,
|
|
1150
1168
|
tool,
|
|
1151
1169
|
});
|
|
1152
|
-
platform
|
|
1170
|
+
setChatAvatarForSession(platform, newChatId, tool, "new", sessionId).catch(() => {});
|
|
1153
1171
|
console.log(`${"=".repeat(60)}`);
|
|
1154
1172
|
return;
|
|
1155
1173
|
}
|
|
@@ -1544,9 +1562,7 @@ export async function handleCommand(
|
|
|
1544
1562
|
);
|
|
1545
1563
|
}
|
|
1546
1564
|
|
|
1547
|
-
platform
|
|
1548
|
-
.setChatAvatar(chatId, descriptionTool, "new")
|
|
1549
|
-
.catch(() => {});
|
|
1565
|
+
setChatAvatarForSession(platform, chatId, descriptionTool, "new", newSessionId).catch(() => {});
|
|
1550
1566
|
|
|
1551
1567
|
await platform.sendCard(
|
|
1552
1568
|
chatId,
|
|
@@ -1716,7 +1732,7 @@ export async function handleCommand(
|
|
|
1716
1732
|
);
|
|
1717
1733
|
}
|
|
1718
1734
|
|
|
1719
|
-
platform
|
|
1735
|
+
setChatAvatarForSession(platform, chatId, target.tool, "new", target.sessionId).catch(() => {});
|
|
1720
1736
|
|
|
1721
1737
|
const targetToolLabel = toolDisplayName(target.tool);
|
|
1722
1738
|
const busyNote = isSessionRunning(target.sessionId)
|
|
@@ -1772,6 +1788,12 @@ export async function handleCommand(
|
|
|
1772
1788
|
}
|
|
1773
1789
|
const enabled = getEffectiveFastModeForTool("codex", sessionId);
|
|
1774
1790
|
await sendFastModeStatus(platform, chatId, enabled).catch(() => {});
|
|
1791
|
+
if (fastArg) {
|
|
1792
|
+
const avatarStatus = isSessionRunning(sessionId) ? "busy" : "idle";
|
|
1793
|
+
await platform.setChatAvatar(chatId, "codex", avatarStatus, { fastMode: enabled }).catch((err) => {
|
|
1794
|
+
console.warn(`[${ts()}] [AVATAR] Fast mode refresh failed: chatId=${chatId} ${(err as Error).message}`);
|
|
1795
|
+
});
|
|
1796
|
+
}
|
|
1775
1797
|
logTrace(tid, "DONE", {
|
|
1776
1798
|
outcome: fastArg ? "fast_switched" : "fast_query",
|
|
1777
1799
|
enabled,
|
package/src/platform-adapter.ts
CHANGED
|
@@ -8,10 +8,11 @@
|
|
|
8
8
|
import type { CursorUsageSummary } from "./cursor-usage.ts";
|
|
9
9
|
import type { CodexUsageSummary } from "./feishu-api.ts";
|
|
10
10
|
|
|
11
|
-
export interface ChatAvatarUsageHints {
|
|
12
|
-
codexUsage?: CodexUsageSummary | null;
|
|
13
|
-
cursorUsage?: CursorUsageSummary | null;
|
|
14
|
-
|
|
11
|
+
export interface ChatAvatarUsageHints {
|
|
12
|
+
codexUsage?: CodexUsageSummary | null;
|
|
13
|
+
cursorUsage?: CursorUsageSummary | null;
|
|
14
|
+
fastMode?: boolean;
|
|
15
|
+
}
|
|
15
16
|
|
|
16
17
|
export interface PlatformAdapter {
|
|
17
18
|
/** 平台标识,用于区分不同平台的行为(如 wechat、feishu 等) */
|
|
@@ -66,4 +67,4 @@ export interface PlatformAdapter {
|
|
|
66
67
|
|
|
67
68
|
/** 更新已发送的进度展示,sequence 保证有序 */
|
|
68
69
|
cardUpdate(cardId: string, cardJson: string, sequence: number): Promise<void>;
|
|
69
|
-
}
|
|
70
|
+
}
|
package/src/session.ts
CHANGED
|
@@ -42,6 +42,13 @@ import { resourceMonitor, registerProcess, unregisterProcess } from "./adapters/
|
|
|
42
42
|
import { buildImSkillsPromptCached, exportSkillSubDocs, clearImSkillsPromptCache } from "./im-skills.ts";
|
|
43
43
|
import type { PlatformAdapter } from "./platform-adapter.ts";
|
|
44
44
|
import { hasResponseStalled, observeResponseProgress } from "./response-stall.ts";
|
|
45
|
+
import {
|
|
46
|
+
MAX_PROCESSED,
|
|
47
|
+
clearFeishuMessageLedgerMemory,
|
|
48
|
+
processedMessages,
|
|
49
|
+
} from "./feishu-message-ingress.ts";
|
|
50
|
+
|
|
51
|
+
export { MAX_PROCESSED, processedMessages };
|
|
45
52
|
|
|
46
53
|
// 微信显示循环压缩:头5 + ... + 尾5,避免在最后一步 sendText 中压缩指令回复
|
|
47
54
|
function compressWechatDisplayText(text: string): string {
|
|
@@ -131,9 +138,6 @@ async function createVisibleProgressCard(
|
|
|
131
138
|
// Shared state (imported by index.ts)
|
|
132
139
|
// ---------------------------------------------------------------------------
|
|
133
140
|
|
|
134
|
-
export const processedMessages = new Set<string>();
|
|
135
|
-
export const MAX_PROCESSED = 5000;
|
|
136
|
-
|
|
137
141
|
/** 每个 chatId 上一次已处理消息的时间戳,用于拦截延迟送达的旧消息 */
|
|
138
142
|
export const lastMsgTimestamps = new Map<string, number>();
|
|
139
143
|
|
|
@@ -463,7 +467,7 @@ export function resetState(): void {
|
|
|
463
467
|
}
|
|
464
468
|
chatSessionMap.clear();
|
|
465
469
|
sessionInfoMap.clear();
|
|
466
|
-
|
|
470
|
+
clearFeishuMessageLedgerMemory();
|
|
467
471
|
lastMsgTimestamps.clear();
|
|
468
472
|
chatPlatformMap.clear();
|
|
469
473
|
for (const prompt of activePrompts.values()) {
|
|
@@ -534,6 +538,18 @@ export function getEffectiveFastModeForTool(tool: string, sessionId?: string): b
|
|
|
534
538
|
}
|
|
535
539
|
return config.codex.fastMode;
|
|
536
540
|
}
|
|
541
|
+
|
|
542
|
+
function setSessionChatAvatar(
|
|
543
|
+
platform: PlatformAdapter,
|
|
544
|
+
chatId: string,
|
|
545
|
+
tool: string,
|
|
546
|
+
status: string,
|
|
547
|
+
sessionId: string,
|
|
548
|
+
): Promise<void> {
|
|
549
|
+
return getEffectiveFastModeForTool(tool, sessionId)
|
|
550
|
+
? platform.setChatAvatar(chatId, tool, status, { fastMode: true })
|
|
551
|
+
: platform.setChatAvatar(chatId, tool, status);
|
|
552
|
+
}
|
|
537
553
|
|
|
538
554
|
/** 为指定 session 设置模型覆盖(/model <name>) */
|
|
539
555
|
export function setSessionModelOverride(sessionId: string, model: string): void {
|
|
@@ -1286,7 +1302,7 @@ export async function runAgentSession(
|
|
|
1286
1302
|
if (displayCards.get(displayChatId) !== display) {
|
|
1287
1303
|
const finalStatus = turnFinalStatus(prevState.status);
|
|
1288
1304
|
finalizeTurnCards(sessionId, prevState.turnCount, finalStatus).catch(() => {});
|
|
1289
|
-
pp
|
|
1305
|
+
setSessionChatAvatar(pp, displayChatId, prevState.tool, "idle", sessionId).catch(() => {});
|
|
1290
1306
|
} else {
|
|
1291
1307
|
const nextSeq = display.sequence + 1;
|
|
1292
1308
|
const { title: headerTitle, template: headerTemplate } = formatTerminalHeader(prevState.status);
|
|
@@ -1306,7 +1322,7 @@ export async function runAgentSession(
|
|
|
1306
1322
|
if (prevTerminalReply && stillOursAfterUpdate && !isFinalReplySentForTurn(prevState)) {
|
|
1307
1323
|
await sendFinalReplyTextOnce(pp, displayChatId, sessionId, prevState.turnCount, prevTerminalReply);
|
|
1308
1324
|
}
|
|
1309
|
-
pp
|
|
1325
|
+
setSessionChatAvatar(pp, displayChatId, prevState.tool, "idle", sessionId).catch(() => {});
|
|
1310
1326
|
}
|
|
1311
1327
|
} else if (pp && prevTerminalReply && !isFinalReplySentForTurn(prevState)) {
|
|
1312
1328
|
// 无 display 记录但上一轮有 finalReply(极快轮次),至少发送
|
|
@@ -1371,7 +1387,7 @@ export async function runAgentSession(
|
|
|
1371
1387
|
// 设置最后活跃群头像为 busy
|
|
1372
1388
|
const activeCid = getLastActiveChat(sessionId) ?? getChatsForSession(sessionId)[0];
|
|
1373
1389
|
if (activeCid) {
|
|
1374
|
-
platform
|
|
1390
|
+
setSessionChatAvatar(platform, activeCid, tool, "busy", sessionId).catch(() => {});
|
|
1375
1391
|
}
|
|
1376
1392
|
|
|
1377
1393
|
const state: AccumulatorState = {
|
|
@@ -1661,7 +1677,7 @@ export async function runAgentSession(
|
|
|
1661
1677
|
const active1 = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
|
|
1662
1678
|
if (active1) {
|
|
1663
1679
|
await platform.sendText(active1, "会话已停止。").catch(() => {});
|
|
1664
|
-
platform
|
|
1680
|
+
setSessionChatAvatar(platform, active1, tool, "idle", sessionId).catch(() => {});
|
|
1665
1681
|
}
|
|
1666
1682
|
console.log(`[${ts()}] Session ${sessionId} stopped (content chunks: ${state.chunkCount})`);
|
|
1667
1683
|
if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "stopped", chunks: state.chunkCount });
|
|
@@ -1691,7 +1707,7 @@ export async function runAgentSession(
|
|
|
1691
1707
|
formatAutoEndedReply(finalReplyToWrite),
|
|
1692
1708
|
);
|
|
1693
1709
|
}
|
|
1694
|
-
pp
|
|
1710
|
+
setSessionChatAvatar(pp, activeAutoEnded, tool, "idle", sessionId).catch(() => {});
|
|
1695
1711
|
|
|
1696
1712
|
if (wasAutoRecovery) {
|
|
1697
1713
|
// 这是紧接第一次停滞而启动的恢复轮;再次发生相同停滞即终止
|
|
@@ -1726,7 +1742,7 @@ export async function runAgentSession(
|
|
|
1726
1742
|
});
|
|
1727
1743
|
}
|
|
1728
1744
|
const activeErr = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
|
|
1729
|
-
if (activeErr) platform
|
|
1745
|
+
if (activeErr) setSessionChatAvatar(platform, activeErr, tool, "idle", sessionId).catch(() => {});
|
|
1730
1746
|
console.log(`[${ts()}] Session ${sessionId} process exited unexpectedly (content chunks: ${state.chunkCount})`);
|
|
1731
1747
|
if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "process_missing", chunks: state.chunkCount });
|
|
1732
1748
|
} else {
|
|
@@ -1749,7 +1765,7 @@ export async function runAgentSession(
|
|
|
1749
1765
|
const pp = platformForChat(active2) ?? platform;
|
|
1750
1766
|
await sendFinalReplyTextOnce(pp, active2, sessionId, nextTurnCount, finalReply);
|
|
1751
1767
|
}
|
|
1752
|
-
platform
|
|
1768
|
+
setSessionChatAvatar(platform, active2, tool, "idle", sessionId).catch(() => {});
|
|
1753
1769
|
}
|
|
1754
1770
|
console.log(`[${ts()}] Session ${sessionId} stream complete (content chunks: ${state.chunkCount})`);
|
|
1755
1771
|
if (tid) logTrace(tid, "SESSION_END", { sessionId, chunks: state.chunkCount, finalTextLen: finalReply.length });
|
|
@@ -1992,7 +2008,7 @@ export function startUnifiedDisplayLoop(): void {
|
|
|
1992
2008
|
finalizeTurnCards(sessionId, state.turnCount, finalSt).catch(() => {});
|
|
1993
2009
|
displayCards.delete(chatId);
|
|
1994
2010
|
}
|
|
1995
|
-
p
|
|
2011
|
+
setSessionChatAvatar(p, chatId, state.tool, "idle", sessionId).catch(() => {});
|
|
1996
2012
|
console.log(`[${ts()}] [DISPLAY] unified loop deleted display for ${chatId} (terminal: ${state.status})`);
|
|
1997
2013
|
} else {
|
|
1998
2014
|
// running: 创建或更新展示
|