chatccc 0.2.45 → 0.2.47
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/im-skills/feishu-skill/receive-send-file.md +3 -11
- package/im-skills/feishu-skill/receive-send-image.md +3 -11
- package/im-skills/feishu-skill/send-file.mjs +5 -6
- package/im-skills/feishu-skill/send-image.mjs +5 -6
- package/im-skills/feishu-skill/skill.md +2 -3
- package/package.json +1 -1
- package/src/__tests__/agent-image-rpc.test.ts +17 -52
- package/src/__tests__/session.test.ts +49 -3
- package/src/agent-file-rpc.ts +108 -106
- package/src/agent-image-rpc.ts +78 -113
- package/src/index.ts +162 -109
- package/src/session-chat-binding.ts +87 -0
- package/src/session.ts +329 -264
- package/src/stream-state.ts +94 -0
- package/src/agent-grants-rpc.ts +0 -71
package/src/agent-image-rpc.ts
CHANGED
|
@@ -1,123 +1,30 @@
|
|
|
1
|
-
import { randomBytes } from "node:crypto";
|
|
2
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
3
2
|
import { extname, isAbsolute, resolve } from "node:path";
|
|
4
3
|
import { stat } from "node:fs/promises";
|
|
5
4
|
|
|
6
5
|
import { getTenantAccessToken, sendImageReply, sendTextReply } from "./feishu-platform.ts";
|
|
7
6
|
import { ts } from "./config.ts";
|
|
8
|
-
import { logTrace } from "./trace.ts";
|
|
9
7
|
import { readUtf8JsonBody } from "./agent-rpc-body.ts";
|
|
8
|
+
import { getAdapterForTool } from "./session.ts";
|
|
9
|
+
import { getChatsForSession } from "./session-chat-binding.ts";
|
|
10
10
|
|
|
11
11
|
export const AGENT_SEND_IMAGE_PATH = "/api/agent/send-image";
|
|
12
12
|
|
|
13
|
-
const DEFAULT_GRANT_TTL_MS = 30 * 60 * 1000;
|
|
14
13
|
const MAX_REQUEST_BYTES = 64 * 1024;
|
|
15
14
|
const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
|
16
15
|
const ALLOWED_IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"]);
|
|
17
16
|
|
|
18
|
-
export interface AgentImageGrant {
|
|
19
|
-
token: string;
|
|
20
|
-
url: string;
|
|
21
|
-
chatId: string;
|
|
22
|
-
sessionId: string;
|
|
23
|
-
cwd: string;
|
|
24
|
-
expiresAt: number;
|
|
25
|
-
traceId: string;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
interface CreateAgentImageGrantInput {
|
|
29
|
-
chatId: string;
|
|
30
|
-
sessionId: string;
|
|
31
|
-
cwd: string;
|
|
32
|
-
port: number;
|
|
33
|
-
traceId?: string;
|
|
34
|
-
nowMs?: number;
|
|
35
|
-
ttlMs?: number;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const imageGrants = new Map<string, AgentImageGrant>();
|
|
39
|
-
|
|
40
|
-
function createToken(): string {
|
|
41
|
-
return randomBytes(24).toString("base64url");
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export function createAgentImageGrant(input: CreateAgentImageGrantInput): AgentImageGrant {
|
|
45
|
-
const now = input.nowMs ?? Date.now();
|
|
46
|
-
const token = createToken();
|
|
47
|
-
const grant: AgentImageGrant = {
|
|
48
|
-
token,
|
|
49
|
-
url: `http://127.0.0.1:${input.port}${AGENT_SEND_IMAGE_PATH}`,
|
|
50
|
-
chatId: input.chatId,
|
|
51
|
-
sessionId: input.sessionId,
|
|
52
|
-
cwd: input.cwd,
|
|
53
|
-
expiresAt: now + (input.ttlMs ?? DEFAULT_GRANT_TTL_MS),
|
|
54
|
-
traceId: input.traceId ?? "",
|
|
55
|
-
};
|
|
56
|
-
imageGrants.set(token, grant);
|
|
57
|
-
if (grant.traceId) logTrace(grant.traceId, "IMAGE_GRANT_CREATED", { sessionId: grant.sessionId, ttlMs: input.ttlMs ?? DEFAULT_GRANT_TTL_MS });
|
|
58
|
-
return grant;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export function revokeAgentImageGrant(token: string): void {
|
|
62
|
-
const grant = imageGrants.get(token);
|
|
63
|
-
if (grant?.traceId) logTrace(grant.traceId, "IMAGE_GRANT_REVOKED", {});
|
|
64
|
-
imageGrants.delete(token);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export function getAgentImageGrantFromAuthorization(
|
|
68
|
-
authorization: string | undefined,
|
|
69
|
-
nowMs = Date.now(),
|
|
70
|
-
): AgentImageGrant | null {
|
|
71
|
-
const match = (authorization ?? "").match(/^Bearer\s+(.+)$/i);
|
|
72
|
-
if (!match) return null;
|
|
73
|
-
const grant = imageGrants.get(match[1]);
|
|
74
|
-
if (!grant) return null;
|
|
75
|
-
if (grant.expiresAt <= nowMs) {
|
|
76
|
-
imageGrants.delete(match[1]);
|
|
77
|
-
return null;
|
|
78
|
-
}
|
|
79
|
-
return grant;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export function buildAgentImageCapabilityPrompt(input: {
|
|
83
|
-
url: string;
|
|
84
|
-
token: string;
|
|
85
|
-
cwd?: string;
|
|
86
|
-
}): string {
|
|
87
|
-
const lines = [
|
|
88
|
-
"[ChatCCC local capability: send image]",
|
|
89
|
-
"You can send an image to the current Feishu chat in real time by calling this local endpoint.",
|
|
90
|
-
"",
|
|
91
|
-
`POST ${input.url}`,
|
|
92
|
-
`Authorization: Bearer ${input.token}`,
|
|
93
|
-
"Content-Type: application/json; charset=utf-8",
|
|
94
|
-
"",
|
|
95
|
-
'Body: {"path":"absolute image file path","caption":"optional caption"}',
|
|
96
|
-
"",
|
|
97
|
-
"Rules:",
|
|
98
|
-
"- Save or choose a local image file first, then call the endpoint.",
|
|
99
|
-
"- Use an absolute local file path. Do not call Feishu Open Platform directly.",
|
|
100
|
-
"- Request body must be UTF-8 encoded JSON bytes; caption supports Unicode text, including Chinese.",
|
|
101
|
-
"- Only call this endpoint when the user asked for an image or when an image is useful to the answer.",
|
|
102
|
-
"[/ChatCCC local capability: send image]",
|
|
103
|
-
];
|
|
104
|
-
if (input.cwd) {
|
|
105
|
-
lines.splice(2, 0, `Current working directory: ${input.cwd}`);
|
|
106
|
-
}
|
|
107
|
-
return lines.join("\n");
|
|
108
|
-
}
|
|
109
|
-
|
|
110
17
|
function jsonReply(res: ServerResponse, status: number, data: unknown): void {
|
|
111
18
|
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
|
112
19
|
res.end(JSON.stringify(data));
|
|
113
20
|
}
|
|
114
21
|
|
|
115
|
-
async function resolveAndValidateImagePath(
|
|
22
|
+
async function resolveAndValidateImagePath(cwd: string, rawPath: unknown): Promise<string> {
|
|
116
23
|
if (typeof rawPath !== "string" || rawPath.trim() === "") {
|
|
117
24
|
throw new Error("path must be a non-empty string");
|
|
118
25
|
}
|
|
119
26
|
|
|
120
|
-
const sessionRoot = resolve(
|
|
27
|
+
const sessionRoot = resolve(cwd);
|
|
121
28
|
const imagePath = isAbsolute(rawPath)
|
|
122
29
|
? resolve(rawPath)
|
|
123
30
|
: resolve(sessionRoot, rawPath);
|
|
@@ -146,43 +53,101 @@ export async function handleAgentImageRequest(
|
|
|
146
53
|
return true;
|
|
147
54
|
}
|
|
148
55
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
56
|
+
let payload: { session_id?: unknown; path?: unknown; caption?: unknown };
|
|
57
|
+
try {
|
|
58
|
+
payload = await readUtf8JsonBody(req, MAX_REQUEST_BYTES);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
jsonReply(res, 400, { ok: false, error: (err as Error).message || "Invalid JSON" });
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const sessionId = typeof payload.session_id === "string" ? payload.session_id : "";
|
|
65
|
+
if (!sessionId) {
|
|
66
|
+
jsonReply(res, 400, { ok: false, error: "Missing session_id" });
|
|
152
67
|
return true;
|
|
153
68
|
}
|
|
154
|
-
const tid = grant.traceId;
|
|
155
69
|
|
|
156
|
-
|
|
70
|
+
// 获取 cwd 以校验路径
|
|
71
|
+
let cwd: string;
|
|
157
72
|
try {
|
|
158
|
-
|
|
73
|
+
const { getSessionTool } = await import("./session.ts");
|
|
74
|
+
const tool = await getSessionTool(sessionId);
|
|
75
|
+
const adapter = getAdapterForTool(tool ?? "claude");
|
|
76
|
+
const info = await adapter.getSessionInfo(sessionId);
|
|
77
|
+
if (!info?.cwd) {
|
|
78
|
+
jsonReply(res, 400, { ok: false, error: "Cannot determine cwd for session" });
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
cwd = info.cwd;
|
|
159
82
|
} catch (err) {
|
|
160
|
-
|
|
161
|
-
jsonReply(res, 400, { ok: false, error: (err as Error).message || "Invalid JSON" });
|
|
83
|
+
jsonReply(res, 500, { ok: false, error: `Failed to get session info: ${(err as Error).message}` });
|
|
162
84
|
return true;
|
|
163
85
|
}
|
|
164
86
|
|
|
165
87
|
let imagePath: string;
|
|
166
88
|
try {
|
|
167
|
-
imagePath = await resolveAndValidateImagePath(
|
|
89
|
+
imagePath = await resolveAndValidateImagePath(cwd, payload.path);
|
|
168
90
|
} catch (err) {
|
|
169
|
-
if (tid) logTrace(tid, "IMAGE_REQ", { outcome: "invalid_path", path: String(payload.path), error: (err as Error).message });
|
|
170
91
|
jsonReply(res, 400, { ok: false, error: (err as Error).message });
|
|
171
92
|
return true;
|
|
172
93
|
}
|
|
173
94
|
|
|
95
|
+
// 发送到所有绑定该 session 的群
|
|
96
|
+
const chatIds = getChatsForSession(sessionId);
|
|
97
|
+
if (chatIds.length === 0) {
|
|
98
|
+
jsonReply(res, 404, { ok: false, error: "No chats bound to this session" });
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
|
|
174
102
|
try {
|
|
175
103
|
const token = await getTenantAccessToken();
|
|
176
104
|
const caption = typeof payload.caption === "string" ? payload.caption.trim() : "";
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
105
|
+
let sentCount = 0;
|
|
106
|
+
for (const cid of chatIds) {
|
|
107
|
+
try {
|
|
108
|
+
await sendImageReply(token, cid, imagePath);
|
|
109
|
+
if (caption) await sendTextReply(token, cid, caption);
|
|
110
|
+
sentCount++;
|
|
111
|
+
} catch (err) {
|
|
112
|
+
console.error(`[${ts()}] [AGENT-IMAGE] send to ${cid} failed: ${(err as Error).message}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
console.log(`[${ts()}] [AGENT-IMAGE] sent image to ${sentCount}/${chatIds.length} chats, session=${sessionId} path=${imagePath}`);
|
|
116
|
+
jsonReply(res, 200, { ok: true, sentTo: sentCount, total: chatIds.length });
|
|
182
117
|
} catch (err) {
|
|
183
|
-
if (tid) logTrace(tid, "IMAGE_REQ", { outcome: "send_failed", path: imagePath!, error: (err as Error).message });
|
|
184
118
|
console.error(`[${ts()}] [AGENT-IMAGE] send failed: ${(err as Error).message}`);
|
|
185
119
|
jsonReply(res, 500, { ok: false, error: (err as Error).message });
|
|
186
120
|
}
|
|
187
121
|
return true;
|
|
188
122
|
}
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// 兼容旧版 buildAgentImageCapabilityPrompt(供 im-skills 使用)
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
export function buildAgentImageCapabilityPrompt(input: {
|
|
129
|
+
url: string;
|
|
130
|
+
sessionId?: string;
|
|
131
|
+
cwd?: string;
|
|
132
|
+
}): string {
|
|
133
|
+
const lines = [
|
|
134
|
+
"[ChatCCC local capability: send image]",
|
|
135
|
+
"You can send an image to all chats bound to this session by calling this local endpoint.",
|
|
136
|
+
"",
|
|
137
|
+
`POST ${input.url}`,
|
|
138
|
+
"Content-Type: application/json; charset=utf-8",
|
|
139
|
+
"",
|
|
140
|
+
`Body: {"session_id":"${input.sessionId ?? "YOUR_SESSION_ID"}","path":"absolute image file path","caption":"optional caption"}`,
|
|
141
|
+
"",
|
|
142
|
+
"Rules:",
|
|
143
|
+
"- Save or choose a local image file first, then call the endpoint.",
|
|
144
|
+
"- Use an absolute local file path. Do not call Feishu Open Platform directly.",
|
|
145
|
+
"- Request body must be UTF-8 encoded JSON bytes; caption supports Unicode text, including Chinese.",
|
|
146
|
+
"- Only call this endpoint when the user asked for an image or when an image is useful to the answer.",
|
|
147
|
+
"[/ChatCCC local capability: send image]",
|
|
148
|
+
];
|
|
149
|
+
if (input.cwd) {
|
|
150
|
+
lines.splice(2, 0, `Current working directory: ${input.cwd}`);
|
|
151
|
+
}
|
|
152
|
+
return lines.join("\n");
|
|
153
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -41,7 +41,6 @@ import {
|
|
|
41
41
|
CLAUDE_EFFORT,
|
|
42
42
|
CLAUDE_MODEL,
|
|
43
43
|
GIT_TIMEOUT_MS,
|
|
44
|
-
ALLOW_INTERRUPT,
|
|
45
44
|
reloadConfigFromDisk,
|
|
46
45
|
anthropicConfigDisplay,
|
|
47
46
|
LOCAL_RELAY_URL,
|
|
@@ -84,17 +83,14 @@ import {
|
|
|
84
83
|
reportPermissionResults,
|
|
85
84
|
setPlatform,
|
|
86
85
|
} from "./feishu-platform.ts";
|
|
87
|
-
import { buildHelpCard, buildStatusCard,
|
|
88
|
-
import { updateCardKitCard } from "./cardkit.ts";
|
|
86
|
+
import { buildHelpCard, buildStatusCard, buildCdContent, buildCdCard, buildSessionsCard } from "./cards.ts";
|
|
89
87
|
import { handleAgentImageRequest } from "./agent-image-rpc.ts";
|
|
90
88
|
import { handleAgentFileRequest } from "./agent-file-rpc.ts";
|
|
91
|
-
import { handleAgentGrantsRequest } from "./agent-grants-rpc.ts";
|
|
92
89
|
import { SimulatedPlatform, SIM_DEFAULT_CHAT_ID } from "./sim-platform.ts";
|
|
93
90
|
import { setMessageHandler } from "./sim-store.ts";
|
|
94
91
|
import { formatGitResult, gitResultHeaderTemplate, runGitCommand } from "./git-command.ts";
|
|
95
92
|
import {
|
|
96
93
|
MAX_PROCESSED,
|
|
97
|
-
chatSessionMap,
|
|
98
94
|
getSessionStatus,
|
|
99
95
|
getAllSessionsStatus,
|
|
100
96
|
initClaudeSession,
|
|
@@ -105,7 +101,19 @@ import {
|
|
|
105
101
|
sessionInfoMap,
|
|
106
102
|
recordSessionRegistry,
|
|
107
103
|
getAdapterForTool,
|
|
104
|
+
stopSession,
|
|
105
|
+
loadSessionRegistryForBinding,
|
|
108
106
|
} from "./session.ts";
|
|
107
|
+
import {
|
|
108
|
+
bindChatToSession,
|
|
109
|
+
unbindChatFromSession,
|
|
110
|
+
getChatsForSession,
|
|
111
|
+
isSessionRunning,
|
|
112
|
+
activePrompts,
|
|
113
|
+
rebuildSessionChatsFromRegistry,
|
|
114
|
+
displayCards,
|
|
115
|
+
} from "./session-chat-binding.ts";
|
|
116
|
+
import { fixStaleStreamStates } from "./stream-state.ts";
|
|
109
117
|
|
|
110
118
|
export function cwdDisplayName(cwd: string): string {
|
|
111
119
|
const trimmed = cwd.trim().replace(/[\\/]+$/, "");
|
|
@@ -462,6 +470,41 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
462
470
|
const cwd = sessionCwd;
|
|
463
471
|
const initialName = sessionChatName("新会话", cwd);
|
|
464
472
|
|
|
473
|
+
// 私聊:不创建群,直接绑定 session 到当前私聊
|
|
474
|
+
if (chatType === "p2p") {
|
|
475
|
+
bindChatToSession(sessionId, chatId);
|
|
476
|
+
sessionInfoMap.set(chatId, {
|
|
477
|
+
sessionId,
|
|
478
|
+
turnCount: 0,
|
|
479
|
+
lastContextTokens: 0,
|
|
480
|
+
startTime: Date.now(),
|
|
481
|
+
tool,
|
|
482
|
+
});
|
|
483
|
+
await setDefaultCwd(cwd, chatId);
|
|
484
|
+
await recordSessionRegistry({
|
|
485
|
+
chatId,
|
|
486
|
+
sessionId,
|
|
487
|
+
tool,
|
|
488
|
+
chatName: initialName,
|
|
489
|
+
turnCount: 0,
|
|
490
|
+
startTime: Date.now(),
|
|
491
|
+
running: false,
|
|
492
|
+
});
|
|
493
|
+
await sendCardReply(
|
|
494
|
+
freshToken, chatId, `${toolLabel} Session Ready`,
|
|
495
|
+
`这是你的 **${toolLabel}** 私聊会话。\n\n` +
|
|
496
|
+
`**Session ID:** ${sessionId}\n` +
|
|
497
|
+
`**工作目录:** \`${cwd}\`\n\n` +
|
|
498
|
+
`直接在这里发消息即可与 ${toolLabel} 对话。\n\n` +
|
|
499
|
+
`发送 **/sessions** 查看所有会话状态。\n` +
|
|
500
|
+
`发送 \`/git <子命令>\` 在本会话工作目录执行 git,例如 \`/git status\`、\`/git log --oneline -n 5\`。`,
|
|
501
|
+
"green"
|
|
502
|
+
);
|
|
503
|
+
console.log(`[${ts()}] [NEW] P2P session created: ${sessionId} (${toolLabel})`);
|
|
504
|
+
logTrace(tid, "DONE", { outcome: "session_ready_p2p", chatId, sessionId, tool });
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
|
|
465
508
|
let newChatId: string;
|
|
466
509
|
try {
|
|
467
510
|
newChatId = await createGroupChat(freshToken, initialName, [openId]);
|
|
@@ -486,6 +529,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
486
529
|
|
|
487
530
|
// 让新群的默认工作目录继承当前会话的 cwd
|
|
488
531
|
await setDefaultCwd(cwd, newChatId);
|
|
532
|
+
bindChatToSession(sessionId, newChatId);
|
|
489
533
|
await recordSessionRegistry({
|
|
490
534
|
chatId: newChatId,
|
|
491
535
|
sessionId,
|
|
@@ -515,24 +559,62 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
515
559
|
return;
|
|
516
560
|
}
|
|
517
561
|
|
|
518
|
-
//
|
|
562
|
+
// 检测会话上下文:群聊从 description 获取,私聊从 session-registry 获取
|
|
563
|
+
let sessionId: string | null = null;
|
|
564
|
+
let descriptionTool: string | null = null;
|
|
565
|
+
let toolLabel: string | null = null;
|
|
566
|
+
let chatInfo: Awaited<ReturnType<typeof getChatInfo>> | undefined;
|
|
567
|
+
let description: string | undefined;
|
|
568
|
+
|
|
519
569
|
if (chatType !== "p2p") {
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
570
|
+
try {
|
|
571
|
+
const token = await getTenantAccessToken();
|
|
572
|
+
chatInfo = await getChatInfo(token, chatId);
|
|
573
|
+
description = chatInfo.description;
|
|
574
|
+
const sessionInfo = extractSessionInfo(description);
|
|
575
|
+
if (sessionInfo) {
|
|
576
|
+
sessionId = sessionInfo.sessionId;
|
|
577
|
+
descriptionTool = sessionInfo.tool;
|
|
578
|
+
toolLabel = toolDisplayName(descriptionTool);
|
|
579
|
+
}
|
|
580
|
+
} catch (err) {
|
|
581
|
+
logTrace(tid, "BRANCH", { reason: "get_chat_info_failed", error: (err as Error).message });
|
|
582
|
+
console.log(`[${ts()}] [INFO] Cannot get chat info for ${chatId}: ${(err as Error).message}`);
|
|
583
|
+
}
|
|
584
|
+
} else {
|
|
585
|
+
// 私聊:从 session-registry.json 获取绑定的 session
|
|
586
|
+
try {
|
|
587
|
+
const registry = await loadSessionRegistryForBinding();
|
|
588
|
+
const record = registry[chatId];
|
|
589
|
+
if (record && record.sessionId && record.tool) {
|
|
590
|
+
sessionId = record.sessionId;
|
|
591
|
+
descriptionTool = record.tool;
|
|
592
|
+
toolLabel = toolDisplayName(descriptionTool);
|
|
593
|
+
// 确保 sessionInfoMap 中有该私聊的信息
|
|
594
|
+
if (!sessionInfoMap.has(chatId)) {
|
|
595
|
+
sessionInfoMap.set(chatId, {
|
|
596
|
+
sessionId,
|
|
597
|
+
turnCount: record.turnCount ?? 0,
|
|
598
|
+
lastContextTokens: record.lastContextTokens ?? 0,
|
|
599
|
+
startTime: record.startTime ?? Date.now(),
|
|
600
|
+
tool: descriptionTool,
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
bindChatToSession(sessionId, chatId);
|
|
604
|
+
}
|
|
605
|
+
} catch (err) {
|
|
606
|
+
console.log(`[${ts()}] [INFO] Cannot load registry for p2p ${chatId}: ${(err as Error).message}`);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
if (sessionId && descriptionTool && toolLabel) {
|
|
611
|
+
// 有会话上下文 — 路由到命令处理或 prompt
|
|
612
|
+
logTrace(tid, "BRANCH", { sessionId, tool: descriptionTool });
|
|
613
|
+
console.log(`[${ts()}] [RESUME] ${toolLabel} session group detected, session=${sessionId} tool=${descriptionTool}`);
|
|
532
614
|
|
|
533
615
|
const freshToken = await getTenantAccessToken();
|
|
534
616
|
|
|
535
|
-
if (isUntitledSessionChatName(chatInfo
|
|
617
|
+
if (chatType !== "p2p" && isUntitledSessionChatName(chatInfo!.name) && !textLower.startsWith("/")) {
|
|
536
618
|
const MAX_PREFIX = 10;
|
|
537
619
|
const prefix = text.slice(0, MAX_PREFIX);
|
|
538
620
|
const adapter = getAdapterForTool(descriptionTool);
|
|
@@ -540,9 +622,9 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
540
622
|
const sessionCwd = info?.cwd ?? (await getDefaultCwd(chatId));
|
|
541
623
|
const newName = sessionChatName(prefix, sessionCwd);
|
|
542
624
|
try {
|
|
543
|
-
await updateChatInfo(freshToken, chatId, newName, description);
|
|
625
|
+
await updateChatInfo(freshToken, chatId, newName, description!);
|
|
544
626
|
console.log(`[${ts()}] [RENAME] First message → group renamed to "${newName}"`);
|
|
545
|
-
await recordSessionRegistry({ chatId, chatName: newName }).catch(() => {});
|
|
627
|
+
await recordSessionRegistry({ chatId, sessionId, tool: descriptionTool, chatName: newName }).catch(() => {});
|
|
546
628
|
} catch (err) {
|
|
547
629
|
console.error(`[${ts()}] [RENAME] Failed: ${(err as Error).message}`);
|
|
548
630
|
}
|
|
@@ -550,15 +632,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
550
632
|
|
|
551
633
|
if (textLower === "/stop") {
|
|
552
634
|
logTrace(tid, "BRANCH", { cmd: "/stop" });
|
|
553
|
-
|
|
554
|
-
if (cEntry) {
|
|
555
|
-
cEntry.stopped = true;
|
|
556
|
-
if (cEntry.spinnerTimer) { clearInterval(cEntry.spinnerTimer); cEntry.spinnerTimer = null; }
|
|
557
|
-
cEntry.close();
|
|
558
|
-
const prevTs = lastMsgTimestamps.get(chatId);
|
|
559
|
-
if (prevTs === undefined || cEntry.msgTimestamp > prevTs) {
|
|
560
|
-
lastMsgTimestamps.set(chatId, cEntry.msgTimestamp);
|
|
561
|
-
}
|
|
635
|
+
if (stopSession(sessionId)) {
|
|
562
636
|
console.log(`[${ts()}] [STOP] User sent /stop, session=${sessionId}`);
|
|
563
637
|
await sendTextReply(freshToken, chatId, "会话已停止。").catch(() => {});
|
|
564
638
|
logTrace(tid, "DONE", { outcome: "stopped" });
|
|
@@ -572,8 +646,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
572
646
|
if (textLower === "/status") {
|
|
573
647
|
logTrace(tid, "BRANCH", { cmd: "/status" });
|
|
574
648
|
const status = await getSessionStatus(chatId);
|
|
575
|
-
const
|
|
576
|
-
const isActive = running && !running.stopped;
|
|
649
|
+
const isActive = isSessionRunning(sessionId);
|
|
577
650
|
const statusText = [
|
|
578
651
|
`**群名:** ${status?.chatName || "—"}`,
|
|
579
652
|
`**Session ID:** \`${status?.sessionId ?? sessionId}\``,
|
|
@@ -636,17 +709,10 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
636
709
|
cwd = await getDefaultCwd(chatId);
|
|
637
710
|
}
|
|
638
711
|
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
existing.close();
|
|
644
|
-
const prevTs = lastMsgTimestamps.get(chatId);
|
|
645
|
-
if (prevTs === undefined || existing.msgTimestamp > prevTs) {
|
|
646
|
-
lastMsgTimestamps.set(chatId, existing.msgTimestamp);
|
|
647
|
-
}
|
|
648
|
-
chatSessionMap.delete(chatId);
|
|
649
|
-
}
|
|
712
|
+
// 不 abort 旧 session,只解绑当前 chat
|
|
713
|
+
// 旧 session 如果正在跑,display loop 继续服务其他群
|
|
714
|
+
unbindChatFromSession(sessionId, chatId);
|
|
715
|
+
displayCards.delete(chatId);
|
|
650
716
|
|
|
651
717
|
let newSessionId: string;
|
|
652
718
|
try {
|
|
@@ -658,6 +724,9 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
658
724
|
return;
|
|
659
725
|
}
|
|
660
726
|
|
|
727
|
+
// 绑定新 session
|
|
728
|
+
bindChatToSession(newSessionId, chatId);
|
|
729
|
+
|
|
661
730
|
const descPrefix = sessionPrefixForTool(descriptionTool);
|
|
662
731
|
const newName = sessionChatName("新会话", cwd);
|
|
663
732
|
await updateChatInfo(freshToken, chatId, newName, `${descPrefix} ${newSessionId}`);
|
|
@@ -683,6 +752,12 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
683
752
|
|
|
684
753
|
setChatAvatar(freshToken, chatId, descriptionTool, "new").catch(() => {});
|
|
685
754
|
|
|
755
|
+
// 如果新 session 有活跃 prompt,启动 display loop 让本群也能看到
|
|
756
|
+
if (isSessionRunning(newSessionId)) {
|
|
757
|
+
const { ensureDisplayLoop } = await import("./session.ts");
|
|
758
|
+
ensureDisplayLoop(newSessionId);
|
|
759
|
+
}
|
|
760
|
+
|
|
686
761
|
await sendCardReply(
|
|
687
762
|
freshToken, chatId, `${toolLabel} Session Reset`,
|
|
688
763
|
`会话已重置为新的 **${toolLabel}** 会话。\n\n` +
|
|
@@ -720,16 +795,10 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
720
795
|
}
|
|
721
796
|
const target = ordered[index];
|
|
722
797
|
|
|
723
|
-
|
|
724
|
-
if (
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
existing2.close();
|
|
728
|
-
const prevTs2 = lastMsgTimestamps.get(chatId);
|
|
729
|
-
if (prevTs2 === undefined || existing2.msgTimestamp > prevTs2) {
|
|
730
|
-
lastMsgTimestamps.set(chatId, existing2.msgTimestamp);
|
|
731
|
-
}
|
|
732
|
-
chatSessionMap.delete(chatId);
|
|
798
|
+
// 不 abort 当前 chat 的旧 session,只解绑再重新绑定
|
|
799
|
+
if (sessionId) {
|
|
800
|
+
unbindChatFromSession(sessionId, chatId);
|
|
801
|
+
displayCards.delete(chatId);
|
|
733
802
|
}
|
|
734
803
|
|
|
735
804
|
const targetAdapter = getAdapterForTool(target.tool);
|
|
@@ -741,6 +810,9 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
741
810
|
cwd2 = await getDefaultCwd(chatId);
|
|
742
811
|
}
|
|
743
812
|
|
|
813
|
+
// 绑定到新 session
|
|
814
|
+
bindChatToSession(target.sessionId, chatId);
|
|
815
|
+
|
|
744
816
|
const descPrefix2 = sessionPrefixForTool(target.tool);
|
|
745
817
|
const newName2 = target.chatName || sessionChatName("新会话", cwd2);
|
|
746
818
|
await updateChatInfo(freshToken, chatId, newName2, `${descPrefix2} ${target.sessionId}`);
|
|
@@ -763,14 +835,21 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
763
835
|
|
|
764
836
|
setChatAvatar(freshToken, chatId, target.tool, "new").catch(() => {});
|
|
765
837
|
|
|
838
|
+
// 如果新 session 有活跃 prompt,加上 display loop
|
|
839
|
+
if (isSessionRunning(target.sessionId)) {
|
|
840
|
+
const { ensureDisplayLoop } = await import("./session.ts");
|
|
841
|
+
ensureDisplayLoop(target.sessionId);
|
|
842
|
+
}
|
|
843
|
+
|
|
766
844
|
const targetToolLabel = toolDisplayName(target.tool);
|
|
845
|
+
const busyNote = isSessionRunning(target.sessionId) ? "\n\n⚠️ 该会话当前正在生成中,请等待完成后再发送消息。" : "";
|
|
767
846
|
await sendCardReply(
|
|
768
847
|
freshToken, chatId, `${targetToolLabel} Session Switched`,
|
|
769
848
|
`已切换到 **${targetToolLabel}** 会话。\n\n` +
|
|
770
849
|
`**序号:** ${index + 1}\n` +
|
|
771
850
|
`**Session ID:** ${target.sessionId}\n` +
|
|
772
851
|
`**工作目录:** \`${cwd2}\`\n\n` +
|
|
773
|
-
|
|
852
|
+
`直接在这里发消息即可继续对话。${busyNote}`,
|
|
774
853
|
"green"
|
|
775
854
|
);
|
|
776
855
|
|
|
@@ -832,50 +911,16 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
832
911
|
return;
|
|
833
912
|
}
|
|
834
913
|
|
|
835
|
-
|
|
836
|
-
if (
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
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
|
-
|
|
854
|
-
existing.stopped = true;
|
|
855
|
-
if (existing.spinnerTimer) { clearInterval(existing.spinnerTimer); existing.spinnerTimer = null; }
|
|
856
|
-
existing.close();
|
|
857
|
-
const prevTs = lastMsgTimestamps.get(chatId);
|
|
858
|
-
if (prevTs === undefined || existing.msgTimestamp > prevTs) {
|
|
859
|
-
lastMsgTimestamps.set(chatId, existing.msgTimestamp);
|
|
860
|
-
}
|
|
861
|
-
chatSessionMap.delete(chatId);
|
|
862
|
-
console.log(`[${ts()}] [INTERRUPT] New message arrived, cancelled previous session ${sessionId}`);
|
|
863
|
-
logTrace(tid, "INTERRUPT", { oldSessionId: sessionId });
|
|
864
|
-
if (existing.cardId) {
|
|
865
|
-
while (existing.cardBusy) {
|
|
866
|
-
await new Promise(r => setTimeout(r, 20));
|
|
867
|
-
}
|
|
868
|
-
const cardId = existing.cardId;
|
|
869
|
-
const currentContent = existing.accumulatedContent;
|
|
870
|
-
const interruptedCard = buildProgressCard(
|
|
871
|
-
currentContent || "新问题已提交,当前回复已中断。",
|
|
872
|
-
{ showStop: false, headerTitle: "已中断", headerTemplate: "yellow" }
|
|
873
|
-
);
|
|
874
|
-
let nextSeq = existing.sequence + 1;
|
|
875
|
-
await updateCardKitCard(freshToken, cardId, interruptedCard, nextSeq).catch((err) => {
|
|
876
|
-
console.error(`[${ts()}] [INTERRUPT] CardKit update failed: ${(err as Error).message}`);
|
|
877
|
-
});
|
|
878
|
-
}
|
|
914
|
+
// 并发检查:同一 session 只能有一个活跃 prompt
|
|
915
|
+
if (isSessionRunning(sessionId)) {
|
|
916
|
+
logTrace(tid, "BLOCKED", { outcome: "session_busy", sessionId });
|
|
917
|
+
console.log(`[${ts()}] [BLOCKED] Session ${sessionId} is already generating, rejecting message from chat ${chatId}`);
|
|
918
|
+
await sendCardReply(
|
|
919
|
+
freshToken, chatId, "生成中",
|
|
920
|
+
"该会话正在生成回复中,请等待完成后再发送新消息。",
|
|
921
|
+
"yellow"
|
|
922
|
+
);
|
|
923
|
+
return;
|
|
879
924
|
}
|
|
880
925
|
|
|
881
926
|
try {
|
|
@@ -895,13 +940,8 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
895
940
|
}
|
|
896
941
|
return;
|
|
897
942
|
}
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
} catch (err) {
|
|
901
|
-
logTrace(tid, "BRANCH", { reason: "get_chat_info_failed", error: (err as Error).message });
|
|
902
|
-
console.log(`[${ts()}] [INFO] Cannot get chat info for ${chatId}: ${(err as Error).message}`);
|
|
903
|
-
}
|
|
904
|
-
} // end if (chatType !== "p2p")
|
|
943
|
+
|
|
944
|
+
// 无会话上下文 → help card
|
|
905
945
|
|
|
906
946
|
// 私聊或群聊无 session info → 发送 help card
|
|
907
947
|
logTrace(tid, "SEND", { method: "help_card", chatId });
|
|
@@ -1151,12 +1191,25 @@ async function startBotServiceCore(): Promise<void> {
|
|
|
1151
1191
|
});
|
|
1152
1192
|
} else {
|
|
1153
1193
|
resetState();
|
|
1194
|
+
// 启动时修正残留的 running 状态并重建 session→chat 映射
|
|
1195
|
+
fixStaleStreamStates().then(async () => {
|
|
1196
|
+
const registry = await loadSessionRegistryForBinding();
|
|
1197
|
+
rebuildSessionChatsFromRegistry(registry);
|
|
1198
|
+
}).catch((err) => console.error(`[${ts()}] Init bindings failed: ${(err as Error).message}`));
|
|
1154
1199
|
|
|
1155
1200
|
const wsClient = new WSClient({
|
|
1156
1201
|
appId: APP_ID,
|
|
1157
1202
|
appSecret: APP_SECRET,
|
|
1158
|
-
onReady: () => {
|
|
1159
|
-
|
|
1203
|
+
onReady: async () => {
|
|
1204
|
+
resetState();
|
|
1205
|
+
const registry = await loadSessionRegistryForBinding();
|
|
1206
|
+
rebuildSessionChatsFromRegistry(registry);
|
|
1207
|
+
},
|
|
1208
|
+
onReconnected: async () => {
|
|
1209
|
+
resetState();
|
|
1210
|
+
const registry = await loadSessionRegistryForBinding();
|
|
1211
|
+
rebuildSessionChatsFromRegistry(registry);
|
|
1212
|
+
},
|
|
1160
1213
|
});
|
|
1161
1214
|
|
|
1162
1215
|
console.log(`\n[启动 6/7] 飞书长连接:正在通过 SDK 建立 WebSocket …`);
|
|
@@ -1212,7 +1265,7 @@ async function main(): Promise<void> {
|
|
|
1212
1265
|
setExtraApiHandler(async (req, res) => {
|
|
1213
1266
|
const injected = await handleSimInjectMessage(req, res);
|
|
1214
1267
|
if (injected) return true;
|
|
1215
|
-
return (await
|
|
1268
|
+
return (await handleAgentImageRequest(req, res)) || (await handleAgentFileRequest(req, res));
|
|
1216
1269
|
});
|
|
1217
1270
|
|
|
1218
1271
|
const simServer = createServer(createUiRouter());
|
|
@@ -1270,7 +1323,7 @@ async function main(): Promise<void> {
|
|
|
1270
1323
|
});
|
|
1271
1324
|
});
|
|
1272
1325
|
setExtraApiHandler(async (req, res) => {
|
|
1273
|
-
return (await
|
|
1326
|
+
return (await handleAgentImageRequest(req, res)) || (await handleAgentFileRequest(req, res));
|
|
1274
1327
|
});
|
|
1275
1328
|
|
|
1276
1329
|
console.log(`[启动 2/7] 环境与凭证检查`);
|