chatccc 0.2.22 → 0.2.24
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/README.md +3 -3
- package/package.json +5 -2
- package/src/__tests__/agent-image-rpc.test.ts +67 -0
- package/src/adapters/codex-session-meta-store.ts +2 -2
- package/src/adapters/cursor-session-meta-store.ts +2 -2
- package/src/agent-image-rpc.ts +204 -0
- package/src/config.ts +89 -9
- package/src/feishu-api.ts +119 -26
- package/src/index.ts +66 -7
- package/src/session.ts +35 -2
- package/src/shared.ts +3 -3
- package/src/trace.ts +51 -0
- package/src/web-ui.ts +12 -2
- package/images/avatars/brand-sources/claude_code_app_icon.png +0 -0
- package/images/avatars/brand-sources/codex_app_icon.png +0 -0
- package/images/avatars/brand-sources/cursor_icon_512.png +0 -0
- package/images/downloads/img_v3_0211k_b240c89c-fde3-4db8-adb1-a77e40d173bg.jpg +0 -0
- package/images/downloads/img_v3_0211k_d061664d-e25b-41ad-be94-442513e4d22g.webp +0 -0
package/README.md
CHANGED
|
@@ -17,11 +17,11 @@ ChatCCC 把 Claude Code、Cursor Agent、Codex (OpenAI) 接入了飞书群聊:
|
|
|
17
17
|
一句话:**在任何设备上打开飞书,就能让 Claude Code / Cursor / Codex 帮你写代码、排查问题、分析项目。**
|
|
18
18
|
|
|
19
19
|
<p align="center">
|
|
20
|
-
<img src="images/img_readme_messages.jpg" alt="飞书会话列表" width="220" />
|
|
20
|
+
<img src="images/img_readme_messages.jpg" alt="飞书会话列表" width="220" align="top" />
|
|
21
21
|
|
|
22
|
-
<img src="images/img_readme_0.jpg" alt="飞书群聊中使用 ChatCCC" width="220" />
|
|
22
|
+
<img src="images/img_readme_0.jpg" alt="飞书群聊中使用 ChatCCC" width="220" align="top" />
|
|
23
23
|
|
|
24
|
-
<img src="images/img_readme_1.jpg" alt="思考过程和工具调用" width="220" />
|
|
24
|
+
<img src="images/img_readme_1.jpg" alt="思考过程和工具调用" width="220" align="top" />
|
|
25
25
|
</p>
|
|
26
26
|
|
|
27
27
|
---
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chatccc",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.24",
|
|
4
4
|
"description": "Feishu bot bridge for Claude Code",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -11,7 +11,10 @@
|
|
|
11
11
|
"files": [
|
|
12
12
|
"src/",
|
|
13
13
|
"bin/",
|
|
14
|
-
"images/",
|
|
14
|
+
"images/img_readme_*.jpg",
|
|
15
|
+
"images/img_readme_*.png",
|
|
16
|
+
"images/avatars/status_*.png",
|
|
17
|
+
"images/avatars/badges/",
|
|
15
18
|
"package.json",
|
|
16
19
|
"README.md",
|
|
17
20
|
"config.sample.json"
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
AGENT_SEND_IMAGE_PATH,
|
|
5
|
+
buildAgentImageCapabilityPrompt,
|
|
6
|
+
createAgentImageGrant,
|
|
7
|
+
getAgentImageGrantFromAuthorization,
|
|
8
|
+
revokeAgentImageGrant,
|
|
9
|
+
} from "../agent-image-rpc.ts";
|
|
10
|
+
|
|
11
|
+
describe("agent image RPC grants", () => {
|
|
12
|
+
it("creates a per-turn grant and validates bearer authorization", () => {
|
|
13
|
+
const grant = createAgentImageGrant({
|
|
14
|
+
chatId: "oc_chat",
|
|
15
|
+
sessionId: "sid-1",
|
|
16
|
+
cwd: "F:/repo",
|
|
17
|
+
port: 18080,
|
|
18
|
+
nowMs: 1000,
|
|
19
|
+
ttlMs: 60_000,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
expect(grant.url).toBe(`http://127.0.0.1:18080${AGENT_SEND_IMAGE_PATH}`);
|
|
23
|
+
expect(grant.token.length).toBeGreaterThan(20);
|
|
24
|
+
|
|
25
|
+
const found = getAgentImageGrantFromAuthorization(
|
|
26
|
+
`Bearer ${grant.token}`,
|
|
27
|
+
30_000,
|
|
28
|
+
);
|
|
29
|
+
expect(found).toMatchObject({
|
|
30
|
+
chatId: "oc_chat",
|
|
31
|
+
sessionId: "sid-1",
|
|
32
|
+
cwd: "F:/repo",
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
revokeAgentImageGrant(grant.token);
|
|
36
|
+
expect(getAgentImageGrantFromAuthorization(`Bearer ${grant.token}`, 30_000)).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("rejects missing, malformed, revoked, and expired authorization", () => {
|
|
40
|
+
const grant = createAgentImageGrant({
|
|
41
|
+
chatId: "oc_chat",
|
|
42
|
+
sessionId: "sid-1",
|
|
43
|
+
cwd: "F:/repo",
|
|
44
|
+
port: 18080,
|
|
45
|
+
nowMs: 1000,
|
|
46
|
+
ttlMs: 10,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
expect(getAgentImageGrantFromAuthorization("", 1000)).toBeNull();
|
|
50
|
+
expect(getAgentImageGrantFromAuthorization(grant.token, 1000)).toBeNull();
|
|
51
|
+
expect(getAgentImageGrantFromAuthorization(`Basic ${grant.token}`, 1000)).toBeNull();
|
|
52
|
+
expect(getAgentImageGrantFromAuthorization(`Bearer ${grant.token}`, 1011)).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("builds prompt instructions without requiring environment variables", () => {
|
|
56
|
+
const prompt = buildAgentImageCapabilityPrompt({
|
|
57
|
+
url: `http://127.0.0.1:18080${AGENT_SEND_IMAGE_PATH}`,
|
|
58
|
+
token: "tok_test",
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
expect(prompt).toContain("POST http://127.0.0.1:18080/api/agent/send-image");
|
|
62
|
+
expect(prompt).toContain("Authorization: Bearer tok_test");
|
|
63
|
+
expect(prompt).toContain("\"path\"");
|
|
64
|
+
expect(prompt).not.toContain("CHATCCC_SEND_IMAGE_URL");
|
|
65
|
+
expect(prompt).not.toContain("CHATCCC_SEND_IMAGE_TOKEN");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
9
9
|
import { dirname, join } from "node:path";
|
|
10
10
|
|
|
11
|
-
import {
|
|
11
|
+
import { USER_DATA_DIR } from "../config.ts";
|
|
12
12
|
|
|
13
13
|
export const CODEX_SESSION_META_FILE = join(
|
|
14
|
-
|
|
14
|
+
USER_DATA_DIR,
|
|
15
15
|
"state",
|
|
16
16
|
"codex-session-meta.json",
|
|
17
17
|
);
|
|
@@ -23,11 +23,11 @@
|
|
|
23
23
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
24
24
|
import { dirname, join } from "node:path";
|
|
25
25
|
|
|
26
|
-
import {
|
|
26
|
+
import { USER_DATA_DIR } from "../config.ts";
|
|
27
27
|
|
|
28
28
|
/** 持久化文件默认路径(生产)。测试可通过 createCursorSessionMetaStore(filePath) 注入。 */
|
|
29
29
|
export const CURSOR_SESSION_META_FILE = join(
|
|
30
|
-
|
|
30
|
+
USER_DATA_DIR,
|
|
31
31
|
"state",
|
|
32
32
|
"cursor-session-meta.json",
|
|
33
33
|
);
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
3
|
+
import { extname, isAbsolute, resolve } from "node:path";
|
|
4
|
+
import { stat } from "node:fs/promises";
|
|
5
|
+
|
|
6
|
+
import { getTenantAccessToken, sendImageReply, sendTextReply } from "./feishu-api.ts";
|
|
7
|
+
import { ts } from "./config.ts";
|
|
8
|
+
import { logTrace } from "./trace.ts";
|
|
9
|
+
|
|
10
|
+
export const AGENT_SEND_IMAGE_PATH = "/api/agent/send-image";
|
|
11
|
+
|
|
12
|
+
const DEFAULT_GRANT_TTL_MS = 30 * 60 * 1000;
|
|
13
|
+
const MAX_REQUEST_BYTES = 64 * 1024;
|
|
14
|
+
const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
|
15
|
+
const ALLOWED_IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"]);
|
|
16
|
+
|
|
17
|
+
export interface AgentImageGrant {
|
|
18
|
+
token: string;
|
|
19
|
+
url: string;
|
|
20
|
+
chatId: string;
|
|
21
|
+
sessionId: string;
|
|
22
|
+
cwd: string;
|
|
23
|
+
expiresAt: number;
|
|
24
|
+
traceId: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface CreateAgentImageGrantInput {
|
|
28
|
+
chatId: string;
|
|
29
|
+
sessionId: string;
|
|
30
|
+
cwd: string;
|
|
31
|
+
port: number;
|
|
32
|
+
traceId?: string;
|
|
33
|
+
nowMs?: number;
|
|
34
|
+
ttlMs?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const imageGrants = new Map<string, AgentImageGrant>();
|
|
38
|
+
|
|
39
|
+
function createToken(): string {
|
|
40
|
+
return randomBytes(24).toString("base64url");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function createAgentImageGrant(input: CreateAgentImageGrantInput): AgentImageGrant {
|
|
44
|
+
const now = input.nowMs ?? Date.now();
|
|
45
|
+
const token = createToken();
|
|
46
|
+
const grant: AgentImageGrant = {
|
|
47
|
+
token,
|
|
48
|
+
url: `http://127.0.0.1:${input.port}${AGENT_SEND_IMAGE_PATH}`,
|
|
49
|
+
chatId: input.chatId,
|
|
50
|
+
sessionId: input.sessionId,
|
|
51
|
+
cwd: input.cwd,
|
|
52
|
+
expiresAt: now + (input.ttlMs ?? DEFAULT_GRANT_TTL_MS),
|
|
53
|
+
traceId: input.traceId ?? "",
|
|
54
|
+
};
|
|
55
|
+
imageGrants.set(token, grant);
|
|
56
|
+
if (grant.traceId) logTrace(grant.traceId, "IMAGE_GRANT_CREATED", { sessionId: grant.sessionId, ttlMs: input.ttlMs ?? DEFAULT_GRANT_TTL_MS });
|
|
57
|
+
return grant;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function revokeAgentImageGrant(token: string): void {
|
|
61
|
+
const grant = imageGrants.get(token);
|
|
62
|
+
if (grant?.traceId) logTrace(grant.traceId, "IMAGE_GRANT_REVOKED", {});
|
|
63
|
+
imageGrants.delete(token);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function getAgentImageGrantFromAuthorization(
|
|
67
|
+
authorization: string | undefined,
|
|
68
|
+
nowMs = Date.now(),
|
|
69
|
+
): AgentImageGrant | null {
|
|
70
|
+
const match = (authorization ?? "").match(/^Bearer\s+(.+)$/i);
|
|
71
|
+
if (!match) return null;
|
|
72
|
+
const grant = imageGrants.get(match[1]);
|
|
73
|
+
if (!grant) return null;
|
|
74
|
+
if (grant.expiresAt <= nowMs) {
|
|
75
|
+
imageGrants.delete(match[1]);
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
return grant;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function buildAgentImageCapabilityPrompt(input: {
|
|
82
|
+
url: string;
|
|
83
|
+
token: string;
|
|
84
|
+
cwd?: string;
|
|
85
|
+
}): string {
|
|
86
|
+
const lines = [
|
|
87
|
+
"[ChatCCC local capability: send image]",
|
|
88
|
+
"You can send an image to the current Feishu chat in real time by calling this local endpoint.",
|
|
89
|
+
"",
|
|
90
|
+
`POST ${input.url}`,
|
|
91
|
+
`Authorization: Bearer ${input.token}`,
|
|
92
|
+
"Content-Type: application/json",
|
|
93
|
+
"",
|
|
94
|
+
'Body: {"path":"absolute image file path","caption":"optional caption"}',
|
|
95
|
+
"",
|
|
96
|
+
"Rules:",
|
|
97
|
+
"- Save or choose a local image file first, then call the endpoint.",
|
|
98
|
+
"- Use an absolute local file path. Do not call Feishu Open Platform directly.",
|
|
99
|
+
"- Only call this endpoint when the user asked for an image or when an image is useful to the answer.",
|
|
100
|
+
"[/ChatCCC local capability: send image]",
|
|
101
|
+
];
|
|
102
|
+
if (input.cwd) {
|
|
103
|
+
lines.splice(2, 0, `Current working directory: ${input.cwd}`);
|
|
104
|
+
}
|
|
105
|
+
return lines.join("\n");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function jsonReply(res: ServerResponse, status: number, data: unknown): void {
|
|
109
|
+
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
|
110
|
+
res.end(JSON.stringify(data));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function readLimitedBody(req: IncomingMessage): Promise<string> {
|
|
114
|
+
return new Promise((resolve, reject) => {
|
|
115
|
+
let body = "";
|
|
116
|
+
let size = 0;
|
|
117
|
+
req.on("data", (chunk: Buffer) => {
|
|
118
|
+
size += chunk.length;
|
|
119
|
+
if (size > MAX_REQUEST_BYTES) {
|
|
120
|
+
reject(new Error("Request body too large"));
|
|
121
|
+
req.destroy();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
body += chunk.toString("utf8");
|
|
125
|
+
});
|
|
126
|
+
req.on("end", () => resolve(body));
|
|
127
|
+
req.on("error", reject);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function resolveAndValidateImagePath(grant: AgentImageGrant, rawPath: unknown): Promise<string> {
|
|
132
|
+
if (typeof rawPath !== "string" || rawPath.trim() === "") {
|
|
133
|
+
throw new Error("path must be a non-empty string");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const sessionRoot = resolve(grant.cwd);
|
|
137
|
+
const imagePath = isAbsolute(rawPath)
|
|
138
|
+
? resolve(rawPath)
|
|
139
|
+
: resolve(sessionRoot, rawPath);
|
|
140
|
+
|
|
141
|
+
const ext = extname(imagePath).toLowerCase();
|
|
142
|
+
if (!ALLOWED_IMAGE_EXTS.has(ext)) {
|
|
143
|
+
throw new Error(`unsupported image extension: ${ext || "(none)"}`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const st = await stat(imagePath);
|
|
147
|
+
if (!st.isFile()) throw new Error("image path is not a file");
|
|
148
|
+
if (st.size <= 0) throw new Error("image file is empty");
|
|
149
|
+
if (st.size > MAX_IMAGE_BYTES) throw new Error("image file is larger than 10MB");
|
|
150
|
+
return imagePath;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function handleAgentImageRequest(
|
|
154
|
+
req: IncomingMessage,
|
|
155
|
+
res: ServerResponse,
|
|
156
|
+
): Promise<boolean> {
|
|
157
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
158
|
+
if (url.pathname !== AGENT_SEND_IMAGE_PATH) return false;
|
|
159
|
+
|
|
160
|
+
if (req.method !== "POST") {
|
|
161
|
+
jsonReply(res, 405, { ok: false, error: "Method not allowed" });
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const grant = getAgentImageGrantFromAuthorization(req.headers.authorization);
|
|
166
|
+
if (!grant) {
|
|
167
|
+
jsonReply(res, 401, { ok: false, error: "Invalid or expired image-send token" });
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
const tid = grant.traceId;
|
|
171
|
+
|
|
172
|
+
let payload: { path?: unknown; caption?: unknown };
|
|
173
|
+
try {
|
|
174
|
+
payload = JSON.parse(await readLimitedBody(req));
|
|
175
|
+
} catch (err) {
|
|
176
|
+
if (tid) logTrace(tid, "IMAGE_REQ", { outcome: "invalid_json", error: (err as Error).message });
|
|
177
|
+
jsonReply(res, 400, { ok: false, error: (err as Error).message || "Invalid JSON" });
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let imagePath: string;
|
|
182
|
+
try {
|
|
183
|
+
imagePath = await resolveAndValidateImagePath(grant, payload.path);
|
|
184
|
+
} catch (err) {
|
|
185
|
+
if (tid) logTrace(tid, "IMAGE_REQ", { outcome: "invalid_path", path: String(payload.path), error: (err as Error).message });
|
|
186
|
+
jsonReply(res, 400, { ok: false, error: (err as Error).message });
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
const token = await getTenantAccessToken();
|
|
192
|
+
const caption = typeof payload.caption === "string" ? payload.caption.trim() : "";
|
|
193
|
+
await sendImageReply(token, grant.chatId, imagePath);
|
|
194
|
+
if (caption) await sendTextReply(token, grant.chatId, caption);
|
|
195
|
+
if (tid) logTrace(tid, "IMAGE_REQ", { outcome: "sent", path: imagePath, hasCaption: !!caption });
|
|
196
|
+
console.log(`[${ts()}] [AGENT-IMAGE] sent image chat=${grant.chatId} session=${grant.sessionId} path=${imagePath}`);
|
|
197
|
+
jsonReply(res, 200, { ok: true });
|
|
198
|
+
} catch (err) {
|
|
199
|
+
if (tid) logTrace(tid, "IMAGE_REQ", { outcome: "send_failed", path: imagePath!, error: (err as Error).message });
|
|
200
|
+
console.error(`[${ts()}] [AGENT-IMAGE] send failed: ${(err as Error).message}`);
|
|
201
|
+
jsonReply(res, 500, { ok: false, error: (err as Error).message });
|
|
202
|
+
}
|
|
203
|
+
return true;
|
|
204
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { existsSync, readFileSync, copyFileSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, readFileSync, copyFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from "node:fs";
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
-
import { appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
6
|
+
import { appendFile, cp, mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
7
7
|
|
|
8
8
|
import { printServiceDidNotStart } from "./exit-banner.ts";
|
|
9
9
|
import { appendStartupTrace, setupFileLogging } from "./shared.ts";
|
|
@@ -34,12 +34,14 @@ export type { ParsedGitTimeout } from "./config-utils.ts";
|
|
|
34
34
|
|
|
35
35
|
export const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
36
36
|
export const PROJECT_ROOT = join(__dirname, "..");
|
|
37
|
-
|
|
37
|
+
/** 用户持久化数据根目录(不随 npm 升级清空) */
|
|
38
|
+
export const USER_DATA_DIR = join(homedir(), ".chatccc");
|
|
39
|
+
export const PID_FILE = join(USER_DATA_DIR, "state", "runtime.pid");
|
|
38
40
|
|
|
39
|
-
export const LOG_DIR = join(
|
|
41
|
+
export const LOG_DIR = join(USER_DATA_DIR, "logs");
|
|
40
42
|
export const fileLog = setupFileLogging(LOG_DIR, "index");
|
|
41
43
|
|
|
42
|
-
export const CHAT_LOGS_DIR = join(
|
|
44
|
+
export const CHAT_LOGS_DIR = join(USER_DATA_DIR, "state", "chat_logs");
|
|
43
45
|
|
|
44
46
|
export async function appendChatLog(chatId: string, sender: string, text: string): Promise<void> {
|
|
45
47
|
try {
|
|
@@ -104,9 +106,82 @@ export interface AppConfig {
|
|
|
104
106
|
export type AgentTool = "claude" | "cursor" | "codex";
|
|
105
107
|
export const AGENT_TOOLS: AgentTool[] = ["claude", "cursor", "codex"];
|
|
106
108
|
|
|
107
|
-
const CONFIG_FILE = join(
|
|
109
|
+
const CONFIG_FILE = join(USER_DATA_DIR, "config.json");
|
|
108
110
|
const CONFIG_SAMPLE_FILE = join(PROJECT_ROOT, "config.sample.json");
|
|
109
111
|
|
|
112
|
+
/**
|
|
113
|
+
* 将旧位置(PROJECT_ROOT)的持久化数据一次性迁移到 USER_DATA_DIR。
|
|
114
|
+
* 仅当 USER_DATA_DIR 下没有 config.json 且 PROJECT_ROOT 下有旧数据时才执行。
|
|
115
|
+
*/
|
|
116
|
+
function migrateLegacyData(): void {
|
|
117
|
+
const oldConfig = join(PROJECT_ROOT, "config.json");
|
|
118
|
+
const oldState = join(PROJECT_ROOT, "state");
|
|
119
|
+
const oldLogs = join(PROJECT_ROOT, "logs");
|
|
120
|
+
const oldImagesDownloads = join(PROJECT_ROOT, "images", "downloads");
|
|
121
|
+
|
|
122
|
+
if (existsSync(CONFIG_FILE)) return; // 已迁移过或全新安装
|
|
123
|
+
|
|
124
|
+
let migrated = false;
|
|
125
|
+
try {
|
|
126
|
+
mkdirSync(USER_DATA_DIR, { recursive: true });
|
|
127
|
+
|
|
128
|
+
if (existsSync(oldConfig)) {
|
|
129
|
+
copyFileSync(oldConfig, CONFIG_FILE);
|
|
130
|
+
console.log(`[MIGRATE] config.json → ${CONFIG_FILE}`);
|
|
131
|
+
migrated = true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (existsSync(oldState)) {
|
|
135
|
+
const destState = join(USER_DATA_DIR, "state");
|
|
136
|
+
if (!existsSync(destState)) {
|
|
137
|
+
// 同步递归复制(cpSync 不可用,改用 copyFileSync 遍历)
|
|
138
|
+
copyDirSync(oldState, destState);
|
|
139
|
+
console.log(`[MIGRATE] state/ → ${destState}`);
|
|
140
|
+
migrated = true;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (existsSync(oldLogs)) {
|
|
145
|
+
const destLogs = join(USER_DATA_DIR, "logs");
|
|
146
|
+
if (!existsSync(destLogs)) {
|
|
147
|
+
copyDirSync(oldLogs, destLogs);
|
|
148
|
+
console.log(`[MIGRATE] logs/ → ${destLogs}`);
|
|
149
|
+
migrated = true;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (existsSync(oldImagesDownloads)) {
|
|
154
|
+
const destDownloads = join(USER_DATA_DIR, "images", "downloads");
|
|
155
|
+
if (!existsSync(destDownloads)) {
|
|
156
|
+
copyDirSync(oldImagesDownloads, destDownloads);
|
|
157
|
+
console.log(`[MIGRATE] images/downloads/ → ${destDownloads}`);
|
|
158
|
+
migrated = true;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
} catch (err) {
|
|
162
|
+
console.error(`[MIGRATE] 迁移失败: ${(err as Error).message}`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (migrated) {
|
|
166
|
+
console.log("[MIGRATE] 旧数据已迁移到 ~/.chatccc/,原位置文件保留未删除。");
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** 递归同步复制目录(仅文件和子目录,不处理符号链接) */
|
|
171
|
+
function copyDirSync(src: string, dest: string): void {
|
|
172
|
+
mkdirSync(dest, { recursive: true });
|
|
173
|
+
for (const entry of readdirSync(src)) {
|
|
174
|
+
const srcPath = join(src, entry);
|
|
175
|
+
const destPath = join(dest, entry);
|
|
176
|
+
const s = statSync(srcPath);
|
|
177
|
+
if (s.isDirectory()) {
|
|
178
|
+
copyDirSync(srcPath, destPath);
|
|
179
|
+
} else if (s.isFile()) {
|
|
180
|
+
copyFileSync(srcPath, destPath);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
110
185
|
/**
|
|
111
186
|
* 是否处于 vitest 测试环境。
|
|
112
187
|
*
|
|
@@ -217,6 +292,10 @@ function loadConfig(): AppConfig {
|
|
|
217
292
|
codex: { enabled: false, defaultAgent: false, path: "", model: "", effort: "" },
|
|
218
293
|
};
|
|
219
294
|
|
|
295
|
+
if (!IS_TEST_ENV) {
|
|
296
|
+
migrateLegacyData();
|
|
297
|
+
}
|
|
298
|
+
|
|
220
299
|
if (!existsSync(CONFIG_FILE)) {
|
|
221
300
|
if (IS_TEST_ENV) {
|
|
222
301
|
// 测试环境下绝不写文件,直接走默认值
|
|
@@ -225,6 +304,7 @@ function loadConfig(): AppConfig {
|
|
|
225
304
|
if (existsSync(CONFIG_SAMPLE_FILE)) {
|
|
226
305
|
console.log(`[CONFIG] config.json 不存在,基于 config.sample.json 创建...`);
|
|
227
306
|
try {
|
|
307
|
+
mkdirSync(dirname(CONFIG_FILE), { recursive: true });
|
|
228
308
|
copyFileSync(CONFIG_SAMPLE_FILE, CONFIG_FILE);
|
|
229
309
|
} catch (err) {
|
|
230
310
|
console.error(`[CONFIG] 无法从 config.sample.json 创建 config.json: ${(err as Error).message}`);
|
|
@@ -469,13 +549,13 @@ export function reloadConfigFromDisk(): void {
|
|
|
469
549
|
|
|
470
550
|
// 新建会话的默认工作路径(/cd 命令设置,持久化到本地文件)
|
|
471
551
|
// 该路径仅影响通过 /new 新建的会话,不影响已有会话的 resume。
|
|
472
|
-
export const DEFAULT_CWD_FILE = join(
|
|
552
|
+
export const DEFAULT_CWD_FILE = join(USER_DATA_DIR, "state", "working_dir.txt");
|
|
473
553
|
|
|
474
554
|
/** 会话工具类型持久化文件 */
|
|
475
|
-
export const SESSIONS_FILE = join(
|
|
555
|
+
export const SESSIONS_FILE = join(USER_DATA_DIR, "state", "sessions.json");
|
|
476
556
|
|
|
477
557
|
/** 最近成功新建会话的工作路径记录(最多 10 条) */
|
|
478
|
-
export const RECENT_DIRS_FILE = join(
|
|
558
|
+
export const RECENT_DIRS_FILE = join(USER_DATA_DIR, "state", "recent_dirs.json");
|
|
479
559
|
export const MAX_RECENT_DIRS = 10;
|
|
480
560
|
|
|
481
561
|
/** 读取最近使用过的工作路径列表(最新的在前) */
|
package/src/feishu-api.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readdir, stat, readFile, mkdir, writeFile } from "node:fs/promises";
|
|
2
|
-
import { resolve as resolvePath } from "node:path";
|
|
2
|
+
import { extname, resolve as resolvePath } from "node:path";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import sharp from "sharp";
|
|
5
5
|
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
BASE_URL,
|
|
10
10
|
CHAT_LOGS_DIR,
|
|
11
11
|
PROJECT_ROOT,
|
|
12
|
+
USER_DATA_DIR,
|
|
12
13
|
CLAUDE_SESSION_PREFIX,
|
|
13
14
|
CURSOR_SESSION_PREFIX,
|
|
14
15
|
CODEX_SESSION_PREFIX,
|
|
@@ -262,7 +263,7 @@ export function extractSessionId(description: string): string | null {
|
|
|
262
263
|
|
|
263
264
|
const AVATAR_DIR = resolvePath(PROJECT_ROOT, "images", "avatars");
|
|
264
265
|
const AVATAR_BADGE_DIR = resolvePath(AVATAR_DIR, "badges");
|
|
265
|
-
const AVATAR_KEY_CACHE_FILE = resolvePath(
|
|
266
|
+
const AVATAR_KEY_CACHE_FILE = resolvePath(USER_DATA_DIR, "state", "avatar-image-keys.json");
|
|
266
267
|
const AVATAR_SOURCES: Record<string, string> = {
|
|
267
268
|
new: resolvePath(AVATAR_DIR, "status_new.png"),
|
|
268
269
|
busy: resolvePath(AVATAR_DIR, "status_busy.png"),
|
|
@@ -409,8 +410,8 @@ export async function setChatAvatar(token: string, chatId: string, tool: string,
|
|
|
409
410
|
// Image download & cache
|
|
410
411
|
// ---------------------------------------------------------------------------
|
|
411
412
|
|
|
412
|
-
const IMAGE_DOWNLOAD_DIR = resolvePath(
|
|
413
|
-
const IMAGE_CACHE_FILE = resolvePath(
|
|
413
|
+
const IMAGE_DOWNLOAD_DIR = resolvePath(USER_DATA_DIR, "images", "downloads");
|
|
414
|
+
const IMAGE_CACHE_FILE = resolvePath(USER_DATA_DIR, "state", "image-cache.json");
|
|
414
415
|
|
|
415
416
|
const imageCache = new Map<string, string>();
|
|
416
417
|
let imageCacheLoaded = false;
|
|
@@ -424,7 +425,7 @@ async function loadImageCache(): Promise<void> {
|
|
|
424
425
|
for (const [key, value] of Object.entries(parsed)) {
|
|
425
426
|
if (typeof value === "string" && value.trim()) imageCache.set(key, value);
|
|
426
427
|
}
|
|
427
|
-
} catch { /* missing or malformed cache is not fatal */ }
|
|
428
|
+
} catch { /* missing or malformed cache is not fatal */ }
|
|
428
429
|
}
|
|
429
430
|
|
|
430
431
|
async function persistImageCache(): Promise<void> {
|
|
@@ -475,7 +476,7 @@ export async function getOrDownloadImage(token: string, messageId: string, fileK
|
|
|
475
476
|
await persistImageCache().catch((err) => {
|
|
476
477
|
console.error(`[${ts()}] [IMAGE] persist cache FAIL: ${(err as Error).message}`);
|
|
477
478
|
});
|
|
478
|
-
console.log(`[${ts()}] [IMAGE] Downloaded ${fileKey} -> ${localPath}`);
|
|
479
|
+
console.log(`[${ts()}] [IMAGE] Downloaded ${fileKey} -> ${localPath}`);
|
|
479
480
|
return localPath;
|
|
480
481
|
}
|
|
481
482
|
|
|
@@ -521,23 +522,103 @@ export async function sendTextReply(
|
|
|
521
522
|
token: string,
|
|
522
523
|
chatId: string,
|
|
523
524
|
text: string
|
|
524
|
-
): Promise<
|
|
525
|
+
): Promise<boolean> {
|
|
525
526
|
const card = JSON.stringify({
|
|
526
527
|
config: { wide_screen_mode: true },
|
|
527
528
|
elements: [{ tag: "markdown", content: text }],
|
|
528
529
|
});
|
|
529
|
-
|
|
530
|
+
try {
|
|
531
|
+
const resp = await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
|
|
532
|
+
method: "POST",
|
|
533
|
+
headers: {
|
|
534
|
+
Authorization: `Bearer ${token}`,
|
|
535
|
+
"Content-Type": "application/json",
|
|
536
|
+
},
|
|
537
|
+
body: JSON.stringify({
|
|
538
|
+
receive_id: chatId,
|
|
539
|
+
msg_type: "interactive",
|
|
540
|
+
content: card,
|
|
541
|
+
}),
|
|
542
|
+
});
|
|
543
|
+
const data = (await resp.json().catch(() => ({}))) as { code: number; msg?: string; data?: { message_id?: string } };
|
|
544
|
+
if (data.code !== 0) {
|
|
545
|
+
console.error(`[${ts()}] [SEND] text FAIL: chatId=${chatId} code=${data.code} msg="${data.msg ?? ""}"`);
|
|
546
|
+
return false;
|
|
547
|
+
}
|
|
548
|
+
console.log(`[${ts()}] [SEND] text OK: chatId=${chatId} msgId=${data.data?.message_id ?? "N/A"}`);
|
|
549
|
+
return true;
|
|
550
|
+
} catch (err) {
|
|
551
|
+
console.error(`[${ts()}] [SEND] text FAIL: chatId=${chatId} ${(err as Error).message}`);
|
|
552
|
+
return false;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function messageImageContentType(imagePath: string): string {
|
|
557
|
+
const ext = extname(imagePath).toLowerCase();
|
|
558
|
+
if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
|
|
559
|
+
if (ext === ".webp") return "image/webp";
|
|
560
|
+
if (ext === ".gif") return "image/gif";
|
|
561
|
+
if (ext === ".bmp") return "image/bmp";
|
|
562
|
+
return "image/png";
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
async function uploadMessageImage(token: string, imagePath: string): Promise<string> {
|
|
566
|
+
const buffer = await readFile(imagePath);
|
|
567
|
+
const blob = new Blob([new Uint8Array(buffer)], {
|
|
568
|
+
type: messageImageContentType(imagePath),
|
|
569
|
+
});
|
|
570
|
+
const form = new FormData();
|
|
571
|
+
form.append("image_type", "message");
|
|
572
|
+
form.append("image", blob, imagePath.split(/[\\/]/).pop() || "image.png");
|
|
573
|
+
|
|
574
|
+
const resp = await fetch(`${BASE_URL}/im/v1/images`, {
|
|
530
575
|
method: "POST",
|
|
531
|
-
headers: {
|
|
532
|
-
|
|
533
|
-
"Content-Type": "application/json",
|
|
534
|
-
},
|
|
535
|
-
body: JSON.stringify({
|
|
536
|
-
receive_id: chatId,
|
|
537
|
-
msg_type: "interactive",
|
|
538
|
-
content: card,
|
|
539
|
-
}),
|
|
576
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
577
|
+
body: form,
|
|
540
578
|
});
|
|
579
|
+
const text = await resp.text();
|
|
580
|
+
let data: { code: number; msg?: string; data?: { image_key?: string } };
|
|
581
|
+
try { data = JSON.parse(text); } catch {
|
|
582
|
+
throw new Error(`uploadMessageImage non-JSON response: ${text.slice(0, 200)}`);
|
|
583
|
+
}
|
|
584
|
+
if (data.code !== 0) throw new Error(`[${data.code}] ${data.msg}`);
|
|
585
|
+
const imageKey = data.data?.image_key;
|
|
586
|
+
if (!imageKey) throw new Error("uploadMessageImage response missing image_key");
|
|
587
|
+
return imageKey;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
export async function sendImageReply(
|
|
591
|
+
token: string,
|
|
592
|
+
chatId: string,
|
|
593
|
+
imagePath: string,
|
|
594
|
+
): Promise<boolean> {
|
|
595
|
+
try {
|
|
596
|
+
const imageKey = await uploadMessageImage(token, imagePath);
|
|
597
|
+
const resp = await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
|
|
598
|
+
method: "POST",
|
|
599
|
+
headers: {
|
|
600
|
+
Authorization: `Bearer ${token}`,
|
|
601
|
+
"Content-Type": "application/json",
|
|
602
|
+
},
|
|
603
|
+
body: JSON.stringify({
|
|
604
|
+
receive_id: chatId,
|
|
605
|
+
msg_type: "image",
|
|
606
|
+
content: JSON.stringify({ image_key: imageKey }),
|
|
607
|
+
}),
|
|
608
|
+
});
|
|
609
|
+
const data = (await resp.json().catch(() => ({}))) as { code?: number; msg?: string; data?: { message_id?: string } };
|
|
610
|
+
if (data.code !== 0) {
|
|
611
|
+
console.error(`[${ts()}] [SEND] image FAIL: chatId=${chatId} path=${imagePath} code=${data.code} msg="${data.msg ?? ""}"`);
|
|
612
|
+
throw new Error(`[${data.code}] ${data.msg ?? "send image failed"}`);
|
|
613
|
+
}
|
|
614
|
+
console.log(`[${ts()}] [SEND] image OK: chatId=${chatId} msgId=${data.data?.message_id ?? "N/A"}`);
|
|
615
|
+
return true;
|
|
616
|
+
} catch (err) {
|
|
617
|
+
if (!(err instanceof Error && err.message.startsWith("["))) {
|
|
618
|
+
console.error(`[${ts()}] [SEND] image FAIL: chatId=${chatId} path=${imagePath} ${(err as Error).message}`);
|
|
619
|
+
}
|
|
620
|
+
throw err;
|
|
621
|
+
}
|
|
541
622
|
}
|
|
542
623
|
|
|
543
624
|
export async function addReaction(
|
|
@@ -561,21 +642,33 @@ export async function sendCardReply(
|
|
|
561
642
|
title: string,
|
|
562
643
|
content: string,
|
|
563
644
|
template = "green"
|
|
564
|
-
): Promise<
|
|
645
|
+
): Promise<boolean> {
|
|
565
646
|
const card = JSON.stringify({
|
|
566
647
|
config: { wide_screen_mode: true },
|
|
567
648
|
header: { template, title: { content: title, tag: "plain_text" } },
|
|
568
649
|
elements: [{ tag: "div", text: { tag: "lark_md", content } }],
|
|
569
650
|
});
|
|
570
651
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
652
|
+
try {
|
|
653
|
+
const resp = await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
|
|
654
|
+
method: "POST",
|
|
655
|
+
headers: {
|
|
656
|
+
Authorization: `Bearer ${token}`,
|
|
657
|
+
"Content-Type": "application/json",
|
|
658
|
+
},
|
|
659
|
+
body: JSON.stringify({ receive_id: chatId, msg_type: "interactive", content: card }),
|
|
660
|
+
});
|
|
661
|
+
const data = (await resp.json().catch(() => ({}))) as { code: number; msg?: string; data?: { message_id?: string } };
|
|
662
|
+
if (data.code !== 0) {
|
|
663
|
+
console.error(`[${ts()}] [SEND] card FAIL: chatId=${chatId} title="${title}" code=${data.code} msg="${data.msg ?? ""}"`);
|
|
664
|
+
return false;
|
|
665
|
+
}
|
|
666
|
+
console.log(`[${ts()}] [SEND] card OK: chatId=${chatId} title="${title}" msgId=${data.data?.message_id ?? "N/A"}`);
|
|
667
|
+
return true;
|
|
668
|
+
} catch (err) {
|
|
669
|
+
console.error(`[${ts()}] [SEND] card FAIL: chatId=${chatId} title="${title}" ${(err as Error).message}`);
|
|
670
|
+
return false;
|
|
671
|
+
}
|
|
579
672
|
}
|
|
580
673
|
|
|
581
674
|
// 重启后,向最后有发言的会话发送 "已重启" 卡片(基于 chat_logs 的文件修改时间)
|
package/src/index.ts
CHANGED
|
@@ -31,7 +31,8 @@ import { WSClient, EventDispatcher } from "@larksuiteoapi/node-sdk";
|
|
|
31
31
|
import WebSocket from "ws";
|
|
32
32
|
|
|
33
33
|
import { appendStartupTrace, attachRelayWebSocket, ensureSingleInstance, freeRelayListenPort, installCrashLogging } from "./shared.ts";
|
|
34
|
-
import { createUiRouter, setReloadConfigHook, startSetupMode } from "./web-ui.ts";
|
|
34
|
+
import { createUiRouter, setExtraApiHandler, setReloadConfigHook, startSetupMode } from "./web-ui.ts";
|
|
35
|
+
import { makeTraceId, logTrace } from "./trace.ts";
|
|
35
36
|
import {
|
|
36
37
|
CHATCCC_PORT,
|
|
37
38
|
APP_ID,
|
|
@@ -81,6 +82,7 @@ import {
|
|
|
81
82
|
} from "./feishu-api.ts";
|
|
82
83
|
import { buildHelpCard, buildStatusCard, buildProgressCard, buildCdContent, buildCdCard, buildSessionsCard } from "./cards.ts";
|
|
83
84
|
import { updateCardKitCard } from "./cardkit.ts";
|
|
85
|
+
import { handleAgentImageRequest } from "./agent-image-rpc.ts";
|
|
84
86
|
import { formatGitResult, gitResultHeaderTemplate, runGitCommand } from "./git-command.ts";
|
|
85
87
|
import {
|
|
86
88
|
MAX_PROCESSED,
|
|
@@ -113,7 +115,7 @@ function isUntitledSessionChatName(name: string): boolean {
|
|
|
113
115
|
// ---------------------------------------------------------------------------
|
|
114
116
|
|
|
115
117
|
interface InnerEvent {
|
|
116
|
-
message?: { message_id?: string; message_type?: string; content?: string; chat_id?: string; create_time?: string };
|
|
118
|
+
message?: { message_id?: string; message_type?: string; content?: string; chat_id?: string; chat_type?: string; create_time?: string };
|
|
117
119
|
sender?: { sender_id?: { open_id?: string; union_id?: string } };
|
|
118
120
|
}
|
|
119
121
|
|
|
@@ -242,10 +244,13 @@ let broadcastToRelay: (data: unknown) => void = () => {};
|
|
|
242
244
|
// Command handler
|
|
243
245
|
// ---------------------------------------------------------------------------
|
|
244
246
|
|
|
245
|
-
async function handleCommand(text: string, chatId: string, openId: string, msgTimestamp: number): Promise<void> {
|
|
247
|
+
async function handleCommand(text: string, chatId: string, openId: string, msgTimestamp: number, chatType = "group", traceId?: string): Promise<void> {
|
|
248
|
+
const tid = traceId ?? makeTraceId();
|
|
246
249
|
if (text === "/restart") {
|
|
250
|
+
logTrace(tid, "BRANCH", { cmd: "/restart" });
|
|
247
251
|
const restartToken = await getTenantAccessToken();
|
|
248
252
|
await sendTextReply(restartToken, chatId, "正在重启...").catch(() => {});
|
|
253
|
+
logTrace(tid, "DONE", { outcome: "restart" });
|
|
249
254
|
console.log(`[${ts()}] [RESTART] Spawning new process...`);
|
|
250
255
|
const child = spawn("npx", ["tsx", "src/index.ts"], {
|
|
251
256
|
cwd: PROJECT_ROOT,
|
|
@@ -259,6 +264,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
259
264
|
}
|
|
260
265
|
|
|
261
266
|
if (text === "/cd" || text.startsWith("/cd ")) {
|
|
267
|
+
logTrace(tid, "BRANCH", { cmd: "/cd", arg: text.slice(3).trim() || "(none)" });
|
|
262
268
|
const cdToken = await getTenantAccessToken();
|
|
263
269
|
const currentDir = await getDefaultCwd();
|
|
264
270
|
|
|
@@ -290,10 +296,12 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
290
296
|
try {
|
|
291
297
|
const s = await stat(targetDir);
|
|
292
298
|
if (!s.isDirectory()) {
|
|
299
|
+
logTrace(tid, "DONE", { outcome: "cd_not_dir", targetDir });
|
|
293
300
|
await sendCardReply(cdToken, chatId, "新会话工作路径", `路径存在但不是目录:\n\`${targetDir}\``, "red");
|
|
294
301
|
return;
|
|
295
302
|
}
|
|
296
303
|
} catch {
|
|
304
|
+
logTrace(tid, "DONE", { outcome: "cd_not_found", targetDir });
|
|
297
305
|
await sendCardReply(cdToken, chatId, "新会话工作路径", `路径不存在:\n\`${targetDir}\``, "red");
|
|
298
306
|
return;
|
|
299
307
|
}
|
|
@@ -310,6 +318,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
310
318
|
try {
|
|
311
319
|
entries = await readdir(targetDir);
|
|
312
320
|
} catch (err) {
|
|
321
|
+
logTrace(tid, "DONE", { outcome: "cd_readdir_fail", error: (err as Error).message });
|
|
313
322
|
await sendCardReply(cdToken, chatId, "新会话工作路径", `无法读取目录:\n\`${targetDir}\`\n\n${(err as Error).message}`, "red");
|
|
314
323
|
return;
|
|
315
324
|
}
|
|
@@ -341,10 +350,12 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
341
350
|
});
|
|
342
351
|
const respData: Record<string, any> = await resp.json().catch(() => ({}));
|
|
343
352
|
console.log(`[${ts()}] [CD] card sent, code=${respData.code}, msgId=${respData.data?.message_id ?? "N/A"}, recentDirs=${recentDirs.length}`);
|
|
353
|
+
logTrace(tid, "DONE", { outcome: "cd_card", code: respData.code, msgId: respData.data?.message_id });
|
|
344
354
|
} else {
|
|
345
355
|
// /cd <path>:切换目录,发送文本卡片
|
|
346
356
|
const content = buildCdContent(targetDir, withStats, isUpdate, sessionCwd);
|
|
347
357
|
await sendCardReply(cdToken, chatId, "新会话工作路径", content, "blue");
|
|
358
|
+
logTrace(tid, "DONE", { outcome: "cd_path", targetDir, isUpdate });
|
|
348
359
|
}
|
|
349
360
|
return;
|
|
350
361
|
}
|
|
@@ -352,8 +363,10 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
352
363
|
if (text === "/new" || text.startsWith("/new ")) {
|
|
353
364
|
const toolArg = text.slice(5).trim();
|
|
354
365
|
const tool = toolArg || "claude";
|
|
366
|
+
logTrace(tid, "BRANCH", { cmd: "/new", tool });
|
|
355
367
|
const validTools = ["claude", "cursor", "codex"];
|
|
356
368
|
if (!validTools.includes(tool)) {
|
|
369
|
+
logTrace(tid, "DONE", { outcome: "new_invalid_tool", tool });
|
|
357
370
|
const warnToken = await getTenantAccessToken();
|
|
358
371
|
await sendCardReply(warnToken, chatId, "Error", `未知的工具类型: "${toolArg}"。支持: claude (Claude Code), cursor (Cursor), codex (Codex)。`, "red");
|
|
359
372
|
return;
|
|
@@ -361,6 +374,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
361
374
|
const toolLabel = toolDisplayName(tool);
|
|
362
375
|
|
|
363
376
|
if (!openId) {
|
|
377
|
+
logTrace(tid, "DONE", { outcome: "new_no_openid" });
|
|
364
378
|
console.log(`[${ts()}] [WARN] Cannot get sender open_id`);
|
|
365
379
|
const warnToken = await getTenantAccessToken();
|
|
366
380
|
await sendCardReply(warnToken, chatId, "Error", "Cannot identify sender.", "red");
|
|
@@ -378,6 +392,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
378
392
|
console.log(`[${ts()}] [STEP 1/4] ${toolLabel} session created: ${sessionId} → OK`);
|
|
379
393
|
} catch (err) {
|
|
380
394
|
console.error(`[${ts()}] [STEP 1/4] FAIL: ${(err as Error).message}`);
|
|
395
|
+
logTrace(tid, "DONE", { outcome: "new_session_fail", error: (err as Error).message });
|
|
381
396
|
await sendCardReply(
|
|
382
397
|
freshToken, chatId, "Error",
|
|
383
398
|
`Failed to initialize ${toolLabel} session:\n${(err as Error).message}`,
|
|
@@ -395,6 +410,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
395
410
|
console.log(`[${ts()}] [STEP 2/4] Created Feishu group: ${newChatId} → OK`);
|
|
396
411
|
} catch (err) {
|
|
397
412
|
console.error(`[${ts()}] [STEP 2/4] FAIL: ${(err as Error).message}`);
|
|
413
|
+
logTrace(tid, "DONE", { outcome: "new_group_fail", error: (err as Error).message });
|
|
398
414
|
await sendCardReply(freshToken, chatId, "Error", `Failed to create group:\n${(err as Error).message}`, "red");
|
|
399
415
|
return;
|
|
400
416
|
}
|
|
@@ -405,6 +421,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
405
421
|
console.log(`[${ts()}] [STEP 3/4] Renamed group → name="${initialName}" (${toolLabel}) → OK`);
|
|
406
422
|
} catch (err) {
|
|
407
423
|
console.error(`[${ts()}] [STEP 3/4] FAIL: ${(err as Error).message}`);
|
|
424
|
+
logTrace(tid, "DONE", { outcome: "new_rename_fail", error: (err as Error).message });
|
|
408
425
|
await sendCardReply(freshToken, chatId, "Error", `Group created but rename failed:\n${(err as Error).message}`, "yellow");
|
|
409
426
|
return;
|
|
410
427
|
}
|
|
@@ -422,11 +439,14 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
422
439
|
);
|
|
423
440
|
|
|
424
441
|
console.log(`[${ts()}] [STEP 4/4] Replied to new group → OK`);
|
|
442
|
+
logTrace(tid, "DONE", { outcome: "session_ready", newChatId, sessionId, tool });
|
|
425
443
|
setChatAvatar(freshToken, newChatId, tool, "new").catch(() => {});
|
|
426
444
|
console.log(`${"=".repeat(60)}`);
|
|
427
445
|
return;
|
|
428
446
|
}
|
|
429
447
|
|
|
448
|
+
// 私聊没有群 description,无法存储/获取 session ID,跳过群聊检测直接发 help card
|
|
449
|
+
if (chatType !== "p2p") {
|
|
430
450
|
try {
|
|
431
451
|
const token = await getTenantAccessToken();
|
|
432
452
|
const chatInfo = await getChatInfo(token, chatId);
|
|
@@ -437,6 +457,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
437
457
|
const sessionId = sessionInfo.sessionId;
|
|
438
458
|
const descriptionTool = sessionInfo.tool;
|
|
439
459
|
const toolLabel = toolDisplayName(descriptionTool);
|
|
460
|
+
logTrace(tid, "BRANCH", { sessionId, tool: descriptionTool });
|
|
440
461
|
console.log(`[${ts()}] [RESUME] ${toolLabel} session group detected, session=${sessionId} tool=${descriptionTool}`);
|
|
441
462
|
|
|
442
463
|
const freshToken = await getTenantAccessToken();
|
|
@@ -457,6 +478,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
457
478
|
}
|
|
458
479
|
|
|
459
480
|
if (text === "/stop") {
|
|
481
|
+
logTrace(tid, "BRANCH", { cmd: "/stop" });
|
|
460
482
|
const cEntry = chatSessionMap.get(chatId);
|
|
461
483
|
if (cEntry) {
|
|
462
484
|
cEntry.stopped = true;
|
|
@@ -464,13 +486,16 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
464
486
|
cEntry.close();
|
|
465
487
|
console.log(`[${ts()}] [STOP] User sent /stop, session=${sessionId}`);
|
|
466
488
|
await sendTextReply(freshToken, chatId, "会话已停止。").catch(() => {});
|
|
489
|
+
logTrace(tid, "DONE", { outcome: "stopped" });
|
|
467
490
|
} else {
|
|
468
491
|
await sendTextReply(freshToken, chatId, "当前没有正在进行的会话。").catch(() => {});
|
|
492
|
+
logTrace(tid, "DONE", { outcome: "stop_no_session" });
|
|
469
493
|
}
|
|
470
494
|
return;
|
|
471
495
|
}
|
|
472
496
|
|
|
473
497
|
if (text === "/status") {
|
|
498
|
+
logTrace(tid, "BRANCH", { cmd: "/status" });
|
|
474
499
|
const status = await getSessionStatus(chatId);
|
|
475
500
|
const running = chatSessionMap.get(chatId);
|
|
476
501
|
const isActive = running && !running.stopped;
|
|
@@ -507,10 +532,12 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
507
532
|
});
|
|
508
533
|
const statusRespData: Record<string, any> = await statusResp.json().catch(() => ({}));
|
|
509
534
|
console.log(`[${ts()}] [STATUS] card sent, code=${statusRespData.code}, msgId=${statusRespData.data?.message_id ?? "N/A"}`);
|
|
535
|
+
logTrace(tid, "DONE", { outcome: "status", code: statusRespData.code });
|
|
510
536
|
return;
|
|
511
537
|
}
|
|
512
538
|
|
|
513
539
|
if (text === "/sessions") {
|
|
540
|
+
logTrace(tid, "BRANCH", { cmd: "/sessions" });
|
|
514
541
|
const allSessions = await getAllSessionsStatus();
|
|
515
542
|
const now = Date.now();
|
|
516
543
|
const cardData = allSessions.map(s => ({
|
|
@@ -532,10 +559,12 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
532
559
|
});
|
|
533
560
|
const sessionsRespData: Record<string, any> = await sessionsResp.json().catch(() => ({}));
|
|
534
561
|
console.log(`[${ts()}] [SESSIONS] card sent, code=${sessionsRespData.code}, count=${allSessions.length}`);
|
|
562
|
+
logTrace(tid, "DONE", { outcome: "sessions", code: sessionsRespData.code, count: allSessions.length });
|
|
535
563
|
return;
|
|
536
564
|
}
|
|
537
565
|
|
|
538
566
|
if (text === "/forget") {
|
|
567
|
+
logTrace(tid, "BRANCH", { cmd: "/forget" });
|
|
539
568
|
const adapter = getAdapterForTool(descriptionTool);
|
|
540
569
|
let cwd: string;
|
|
541
570
|
try {
|
|
@@ -558,6 +587,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
558
587
|
const init = await initClaudeSession(descriptionTool, cwd);
|
|
559
588
|
newSessionId = init.sessionId;
|
|
560
589
|
} catch (err) {
|
|
590
|
+
logTrace(tid, "DONE", { outcome: "forget_session_fail", error: (err as Error).message });
|
|
561
591
|
await sendCardReply(freshToken, chatId, "Error", `Failed to create new session:\n${(err as Error).message}`, "red");
|
|
562
592
|
return;
|
|
563
593
|
}
|
|
@@ -587,6 +617,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
587
617
|
);
|
|
588
618
|
|
|
589
619
|
console.log(`[${ts()}] [FORGET] Session ${sessionId} → ${newSessionId} (same cwd=${cwd})`);
|
|
620
|
+
logTrace(tid, "DONE", { outcome: "forget", newSessionId, cwd });
|
|
590
621
|
return;
|
|
591
622
|
}
|
|
592
623
|
|
|
@@ -595,7 +626,9 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
595
626
|
// getDefaultCwd(下一次 /new 才会使用的默认路径)。
|
|
596
627
|
if (text.startsWith("/git ") || text === "/git") {
|
|
597
628
|
const args = text === "/git" ? "" : text.slice(5).trim();
|
|
629
|
+
logTrace(tid, "BRANCH", { cmd: "/git", args: args || "(none)" });
|
|
598
630
|
if (!args) {
|
|
631
|
+
logTrace(tid, "DONE", { outcome: "git_no_args" });
|
|
599
632
|
await sendCardReply(
|
|
600
633
|
freshToken, chatId, "/git",
|
|
601
634
|
"用法:`/git <子命令> [参数]`,例如 `/git status`、`/git log --oneline -n 5`。",
|
|
@@ -613,6 +646,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
613
646
|
console.error(`[${ts()}] [GIT] getSessionInfo FAIL: ${(err as Error).message}`);
|
|
614
647
|
}
|
|
615
648
|
if (!cwd) {
|
|
649
|
+
logTrace(tid, "DONE", { outcome: "git_no_cwd", tool: descriptionTool });
|
|
616
650
|
// Cursor 会话的 cwd 依赖 state/cursor-session-meta.json 持久化映射;
|
|
617
651
|
// 升级前创建的旧会话或映射文件丢失时,向会话发送一次普通消息即可触发
|
|
618
652
|
// adapter 自动学习并补全(resume 流首条 init 事件携带 cwd)。
|
|
@@ -630,12 +664,14 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
630
664
|
const content = formatGitResult(args, cwd, result);
|
|
631
665
|
const template = gitResultHeaderTemplate(result);
|
|
632
666
|
await sendCardReply(freshToken, chatId, "/git 输出", content, template);
|
|
667
|
+
logTrace(tid, "DONE", { outcome: "git_result", exitCode: result.exitCode, durationMs: result.durationMs });
|
|
633
668
|
return;
|
|
634
669
|
}
|
|
635
670
|
|
|
636
671
|
const existing = chatSessionMap.get(chatId);
|
|
637
672
|
if (existing && !existing.stopped) {
|
|
638
673
|
if (msgTimestamp <= existing.msgTimestamp) {
|
|
674
|
+
logTrace(tid, "DONE", { outcome: "skip_old_message", msgTimestamp, existingTimestamp: existing.msgTimestamp });
|
|
639
675
|
console.log(`[${ts()}] [SKIP] Older message (${msgTimestamp} <= ${existing.msgTimestamp}), ignoring`);
|
|
640
676
|
return;
|
|
641
677
|
}
|
|
@@ -644,6 +680,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
644
680
|
existing.close();
|
|
645
681
|
chatSessionMap.delete(chatId);
|
|
646
682
|
console.log(`[${ts()}] [INTERRUPT] New message arrived, cancelled previous session ${sessionId}`);
|
|
683
|
+
logTrace(tid, "INTERRUPT", { oldSessionId: sessionId });
|
|
647
684
|
if (existing.cardId) {
|
|
648
685
|
while (existing.cardBusy) {
|
|
649
686
|
await new Promise(r => setTimeout(r, 20));
|
|
@@ -662,9 +699,12 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
662
699
|
}
|
|
663
700
|
|
|
664
701
|
try {
|
|
665
|
-
|
|
702
|
+
logTrace(tid, "RESUME", { sessionId, tool: descriptionTool });
|
|
703
|
+
await resumeAndPrompt(sessionId, text, freshToken, chatId, msgTimestamp, descriptionTool, tid);
|
|
704
|
+
logTrace(tid, "DONE", { outcome: "resume_done", sessionId });
|
|
666
705
|
console.log(`[${ts()}] [RESUME] Session ${sessionId} done`);
|
|
667
706
|
} catch (err) {
|
|
707
|
+
logTrace(tid, "DONE", { outcome: "resume_fail", error: (err as Error).message });
|
|
668
708
|
console.error(`[${ts()}] [RESUME] FAIL: ${(err as Error).message}`);
|
|
669
709
|
fileLog.flush();
|
|
670
710
|
await sendCardReply(
|
|
@@ -675,13 +715,19 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
675
715
|
}
|
|
676
716
|
return;
|
|
677
717
|
}
|
|
718
|
+
// 群聊但 description 中没有 session info → 也走 help card
|
|
719
|
+
logTrace(tid, "BRANCH", { reason: "no_session_info_in_group" });
|
|
678
720
|
} catch (err) {
|
|
721
|
+
logTrace(tid, "BRANCH", { reason: "get_chat_info_failed", error: (err as Error).message });
|
|
679
722
|
console.log(`[${ts()}] [INFO] Cannot get chat info for ${chatId}: ${(err as Error).message}`);
|
|
680
723
|
}
|
|
724
|
+
} // end if (chatType !== "p2p")
|
|
681
725
|
|
|
726
|
+
// 私聊或群聊无 session info → 发送 help card
|
|
727
|
+
logTrace(tid, "SEND", { method: "help_card", chatId });
|
|
682
728
|
const replyToken = await getTenantAccessToken();
|
|
683
729
|
const card = buildHelpCard(text);
|
|
684
|
-
await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
|
|
730
|
+
const helpResp = await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
|
|
685
731
|
method: "POST",
|
|
686
732
|
headers: {
|
|
687
733
|
Authorization: `Bearer ${replyToken}`,
|
|
@@ -689,6 +735,14 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
689
735
|
},
|
|
690
736
|
body: JSON.stringify({ receive_id: chatId, msg_type: "interactive", content: card }),
|
|
691
737
|
});
|
|
738
|
+
const helpData = (await helpResp.json().catch(() => ({}))) as { code: number; msg?: string; data?: { message_id?: string } };
|
|
739
|
+
if (helpData.code !== 0) {
|
|
740
|
+
console.error(`[${ts()}] [SEND] help_card FAIL: chatId=${chatId} code=${helpData.code} msg="${helpData.msg ?? ""}"`);
|
|
741
|
+
logTrace(tid, "DONE", { outcome: "help_card_fail", code: helpData.code, msg: helpData.msg });
|
|
742
|
+
} else {
|
|
743
|
+
console.log(`[${ts()}] [SEND] help_card OK: chatId=${chatId} msgId=${helpData.data?.message_id ?? "N/A"}`);
|
|
744
|
+
logTrace(tid, "DONE", { outcome: "help_card_sent", msgId: helpData.data?.message_id });
|
|
745
|
+
}
|
|
692
746
|
}
|
|
693
747
|
|
|
694
748
|
// ---------------------------------------------------------------------------
|
|
@@ -779,6 +833,7 @@ async function startBotServiceCore(): Promise<void> {
|
|
|
779
833
|
const eventDispatcher = new EventDispatcher({});
|
|
780
834
|
eventDispatcher.register({
|
|
781
835
|
"im.message.receive_v1": async (data: Evt) => {
|
|
836
|
+
const traceId = makeTraceId();
|
|
782
837
|
try {
|
|
783
838
|
broadcastToRelay(data);
|
|
784
839
|
|
|
@@ -803,8 +858,9 @@ async function startBotServiceCore(): Promise<void> {
|
|
|
803
858
|
const sender = event.sender;
|
|
804
859
|
const openId = sender?.sender_id?.open_id ?? "";
|
|
805
860
|
const chatId = message.chat_id ?? "";
|
|
861
|
+
const chatType = message.chat_type ?? "group";
|
|
806
862
|
|
|
807
|
-
console.log(`[MSG] sender=${openId} chat=${chatId} text="${text}"`);
|
|
863
|
+
console.log(`[MSG] sender=${openId} chat=${chatId} type=${chatType} text="${text}"`);
|
|
808
864
|
appendChatLog(chatId, openId, text);
|
|
809
865
|
|
|
810
866
|
if (messageId) {
|
|
@@ -819,13 +875,15 @@ async function startBotServiceCore(): Promise<void> {
|
|
|
819
875
|
|
|
820
876
|
if (!text) return;
|
|
821
877
|
const msgTimestamp = parseInt(message.create_time ?? "0", 10) || Date.now();
|
|
878
|
+
logTrace(traceId, "RECV", { chatId, chatType, text: text.slice(0, 100) });
|
|
822
879
|
const delayNotice = formatDelayNotice(msgTimestamp);
|
|
823
880
|
if (delayNotice) {
|
|
824
881
|
const delayToken = await getTenantAccessToken();
|
|
825
882
|
await sendCardReply(delayToken, chatId, "延迟送达", delayNotice, "yellow").catch(() => {});
|
|
826
883
|
}
|
|
827
|
-
await handleCommand(text, chatId, openId, msgTimestamp);
|
|
884
|
+
await handleCommand(text, chatId, openId, msgTimestamp, chatType, traceId);
|
|
828
885
|
} catch (err) {
|
|
886
|
+
logTrace(traceId, "ERROR", { message: (err as Error).message });
|
|
829
887
|
console.error(`[${ts()}] [FATAL] im.message.receive_v1 handler crashed: ${(err as Error).message}`);
|
|
830
888
|
}
|
|
831
889
|
},
|
|
@@ -990,6 +1048,7 @@ async function main(): Promise<void> {
|
|
|
990
1048
|
appIdMask: maskAppId(APP_ID),
|
|
991
1049
|
});
|
|
992
1050
|
});
|
|
1051
|
+
setExtraApiHandler(handleAgentImageRequest);
|
|
993
1052
|
|
|
994
1053
|
console.log(`[启动 2/7] 环境与凭证检查`);
|
|
995
1054
|
reportEnvironmentVariableReadout();
|
package/src/session.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
CLAUDE_BASE_URL,
|
|
7
7
|
CLAUDE_EFFORT,
|
|
8
8
|
CLAUDE_MODEL,
|
|
9
|
+
CHATCCC_PORT,
|
|
9
10
|
SESSIONS_FILE,
|
|
10
11
|
addRecentDir,
|
|
11
12
|
anthropicConfigDisplay,
|
|
@@ -23,11 +24,17 @@ import {
|
|
|
23
24
|
updateCardKitCard,
|
|
24
25
|
} from "./cardkit.ts";
|
|
25
26
|
import { sendTextReply, setChatAvatar } from "./feishu-api.ts";
|
|
27
|
+
import { logTrace } from "./trace.ts";
|
|
26
28
|
import type { UnifiedBlock } from "./adapters/adapter-interface.ts";
|
|
27
29
|
import type { ToolAdapter } from "./adapters/adapter-interface.ts";
|
|
28
30
|
import { createClaudeAdapter } from "./adapters/claude-adapter.ts";
|
|
29
31
|
import { createCursorAdapter } from "./adapters/cursor-adapter.ts";
|
|
30
32
|
import { createCodexAdapter } from "./adapters/codex-adapter.ts";
|
|
33
|
+
import {
|
|
34
|
+
buildAgentImageCapabilityPrompt,
|
|
35
|
+
createAgentImageGrant,
|
|
36
|
+
revokeAgentImageGrant,
|
|
37
|
+
} from "./agent-image-rpc.ts";
|
|
31
38
|
|
|
32
39
|
// ---------------------------------------------------------------------------
|
|
33
40
|
// Shared state (imported by index.ts)
|
|
@@ -251,7 +258,7 @@ export function accumulateBlockContent(
|
|
|
251
258
|
}
|
|
252
259
|
|
|
253
260
|
// ---------------------------------------------------------------------------
|
|
254
|
-
// AI tool session management
|
|
261
|
+
// AI tool session management
|
|
255
262
|
// ---------------------------------------------------------------------------
|
|
256
263
|
|
|
257
264
|
/**
|
|
@@ -300,13 +307,34 @@ export async function resumeAndPrompt(
|
|
|
300
307
|
chatId: string,
|
|
301
308
|
msgTimestamp: number,
|
|
302
309
|
tool: string,
|
|
310
|
+
traceId?: string,
|
|
303
311
|
): Promise<void> {
|
|
312
|
+
const tid = traceId ?? "";
|
|
304
313
|
const adapter = getAdapterForTool(tool);
|
|
305
314
|
const info = await adapter.getSessionInfo(sessionId);
|
|
306
315
|
const cwd = info?.cwd ?? (await getDefaultCwd());
|
|
316
|
+
if (tid) logTrace(tid, "SESSION_START", { sessionId, tool, cwd, turn: (sessionInfoMap.get(chatId)?.turnCount ?? 0) + 1 });
|
|
307
317
|
console.log(
|
|
308
318
|
`[${ts()}] Resuming ${adapter.displayName} session: ${sessionId} (${formatToolConfigForLog(tool, info?.model)}, cwd=${cwd})`
|
|
309
319
|
);
|
|
320
|
+
const imageGrant = createAgentImageGrant({
|
|
321
|
+
chatId,
|
|
322
|
+
sessionId,
|
|
323
|
+
cwd,
|
|
324
|
+
port: CHATCCC_PORT,
|
|
325
|
+
traceId: tid || undefined,
|
|
326
|
+
});
|
|
327
|
+
const userTextWithCapabilities = [
|
|
328
|
+
buildAgentImageCapabilityPrompt({
|
|
329
|
+
url: imageGrant.url,
|
|
330
|
+
token: imageGrant.token,
|
|
331
|
+
cwd,
|
|
332
|
+
}),
|
|
333
|
+
"",
|
|
334
|
+
"[User message]",
|
|
335
|
+
userText,
|
|
336
|
+
"[/User message]",
|
|
337
|
+
].join("\n");
|
|
310
338
|
|
|
311
339
|
const controller = new AbortController();
|
|
312
340
|
|
|
@@ -338,6 +366,7 @@ export async function resumeAndPrompt(
|
|
|
338
366
|
|
|
339
367
|
let cardId: string | null = null;
|
|
340
368
|
cardId = await createCardKitCard(token, buildProgressCard("", { showStop: true, headerTitle: "生成中..." })).catch((err) => {
|
|
369
|
+
if (tid) logTrace(tid, "CARD_CREATE_FAIL", { error: (err as Error).message });
|
|
341
370
|
console.error(`[${ts()}] [CARDIKT] createCard FAIL: chatId=${chatId} ${(err as Error).message}`);
|
|
342
371
|
fileLog.flush();
|
|
343
372
|
sendTextReply(token, chatId, "⚠️ 流式卡片创建失败(可能因限流),将使用文本回复。").catch(() => {});
|
|
@@ -347,6 +376,7 @@ export async function resumeAndPrompt(
|
|
|
347
376
|
const cEntry = chatSessionMap.get(chatId);
|
|
348
377
|
if (cEntry) { cEntry.cardId = cardId; cEntry.sequence = 1; }
|
|
349
378
|
const sendOk = await sendCardKitMessage(token, chatId, cardId).catch((err) => {
|
|
379
|
+
if (tid) logTrace(tid, "CARD_SEND_FAIL", { cardId, error: (err as Error).message });
|
|
350
380
|
console.error(`[${ts()}] [CARDIKT] sendMessage FAIL: chatId=${chatId} cardId=${cardId} ${(err as Error).message}`);
|
|
351
381
|
fileLog.flush();
|
|
352
382
|
return false;
|
|
@@ -445,7 +475,7 @@ export async function resumeAndPrompt(
|
|
|
445
475
|
}
|
|
446
476
|
|
|
447
477
|
try {
|
|
448
|
-
for await (const unifiedMsg of adapter.prompt(sessionId,
|
|
478
|
+
for await (const unifiedMsg of adapter.prompt(sessionId, userTextWithCapabilities, cwd, controller.signal)) {
|
|
449
479
|
for (const block of unifiedMsg.blocks) {
|
|
450
480
|
accumulateBlockContent(block, state);
|
|
451
481
|
|
|
@@ -460,6 +490,7 @@ export async function resumeAndPrompt(
|
|
|
460
490
|
console.error(`[${ts()}] [STREAM] Error in stream loop: ${(streamErr as Error).message}`);
|
|
461
491
|
} finally {
|
|
462
492
|
if (sendInterval) clearInterval(sendInterval);
|
|
493
|
+
revokeAgentImageGrant(imageGrant.token);
|
|
463
494
|
}
|
|
464
495
|
|
|
465
496
|
const cEntry = chatSessionMap.get(chatId);
|
|
@@ -501,6 +532,7 @@ export async function resumeAndPrompt(
|
|
|
501
532
|
);
|
|
502
533
|
}
|
|
503
534
|
console.log(`[${ts()}] Session ${sessionId} stopped by user (content chunks: ${state.chunkCount})`);
|
|
535
|
+
if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "stopped", chunks: state.chunkCount });
|
|
504
536
|
return;
|
|
505
537
|
}
|
|
506
538
|
|
|
@@ -530,6 +562,7 @@ export async function resumeAndPrompt(
|
|
|
530
562
|
}
|
|
531
563
|
|
|
532
564
|
console.log(`[${ts()}] Session ${sessionId} stream complete (content chunks: ${state.chunkCount})`);
|
|
565
|
+
if (tid) logTrace(tid, "SESSION_END", { sessionId, chunks: state.chunkCount, finalTextLen: finalReply.length });
|
|
533
566
|
}
|
|
534
567
|
|
|
535
568
|
// ---------------------------------------------------------------------------
|
package/src/shared.ts
CHANGED
|
@@ -9,14 +9,14 @@ import {
|
|
|
9
9
|
writeFileSync,
|
|
10
10
|
} from "node:fs";
|
|
11
11
|
import { createServer } from "node:http";
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
14
|
import { WebSocketServer, WebSocket } from "ws";
|
|
15
15
|
|
|
16
16
|
import { printServiceDidNotStart } from "./exit-banner.ts";
|
|
17
17
|
|
|
18
18
|
/** 与 config.LOG_DIR 一致(避免 shared 依赖 config 造成循环引用) */
|
|
19
|
-
const BANNER_LOG_DIR = join(
|
|
19
|
+
const BANNER_LOG_DIR = join(homedir(), ".chatccc", "logs");
|
|
20
20
|
|
|
21
21
|
const STARTUP_TRACE_FILE = join(BANNER_LOG_DIR, "startup-trace.log");
|
|
22
22
|
|
package/src/trace.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 轻量级消息全链路 trace 工具。
|
|
3
|
+
*
|
|
4
|
+
* 每条消息生成唯一 traceId,在关键决策点、API 调用、消息发送时输出结构化日志。
|
|
5
|
+
* grep traceId 即可看到该消息的完整 RECV → BRANCH → SEND → DONE 链路。
|
|
6
|
+
*
|
|
7
|
+
* 设计原则:trace 自身绝不能抛异常导致主流程中断——所有函数内都有 try/catch 兜底。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
let _traceSeq = 0;
|
|
11
|
+
|
|
12
|
+
/** 基于时间戳 + 递增序号生成短 trace ID(约 10 字符) */
|
|
13
|
+
export function makeTraceId(): string {
|
|
14
|
+
try {
|
|
15
|
+
_traceSeq++;
|
|
16
|
+
const ts = Date.now().toString(36).slice(-6);
|
|
17
|
+
const seq = _traceSeq.toString(36).padStart(3, "0");
|
|
18
|
+
return `${ts}${seq}`;
|
|
19
|
+
} catch {
|
|
20
|
+
// 极端情况(计数器溢出等)降级为纯时间戳
|
|
21
|
+
return Date.now().toString(36);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 输出一条 trace 日志。
|
|
27
|
+
* detail 中的值做浅层截断(字符串 > 200 字符会截断),避免日志膨胀。
|
|
28
|
+
*/
|
|
29
|
+
export function logTrace(traceId: string, step: string, detail?: Record<string, unknown>): void {
|
|
30
|
+
try {
|
|
31
|
+
const safe: Record<string, unknown> = {};
|
|
32
|
+
if (detail) {
|
|
33
|
+
for (const [k, v] of Object.entries(detail)) {
|
|
34
|
+
if (typeof v === "string" && v.length > 200) {
|
|
35
|
+
safe[k] = v.slice(0, 200) + "...(truncated)";
|
|
36
|
+
} else {
|
|
37
|
+
safe[k] = v;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const extra = Object.keys(safe).length > 0 ? " " + JSON.stringify(safe) : "";
|
|
42
|
+
console.log(`[TRACE ${traceId}] ${step}${extra}`);
|
|
43
|
+
} catch {
|
|
44
|
+
// trace 自身绝不能抛异常
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 重置序号(仅单测使用,确保 trace ID 可预测) */
|
|
49
|
+
export function _resetTraceSeqForTest(): void {
|
|
50
|
+
_traceSeq = 0;
|
|
51
|
+
}
|
package/src/web-ui.ts
CHANGED
|
@@ -9,15 +9,17 @@
|
|
|
9
9
|
import { createServer, IncomingMessage, ServerResponse } from "node:http";
|
|
10
10
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
11
|
import { readFile, writeFile, stat } from "node:fs/promises";
|
|
12
|
+
import { homedir } from "node:os";
|
|
12
13
|
import { join, dirname } from "node:path";
|
|
13
14
|
import { fileURLToPath } from "node:url";
|
|
14
15
|
import { spawn, execSync } from "node:child_process";
|
|
15
16
|
|
|
16
17
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
17
18
|
const PROJECT_ROOT = join(__dirname, "..");
|
|
18
|
-
const
|
|
19
|
+
const USER_DATA_DIR = join(homedir(), ".chatccc");
|
|
20
|
+
const CONFIG_FILE = join(USER_DATA_DIR, "config.json");
|
|
19
21
|
const CONFIG_SAMPLE_FILE = join(PROJECT_ROOT, "config.sample.json");
|
|
20
|
-
const PID_FILE = join(
|
|
22
|
+
const PID_FILE = join(USER_DATA_DIR, "state", "runtime.pid");
|
|
21
23
|
|
|
22
24
|
// ---------------------------------------------------------------------------
|
|
23
25
|
// Helpers — config.json parsing & generation
|
|
@@ -1570,6 +1572,8 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise
|
|
|
1570
1572
|
const url = req.url ?? "/";
|
|
1571
1573
|
const method = req.method ?? "GET";
|
|
1572
1574
|
|
|
1575
|
+
if (extraApiHandler && await extraApiHandler(req, res)) return;
|
|
1576
|
+
|
|
1573
1577
|
// API routes
|
|
1574
1578
|
if (url === "/api/check" && method === "GET") return handleApiCheck(req, res);
|
|
1575
1579
|
if (url === "/api/config" && method === "GET") return handleGetConfig(req, res);
|
|
@@ -1662,6 +1666,8 @@ let setupActivateHook: SetupActivateHook | null = null;
|
|
|
1662
1666
|
// 由 index.ts 注入,因为 web-ui.ts 自身**不应**直接 import config.ts——后者顶层
|
|
1663
1667
|
// 有 loadConfig 副作用,被 web-ui.ts 间接 import 会污染所有依赖 web-ui.ts 的单测。
|
|
1664
1668
|
let reloadConfigHook: (() => void) | null = null;
|
|
1669
|
+
type ExtraApiHandler = (req: IncomingMessage, res: ServerResponse) => Promise<boolean> | boolean;
|
|
1670
|
+
let extraApiHandler: ExtraApiHandler | null = null;
|
|
1665
1671
|
|
|
1666
1672
|
/**
|
|
1667
1673
|
* 注册"reload config"回调。约定:
|
|
@@ -1673,6 +1679,10 @@ export function setReloadConfigHook(hook: () => void | Promise<void>): void {
|
|
|
1673
1679
|
reloadConfigHook = hook;
|
|
1674
1680
|
}
|
|
1675
1681
|
|
|
1682
|
+
export function setExtraApiHandler(handler: ExtraApiHandler): void {
|
|
1683
|
+
extraApiHandler = handler;
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1676
1686
|
export function startSetupMode(port: number, options: StartSetupModeOptions = {}): void {
|
|
1677
1687
|
const router = createUiRouter();
|
|
1678
1688
|
const server = createServer(router);
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|