chatccc 0.2.41 → 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 -222
- package/package.json +59 -59
- 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
|
@@ -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 */ }
|