chatccc 0.2.45 → 0.2.46
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 +31 -1
- package/src/agent-file-rpc.ts +108 -106
- package/src/agent-image-rpc.ts +78 -113
- package/src/index.ts +158 -109
- package/src/session-chat-binding.ts +87 -0
- package/src/session.ts +328 -263
- 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,18 @@ 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
|
+
} from "./session-chat-binding.ts";
|
|
115
|
+
import { fixStaleStreamStates } from "./stream-state.ts";
|
|
109
116
|
|
|
110
117
|
export function cwdDisplayName(cwd: string): string {
|
|
111
118
|
const trimmed = cwd.trim().replace(/[\\/]+$/, "");
|
|
@@ -462,6 +469,41 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
462
469
|
const cwd = sessionCwd;
|
|
463
470
|
const initialName = sessionChatName("新会话", cwd);
|
|
464
471
|
|
|
472
|
+
// 私聊:不创建群,直接绑定 session 到当前私聊
|
|
473
|
+
if (chatType === "p2p") {
|
|
474
|
+
bindChatToSession(sessionId, chatId);
|
|
475
|
+
sessionInfoMap.set(chatId, {
|
|
476
|
+
sessionId,
|
|
477
|
+
turnCount: 0,
|
|
478
|
+
lastContextTokens: 0,
|
|
479
|
+
startTime: Date.now(),
|
|
480
|
+
tool,
|
|
481
|
+
});
|
|
482
|
+
await setDefaultCwd(cwd, chatId);
|
|
483
|
+
await recordSessionRegistry({
|
|
484
|
+
chatId,
|
|
485
|
+
sessionId,
|
|
486
|
+
tool,
|
|
487
|
+
chatName: initialName,
|
|
488
|
+
turnCount: 0,
|
|
489
|
+
startTime: Date.now(),
|
|
490
|
+
running: false,
|
|
491
|
+
});
|
|
492
|
+
await sendCardReply(
|
|
493
|
+
freshToken, chatId, `${toolLabel} Session Ready`,
|
|
494
|
+
`这是你的 **${toolLabel}** 私聊会话。\n\n` +
|
|
495
|
+
`**Session ID:** ${sessionId}\n` +
|
|
496
|
+
`**工作目录:** \`${cwd}\`\n\n` +
|
|
497
|
+
`直接在这里发消息即可与 ${toolLabel} 对话。\n\n` +
|
|
498
|
+
`发送 **/sessions** 查看所有会话状态。\n` +
|
|
499
|
+
`发送 \`/git <子命令>\` 在本会话工作目录执行 git,例如 \`/git status\`、\`/git log --oneline -n 5\`。`,
|
|
500
|
+
"green"
|
|
501
|
+
);
|
|
502
|
+
console.log(`[${ts()}] [NEW] P2P session created: ${sessionId} (${toolLabel})`);
|
|
503
|
+
logTrace(tid, "DONE", { outcome: "session_ready_p2p", chatId, sessionId, tool });
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
|
|
465
507
|
let newChatId: string;
|
|
466
508
|
try {
|
|
467
509
|
newChatId = await createGroupChat(freshToken, initialName, [openId]);
|
|
@@ -515,24 +557,62 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
515
557
|
return;
|
|
516
558
|
}
|
|
517
559
|
|
|
518
|
-
//
|
|
560
|
+
// 检测会话上下文:群聊从 description 获取,私聊从 session-registry 获取
|
|
561
|
+
let sessionId: string | null = null;
|
|
562
|
+
let descriptionTool: string | null = null;
|
|
563
|
+
let toolLabel: string | null = null;
|
|
564
|
+
let chatInfo: Awaited<ReturnType<typeof getChatInfo>> | undefined;
|
|
565
|
+
let description: string | undefined;
|
|
566
|
+
|
|
519
567
|
if (chatType !== "p2p") {
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
568
|
+
try {
|
|
569
|
+
const token = await getTenantAccessToken();
|
|
570
|
+
chatInfo = await getChatInfo(token, chatId);
|
|
571
|
+
description = chatInfo.description;
|
|
572
|
+
const sessionInfo = extractSessionInfo(description);
|
|
573
|
+
if (sessionInfo) {
|
|
574
|
+
sessionId = sessionInfo.sessionId;
|
|
575
|
+
descriptionTool = sessionInfo.tool;
|
|
576
|
+
toolLabel = toolDisplayName(descriptionTool);
|
|
577
|
+
}
|
|
578
|
+
} catch (err) {
|
|
579
|
+
logTrace(tid, "BRANCH", { reason: "get_chat_info_failed", error: (err as Error).message });
|
|
580
|
+
console.log(`[${ts()}] [INFO] Cannot get chat info for ${chatId}: ${(err as Error).message}`);
|
|
581
|
+
}
|
|
582
|
+
} else {
|
|
583
|
+
// 私聊:从 session-registry.json 获取绑定的 session
|
|
584
|
+
try {
|
|
585
|
+
const registry = await loadSessionRegistryForBinding();
|
|
586
|
+
const record = registry[chatId];
|
|
587
|
+
if (record && record.sessionId && record.tool) {
|
|
588
|
+
sessionId = record.sessionId;
|
|
589
|
+
descriptionTool = record.tool;
|
|
590
|
+
toolLabel = toolDisplayName(descriptionTool);
|
|
591
|
+
// 确保 sessionInfoMap 中有该私聊的信息
|
|
592
|
+
if (!sessionInfoMap.has(chatId)) {
|
|
593
|
+
sessionInfoMap.set(chatId, {
|
|
594
|
+
sessionId,
|
|
595
|
+
turnCount: record.turnCount ?? 0,
|
|
596
|
+
lastContextTokens: record.lastContextTokens ?? 0,
|
|
597
|
+
startTime: record.startTime ?? Date.now(),
|
|
598
|
+
tool: descriptionTool,
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
bindChatToSession(sessionId, chatId);
|
|
602
|
+
}
|
|
603
|
+
} catch (err) {
|
|
604
|
+
console.log(`[${ts()}] [INFO] Cannot load registry for p2p ${chatId}: ${(err as Error).message}`);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if (sessionId && descriptionTool && toolLabel) {
|
|
609
|
+
// 有会话上下文 — 路由到命令处理或 prompt
|
|
610
|
+
logTrace(tid, "BRANCH", { sessionId, tool: descriptionTool });
|
|
611
|
+
console.log(`[${ts()}] [RESUME] ${toolLabel} session group detected, session=${sessionId} tool=${descriptionTool}`);
|
|
532
612
|
|
|
533
613
|
const freshToken = await getTenantAccessToken();
|
|
534
614
|
|
|
535
|
-
if (isUntitledSessionChatName(chatInfo
|
|
615
|
+
if (chatType !== "p2p" && isUntitledSessionChatName(chatInfo!.name) && !textLower.startsWith("/")) {
|
|
536
616
|
const MAX_PREFIX = 10;
|
|
537
617
|
const prefix = text.slice(0, MAX_PREFIX);
|
|
538
618
|
const adapter = getAdapterForTool(descriptionTool);
|
|
@@ -540,9 +620,9 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
540
620
|
const sessionCwd = info?.cwd ?? (await getDefaultCwd(chatId));
|
|
541
621
|
const newName = sessionChatName(prefix, sessionCwd);
|
|
542
622
|
try {
|
|
543
|
-
await updateChatInfo(freshToken, chatId, newName, description);
|
|
623
|
+
await updateChatInfo(freshToken, chatId, newName, description!);
|
|
544
624
|
console.log(`[${ts()}] [RENAME] First message → group renamed to "${newName}"`);
|
|
545
|
-
await recordSessionRegistry({ chatId, chatName: newName }).catch(() => {});
|
|
625
|
+
await recordSessionRegistry({ chatId, sessionId, tool: descriptionTool, chatName: newName }).catch(() => {});
|
|
546
626
|
} catch (err) {
|
|
547
627
|
console.error(`[${ts()}] [RENAME] Failed: ${(err as Error).message}`);
|
|
548
628
|
}
|
|
@@ -550,15 +630,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
550
630
|
|
|
551
631
|
if (textLower === "/stop") {
|
|
552
632
|
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
|
-
}
|
|
633
|
+
if (stopSession(sessionId)) {
|
|
562
634
|
console.log(`[${ts()}] [STOP] User sent /stop, session=${sessionId}`);
|
|
563
635
|
await sendTextReply(freshToken, chatId, "会话已停止。").catch(() => {});
|
|
564
636
|
logTrace(tid, "DONE", { outcome: "stopped" });
|
|
@@ -572,8 +644,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
572
644
|
if (textLower === "/status") {
|
|
573
645
|
logTrace(tid, "BRANCH", { cmd: "/status" });
|
|
574
646
|
const status = await getSessionStatus(chatId);
|
|
575
|
-
const
|
|
576
|
-
const isActive = running && !running.stopped;
|
|
647
|
+
const isActive = isSessionRunning(sessionId);
|
|
577
648
|
const statusText = [
|
|
578
649
|
`**群名:** ${status?.chatName || "—"}`,
|
|
579
650
|
`**Session ID:** \`${status?.sessionId ?? sessionId}\``,
|
|
@@ -636,17 +707,9 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
636
707
|
cwd = await getDefaultCwd(chatId);
|
|
637
708
|
}
|
|
638
709
|
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
if (existing.spinnerTimer) { clearInterval(existing.spinnerTimer); existing.spinnerTimer = null; }
|
|
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
|
-
}
|
|
710
|
+
// 不 abort 旧 session,只解绑当前 chat
|
|
711
|
+
// 旧 session 如果正在跑,display loop 继续服务其他群
|
|
712
|
+
unbindChatFromSession(sessionId, chatId);
|
|
650
713
|
|
|
651
714
|
let newSessionId: string;
|
|
652
715
|
try {
|
|
@@ -658,6 +721,9 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
658
721
|
return;
|
|
659
722
|
}
|
|
660
723
|
|
|
724
|
+
// 绑定新 session
|
|
725
|
+
bindChatToSession(newSessionId, chatId);
|
|
726
|
+
|
|
661
727
|
const descPrefix = sessionPrefixForTool(descriptionTool);
|
|
662
728
|
const newName = sessionChatName("新会话", cwd);
|
|
663
729
|
await updateChatInfo(freshToken, chatId, newName, `${descPrefix} ${newSessionId}`);
|
|
@@ -683,6 +749,12 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
683
749
|
|
|
684
750
|
setChatAvatar(freshToken, chatId, descriptionTool, "new").catch(() => {});
|
|
685
751
|
|
|
752
|
+
// 如果新 session 有活跃 prompt,启动 display loop 让本群也能看到
|
|
753
|
+
if (isSessionRunning(newSessionId)) {
|
|
754
|
+
const { ensureDisplayLoop } = await import("./session.ts");
|
|
755
|
+
ensureDisplayLoop(newSessionId);
|
|
756
|
+
}
|
|
757
|
+
|
|
686
758
|
await sendCardReply(
|
|
687
759
|
freshToken, chatId, `${toolLabel} Session Reset`,
|
|
688
760
|
`会话已重置为新的 **${toolLabel}** 会话。\n\n` +
|
|
@@ -720,16 +792,9 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
720
792
|
}
|
|
721
793
|
const target = ordered[index];
|
|
722
794
|
|
|
723
|
-
|
|
724
|
-
if (
|
|
725
|
-
|
|
726
|
-
if (existing2.spinnerTimer) { clearInterval(existing2.spinnerTimer); existing2.spinnerTimer = null; }
|
|
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);
|
|
795
|
+
// 不 abort 当前 chat 的旧 session,只解绑再重新绑定
|
|
796
|
+
if (sessionId) {
|
|
797
|
+
unbindChatFromSession(sessionId, chatId);
|
|
733
798
|
}
|
|
734
799
|
|
|
735
800
|
const targetAdapter = getAdapterForTool(target.tool);
|
|
@@ -741,6 +806,9 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
741
806
|
cwd2 = await getDefaultCwd(chatId);
|
|
742
807
|
}
|
|
743
808
|
|
|
809
|
+
// 绑定到新 session
|
|
810
|
+
bindChatToSession(target.sessionId, chatId);
|
|
811
|
+
|
|
744
812
|
const descPrefix2 = sessionPrefixForTool(target.tool);
|
|
745
813
|
const newName2 = target.chatName || sessionChatName("新会话", cwd2);
|
|
746
814
|
await updateChatInfo(freshToken, chatId, newName2, `${descPrefix2} ${target.sessionId}`);
|
|
@@ -763,14 +831,21 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
763
831
|
|
|
764
832
|
setChatAvatar(freshToken, chatId, target.tool, "new").catch(() => {});
|
|
765
833
|
|
|
834
|
+
// 如果新 session 有活跃 prompt,加上 display loop
|
|
835
|
+
if (isSessionRunning(target.sessionId)) {
|
|
836
|
+
const { ensureDisplayLoop } = await import("./session.ts");
|
|
837
|
+
ensureDisplayLoop(target.sessionId);
|
|
838
|
+
}
|
|
839
|
+
|
|
766
840
|
const targetToolLabel = toolDisplayName(target.tool);
|
|
841
|
+
const busyNote = isSessionRunning(target.sessionId) ? "\n\n⚠️ 该会话当前正在生成中,请等待完成后再发送消息。" : "";
|
|
767
842
|
await sendCardReply(
|
|
768
843
|
freshToken, chatId, `${targetToolLabel} Session Switched`,
|
|
769
844
|
`已切换到 **${targetToolLabel}** 会话。\n\n` +
|
|
770
845
|
`**序号:** ${index + 1}\n` +
|
|
771
846
|
`**Session ID:** ${target.sessionId}\n` +
|
|
772
847
|
`**工作目录:** \`${cwd2}\`\n\n` +
|
|
773
|
-
|
|
848
|
+
`直接在这里发消息即可继续对话。${busyNote}`,
|
|
774
849
|
"green"
|
|
775
850
|
);
|
|
776
851
|
|
|
@@ -832,50 +907,16 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
832
907
|
return;
|
|
833
908
|
}
|
|
834
909
|
|
|
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
|
-
}
|
|
910
|
+
// 并发检查:同一 session 只能有一个活跃 prompt
|
|
911
|
+
if (isSessionRunning(sessionId)) {
|
|
912
|
+
logTrace(tid, "BLOCKED", { outcome: "session_busy", sessionId });
|
|
913
|
+
console.log(`[${ts()}] [BLOCKED] Session ${sessionId} is already generating, rejecting message from chat ${chatId}`);
|
|
914
|
+
await sendCardReply(
|
|
915
|
+
freshToken, chatId, "生成中",
|
|
916
|
+
"该会话正在生成回复中,请等待完成后再发送新消息。",
|
|
917
|
+
"yellow"
|
|
918
|
+
);
|
|
919
|
+
return;
|
|
879
920
|
}
|
|
880
921
|
|
|
881
922
|
try {
|
|
@@ -895,13 +936,8 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
895
936
|
}
|
|
896
937
|
return;
|
|
897
938
|
}
|
|
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")
|
|
939
|
+
|
|
940
|
+
// 无会话上下文 → help card
|
|
905
941
|
|
|
906
942
|
// 私聊或群聊无 session info → 发送 help card
|
|
907
943
|
logTrace(tid, "SEND", { method: "help_card", chatId });
|
|
@@ -1151,12 +1187,25 @@ async function startBotServiceCore(): Promise<void> {
|
|
|
1151
1187
|
});
|
|
1152
1188
|
} else {
|
|
1153
1189
|
resetState();
|
|
1190
|
+
// 启动时修正残留的 running 状态并重建 session→chat 映射
|
|
1191
|
+
fixStaleStreamStates().then(async () => {
|
|
1192
|
+
const registry = await loadSessionRegistryForBinding();
|
|
1193
|
+
rebuildSessionChatsFromRegistry(registry);
|
|
1194
|
+
}).catch((err) => console.error(`[${ts()}] Init bindings failed: ${(err as Error).message}`));
|
|
1154
1195
|
|
|
1155
1196
|
const wsClient = new WSClient({
|
|
1156
1197
|
appId: APP_ID,
|
|
1157
1198
|
appSecret: APP_SECRET,
|
|
1158
|
-
onReady: () => {
|
|
1159
|
-
|
|
1199
|
+
onReady: async () => {
|
|
1200
|
+
resetState();
|
|
1201
|
+
const registry = await loadSessionRegistryForBinding();
|
|
1202
|
+
rebuildSessionChatsFromRegistry(registry);
|
|
1203
|
+
},
|
|
1204
|
+
onReconnected: async () => {
|
|
1205
|
+
resetState();
|
|
1206
|
+
const registry = await loadSessionRegistryForBinding();
|
|
1207
|
+
rebuildSessionChatsFromRegistry(registry);
|
|
1208
|
+
},
|
|
1160
1209
|
});
|
|
1161
1210
|
|
|
1162
1211
|
console.log(`\n[启动 6/7] 飞书长连接:正在通过 SDK 建立 WebSocket …`);
|
|
@@ -1212,7 +1261,7 @@ async function main(): Promise<void> {
|
|
|
1212
1261
|
setExtraApiHandler(async (req, res) => {
|
|
1213
1262
|
const injected = await handleSimInjectMessage(req, res);
|
|
1214
1263
|
if (injected) return true;
|
|
1215
|
-
return (await
|
|
1264
|
+
return (await handleAgentImageRequest(req, res)) || (await handleAgentFileRequest(req, res));
|
|
1216
1265
|
});
|
|
1217
1266
|
|
|
1218
1267
|
const simServer = createServer(createUiRouter());
|
|
@@ -1270,7 +1319,7 @@ async function main(): Promise<void> {
|
|
|
1270
1319
|
});
|
|
1271
1320
|
});
|
|
1272
1321
|
setExtraApiHandler(async (req, res) => {
|
|
1273
|
-
return (await
|
|
1322
|
+
return (await handleAgentImageRequest(req, res)) || (await handleAgentFileRequest(req, res));
|
|
1274
1323
|
});
|
|
1275
1324
|
|
|
1276
1325
|
console.log(`[启动 2/7] 环境与凭证检查`);
|