chatccc 0.2.40 → 0.2.42
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/demo/ilink_echo_probe.ts +222 -0
- package/package.json +59 -54
- package/src/__tests__/cards.test.ts +22 -6
- package/src/__tests__/crash-logging.test.ts +62 -62
- package/src/__tests__/session.test.ts +124 -11
- package/src/cards.ts +5 -3
- package/src/index.ts +116 -13
- package/src/session.ts +131 -7
- package/src/shared.ts +63 -63
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenILink WeChat Echo Probe
|
|
3
|
+
* ===========================
|
|
4
|
+
* Minimal scan-login and text echo demo for the WeChat iLink channel.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* npm run demo:ilink-echo
|
|
8
|
+
*
|
|
9
|
+
* Behavior:
|
|
10
|
+
* When a user sends text to the logged-in WeChat account, this demo replies
|
|
11
|
+
* with "收到:" plus the received text.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { createRequire } from "node:module";
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
Client as OpenIlinkWire,
|
|
21
|
+
extractText,
|
|
22
|
+
type GetUpdatesResponse,
|
|
23
|
+
type WeixinMessage,
|
|
24
|
+
} from "@openilink/openilink-sdk-node";
|
|
25
|
+
|
|
26
|
+
import { setupFileLogging } from "../src/shared.ts";
|
|
27
|
+
|
|
28
|
+
interface TerminalQrRenderer {
|
|
29
|
+
generate(
|
|
30
|
+
input: string,
|
|
31
|
+
opts: { small: boolean },
|
|
32
|
+
callback: (qrcode: string) => void,
|
|
33
|
+
): void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface EchoProbeSnapshot {
|
|
37
|
+
token?: string;
|
|
38
|
+
baseUrl?: string;
|
|
39
|
+
pollCursor?: string;
|
|
40
|
+
botId?: string;
|
|
41
|
+
userId?: string;
|
|
42
|
+
lastSeenAt?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
46
|
+
const PROJECT_ROOT = join(__dirname, "..");
|
|
47
|
+
const SNAPSHOT_PATH = join(PROJECT_ROOT, "state", "ilink-echo-probe.json");
|
|
48
|
+
const LOG_DIR = join(__dirname, "logs");
|
|
49
|
+
const requireFromDemo = createRequire(import.meta.url);
|
|
50
|
+
const terminalQr = requireFromDemo("qrcode-terminal") as TerminalQrRenderer;
|
|
51
|
+
|
|
52
|
+
setupFileLogging(LOG_DIR, "ilink-echo-probe");
|
|
53
|
+
|
|
54
|
+
let acceptingEvents = true;
|
|
55
|
+
|
|
56
|
+
function ensureParentDir(filePath: string): void {
|
|
57
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readSnapshot(): EchoProbeSnapshot {
|
|
61
|
+
if (!existsSync(SNAPSHOT_PATH)) {
|
|
62
|
+
return {};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
return JSON.parse(readFileSync(SNAPSHOT_PATH, "utf8")) as EchoProbeSnapshot;
|
|
67
|
+
} catch (error) {
|
|
68
|
+
console.warn(`Cannot read saved iLink probe state: ${(error as Error).message}`);
|
|
69
|
+
return {};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function writeSnapshot(snapshot: EchoProbeSnapshot): void {
|
|
74
|
+
ensureParentDir(SNAPSHOT_PATH);
|
|
75
|
+
writeFileSync(SNAPSHOT_PATH, `${JSON.stringify(snapshot, null, 2)}\n`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function updateSnapshot(patch: Partial<EchoProbeSnapshot>): EchoProbeSnapshot {
|
|
79
|
+
const next = {
|
|
80
|
+
...readSnapshot(),
|
|
81
|
+
...patch,
|
|
82
|
+
lastSeenAt: new Date().toISOString(),
|
|
83
|
+
};
|
|
84
|
+
writeSnapshot(next);
|
|
85
|
+
return next;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function peerKeyOf(message: WeixinMessage): string {
|
|
89
|
+
return String(message.from_user_id ?? "");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function textOf(message: WeixinMessage): string {
|
|
93
|
+
return extractText(message).trim();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function printScanMaterial(content: string): void {
|
|
97
|
+
console.log("\n========== iLink scan content ==========");
|
|
98
|
+
console.log(content);
|
|
99
|
+
console.log("========== iLink terminal QR ==========");
|
|
100
|
+
terminalQr.generate(content, { small: true }, (renderedQr) => {
|
|
101
|
+
console.log(renderedQr);
|
|
102
|
+
});
|
|
103
|
+
console.log("========== end iLink QR ==========\n");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function prepareProbeWire(saved: EchoProbeSnapshot): Promise<OpenIlinkWire> {
|
|
107
|
+
const ilinkWire = new OpenIlinkWire("", {
|
|
108
|
+
base_url: saved.baseUrl,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
console.log("Starting iLink QR login. The QR is shown on every run.");
|
|
112
|
+
const loginResult = await ilinkWire.loginWithQr({
|
|
113
|
+
on_qrcode: (content) => {
|
|
114
|
+
printScanMaterial(content);
|
|
115
|
+
},
|
|
116
|
+
on_scanned: () => {
|
|
117
|
+
console.log("QR scanned. Confirm login in WeChat.");
|
|
118
|
+
},
|
|
119
|
+
on_expired: (attempt, maxAttempts) => {
|
|
120
|
+
console.log(`QR expired, refreshing (${attempt}/${maxAttempts}).`);
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
if (!loginResult.connected) {
|
|
125
|
+
throw new Error(`iLink QR login failed: ${loginResult.message}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
updateSnapshot({
|
|
129
|
+
token: loginResult.bot_token ?? ilinkWire.token,
|
|
130
|
+
baseUrl: loginResult.base_url ?? ilinkWire.baseUrl,
|
|
131
|
+
botId: loginResult.bot_id,
|
|
132
|
+
userId: loginResult.user_id,
|
|
133
|
+
pollCursor: "",
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
console.log(`iLink login ready. BotID=${loginResult.bot_id ?? ""}`);
|
|
137
|
+
return ilinkWire;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function mirrorIncomingLine(
|
|
141
|
+
ilinkWire: OpenIlinkWire,
|
|
142
|
+
message: WeixinMessage,
|
|
143
|
+
): Promise<void> {
|
|
144
|
+
const peerKey = peerKeyOf(message);
|
|
145
|
+
if (!peerKey) {
|
|
146
|
+
console.warn("Skip message without from_user_id.");
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const incomingText = textOf(message);
|
|
151
|
+
const outgoingText = incomingText ? `收到:${incomingText}` : "收到:[non-text message]";
|
|
152
|
+
const contextToken = message.context_token;
|
|
153
|
+
|
|
154
|
+
if (contextToken) {
|
|
155
|
+
await ilinkWire.sendText(peerKey, outgoingText, contextToken);
|
|
156
|
+
} else {
|
|
157
|
+
await ilinkWire.push(peerKey, outgoingText);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
console.log(`Echoed to ${peerKey}: ${outgoingText.slice(0, 120)}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function rememberPollingCursor(response: GetUpdatesResponse): void {
|
|
164
|
+
const pollCursor = response.sync_buf ?? response.get_updates_buf;
|
|
165
|
+
if (!pollCursor) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
updateSnapshot({ pollCursor });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function installStopHooks(): void {
|
|
172
|
+
const stop = (): void => {
|
|
173
|
+
if (!acceptingEvents) {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
acceptingEvents = false;
|
|
177
|
+
console.log("Stopping iLink echo probe...");
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
process.once("SIGINT", stop);
|
|
181
|
+
process.once("SIGTERM", stop);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function runProbe(): Promise<void> {
|
|
185
|
+
installStopHooks();
|
|
186
|
+
|
|
187
|
+
const saved = readSnapshot();
|
|
188
|
+
const ilinkWire = await prepareProbeWire(saved);
|
|
189
|
+
const latest = readSnapshot();
|
|
190
|
+
|
|
191
|
+
console.log("Listening for WeChat messages. Send text to the small account.");
|
|
192
|
+
console.log(`State file: ${SNAPSHOT_PATH}`);
|
|
193
|
+
console.log(`Log dir: ${LOG_DIR}`);
|
|
194
|
+
|
|
195
|
+
await ilinkWire.monitor(
|
|
196
|
+
async (message) => {
|
|
197
|
+
try {
|
|
198
|
+
await mirrorIncomingLine(ilinkWire, message);
|
|
199
|
+
} catch (error) {
|
|
200
|
+
console.error(`Echo failed: ${(error as Error).stack ?? (error as Error).message}`);
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
initial_buf: latest.pollCursor ?? "",
|
|
205
|
+
on_buf_update: (pollCursor) => updateSnapshot({ pollCursor }),
|
|
206
|
+
on_response: rememberPollingCursor,
|
|
207
|
+
on_error: (error) => {
|
|
208
|
+
console.error(`iLink monitor error: ${error.stack ?? error.message}`);
|
|
209
|
+
},
|
|
210
|
+
on_session_expired: () => {
|
|
211
|
+
console.error("iLink session expired. Restart with --fresh to scan again.");
|
|
212
|
+
acceptingEvents = false;
|
|
213
|
+
},
|
|
214
|
+
should_continue: () => acceptingEvents,
|
|
215
|
+
},
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
runProbe().catch((error: Error) => {
|
|
220
|
+
console.error(`Fatal iLink echo probe error: ${error.stack ?? error.message}`);
|
|
221
|
+
process.exitCode = 1;
|
|
222
|
+
});
|
package/package.json
CHANGED
|
@@ -1,54 +1,59 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "chatccc",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "Feishu bot bridge for Claude Code",
|
|
5
|
-
"license": "Apache-2.0",
|
|
6
|
-
"type": "module",
|
|
7
|
-
"main": "./src/index.ts",
|
|
8
|
-
"bin": {
|
|
9
|
-
"chatccc": "bin/chatccc.mjs"
|
|
10
|
-
},
|
|
11
|
-
"files": [
|
|
12
|
-
"src/",
|
|
13
|
-
"bin/",
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
"images/img_readme_*.
|
|
17
|
-
"images/
|
|
18
|
-
"images/avatars/
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
"
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"demo:bot-test
|
|
29
|
-
"demo:
|
|
30
|
-
"demo:create-group
|
|
31
|
-
"demo:
|
|
32
|
-
"demo:
|
|
33
|
-
"demo:
|
|
34
|
-
"
|
|
35
|
-
"
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
"
|
|
43
|
-
"
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
"
|
|
47
|
-
"
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
"
|
|
53
|
-
|
|
54
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "chatccc",
|
|
3
|
+
"version": "0.2.42",
|
|
4
|
+
"description": "Feishu bot bridge for Claude Code",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./src/index.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"chatccc": "bin/chatccc.mjs"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src/",
|
|
13
|
+
"bin/",
|
|
14
|
+
"demo/ilink_echo_probe.ts",
|
|
15
|
+
"im-skills/",
|
|
16
|
+
"images/img_readme_*.jpg",
|
|
17
|
+
"images/img_readme_*.png",
|
|
18
|
+
"images/avatars/status_*.png",
|
|
19
|
+
"images/avatars/badges/",
|
|
20
|
+
"package.json",
|
|
21
|
+
"README.md",
|
|
22
|
+
"config.sample.json"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"dev": "tsx src/index.ts",
|
|
26
|
+
"chatccc": "tsx src/index.ts",
|
|
27
|
+
"start": "tsx src/index.ts",
|
|
28
|
+
"demo:bot-test": "tsx demo/bot_test.ts",
|
|
29
|
+
"demo:bot-test:local": "tsx demo/bot_test.ts --local",
|
|
30
|
+
"demo:create-group": "tsx src/index.ts",
|
|
31
|
+
"demo:create-group:local": "tsx src/index.ts --local",
|
|
32
|
+
"demo:permission-check": "tsx demo/permission_check.ts",
|
|
33
|
+
"demo:claude-hi": "tsx demo/claude_say_hi.ts",
|
|
34
|
+
"demo:codex-hi": "tsx demo/codex_say_hi.ts",
|
|
35
|
+
"demo:ilink-echo": "tsx demo/ilink_echo_probe.ts",
|
|
36
|
+
"test": "vitest run",
|
|
37
|
+
"test:watch": "vitest"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@anthropic-ai/claude-agent-sdk": "0.2.133",
|
|
41
|
+
"@larksuiteoapi/node-sdk": "^1.59.0",
|
|
42
|
+
"@openilink/openilink-sdk-node": "^0.6.0",
|
|
43
|
+
"nodemailer": "^8.0.7",
|
|
44
|
+
"qrcode-terminal": "^0.12.0",
|
|
45
|
+
"sharp": "^0.34.5",
|
|
46
|
+
"tsx": "^4.0.0",
|
|
47
|
+
"ws": "^8.18.0"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/node": "^20.0.0",
|
|
51
|
+
"@types/qrcode-terminal": "^0.12.2",
|
|
52
|
+
"@types/ws": "^8.18.1",
|
|
53
|
+
"typescript": "^5.0.0",
|
|
54
|
+
"vitest": "^4.1.5"
|
|
55
|
+
},
|
|
56
|
+
"engines": {
|
|
57
|
+
"node": ">=20"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -311,7 +311,7 @@ describe("buildSessionsCard", () => {
|
|
|
311
311
|
|
|
312
312
|
it("returns valid JSON with session listing", () => {
|
|
313
313
|
const card = buildSessionsCard([
|
|
314
|
-
{ sessionId: "abc123", active: true, turnCount: 5, elapsedSeconds: 120, model: "Claude Opus 4.7", tool: "claude" },
|
|
314
|
+
{ sessionId: "abc123", chatName: "test-group", active: true, turnCount: 5, elapsedSeconds: 120, model: "Claude Opus 4.7", tool: "claude" },
|
|
315
315
|
]);
|
|
316
316
|
const parsed = JSON.parse(card);
|
|
317
317
|
expect(parsed.elements[0].text.content).toContain("共 **1** 个会话");
|
|
@@ -322,7 +322,7 @@ describe("buildSessionsCard", () => {
|
|
|
322
322
|
|
|
323
323
|
it("shows idle status for inactive sessions", () => {
|
|
324
324
|
const card = buildSessionsCard([
|
|
325
|
-
{ sessionId: "xyz", active: false, turnCount: 0, elapsedSeconds: null, model: "Claude Sonnet 4.6", tool: "claude" },
|
|
325
|
+
{ sessionId: "xyz", chatName: "", active: false, turnCount: 0, elapsedSeconds: null, model: "Claude Sonnet 4.6", tool: "claude" },
|
|
326
326
|
]);
|
|
327
327
|
const parsed = JSON.parse(card);
|
|
328
328
|
expect(parsed.elements[0].text.content).toContain("⚪ 空闲");
|
|
@@ -330,7 +330,7 @@ describe("buildSessionsCard", () => {
|
|
|
330
330
|
|
|
331
331
|
it("shows elapsed time for active sessions", () => {
|
|
332
332
|
const card = buildSessionsCard([
|
|
333
|
-
{ sessionId: "active123", active: true, turnCount: 3, elapsedSeconds: 95, model: "Claude Opus 4.7", tool: "claude" },
|
|
333
|
+
{ sessionId: "active123", chatName: "", active: true, turnCount: 3, elapsedSeconds: 95, model: "Claude Opus 4.7", tool: "claude" },
|
|
334
334
|
]);
|
|
335
335
|
const parsed = JSON.parse(card);
|
|
336
336
|
expect(parsed.elements[0].text.content).toContain("1分35秒");
|
|
@@ -338,8 +338,8 @@ describe("buildSessionsCard", () => {
|
|
|
338
338
|
|
|
339
339
|
it("separates Claude Code and Cursor sessions", () => {
|
|
340
340
|
const card = buildSessionsCard([
|
|
341
|
-
{ sessionId: "c1", active: false, turnCount: 1, elapsedSeconds: null, model: "(留空)", tool: "claude" },
|
|
342
|
-
{ sessionId: "c2", active: false, turnCount: 2, elapsedSeconds: null, model: "claude-opus-4-7-max", tool: "cursor" },
|
|
341
|
+
{ sessionId: "c1", chatName: "", active: false, turnCount: 1, elapsedSeconds: null, model: "(留空)", tool: "claude" },
|
|
342
|
+
{ sessionId: "c2", chatName: "", active: false, turnCount: 2, elapsedSeconds: null, model: "claude-opus-4-7-max", tool: "cursor" },
|
|
343
343
|
]);
|
|
344
344
|
const parsed = JSON.parse(card);
|
|
345
345
|
const content: string = parsed.elements[0].text.content;
|
|
@@ -349,7 +349,7 @@ describe("buildSessionsCard", () => {
|
|
|
349
349
|
|
|
350
350
|
it("omits Cursor section when no Cursor sessions", () => {
|
|
351
351
|
const card = buildSessionsCard([
|
|
352
|
-
{ sessionId: "c1", active: false, turnCount: 1, elapsedSeconds: null, model: "(留空)", tool: "claude" },
|
|
352
|
+
{ sessionId: "c1", chatName: "", active: false, turnCount: 1, elapsedSeconds: null, model: "(留空)", tool: "claude" },
|
|
353
353
|
]);
|
|
354
354
|
const parsed = JSON.parse(card);
|
|
355
355
|
const content: string = parsed.elements[0].text.content;
|
|
@@ -357,6 +357,22 @@ describe("buildSessionsCard", () => {
|
|
|
357
357
|
expect(content).not.toContain("Cursor 会话");
|
|
358
358
|
});
|
|
359
359
|
|
|
360
|
+
it("displays chatName when provided", () => {
|
|
361
|
+
const card = buildSessionsCard([
|
|
362
|
+
{ sessionId: "abc123", chatName: "帮我写代码-src", active: false, turnCount: 2, elapsedSeconds: null, model: "Claude Opus 4.7", tool: "claude" },
|
|
363
|
+
]);
|
|
364
|
+
const parsed = JSON.parse(card);
|
|
365
|
+
expect(parsed.elements[0].text.content).toContain("帮我写代码-src");
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("includes /session help text in non-empty card", () => {
|
|
369
|
+
const card = buildSessionsCard([
|
|
370
|
+
{ sessionId: "abc123", chatName: "", active: false, turnCount: 2, elapsedSeconds: null, model: "Claude Opus 4.7", tool: "claude" },
|
|
371
|
+
]);
|
|
372
|
+
const parsed = JSON.parse(card);
|
|
373
|
+
expect(parsed.elements[2].text.content).toContain("/session 数字");
|
|
374
|
+
});
|
|
375
|
+
|
|
360
376
|
it("includes close button", () => {
|
|
361
377
|
const card = buildSessionsCard([]);
|
|
362
378
|
const parsed = JSON.parse(card);
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
2
|
-
import { tmpdir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import { describe, it, expect, vi } from "vitest";
|
|
5
|
-
|
|
6
|
-
import { buildCrashLoggingHandlers, installCrashLogging, setupFileLogging } from "../shared.ts";
|
|
1
|
+
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { describe, it, expect, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
import { buildCrashLoggingHandlers, installCrashLogging, setupFileLogging } from "../shared.ts";
|
|
7
7
|
|
|
8
8
|
describe("buildCrashLoggingHandlers", () => {
|
|
9
9
|
it("uncaughtException: 写诊断、刷新日志、调用 onFatal", () => {
|
|
@@ -144,7 +144,7 @@ describe("buildCrashLoggingHandlers", () => {
|
|
|
144
144
|
});
|
|
145
145
|
});
|
|
146
146
|
|
|
147
|
-
describe("installCrashLogging", () => {
|
|
147
|
+
describe("installCrashLogging", () => {
|
|
148
148
|
it("注册所有相关事件监听器", () => {
|
|
149
149
|
const before = {
|
|
150
150
|
uncaught: process.listenerCount("uncaughtException"),
|
|
@@ -209,58 +209,58 @@ describe("installCrashLogging", () => {
|
|
|
209
209
|
} finally {
|
|
210
210
|
cleanup();
|
|
211
211
|
}
|
|
212
|
-
});
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
describe("setupFileLogging", () => {
|
|
216
|
-
it("flush 后继续写日志不会触发 write after end,且日志已落盘", async () => {
|
|
217
|
-
const originalLog = console.log;
|
|
218
|
-
const originalError = console.error;
|
|
219
|
-
console.log = vi.fn() as never;
|
|
220
|
-
console.error = vi.fn() as never;
|
|
221
|
-
const dir = await mkdtemp(join(tmpdir(), "chatccc-log-"));
|
|
222
|
-
|
|
223
|
-
try {
|
|
224
|
-
const fileLog = setupFileLogging(dir, "index");
|
|
225
|
-
|
|
226
|
-
console.log("before flush");
|
|
227
|
-
fileLog.flush();
|
|
228
|
-
|
|
229
|
-
expect(() => console.error("after flush")).not.toThrow();
|
|
230
|
-
fileLog.flush();
|
|
231
|
-
|
|
232
|
-
const content = await readFile(fileLog.logPath, "utf8");
|
|
233
|
-
expect(content).toContain("[LOG] before flush");
|
|
234
|
-
expect(content).toContain("[ERR] after flush");
|
|
235
|
-
} finally {
|
|
236
|
-
console.log = originalLog;
|
|
237
|
-
console.error = originalError;
|
|
238
|
-
await rm(dir, { recursive: true, force: true });
|
|
239
|
-
}
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
it("日志参数无法 JSON 序列化时也不会让 console 调用抛错", async () => {
|
|
243
|
-
const originalLog = console.log;
|
|
244
|
-
const originalError = console.error;
|
|
245
|
-
console.log = vi.fn() as never;
|
|
246
|
-
console.error = vi.fn() as never;
|
|
247
|
-
const dir = await mkdtemp(join(tmpdir(), "chatccc-log-"));
|
|
248
|
-
|
|
249
|
-
try {
|
|
250
|
-
const fileLog = setupFileLogging(dir, "index");
|
|
251
|
-
const circular: Record<string, unknown> = { name: "root" };
|
|
252
|
-
circular.self = circular;
|
|
253
|
-
|
|
254
|
-
expect(() => console.log("circular", circular)).not.toThrow();
|
|
255
|
-
fileLog.flush();
|
|
256
|
-
|
|
257
|
-
const content = await readFile(fileLog.logPath, "utf8");
|
|
258
|
-
expect(content).toContain("[LOG] circular");
|
|
259
|
-
expect(content).toContain("self");
|
|
260
|
-
} finally {
|
|
261
|
-
console.log = originalLog;
|
|
262
|
-
console.error = originalError;
|
|
263
|
-
await rm(dir, { recursive: true, force: true });
|
|
264
|
-
}
|
|
265
|
-
});
|
|
266
|
-
});
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe("setupFileLogging", () => {
|
|
216
|
+
it("flush 后继续写日志不会触发 write after end,且日志已落盘", async () => {
|
|
217
|
+
const originalLog = console.log;
|
|
218
|
+
const originalError = console.error;
|
|
219
|
+
console.log = vi.fn() as never;
|
|
220
|
+
console.error = vi.fn() as never;
|
|
221
|
+
const dir = await mkdtemp(join(tmpdir(), "chatccc-log-"));
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
const fileLog = setupFileLogging(dir, "index");
|
|
225
|
+
|
|
226
|
+
console.log("before flush");
|
|
227
|
+
fileLog.flush();
|
|
228
|
+
|
|
229
|
+
expect(() => console.error("after flush")).not.toThrow();
|
|
230
|
+
fileLog.flush();
|
|
231
|
+
|
|
232
|
+
const content = await readFile(fileLog.logPath, "utf8");
|
|
233
|
+
expect(content).toContain("[LOG] before flush");
|
|
234
|
+
expect(content).toContain("[ERR] after flush");
|
|
235
|
+
} finally {
|
|
236
|
+
console.log = originalLog;
|
|
237
|
+
console.error = originalError;
|
|
238
|
+
await rm(dir, { recursive: true, force: true });
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("日志参数无法 JSON 序列化时也不会让 console 调用抛错", async () => {
|
|
243
|
+
const originalLog = console.log;
|
|
244
|
+
const originalError = console.error;
|
|
245
|
+
console.log = vi.fn() as never;
|
|
246
|
+
console.error = vi.fn() as never;
|
|
247
|
+
const dir = await mkdtemp(join(tmpdir(), "chatccc-log-"));
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
const fileLog = setupFileLogging(dir, "index");
|
|
251
|
+
const circular: Record<string, unknown> = { name: "root" };
|
|
252
|
+
circular.self = circular;
|
|
253
|
+
|
|
254
|
+
expect(() => console.log("circular", circular)).not.toThrow();
|
|
255
|
+
fileLog.flush();
|
|
256
|
+
|
|
257
|
+
const content = await readFile(fileLog.logPath, "utf8");
|
|
258
|
+
expect(content).toContain("[LOG] circular");
|
|
259
|
+
expect(content).toContain("self");
|
|
260
|
+
} finally {
|
|
261
|
+
console.log = originalLog;
|
|
262
|
+
console.error = originalError;
|
|
263
|
+
await rm(dir, { recursive: true, force: true });
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
});
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
2
5
|
import {
|
|
3
6
|
chatSessionMap,
|
|
4
7
|
sessionInfoMap,
|
|
@@ -7,9 +10,12 @@ import {
|
|
|
7
10
|
resetState,
|
|
8
11
|
getSessionStatus,
|
|
9
12
|
getAllSessionsStatus,
|
|
13
|
+
recordSessionRegistry,
|
|
10
14
|
accumulateBlockContent,
|
|
11
15
|
pickFinalReply,
|
|
12
16
|
UNKNOWN_MODEL_PLACEHOLDER,
|
|
17
|
+
_setSessionRegistryFileForTest,
|
|
18
|
+
_resetSessionRegistryFileForTest,
|
|
13
19
|
_setAdapterForToolForTest,
|
|
14
20
|
_clearAdapterCacheForTest,
|
|
15
21
|
} from "../session.ts";
|
|
@@ -196,39 +202,146 @@ describe("getSessionStatus", () => {
|
|
|
196
202
|
});
|
|
197
203
|
|
|
198
204
|
describe("getAllSessionsStatus", () => {
|
|
199
|
-
|
|
205
|
+
let registryFile = "";
|
|
206
|
+
|
|
207
|
+
beforeEach(async () => {
|
|
200
208
|
chatSessionMap.clear();
|
|
201
209
|
sessionInfoMap.clear();
|
|
210
|
+
const dir = await mkdtemp(join(tmpdir(), "chatccc-session-registry-"));
|
|
211
|
+
registryFile = join(dir, "session-registry.json");
|
|
212
|
+
_setSessionRegistryFileForTest(registryFile);
|
|
202
213
|
});
|
|
203
214
|
|
|
204
|
-
afterEach(() => {
|
|
215
|
+
afterEach(async () => {
|
|
205
216
|
_clearAdapterCacheForTest();
|
|
217
|
+
_resetSessionRegistryFileForTest();
|
|
218
|
+
if (registryFile) {
|
|
219
|
+
await rm(dirname(registryFile), { recursive: true, force: true });
|
|
220
|
+
}
|
|
206
221
|
});
|
|
207
222
|
|
|
208
223
|
it("returns empty array when no sessions", async () => {
|
|
209
224
|
await expect(getAllSessionsStatus()).resolves.toEqual([]);
|
|
210
225
|
});
|
|
211
226
|
|
|
212
|
-
it("
|
|
227
|
+
it("does not read memory-only sessions", async () => {
|
|
213
228
|
mockSessionInfo("chat1", { sessionId: "s1" });
|
|
214
229
|
mockSessionInfo("chat2", { sessionId: "s2" });
|
|
215
230
|
mockActiveSession("chat1");
|
|
231
|
+
|
|
232
|
+
const result = await getAllSessionsStatus();
|
|
233
|
+
expect(result).toEqual([]);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("returns statuses from disk registry", async () => {
|
|
237
|
+
await recordSessionRegistry({
|
|
238
|
+
chatId: "chat1",
|
|
239
|
+
sessionId: "s1",
|
|
240
|
+
tool: "claude",
|
|
241
|
+
chatName: "test-chat-1",
|
|
242
|
+
turnCount: 2,
|
|
243
|
+
startTime: 1000,
|
|
244
|
+
updatedAt: 2000,
|
|
245
|
+
running: true,
|
|
246
|
+
});
|
|
247
|
+
await recordSessionRegistry({
|
|
248
|
+
chatId: "chat2",
|
|
249
|
+
sessionId: "s2",
|
|
250
|
+
tool: "claude",
|
|
251
|
+
chatName: "test-chat-2",
|
|
252
|
+
turnCount: 0,
|
|
253
|
+
startTime: 900,
|
|
254
|
+
updatedAt: 1900,
|
|
255
|
+
running: false,
|
|
256
|
+
});
|
|
257
|
+
|
|
216
258
|
const result = await getAllSessionsStatus();
|
|
217
259
|
expect(result).toHaveLength(2);
|
|
260
|
+
expect(result[0].chatId).toBe("chat1");
|
|
261
|
+
expect(result[0].active).toBe(true);
|
|
262
|
+
expect(result[0].turnCount).toBe(2);
|
|
263
|
+
expect(result[0].chatName).toBe("test-chat-1");
|
|
264
|
+
expect(result[1].chatId).toBe("chat2");
|
|
265
|
+
expect(result[1].active).toBe(false);
|
|
266
|
+
expect(result[1].chatName).toBe("test-chat-2");
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("returns recent disk sessions by updatedAt desc, limited to 20", async () => {
|
|
270
|
+
for (let i = 0; i < 25; i++) {
|
|
271
|
+
await recordSessionRegistry({
|
|
272
|
+
chatId: `chat-${i}`,
|
|
273
|
+
sessionId: `sid-${i}`,
|
|
274
|
+
tool: "claude",
|
|
275
|
+
startTime: i,
|
|
276
|
+
updatedAt: 1000 + i,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const result = await getAllSessionsStatus();
|
|
281
|
+
expect(result).toHaveLength(20);
|
|
282
|
+
expect(result[0].chatId).toBe("chat-24");
|
|
283
|
+
expect(result[19].chatId).toBe("chat-5");
|
|
284
|
+
expect(result.some((r) => r.chatId === "chat-4")).toBe(false);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("marks disk-running sessions as active without checking chatSessionMap", async () => {
|
|
288
|
+
await recordSessionRegistry({
|
|
289
|
+
chatId: "chat1",
|
|
290
|
+
sessionId: "s1",
|
|
291
|
+
tool: "claude",
|
|
292
|
+
running: true,
|
|
293
|
+
updatedAt: 1000,
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
const result = await getAllSessionsStatus();
|
|
218
297
|
expect(result.find(r => r.chatId === "chat1")!.active).toBe(true);
|
|
219
|
-
expect(result.find(r => r.chatId === "chat2")!.active).toBe(false);
|
|
220
298
|
});
|
|
221
299
|
|
|
222
|
-
it("
|
|
223
|
-
|
|
224
|
-
|
|
300
|
+
it("persists chatName across updates and defaults to empty string when not set", async () => {
|
|
301
|
+
await recordSessionRegistry({
|
|
302
|
+
chatId: "chat-a",
|
|
303
|
+
sessionId: "sa",
|
|
304
|
+
tool: "claude",
|
|
305
|
+
chatName: "My Chat",
|
|
306
|
+
updatedAt: 100,
|
|
307
|
+
});
|
|
308
|
+
// Update without chatName — should keep existing
|
|
309
|
+
await recordSessionRegistry({
|
|
310
|
+
chatId: "chat-a",
|
|
311
|
+
sessionId: "sa",
|
|
312
|
+
tool: "claude",
|
|
313
|
+
updatedAt: 200,
|
|
314
|
+
});
|
|
315
|
+
const result = await getAllSessionsStatus();
|
|
316
|
+
expect(result.find(r => r.chatId === "chat-a")!.chatName).toBe("My Chat");
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("chatName defaults to empty string for sessions without it", async () => {
|
|
320
|
+
await recordSessionRegistry({
|
|
321
|
+
chatId: "chat-b",
|
|
322
|
+
sessionId: "sb",
|
|
323
|
+
tool: "claude",
|
|
324
|
+
updatedAt: 100,
|
|
325
|
+
});
|
|
225
326
|
const result = await getAllSessionsStatus();
|
|
226
|
-
expect(result
|
|
327
|
+
expect(result.find(r => r.chatId === "chat-b")!.chatName).toBe("");
|
|
227
328
|
});
|
|
228
329
|
|
|
229
330
|
it("混合 claude + cursor 会话:各自取自己来源的 model/effort", async () => {
|
|
230
|
-
|
|
231
|
-
|
|
331
|
+
await recordSessionRegistry({
|
|
332
|
+
chatId: "chat-c",
|
|
333
|
+
sessionId: "sid-c",
|
|
334
|
+
tool: "claude",
|
|
335
|
+
chatName: "claude-chat",
|
|
336
|
+
updatedAt: 100,
|
|
337
|
+
});
|
|
338
|
+
await recordSessionRegistry({
|
|
339
|
+
chatId: "chat-x",
|
|
340
|
+
sessionId: "sid-x",
|
|
341
|
+
tool: "cursor",
|
|
342
|
+
chatName: "cursor-chat",
|
|
343
|
+
updatedAt: 200,
|
|
344
|
+
});
|
|
232
345
|
_setAdapterForToolForTest(
|
|
233
346
|
"cursor",
|
|
234
347
|
mockAdapter((sid) =>
|
|
@@ -473,4 +586,4 @@ describe("pickFinalReply", () => {
|
|
|
473
586
|
}),
|
|
474
587
|
).toBe("");
|
|
475
588
|
});
|
|
476
|
-
});
|
|
589
|
+
});
|
package/src/cards.ts
CHANGED
|
@@ -254,6 +254,7 @@ export function buildCdCard(
|
|
|
254
254
|
// 所有会话列表卡片(Claude Code 优先,然后 Cursor)
|
|
255
255
|
export function buildSessionsCard(sessions: Array<{
|
|
256
256
|
sessionId: string;
|
|
257
|
+
chatName: string;
|
|
257
258
|
active: boolean;
|
|
258
259
|
turnCount: number;
|
|
259
260
|
elapsedSeconds: number | null;
|
|
@@ -274,7 +275,7 @@ export function buildSessionsCard(sessions: Array<{
|
|
|
274
275
|
config: { wide_screen_mode: true },
|
|
275
276
|
header: { template: "blue", title: { content: "所有会话", tag: "plain_text" } },
|
|
276
277
|
elements: [
|
|
277
|
-
{ tag: "div", text: { tag: "lark_md", content: `当前没有会话记录。\n\n使用 **/new**(默认 ${defaultToolLabel})、**/new claude**、**/new cursor** 或 **/new codex**
|
|
278
|
+
{ tag: "div", text: { tag: "lark_md", content: `当前没有会话记录。\n\n使用 **/new**(默认 ${defaultToolLabel})、**/new claude**、**/new cursor** 或 **/new codex** 创建新会话。\n创建后可在任意会话群内发送 **/sessions** 查看列表,用 **/session 数字** 切换会话。` } },
|
|
278
279
|
{ tag: "hr" },
|
|
279
280
|
{ tag: "action", actions: [{ tag: "button", text: { tag: "plain_text", content: "收起" }, type: "default", value: { action: "close" } }] },
|
|
280
281
|
],
|
|
@@ -291,7 +292,8 @@ export function buildSessionsCard(sessions: Array<{
|
|
|
291
292
|
extra = ` | 本轮: ${mins}分${secs}秒`;
|
|
292
293
|
}
|
|
293
294
|
const toolLabel = s.tool === "cursor" ? "Cursor" : s.tool === "codex" ? "Codex" : "Claude Code";
|
|
294
|
-
|
|
295
|
+
const namePart = s.chatName ? `**${s.chatName}** ` : "";
|
|
296
|
+
return `**${i + 1}.** ${namePart}\`${shortId}\` ${status} | 工具: ${toolLabel} | 轮数: ${s.turnCount} | ${s.model}${extra}`;
|
|
295
297
|
};
|
|
296
298
|
|
|
297
299
|
const lines: string[] = [`共 **${sessions.length}** 个会话:`, ""];
|
|
@@ -326,7 +328,7 @@ export function buildSessionsCard(sessions: Array<{
|
|
|
326
328
|
elements: [
|
|
327
329
|
{ tag: "div", text: { tag: "lark_md", content: lines.join("\n") } },
|
|
328
330
|
{ tag: "hr" },
|
|
329
|
-
{ tag: "div", text: { tag: "lark_md", content: "在会话群内发送 **/forget** 可重置当前会话(创建新 Session
|
|
331
|
+
{ tag: "div", text: { tag: "lark_md", content: "在会话群内发送 **/forget** 可重置当前会话(创建新 Session,保留工作目录和群聊)。\n发送 **/session 数字**(如 `/session 1`)可将当前群聊切换到列表中对应编号的会话。" } },
|
|
330
332
|
{ tag: "hr" },
|
|
331
333
|
{
|
|
332
334
|
tag: "action",
|
package/src/index.ts
CHANGED
|
@@ -102,6 +102,7 @@ import {
|
|
|
102
102
|
resetState,
|
|
103
103
|
resumeAndPrompt,
|
|
104
104
|
sessionInfoMap,
|
|
105
|
+
recordSessionRegistry,
|
|
105
106
|
getAdapterForTool,
|
|
106
107
|
} from "./session.ts";
|
|
107
108
|
|
|
@@ -309,7 +310,8 @@ async function handleSimInjectMessage(req: IncomingMessage, res: ServerResponse)
|
|
|
309
310
|
|
|
310
311
|
async function handleCommand(text: string, chatId: string, openId: string, msgTimestamp: number, chatType = "group", traceId?: string): Promise<void> {
|
|
311
312
|
const tid = traceId ?? makeTraceId();
|
|
312
|
-
|
|
313
|
+
const textLower = text.toLowerCase();
|
|
314
|
+
if (textLower === "/restart") {
|
|
313
315
|
logTrace(tid, "BRANCH", { cmd: "/restart" });
|
|
314
316
|
const restartToken = await getTenantAccessToken();
|
|
315
317
|
await sendTextReply(restartToken, chatId, "正在重启...").catch(() => {});
|
|
@@ -326,7 +328,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
326
328
|
return;
|
|
327
329
|
}
|
|
328
330
|
|
|
329
|
-
if (
|
|
331
|
+
if (textLower === "/cd" || textLower.startsWith("/cd ")) {
|
|
330
332
|
logTrace(tid, "BRANCH", { cmd: "/cd", arg: text.slice(3).trim() || "(none)" });
|
|
331
333
|
const cdToken = await getTenantAccessToken();
|
|
332
334
|
const currentDir = await getDefaultCwd(chatId);
|
|
@@ -415,8 +417,8 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
415
417
|
return;
|
|
416
418
|
}
|
|
417
419
|
|
|
418
|
-
if (
|
|
419
|
-
const toolArg = text.slice(5).trim();
|
|
420
|
+
if (textLower === "/new" || textLower.startsWith("/new ")) {
|
|
421
|
+
const toolArg = text.slice(5).trim().toLowerCase();
|
|
420
422
|
const tool = toolArg || "claude";
|
|
421
423
|
logTrace(tid, "BRANCH", { cmd: "/new", tool });
|
|
422
424
|
const validTools = ["claude", "cursor", "codex"];
|
|
@@ -483,6 +485,15 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
483
485
|
|
|
484
486
|
// 让新群的默认工作目录继承当前会话的 cwd
|
|
485
487
|
await setDefaultCwd(cwd, newChatId);
|
|
488
|
+
await recordSessionRegistry({
|
|
489
|
+
chatId: newChatId,
|
|
490
|
+
sessionId,
|
|
491
|
+
tool,
|
|
492
|
+
chatName: initialName,
|
|
493
|
+
turnCount: 0,
|
|
494
|
+
startTime: Date.now(),
|
|
495
|
+
running: false,
|
|
496
|
+
});
|
|
486
497
|
|
|
487
498
|
const adapter = getAdapterForTool(tool);
|
|
488
499
|
await sendCardReply(
|
|
@@ -530,12 +541,13 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
530
541
|
try {
|
|
531
542
|
await updateChatInfo(freshToken, chatId, newName, description);
|
|
532
543
|
console.log(`[${ts()}] [RENAME] First message → group renamed to "${newName}"`);
|
|
544
|
+
await recordSessionRegistry({ chatId, chatName: newName }).catch(() => {});
|
|
533
545
|
} catch (err) {
|
|
534
546
|
console.error(`[${ts()}] [RENAME] Failed: ${(err as Error).message}`);
|
|
535
547
|
}
|
|
536
548
|
}
|
|
537
549
|
|
|
538
|
-
if (
|
|
550
|
+
if (textLower === "/stop") {
|
|
539
551
|
logTrace(tid, "BRANCH", { cmd: "/stop" });
|
|
540
552
|
const cEntry = chatSessionMap.get(chatId);
|
|
541
553
|
if (cEntry) {
|
|
@@ -556,7 +568,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
556
568
|
return;
|
|
557
569
|
}
|
|
558
570
|
|
|
559
|
-
if (
|
|
571
|
+
if (textLower === "/status") {
|
|
560
572
|
logTrace(tid, "BRANCH", { cmd: "/status" });
|
|
561
573
|
const status = await getSessionStatus(chatId);
|
|
562
574
|
const running = chatSessionMap.get(chatId);
|
|
@@ -590,12 +602,13 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
590
602
|
return;
|
|
591
603
|
}
|
|
592
604
|
|
|
593
|
-
if (
|
|
605
|
+
if (textLower === "/sessions") {
|
|
594
606
|
logTrace(tid, "BRANCH", { cmd: "/sessions" });
|
|
595
607
|
const allSessions = await getAllSessionsStatus();
|
|
596
608
|
const now = Date.now();
|
|
597
609
|
const cardData = allSessions.map(s => ({
|
|
598
610
|
sessionId: s.sessionId,
|
|
611
|
+
chatName: s.chatName,
|
|
599
612
|
active: s.active,
|
|
600
613
|
turnCount: s.turnCount,
|
|
601
614
|
elapsedSeconds: s.active ? Math.floor((now - s.startTime) / 1000) : null,
|
|
@@ -609,7 +622,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
609
622
|
return;
|
|
610
623
|
}
|
|
611
624
|
|
|
612
|
-
if (
|
|
625
|
+
if (textLower === "/forget") {
|
|
613
626
|
logTrace(tid, "BRANCH", { cmd: "/forget" });
|
|
614
627
|
const adapter = getAdapterForTool(descriptionTool);
|
|
615
628
|
let cwd: string;
|
|
@@ -643,9 +656,11 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
643
656
|
}
|
|
644
657
|
|
|
645
658
|
const descPrefix = sessionPrefixForTool(descriptionTool);
|
|
646
|
-
const
|
|
647
|
-
|
|
648
|
-
|
|
659
|
+
const newName = isUntitledSessionChatName(chatInfo.name)
|
|
660
|
+
? sessionChatName("新会话", cwd)
|
|
661
|
+
: chatInfo.name;
|
|
662
|
+
await updateChatInfo(freshToken, chatId, newName, `${descPrefix} ${newSessionId}`);
|
|
663
|
+
console.log(`[${ts()}] [FORGET] Group updated: name="${newName}" desc="${descPrefix} ${newSessionId}"`);
|
|
649
664
|
|
|
650
665
|
sessionInfoMap.set(chatId, {
|
|
651
666
|
sessionId: newSessionId,
|
|
@@ -654,6 +669,16 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
654
669
|
startTime: Date.now(),
|
|
655
670
|
tool: descriptionTool,
|
|
656
671
|
});
|
|
672
|
+
await recordSessionRegistry({
|
|
673
|
+
chatId,
|
|
674
|
+
sessionId: newSessionId,
|
|
675
|
+
tool: descriptionTool,
|
|
676
|
+
chatName: newName,
|
|
677
|
+
turnCount: 0,
|
|
678
|
+
lastContextTokens: 0,
|
|
679
|
+
startTime: Date.now(),
|
|
680
|
+
running: false,
|
|
681
|
+
});
|
|
657
682
|
|
|
658
683
|
setChatAvatar(freshToken, chatId, descriptionTool, "new").catch(() => {});
|
|
659
684
|
|
|
@@ -671,10 +696,88 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
671
696
|
return;
|
|
672
697
|
}
|
|
673
698
|
|
|
699
|
+
// /session <number>:切换到 /sessions 列表中的指定会话
|
|
700
|
+
const sessionMatch = textLower.match(/^\/session\s+(\d+)$/);
|
|
701
|
+
if (sessionMatch) {
|
|
702
|
+
const index = parseInt(sessionMatch[1], 10) - 1;
|
|
703
|
+
logTrace(tid, "BRANCH", { cmd: "/session", index: index + 1 });
|
|
704
|
+
const allSessions = await getAllSessionsStatus();
|
|
705
|
+
if (allSessions.length === 0) {
|
|
706
|
+
await sendCardReply(freshToken, chatId, "/session", "暂无历史会话。", "yellow");
|
|
707
|
+
logTrace(tid, "DONE", { outcome: "session_no_sessions" });
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
if (index < 0 || index >= allSessions.length) {
|
|
711
|
+
await sendCardReply(freshToken, chatId, "/session", `序号超出范围,当前共 ${allSessions.length} 个会话。`, "yellow");
|
|
712
|
+
logTrace(tid, "DONE", { outcome: "session_out_of_range", index: index + 1, total: allSessions.length });
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
const target = allSessions[index];
|
|
716
|
+
|
|
717
|
+
const existing2 = chatSessionMap.get(chatId);
|
|
718
|
+
if (existing2) {
|
|
719
|
+
existing2.stopped = true;
|
|
720
|
+
if (existing2.spinnerTimer) { clearInterval(existing2.spinnerTimer); existing2.spinnerTimer = null; }
|
|
721
|
+
existing2.close();
|
|
722
|
+
const prevTs2 = lastMsgTimestamps.get(chatId);
|
|
723
|
+
if (prevTs2 === undefined || existing2.msgTimestamp > prevTs2) {
|
|
724
|
+
lastMsgTimestamps.set(chatId, existing2.msgTimestamp);
|
|
725
|
+
}
|
|
726
|
+
chatSessionMap.delete(chatId);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
const targetAdapter = getAdapterForTool(target.tool);
|
|
730
|
+
let cwd2: string;
|
|
731
|
+
try {
|
|
732
|
+
const targetInfo = await targetAdapter.getSessionInfo(target.sessionId);
|
|
733
|
+
cwd2 = targetInfo?.cwd ?? (await getDefaultCwd(chatId));
|
|
734
|
+
} catch {
|
|
735
|
+
cwd2 = await getDefaultCwd(chatId);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const descPrefix2 = sessionPrefixForTool(target.tool);
|
|
739
|
+
const newName2 = isUntitledSessionChatName(chatInfo.name)
|
|
740
|
+
? sessionChatName("新会话", cwd2)
|
|
741
|
+
: chatInfo.name;
|
|
742
|
+
await updateChatInfo(freshToken, chatId, newName2, `${descPrefix2} ${target.sessionId}`);
|
|
743
|
+
|
|
744
|
+
sessionInfoMap.set(chatId, {
|
|
745
|
+
sessionId: target.sessionId,
|
|
746
|
+
turnCount: target.turnCount,
|
|
747
|
+
lastContextTokens: 0,
|
|
748
|
+
startTime: Date.now(),
|
|
749
|
+
tool: target.tool,
|
|
750
|
+
});
|
|
751
|
+
await recordSessionRegistry({
|
|
752
|
+
chatId,
|
|
753
|
+
sessionId: target.sessionId,
|
|
754
|
+
tool: target.tool,
|
|
755
|
+
chatName: newName2,
|
|
756
|
+
running: false,
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
setChatAvatar(freshToken, chatId, target.tool, "new").catch(() => {});
|
|
760
|
+
|
|
761
|
+
const targetToolLabel = toolDisplayName(target.tool);
|
|
762
|
+
await sendCardReply(
|
|
763
|
+
freshToken, chatId, `${targetToolLabel} Session Switched`,
|
|
764
|
+
`已切换到 **${targetToolLabel}** 会话。\n\n` +
|
|
765
|
+
`**序号:** ${index + 1}\n` +
|
|
766
|
+
`**Session ID:** ${target.sessionId}\n` +
|
|
767
|
+
`**工作目录:** \`${cwd2}\`\n\n` +
|
|
768
|
+
`直接在这里发消息即可继续对话。`,
|
|
769
|
+
"green"
|
|
770
|
+
);
|
|
771
|
+
|
|
772
|
+
console.log(`[${ts()}] [SESSION] Switched to session ${target.sessionId} (#${index + 1})`);
|
|
773
|
+
logTrace(tid, "DONE", { outcome: "session_switch", sessionId: target.sessionId, index: index + 1, cwd: cwd2 });
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
|
|
674
777
|
// /git <args>:在「当前会话工作目录」执行 git 命令,把输出回发到群里。
|
|
675
778
|
// 注意 cwd 必须取自 adapter.getSessionInfo(会话真实 cwd),而非
|
|
676
779
|
// getDefaultCwd(下一次 /new 才会使用的默认路径)。
|
|
677
|
-
if (
|
|
780
|
+
if (textLower.startsWith("/git ") || textLower === "/git") {
|
|
678
781
|
const args = text === "/git" ? "" : text.slice(5).trim();
|
|
679
782
|
logTrace(tid, "BRANCH", { cmd: "/git", args: args || "(none)" });
|
|
680
783
|
if (!args) {
|
|
@@ -1011,7 +1114,7 @@ async function startBotServiceCore(): Promise<void> {
|
|
|
1011
1114
|
const openId = event.sender?.sender_id?.open_id ?? "";
|
|
1012
1115
|
const chatId = message.chat_id ?? "";
|
|
1013
1116
|
appendChatLog(chatId, openId, text);
|
|
1014
|
-
if (text === "/new" && openId) {
|
|
1117
|
+
if (text.toLowerCase() === "/new" && openId) {
|
|
1015
1118
|
console.log(`[MSG] /new from ${openId}, but local relay does not handle /new yet. Use SDK mode.`);
|
|
1016
1119
|
}
|
|
1017
1120
|
} catch { /* ignore */ }
|
package/src/session.ts
CHANGED
|
@@ -168,6 +168,87 @@ export async function getSessionTool(sessionId: string): Promise<string | null>
|
|
|
168
168
|
return record?.tool ?? null;
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// Conversation session registry for /sessions
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
export const SESSION_REGISTRY_FILE = join(USER_DATA_DIR, "state", "session-registry.json");
|
|
176
|
+
let sessionRegistryFile = SESSION_REGISTRY_FILE;
|
|
177
|
+
|
|
178
|
+
export interface SessionRegistryUpdate {
|
|
179
|
+
chatId: string;
|
|
180
|
+
sessionId: string;
|
|
181
|
+
tool: string;
|
|
182
|
+
chatName?: string;
|
|
183
|
+
turnCount?: number;
|
|
184
|
+
lastContextTokens?: number;
|
|
185
|
+
startTime?: number;
|
|
186
|
+
updatedAt?: number;
|
|
187
|
+
running?: boolean;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
interface SessionRegistryRecord {
|
|
191
|
+
chatId: string;
|
|
192
|
+
sessionId: string;
|
|
193
|
+
tool: string;
|
|
194
|
+
chatName: string;
|
|
195
|
+
turnCount: number;
|
|
196
|
+
lastContextTokens: number;
|
|
197
|
+
startTime: number;
|
|
198
|
+
updatedAt: number;
|
|
199
|
+
running: boolean;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
type SessionRegistryData = Record<string, SessionRegistryRecord>;
|
|
203
|
+
|
|
204
|
+
async function loadSessionRegistry(): Promise<SessionRegistryData> {
|
|
205
|
+
try {
|
|
206
|
+
const raw = await readFile(sessionRegistryFile, "utf-8");
|
|
207
|
+
const parsed = JSON.parse(raw) as SessionRegistryData;
|
|
208
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
209
|
+
} catch {
|
|
210
|
+
return {};
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function saveSessionRegistry(data: SessionRegistryData): Promise<void> {
|
|
215
|
+
try {
|
|
216
|
+
await mkdir(dirname(sessionRegistryFile), { recursive: true });
|
|
217
|
+
await writeFile(sessionRegistryFile, JSON.stringify(data, null, 2), "utf-8");
|
|
218
|
+
} catch (err) {
|
|
219
|
+
console.error(`[${ts()}] Failed to save session-registry.json: ${(err as Error).message}`);
|
|
220
|
+
fileLog.flush();
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export async function recordSessionRegistry(update: SessionRegistryUpdate): Promise<void> {
|
|
225
|
+
const data = await loadSessionRegistry();
|
|
226
|
+
const existing = data[update.chatId];
|
|
227
|
+
const now = update.updatedAt ?? Date.now();
|
|
228
|
+
|
|
229
|
+
data[update.chatId] = {
|
|
230
|
+
chatId: update.chatId,
|
|
231
|
+
sessionId: update.sessionId,
|
|
232
|
+
tool: update.tool,
|
|
233
|
+
chatName: update.chatName ?? existing?.chatName ?? "",
|
|
234
|
+
turnCount: update.turnCount ?? existing?.turnCount ?? 0,
|
|
235
|
+
lastContextTokens: update.lastContextTokens ?? existing?.lastContextTokens ?? 0,
|
|
236
|
+
startTime: update.startTime ?? existing?.startTime ?? now,
|
|
237
|
+
updatedAt: now,
|
|
238
|
+
running: update.running ?? existing?.running ?? false,
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
await saveSessionRegistry(data);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function _setSessionRegistryFileForTest(filePath: string): void {
|
|
245
|
+
sessionRegistryFile = filePath;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function _resetSessionRegistryFileForTest(): void {
|
|
249
|
+
sessionRegistryFile = SESSION_REGISTRY_FILE;
|
|
250
|
+
}
|
|
251
|
+
|
|
171
252
|
// ---------------------------------------------------------------------------
|
|
172
253
|
// accumulateBlockContent — 将 UnifiedBlock 累积到渲染状态(纯函数,可测试)
|
|
173
254
|
// ---------------------------------------------------------------------------
|
|
@@ -385,13 +466,24 @@ export async function resumeAndPrompt(
|
|
|
385
466
|
|
|
386
467
|
const now = Date.now();
|
|
387
468
|
const existingInfo = sessionInfoMap.get(chatId);
|
|
469
|
+
const nextTurnCount = (existingInfo?.turnCount ?? 0) + 1;
|
|
470
|
+
const nextContextTokens = existingInfo?.lastContextTokens ?? 0;
|
|
388
471
|
sessionInfoMap.set(chatId, {
|
|
389
472
|
sessionId,
|
|
390
|
-
turnCount:
|
|
391
|
-
lastContextTokens:
|
|
473
|
+
turnCount: nextTurnCount,
|
|
474
|
+
lastContextTokens: nextContextTokens,
|
|
392
475
|
startTime: now,
|
|
393
476
|
tool,
|
|
394
477
|
});
|
|
478
|
+
await recordSessionRegistry({
|
|
479
|
+
chatId,
|
|
480
|
+
sessionId,
|
|
481
|
+
tool,
|
|
482
|
+
turnCount: nextTurnCount,
|
|
483
|
+
lastContextTokens: nextContextTokens,
|
|
484
|
+
startTime: now,
|
|
485
|
+
running: true,
|
|
486
|
+
});
|
|
395
487
|
|
|
396
488
|
let cardId: string | null = null;
|
|
397
489
|
cardId = await createCardKitCard(token, buildProgressCard("", { showStop: true, headerTitle: "生成中..." })).catch((err) => {
|
|
@@ -512,6 +604,13 @@ export async function resumeAndPrompt(
|
|
|
512
604
|
if (block.type === "compact_boundary" && block.post_tokens) {
|
|
513
605
|
const info = sessionInfoMap.get(chatId);
|
|
514
606
|
if (info) { info.lastContextTokens = block.post_tokens; }
|
|
607
|
+
await recordSessionRegistry({
|
|
608
|
+
chatId,
|
|
609
|
+
sessionId,
|
|
610
|
+
tool,
|
|
611
|
+
lastContextTokens: block.post_tokens,
|
|
612
|
+
running: true,
|
|
613
|
+
});
|
|
515
614
|
}
|
|
516
615
|
}
|
|
517
616
|
}
|
|
@@ -561,6 +660,16 @@ export async function resumeAndPrompt(
|
|
|
561
660
|
const finalReply = pickFinalReply(state).trim();
|
|
562
661
|
|
|
563
662
|
if (wasStopped) {
|
|
663
|
+
const finalInfo = sessionInfoMap.get(chatId);
|
|
664
|
+
await recordSessionRegistry({
|
|
665
|
+
chatId,
|
|
666
|
+
sessionId,
|
|
667
|
+
tool,
|
|
668
|
+
turnCount: finalInfo?.turnCount ?? nextTurnCount,
|
|
669
|
+
lastContextTokens: finalInfo?.lastContextTokens ?? nextContextTokens,
|
|
670
|
+
startTime: finalInfo?.startTime ?? now,
|
|
671
|
+
running: false,
|
|
672
|
+
});
|
|
564
673
|
if (finalReply) {
|
|
565
674
|
await sendTextReply(token, chatId, finalReply).catch((err) =>
|
|
566
675
|
console.error(`[${ts()}] Failed to send partial text: ${(err as Error).message}`)
|
|
@@ -597,6 +706,16 @@ export async function resumeAndPrompt(
|
|
|
597
706
|
}
|
|
598
707
|
|
|
599
708
|
console.log(`[${ts()}] Session ${sessionId} stream complete (content chunks: ${state.chunkCount})`);
|
|
709
|
+
const finalInfo = sessionInfoMap.get(chatId);
|
|
710
|
+
await recordSessionRegistry({
|
|
711
|
+
chatId,
|
|
712
|
+
sessionId,
|
|
713
|
+
tool,
|
|
714
|
+
turnCount: finalInfo?.turnCount ?? nextTurnCount,
|
|
715
|
+
lastContextTokens: finalInfo?.lastContextTokens ?? nextContextTokens,
|
|
716
|
+
startTime: finalInfo?.startTime ?? now,
|
|
717
|
+
running: false,
|
|
718
|
+
});
|
|
600
719
|
if (tid) logTrace(tid, "SESSION_END", { sessionId, chunks: state.chunkCount, finalTextLen: finalReply.length });
|
|
601
720
|
}
|
|
602
721
|
|
|
@@ -681,6 +800,7 @@ export async function getSessionStatus(chatId: string): Promise<SessionStatus |
|
|
|
681
800
|
export interface SessionsListEntry {
|
|
682
801
|
chatId: string;
|
|
683
802
|
sessionId: string;
|
|
803
|
+
chatName: string;
|
|
684
804
|
active: boolean;
|
|
685
805
|
turnCount: number;
|
|
686
806
|
startTime: number;
|
|
@@ -691,16 +811,20 @@ export interface SessionsListEntry {
|
|
|
691
811
|
}
|
|
692
812
|
|
|
693
813
|
export async function getAllSessionsStatus(): Promise<SessionsListEntry[]> {
|
|
694
|
-
const
|
|
814
|
+
const registry = await loadSessionRegistry();
|
|
815
|
+
const entries = Object.values(registry)
|
|
816
|
+
.filter((record) => record.chatId && record.sessionId && record.tool)
|
|
817
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
818
|
+
.slice(0, 20);
|
|
695
819
|
// 并行解析每个 session 的 model/effort(cursor 涉及异步 store IO)
|
|
696
820
|
return Promise.all(
|
|
697
|
-
entries.map(async (
|
|
698
|
-
const active = chatSessionMap.get(chatId);
|
|
821
|
+
entries.map(async (info) => {
|
|
699
822
|
const { model, effort } = await resolveModelEffort(info.tool, info.sessionId);
|
|
700
823
|
return {
|
|
701
|
-
chatId,
|
|
824
|
+
chatId: info.chatId,
|
|
702
825
|
sessionId: info.sessionId,
|
|
703
|
-
|
|
826
|
+
chatName: info.chatName || "",
|
|
827
|
+
active: info.running === true,
|
|
704
828
|
turnCount: info.turnCount,
|
|
705
829
|
startTime: info.startTime,
|
|
706
830
|
model,
|
package/src/shared.ts
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
import { execSync } from "node:child_process";
|
|
2
|
-
import {
|
|
3
|
-
appendFileSync,
|
|
4
|
-
existsSync,
|
|
5
|
-
mkdirSync,
|
|
6
|
-
readFileSync,
|
|
7
|
-
unlinkSync,
|
|
8
|
-
writeFileSync,
|
|
9
|
-
} from "node:fs";
|
|
10
|
-
import { createServer } from "node:http";
|
|
11
|
-
import { homedir } from "node:os";
|
|
12
|
-
import { join } from "node:path";
|
|
13
|
-
import { inspect } from "node:util";
|
|
14
|
-
import { WebSocketServer, WebSocket } from "ws";
|
|
2
|
+
import {
|
|
3
|
+
appendFileSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
unlinkSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from "node:fs";
|
|
10
|
+
import { createServer } from "node:http";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { inspect } from "node:util";
|
|
14
|
+
import { WebSocketServer, WebSocket } from "ws";
|
|
15
15
|
|
|
16
16
|
import { printServiceDidNotStart } from "./exit-banner.ts";
|
|
17
17
|
|
|
@@ -355,56 +355,56 @@ export function installCrashLogging(opts: CrashHandlersOptions = {}): InstallCra
|
|
|
355
355
|
// 文件日志:同时输出到控制台和日志文件
|
|
356
356
|
// ---------------------------------------------------------------------------
|
|
357
357
|
|
|
358
|
-
export function setupFileLogging(logDir: string, prefix: string): { logPath: string; flush: () => void } {
|
|
359
|
-
mkdirSync(logDir, { recursive: true });
|
|
360
|
-
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
361
|
-
const logPath = join(logDir, `${prefix}-${ts}.log`);
|
|
362
|
-
writeFileSync(logPath, "", { flag: "a", encoding: "utf8" });
|
|
363
|
-
const origConsoleLog = console.log.bind(console);
|
|
364
|
-
const origConsoleError = console.error.bind(console);
|
|
365
|
-
const formatArg = (arg: unknown): string => {
|
|
366
|
-
if (typeof arg === "string") return arg;
|
|
367
|
-
if (arg instanceof Error) return arg.stack ?? arg.message;
|
|
368
|
-
return inspect(arg, { depth: 6, breakLength: Infinity, maxArrayLength: 200 });
|
|
369
|
-
};
|
|
370
|
-
const writeLine = (level: string, args: unknown[]) => {
|
|
371
|
-
try {
|
|
372
|
-
const line = args.map(formatArg).join(" ");
|
|
373
|
-
appendFileSync(logPath, `[${new Date().toISOString()}] [${level}] ${line}\n`, "utf8");
|
|
374
|
-
} catch (err) {
|
|
375
|
-
try {
|
|
376
|
-
appendStartupTrace("fileLog write failed", { level, error: err instanceof Error ? err.message : String(err) });
|
|
377
|
-
} catch {
|
|
378
|
-
// 日志系统自身不能影响主流程
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
};
|
|
382
|
-
console.log = (...args: unknown[]) => {
|
|
383
|
-
writeLine("LOG", args);
|
|
384
|
-
try {
|
|
385
|
-
origConsoleLog(...args);
|
|
386
|
-
} catch {
|
|
387
|
-
// 控制台输出失败也不能拖垮服务
|
|
388
|
-
}
|
|
389
|
-
};
|
|
390
|
-
console.error = (...args: unknown[]) => {
|
|
391
|
-
writeLine("ERR", args);
|
|
392
|
-
try {
|
|
393
|
-
origConsoleError(...args);
|
|
394
|
-
} catch {
|
|
395
|
-
// 控制台输出失败也不能拖垮服务
|
|
396
|
-
}
|
|
397
|
-
};
|
|
398
|
-
const flush = () => {
|
|
399
|
-
try {
|
|
400
|
-
appendFileSync(logPath, "", "utf8");
|
|
401
|
-
} catch (err) {
|
|
402
|
-
appendStartupTrace("fileLog flush failed", { error: err instanceof Error ? err.message : String(err) });
|
|
403
|
-
}
|
|
404
|
-
};
|
|
405
|
-
origConsoleLog(`Log file: ${logPath}`);
|
|
406
|
-
return { logPath, flush };
|
|
407
|
-
}
|
|
358
|
+
export function setupFileLogging(logDir: string, prefix: string): { logPath: string; flush: () => void } {
|
|
359
|
+
mkdirSync(logDir, { recursive: true });
|
|
360
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
361
|
+
const logPath = join(logDir, `${prefix}-${ts}.log`);
|
|
362
|
+
writeFileSync(logPath, "", { flag: "a", encoding: "utf8" });
|
|
363
|
+
const origConsoleLog = console.log.bind(console);
|
|
364
|
+
const origConsoleError = console.error.bind(console);
|
|
365
|
+
const formatArg = (arg: unknown): string => {
|
|
366
|
+
if (typeof arg === "string") return arg;
|
|
367
|
+
if (arg instanceof Error) return arg.stack ?? arg.message;
|
|
368
|
+
return inspect(arg, { depth: 6, breakLength: Infinity, maxArrayLength: 200 });
|
|
369
|
+
};
|
|
370
|
+
const writeLine = (level: string, args: unknown[]) => {
|
|
371
|
+
try {
|
|
372
|
+
const line = args.map(formatArg).join(" ");
|
|
373
|
+
appendFileSync(logPath, `[${new Date().toISOString()}] [${level}] ${line}\n`, "utf8");
|
|
374
|
+
} catch (err) {
|
|
375
|
+
try {
|
|
376
|
+
appendStartupTrace("fileLog write failed", { level, error: err instanceof Error ? err.message : String(err) });
|
|
377
|
+
} catch {
|
|
378
|
+
// 日志系统自身不能影响主流程
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
console.log = (...args: unknown[]) => {
|
|
383
|
+
writeLine("LOG", args);
|
|
384
|
+
try {
|
|
385
|
+
origConsoleLog(...args);
|
|
386
|
+
} catch {
|
|
387
|
+
// 控制台输出失败也不能拖垮服务
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
console.error = (...args: unknown[]) => {
|
|
391
|
+
writeLine("ERR", args);
|
|
392
|
+
try {
|
|
393
|
+
origConsoleError(...args);
|
|
394
|
+
} catch {
|
|
395
|
+
// 控制台输出失败也不能拖垮服务
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
const flush = () => {
|
|
399
|
+
try {
|
|
400
|
+
appendFileSync(logPath, "", "utf8");
|
|
401
|
+
} catch (err) {
|
|
402
|
+
appendStartupTrace("fileLog flush failed", { error: err instanceof Error ? err.message : String(err) });
|
|
403
|
+
}
|
|
404
|
+
};
|
|
405
|
+
origConsoleLog(`Log file: ${logPath}`);
|
|
406
|
+
return { logPath, flush };
|
|
407
|
+
}
|
|
408
408
|
|
|
409
409
|
// ---------------------------------------------------------------------------
|
|
410
410
|
// 本地 WebSocket 中继服务器(同一端口、多客户端广播)
|