chatccc 0.2.42 → 0.2.44
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/config.sample.json +1 -0
- package/demo/ilink_echo_probe.ts +222 -222
- package/package.json +59 -59
- package/src/__tests__/crash-logging.test.ts +62 -62
- package/src/config.ts +6 -0
- package/src/index.ts +31 -15
- package/src/session.ts +5 -0
- package/src/shared.ts +63 -63
package/config.sample.json
CHANGED
package/demo/ilink_echo_probe.ts
CHANGED
|
@@ -1,222 +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
|
-
});
|
|
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,59 +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
|
-
"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
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "chatccc",
|
|
3
|
+
"version": "0.2.44",
|
|
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
|
+
}
|
|
@@ -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
|
+
});
|
package/src/config.ts
CHANGED
|
@@ -98,6 +98,8 @@ export interface AppConfig {
|
|
|
98
98
|
feishu: FeishuConfig;
|
|
99
99
|
port: number;
|
|
100
100
|
gitTimeoutSeconds: number;
|
|
101
|
+
/** 若为 false,AI 生成过程中用户发送消息不会打断,须先点「停止」再发送新消息 */
|
|
102
|
+
allowInterrupt: boolean;
|
|
101
103
|
claude: ClaudeConfig;
|
|
102
104
|
cursor: CursorConfig;
|
|
103
105
|
codex: CodexConfig;
|
|
@@ -287,6 +289,7 @@ function loadConfig(): AppConfig {
|
|
|
287
289
|
feishu: { appId: "", appSecret: "" },
|
|
288
290
|
port: 18080,
|
|
289
291
|
gitTimeoutSeconds: 180,
|
|
292
|
+
allowInterrupt: false,
|
|
290
293
|
claude: { enabled: false, defaultAgent: true, model: "", effort: "", apiKey: "", baseUrl: "" },
|
|
291
294
|
cursor: { enabled: false, defaultAgent: false, path: "", model: "claude-opus-4-7-max" },
|
|
292
295
|
codex: { enabled: false, defaultAgent: false, path: "", model: "", effort: "" },
|
|
@@ -403,6 +406,7 @@ function loadConfig(): AppConfig {
|
|
|
403
406
|
},
|
|
404
407
|
port: typeof parsed.port === "number" ? parsed.port : 18080,
|
|
405
408
|
gitTimeoutSeconds: typeof parsed.gitTimeoutSeconds === "number" ? parsed.gitTimeoutSeconds : 180,
|
|
409
|
+
allowInterrupt: typeof parsed.allowInterrupt === "boolean" ? parsed.allowInterrupt : false,
|
|
406
410
|
claude: {
|
|
407
411
|
enabled: claudeEnabled,
|
|
408
412
|
defaultAgent: defaultTool === "claude",
|
|
@@ -476,6 +480,7 @@ export let CLAUDE_BASE_URL = config.claude.baseUrl;
|
|
|
476
480
|
|
|
477
481
|
export let GIT_TIMEOUT_SECONDS = config.gitTimeoutSeconds;
|
|
478
482
|
export let GIT_TIMEOUT_MS = GIT_TIMEOUT_SECONDS * 1000;
|
|
483
|
+
export let ALLOW_INTERRUPT = config.allowInterrupt;
|
|
479
484
|
|
|
480
485
|
/** 探测 cursor-agent 安装路径(优先配置,其次 LocalAppData,最后默认 agent) */
|
|
481
486
|
function detectCursorAgent(): string {
|
|
@@ -540,6 +545,7 @@ export function applyLoadedConfig(next: AppConfig): void {
|
|
|
540
545
|
CLAUDE_BASE_URL = next.claude.baseUrl;
|
|
541
546
|
GIT_TIMEOUT_SECONDS = next.gitTimeoutSeconds;
|
|
542
547
|
GIT_TIMEOUT_MS = GIT_TIMEOUT_SECONDS * 1000;
|
|
548
|
+
ALLOW_INTERRUPT = next.allowInterrupt;
|
|
543
549
|
CURSOR_AGENT_COMMAND = detectCursorAgent();
|
|
544
550
|
CURSOR_AGENT_ARGS = resolveCursorAgentArgs();
|
|
545
551
|
}
|
package/src/index.ts
CHANGED
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
CLAUDE_EFFORT,
|
|
42
42
|
CLAUDE_MODEL,
|
|
43
43
|
GIT_TIMEOUT_MS,
|
|
44
|
+
ALLOW_INTERRUPT,
|
|
44
45
|
reloadConfigFromDisk,
|
|
45
46
|
anthropicConfigDisplay,
|
|
46
47
|
LOCAL_RELAY_URL,
|
|
@@ -574,6 +575,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
574
575
|
const running = chatSessionMap.get(chatId);
|
|
575
576
|
const isActive = running && !running.stopped;
|
|
576
577
|
const statusText = [
|
|
578
|
+
`**群名:** ${status?.chatName || "—"}`,
|
|
577
579
|
`**Session ID:** \`${status?.sessionId ?? sessionId}\``,
|
|
578
580
|
`**工具:** ${toolLabel}`,
|
|
579
581
|
`**状态:** ${isActive ? "🟢 运行中" : "⚪ 空闲"}`,
|
|
@@ -606,7 +608,8 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
606
608
|
logTrace(tid, "BRANCH", { cmd: "/sessions" });
|
|
607
609
|
const allSessions = await getAllSessionsStatus();
|
|
608
610
|
const now = Date.now();
|
|
609
|
-
const
|
|
611
|
+
const others = allSessions.filter(s => s.chatId !== chatId);
|
|
612
|
+
const cardData = others.map(s => ({
|
|
610
613
|
sessionId: s.sessionId,
|
|
611
614
|
chatName: s.chatName,
|
|
612
615
|
active: s.active,
|
|
@@ -617,8 +620,8 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
617
620
|
}));
|
|
618
621
|
const card = buildSessionsCard(cardData);
|
|
619
622
|
const ok = await sendRawCard(freshToken, chatId, card);
|
|
620
|
-
console.log(`[${ts()}] [SESSIONS] card sent, ok=${ok}, count=${
|
|
621
|
-
logTrace(tid, "DONE", { outcome: "sessions", ok, count:
|
|
623
|
+
console.log(`[${ts()}] [SESSIONS] card sent, ok=${ok}, count=${others.length}`);
|
|
624
|
+
logTrace(tid, "DONE", { outcome: "sessions", ok, count: others.length });
|
|
622
625
|
return;
|
|
623
626
|
}
|
|
624
627
|
|
|
@@ -656,9 +659,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
656
659
|
}
|
|
657
660
|
|
|
658
661
|
const descPrefix = sessionPrefixForTool(descriptionTool);
|
|
659
|
-
const newName =
|
|
660
|
-
? sessionChatName("新会话", cwd)
|
|
661
|
-
: chatInfo.name;
|
|
662
|
+
const newName = sessionChatName("新会话", cwd);
|
|
662
663
|
await updateChatInfo(freshToken, chatId, newName, `${descPrefix} ${newSessionId}`);
|
|
663
664
|
console.log(`[${ts()}] [FORGET] Group updated: name="${newName}" desc="${descPrefix} ${newSessionId}"`);
|
|
664
665
|
|
|
@@ -702,17 +703,22 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
702
703
|
const index = parseInt(sessionMatch[1], 10) - 1;
|
|
703
704
|
logTrace(tid, "BRANCH", { cmd: "/session", index: index + 1 });
|
|
704
705
|
const allSessions = await getAllSessionsStatus();
|
|
705
|
-
|
|
706
|
+
// 与 buildSessionsCard 保持一致的排序:Claude Code → Cursor → Codex,组内保持 updatedAt 降序
|
|
707
|
+
const claudeOrdered = allSessions.filter(s => s.tool !== "cursor" && s.tool !== "codex");
|
|
708
|
+
const cursorOrdered = allSessions.filter(s => s.tool === "cursor");
|
|
709
|
+
const codexOrdered = allSessions.filter(s => s.tool === "codex");
|
|
710
|
+
const ordered = [...claudeOrdered, ...cursorOrdered, ...codexOrdered].filter(s => s.chatId !== chatId);
|
|
711
|
+
if (ordered.length === 0) {
|
|
706
712
|
await sendCardReply(freshToken, chatId, "/session", "暂无历史会话。", "yellow");
|
|
707
713
|
logTrace(tid, "DONE", { outcome: "session_no_sessions" });
|
|
708
714
|
return;
|
|
709
715
|
}
|
|
710
|
-
if (index < 0 || index >=
|
|
711
|
-
await sendCardReply(freshToken, chatId, "/session", `序号超出范围,当前共 ${
|
|
712
|
-
logTrace(tid, "DONE", { outcome: "session_out_of_range", index: index + 1, total:
|
|
716
|
+
if (index < 0 || index >= ordered.length) {
|
|
717
|
+
await sendCardReply(freshToken, chatId, "/session", `序号超出范围,当前共 ${ordered.length} 个会话。`, "yellow");
|
|
718
|
+
logTrace(tid, "DONE", { outcome: "session_out_of_range", index: index + 1, total: ordered.length });
|
|
713
719
|
return;
|
|
714
720
|
}
|
|
715
|
-
const target =
|
|
721
|
+
const target = ordered[index];
|
|
716
722
|
|
|
717
723
|
const existing2 = chatSessionMap.get(chatId);
|
|
718
724
|
if (existing2) {
|
|
@@ -736,10 +742,9 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
736
742
|
}
|
|
737
743
|
|
|
738
744
|
const descPrefix2 = sessionPrefixForTool(target.tool);
|
|
739
|
-
const newName2 =
|
|
740
|
-
? sessionChatName("新会话", cwd2)
|
|
741
|
-
: chatInfo.name;
|
|
745
|
+
const newName2 = target.chatName || sessionChatName("新会话", cwd2);
|
|
742
746
|
await updateChatInfo(freshToken, chatId, newName2, `${descPrefix2} ${target.sessionId}`);
|
|
747
|
+
console.log(`[${ts()}] [SESSION] Switched to session ${target.sessionId} (#${index + 1}), name="${newName2}"`);
|
|
743
748
|
|
|
744
749
|
sessionInfoMap.set(chatId, {
|
|
745
750
|
sessionId: target.sessionId,
|
|
@@ -769,7 +774,6 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
769
774
|
"green"
|
|
770
775
|
);
|
|
771
776
|
|
|
772
|
-
console.log(`[${ts()}] [SESSION] Switched to session ${target.sessionId} (#${index + 1})`);
|
|
773
777
|
logTrace(tid, "DONE", { outcome: "session_switch", sessionId: target.sessionId, index: index + 1, cwd: cwd2 });
|
|
774
778
|
return;
|
|
775
779
|
}
|
|
@@ -835,6 +839,18 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
835
839
|
console.log(`[${ts()}] [SKIP] Older message (${msgTimestamp} <= ${existing.msgTimestamp}), ignoring`);
|
|
836
840
|
return;
|
|
837
841
|
}
|
|
842
|
+
|
|
843
|
+
if (!ALLOW_INTERRUPT) {
|
|
844
|
+
logTrace(tid, "BLOCKED", { outcome: "interrupt_disabled", sessionId });
|
|
845
|
+
console.log(`[${ts()}] [BLOCKED] allowInterrupt=false, ignoring message during generation. Hint sent to user.`);
|
|
846
|
+
await sendCardReply(
|
|
847
|
+
freshToken, chatId, "生成中",
|
|
848
|
+
"AI 正在生成回复中,Agent 不会遗忘当前进度,停止后仍可从断点继续。\n请先点击卡片上的「停止」按钮,再发送新消息。",
|
|
849
|
+
"yellow"
|
|
850
|
+
);
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
|
|
838
854
|
existing.stopped = true;
|
|
839
855
|
if (existing.spinnerTimer) { clearInterval(existing.spinnerTimer); existing.spinnerTimer = null; }
|
|
840
856
|
existing.close();
|
package/src/session.ts
CHANGED
|
@@ -739,6 +739,7 @@ export const UNKNOWN_MODEL_PLACEHOLDER = "—";
|
|
|
739
739
|
|
|
740
740
|
export interface SessionStatus {
|
|
741
741
|
sessionId: string;
|
|
742
|
+
chatName: string;
|
|
742
743
|
running: boolean;
|
|
743
744
|
turnCount: number;
|
|
744
745
|
lastContextTokens: number;
|
|
@@ -785,8 +786,12 @@ export async function getSessionStatus(chatId: string): Promise<SessionStatus |
|
|
|
785
786
|
const active = chatSessionMap.get(chatId);
|
|
786
787
|
const { model, effort } = await resolveModelEffort(info.tool, info.sessionId);
|
|
787
788
|
|
|
789
|
+
const registry = await loadSessionRegistry();
|
|
790
|
+
const chatName = registry[chatId]?.chatName ?? "";
|
|
791
|
+
|
|
788
792
|
return {
|
|
789
793
|
sessionId: info.sessionId,
|
|
794
|
+
chatName,
|
|
790
795
|
running: active !== undefined && !active.stopped,
|
|
791
796
|
turnCount: info.turnCount,
|
|
792
797
|
lastContextTokens: info.lastContextTokens,
|
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 中继服务器(同一端口、多客户端广播)
|