mu-agent-bridge 1.0.35 → 1.0.39
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 +2 -0
- package/dist/src-ts/channel-sessions.js +90 -24
- package/dist/src-ts/control-server.js +48 -7
- package/dist/src-ts/plugin-manager.js +47 -0
- package/dist/src-ts/plugins/chatgpt/cdp-client.js +72 -0
- package/dist/src-ts/plugins/chatgpt/index.js +122 -4
- package/dist/src-ts/plugins/doubao/cdp-client.js +243 -18
- package/dist/src-ts/plugins/doubao/index.js +81 -5
- package/dist/src-ts/plugins/doubao/session-history.js +123 -0
- package/dist/src-ts/plugins/dsh/index.js +16 -2
- package/dist/src-ts/plugins/dsh/session-history.js +371 -0
- package/dist/src-ts/plugins/qwenwork/cdp-client.js +8 -0
- package/dist/src-ts/plugins/qwenwork/index.js +12 -1
- package/dist/src-ts/plugins/qwenwork/session-history.js +245 -0
- package/dist/src-ts/plugins/workbuddy/cdp-client.js +33 -0
- package/dist/src-ts/plugins/workbuddy/index.js +21 -1
- package/dist/src-ts/plugins/workbuddy/session-history.js +121 -0
- package/dist/src-ts/session-archive.js +509 -0
- package/dist/src-ts/session-history.js +2 -0
- package/dist/src-ts/session-management.js +665 -0
- package/dist/src-ts/session-summary.js +171 -0
- package/dist/tests-ts/channel-sessions.test.js +233 -2
- package/dist/tests-ts/chatgpt-history.test.js +197 -0
- package/dist/tests-ts/control-server.test.js +39 -0
- package/dist/tests-ts/doubao-history.test.js +172 -0
- package/dist/tests-ts/doubao-plugin.test.js +124 -3
- package/dist/tests-ts/dsh-history.test.js +165 -0
- package/dist/tests-ts/native-session-management.test.js +195 -0
- package/dist/tests-ts/plugin-manager.test.js +254 -3
- package/dist/tests-ts/qwenwork-history.test.js +339 -0
- package/dist/tests-ts/session-archive.test.js +422 -0
- package/dist/tests-ts/session-management.test.js +323 -0
- package/dist/tests-ts/session-summary.test.js +199 -0
- package/dist/tests-ts/workbuddy-history.test.js +256 -0
- package/package.json +1 -1
- package/plugins/chatgpt/dist/src-ts/plugins/chatgpt/cdp-client.js +72 -0
- package/plugins/chatgpt/dist/src-ts/plugins/chatgpt/index.js +122 -4
- package/plugins/chatgpt/package.json +1 -1
- package/plugins/chatgpt/plugin.json +14 -1
- package/plugins/doubao/dist/src-ts/plugins/doubao/cdp-client.js +243 -18
- package/plugins/doubao/dist/src-ts/plugins/doubao/index.js +81 -5
- package/plugins/doubao/dist/src-ts/plugins/doubao/session-history.js +123 -0
- package/plugins/doubao/package.json +1 -1
- package/plugins/doubao/plugin.json +1 -1
- package/plugins/dsh/config.schema.json +2 -1
- package/plugins/dsh/dist/src-ts/plugins/dsh/index.js +16 -2
- package/plugins/dsh/dist/src-ts/plugins/dsh/session-history.js +371 -0
- package/plugins/dsh/package.json +1 -1
- package/plugins/dsh/plugin.json +1 -1
- package/plugins/qwenwork/dist/src-ts/plugins/qwenwork/cdp-client.js +8 -0
- package/plugins/qwenwork/dist/src-ts/plugins/qwenwork/index.js +12 -1
- package/plugins/qwenwork/dist/src-ts/plugins/qwenwork/session-history.js +245 -0
- package/plugins/qwenwork/package.json +1 -1
- package/plugins/qwenwork/plugin.json +1 -1
- package/plugins/workbuddy/dist/src-ts/plugins/workbuddy/cdp-client.js +33 -0
- package/plugins/workbuddy/dist/src-ts/plugins/workbuddy/index.js +21 -1
- package/plugins/workbuddy/dist/src-ts/plugins/workbuddy/session-history.js +121 -0
- package/plugins/workbuddy/package.json +1 -1
- package/plugins/workbuddy/plugin.json +1 -1
package/README.md
CHANGED
|
@@ -44,6 +44,8 @@ abweb 的“配置码”栏;配对码 10 分钟有效,只能使用一次,
|
|
|
44
44
|
`AGENT_BRIDGE_PLUGINS_DIR`、`AGENT_BRIDGE_STATE_DIR`。默认插件目录是仓库的 `plugins/`,状态目录
|
|
45
45
|
是 Windows 的 `%APPDATA%\agent-bridge\state`。
|
|
46
46
|
|
|
47
|
+
跨会话读取支持按来源授权的 AUN/飞书托管记录,以及另外授权的 ChatGPT、WorkBuddy 受限原生历史,并通过独立无工具端点总结。归档默认关闭,千问办公暂不支持原生历史。配置与 `/sessions` 命令见[跨会话读取说明](docs/cross-session-reading.md)。
|
|
48
|
+
|
|
47
49
|
## npm 插件
|
|
48
50
|
|
|
49
51
|
默认包 `mu-agent-bridge` 已内置 abweb、AUN、千问办公、DSH、豆包、飞书、WorkBuddy 和 ChatGPT/Codex 八个插件,直接安装即可使用:
|
|
@@ -1,13 +1,25 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.ChannelSessions = void 0;
|
|
4
|
+
exports.channelSourceKey = channelSourceKey;
|
|
4
5
|
const node_crypto_1 = require("node:crypto");
|
|
5
6
|
const promises_1 = require("node:fs/promises");
|
|
6
7
|
const node_path_1 = require("node:path");
|
|
8
|
+
function channelSourceKey(message) {
|
|
9
|
+
return JSON.stringify([message.channel, message.recipientId || "", message.chatId || "", message.peerId, message.threadId || ""]);
|
|
10
|
+
}
|
|
11
|
+
const isRecord = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
12
|
+
const validSession = (value) => {
|
|
13
|
+
if (!isRecord(value))
|
|
14
|
+
return false;
|
|
15
|
+
return [value.source, value.agentPlugin, value.sessionId, value.updatedAt].every(item => typeof item === "string")
|
|
16
|
+
&& (value.newSession === undefined || typeof value.newSession === "boolean");
|
|
17
|
+
};
|
|
7
18
|
const unsupportedCommands = /^(?:\/(?:s|session|slist|name|rename|del|status|stop|pause|resume|compact|fork|rewind|rw|repair|renew))(?:[ \t]+[^\r\n]*)?$/;
|
|
8
19
|
class ChannelSessions {
|
|
9
20
|
directory;
|
|
10
21
|
sessions = {};
|
|
22
|
+
active = {};
|
|
11
23
|
loaded;
|
|
12
24
|
writes = Promise.resolve();
|
|
13
25
|
pending = new Map();
|
|
@@ -18,25 +30,87 @@ class ChannelSessions {
|
|
|
18
30
|
this.loaded ??= (async () => {
|
|
19
31
|
try {
|
|
20
32
|
const data = JSON.parse(await (0, promises_1.readFile)((0, node_path_1.join)(this.directory, "channel-sessions.json"), "utf8"));
|
|
21
|
-
if (!
|
|
33
|
+
if (!isRecord(data))
|
|
22
34
|
throw new Error("通道会话文件格式无效");
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
35
|
+
const sessions = {};
|
|
36
|
+
const active = {};
|
|
37
|
+
if (data.version === 1) {
|
|
38
|
+
if (!isRecord(data.sessions) || !isRecord(data.active))
|
|
39
|
+
throw new Error("通道会话文件格式无效");
|
|
40
|
+
for (const [id, entry] of Object.entries(data.sessions)) {
|
|
41
|
+
if (!validSession(entry) || entry.logicalSessionId !== id || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id))
|
|
42
|
+
throw new Error("通道会话记录无效");
|
|
43
|
+
sessions[id] = entry;
|
|
44
|
+
}
|
|
45
|
+
for (const [source, id] of Object.entries(data.active)) {
|
|
46
|
+
if (typeof id !== "string" || sessions[id]?.source !== source)
|
|
47
|
+
throw new Error("通道活动绑定无效");
|
|
48
|
+
active[source] = id;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
for (const [source, entry] of Object.entries(data)) {
|
|
53
|
+
if (!validSession(entry) || entry.source !== source)
|
|
54
|
+
throw new Error("通道会话记录无效");
|
|
55
|
+
const logicalSessionId = (0, node_crypto_1.randomUUID)();
|
|
56
|
+
sessions[logicalSessionId] = { ...entry, logicalSessionId };
|
|
57
|
+
active[source] = logicalSessionId;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (data.version !== 1)
|
|
61
|
+
await this.persist(active, sessions);
|
|
62
|
+
else {
|
|
63
|
+
this.sessions = sessions;
|
|
64
|
+
this.active = active;
|
|
28
65
|
}
|
|
29
66
|
}
|
|
30
67
|
catch (error) {
|
|
31
|
-
if (error.code !== "ENOENT")
|
|
68
|
+
if (error.code !== "ENOENT") {
|
|
69
|
+
this.loaded = undefined;
|
|
32
70
|
throw error;
|
|
71
|
+
}
|
|
33
72
|
}
|
|
34
73
|
})();
|
|
35
74
|
await this.loaded;
|
|
36
75
|
}
|
|
37
|
-
async
|
|
76
|
+
async persist(active, sessions) {
|
|
77
|
+
await (0, promises_1.mkdir)(this.directory, { recursive: true, mode: 0o700 });
|
|
78
|
+
await (0, promises_1.chmod)(this.directory, 0o700);
|
|
79
|
+
const file = (0, node_path_1.join)(this.directory, "channel-sessions.json");
|
|
80
|
+
const temporary = `${file}.${process.pid}.${(0, node_crypto_1.randomUUID)()}.tmp`;
|
|
81
|
+
try {
|
|
82
|
+
await (0, promises_1.writeFile)(temporary, `${JSON.stringify({ version: 1, active, sessions }, null, 2)}\n`, { mode: 0o600, flag: "wx" });
|
|
83
|
+
await (0, promises_1.rename)(temporary, file);
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
await (0, promises_1.rm)(temporary, { force: true });
|
|
87
|
+
}
|
|
88
|
+
this.active = active;
|
|
89
|
+
this.sessions = sessions;
|
|
90
|
+
}
|
|
91
|
+
save(entry) {
|
|
92
|
+
const save = this.writes.catch(() => { }).then(() => this.persist({ ...this.active, [entry.source]: entry.logicalSessionId }, { ...this.sessions, [entry.logicalSessionId]: entry }));
|
|
93
|
+
this.writes = save;
|
|
94
|
+
return save;
|
|
95
|
+
}
|
|
96
|
+
async list() { await this.load(); return Object.values(this.sessions).map(entry => ({ ...entry })).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); }
|
|
97
|
+
isBusy(source) { return this.pending.has(source); }
|
|
98
|
+
async current(message, agentPlugin) {
|
|
99
|
+
await this.load();
|
|
100
|
+
const source = channelSourceKey(message);
|
|
101
|
+
const current = this.writes.catch(() => { }).then(async () => {
|
|
102
|
+
const entry = this.sessions[this.active[source]];
|
|
103
|
+
if (entry)
|
|
104
|
+
return { ...entry };
|
|
105
|
+
const next = { source, logicalSessionId: (0, node_crypto_1.randomUUID)(), agentPlugin, sessionId: (0, node_crypto_1.randomUUID)(), updatedAt: new Date().toISOString(), newSession: true };
|
|
106
|
+
await this.persist({ ...this.active, [source]: next.logicalSessionId }, { ...this.sessions, [next.logicalSessionId]: next });
|
|
107
|
+
return { ...next };
|
|
108
|
+
});
|
|
109
|
+
this.writes = current.then(() => undefined, () => undefined);
|
|
110
|
+
return current;
|
|
111
|
+
}
|
|
38
112
|
async run(message, agentPlugin, call, control) {
|
|
39
|
-
const source =
|
|
113
|
+
const source = channelSourceKey(message);
|
|
40
114
|
const hasAttachments = Boolean(message.attachments?.length);
|
|
41
115
|
const command = hasAttachments ? undefined : message.text?.trim().match(/^\/new(?:[ \t]+([^\r\n]+))?$/);
|
|
42
116
|
const unsupported = hasAttachments ? undefined : message.text?.trim().match(unsupportedCommands);
|
|
@@ -59,25 +133,17 @@ class ChannelSessions {
|
|
|
59
133
|
const previous = this.pending.get(source) ?? Promise.resolve();
|
|
60
134
|
const turn = previous.catch(() => { }).then(async () => {
|
|
61
135
|
await this.load();
|
|
62
|
-
const
|
|
63
|
-
const
|
|
136
|
+
const active = this.sessions[this.active[source]];
|
|
137
|
+
const entry = !command && active?.agentPlugin === agentPlugin ? active : undefined;
|
|
138
|
+
const sessionId = entry?.sessionId ?? (0, node_crypto_1.randomUUID)();
|
|
64
139
|
const response = command
|
|
65
140
|
? await control({ operation: "new", sessionId, ...(command[1]?.trim() ? { name: command[1].trim() } : {}) })
|
|
66
|
-
: await call(sessionId, !entry || entry.
|
|
141
|
+
: await call(sessionId, !entry || entry.newSession === true);
|
|
67
142
|
if (command && (typeof response.sessionId !== "string" || !response.sessionId.trim()))
|
|
68
143
|
throw new Error("插件未返回有效的新会话标识,原会话绑定保持不变");
|
|
69
|
-
const next = { source, agentPlugin, sessionId: response.sessionId || sessionId, updatedAt: new Date().toISOString(), ...(command ? { newSession: true } : {}) };
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
await (0, promises_1.mkdir)(this.directory, { recursive: true });
|
|
73
|
-
const file = (0, node_path_1.join)(this.directory, "channel-sessions.json");
|
|
74
|
-
await (0, promises_1.writeFile)(`${file}.tmp`, `${JSON.stringify(snapshot, null, 2)}\n`, { mode: 0o600 });
|
|
75
|
-
await (0, promises_1.rename)(`${file}.tmp`, file);
|
|
76
|
-
this.sessions = snapshot;
|
|
77
|
-
});
|
|
78
|
-
this.writes = save;
|
|
79
|
-
await save;
|
|
80
|
-
return response;
|
|
144
|
+
const next = { source, logicalSessionId: entry?.logicalSessionId ?? (0, node_crypto_1.randomUUID)(), agentPlugin, sessionId: response.sessionId || sessionId, updatedAt: new Date().toISOString(), ...(command ? { newSession: true } : {}) };
|
|
145
|
+
await this.save(next);
|
|
146
|
+
return command ? response : { ...response, source, logicalSessionId: next.logicalSessionId, agentPlugin, request: message, response };
|
|
81
147
|
});
|
|
82
148
|
this.pending.set(source, turn);
|
|
83
149
|
try {
|
|
@@ -135,6 +135,36 @@ class ControlServer {
|
|
|
135
135
|
const authorized = (Boolean(this.options.token) && authorization === `Bearer ${this.options.token}`) || (authorization?.startsWith("Bearer ") && (this.pairedTokens.get((0, node_crypto_1.createHash)("sha256").update(authorization.slice(7)).digest("hex")) ?? 0) > Date.now());
|
|
136
136
|
if (this.options.token && !isLoopback(req.socket.remoteAddress) && !authorized && !(method === "GET" && url.pathname.startsWith("/abweb")))
|
|
137
137
|
return this.json(res, 401, { error: "unauthorized" });
|
|
138
|
+
if (parts[0] === "api" && parts[1] === "session-history") {
|
|
139
|
+
if (!isLoopback(req.socket.remoteAddress) && !authorized)
|
|
140
|
+
return this.json(res, 401, { error: "会话归档管理仅允许本机或已认证调用方" });
|
|
141
|
+
const history = this.options.manager.sessionManagement;
|
|
142
|
+
const actor = authorized ? "authenticated" : "local";
|
|
143
|
+
if (method === "GET" && parts.length === 3 && parts[2] === "providers")
|
|
144
|
+
return this.json(res, 200, history.providers());
|
|
145
|
+
if (method === "GET" && parts.length === 3 && parts[2] === "config")
|
|
146
|
+
return this.json(res, 200, await history.config());
|
|
147
|
+
if (method === "PATCH" && parts.length === 3 && parts[2] === "config") {
|
|
148
|
+
await history.saveConfig(await this.body(req, 64 * 1024), actor);
|
|
149
|
+
return this.json(res, 200, { ok: true });
|
|
150
|
+
}
|
|
151
|
+
if (method === "DELETE" && parts.length === 2) {
|
|
152
|
+
await history.clear(actor);
|
|
153
|
+
return this.json(res, 200, { ok: true });
|
|
154
|
+
}
|
|
155
|
+
if (method === "DELETE" && parts.length === 4 && parts[2] === "sessions") {
|
|
156
|
+
await history.deleteSession(decodeURIComponent(parts[3]), actor);
|
|
157
|
+
return this.json(res, 200, { ok: true });
|
|
158
|
+
}
|
|
159
|
+
if (method === "POST" && parts.length === 3 && parts[2] === "delete-source") {
|
|
160
|
+
const body = await this.body(req, 64 * 1024);
|
|
161
|
+
if (typeof body.source !== "string")
|
|
162
|
+
return this.json(res, 400, { error: "需要有效的完整来源键" });
|
|
163
|
+
await history.deleteSource(body.source, actor);
|
|
164
|
+
return this.json(res, 200, { ok: true });
|
|
165
|
+
}
|
|
166
|
+
return this.json(res, 404, { error: "会话归档接口不存在" });
|
|
167
|
+
}
|
|
138
168
|
if (parts[0] === "api" && parts[1] === "agents" && parts[2] && parts[3] === "v1") {
|
|
139
169
|
const id = decodeURIComponent(parts[2]);
|
|
140
170
|
if (req.headers["x-evolcore-session-id"] !== undefined && !authorized && !isLoopback(req.socket.remoteAddress))
|
|
@@ -312,14 +342,25 @@ class ControlServer {
|
|
|
312
342
|
const loaded = this.options.manager.get("aun");
|
|
313
343
|
return loaded?.record.status === "ready" ? loaded.instance?.identity : undefined;
|
|
314
344
|
}
|
|
315
|
-
async body(req
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
345
|
+
async body(req, maxBytes = Infinity) {
|
|
346
|
+
const chunks = [];
|
|
347
|
+
let size = 0;
|
|
348
|
+
for await (const chunk of req) {
|
|
349
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
350
|
+
size += buffer.length;
|
|
351
|
+
if (size > maxBytes)
|
|
352
|
+
throw Object.assign(new Error("请求正文不能超过 64 KiB"), { statusCode: 413 });
|
|
353
|
+
chunks.push(buffer);
|
|
354
|
+
}
|
|
355
|
+
if (!size)
|
|
356
|
+
return {};
|
|
357
|
+
try {
|
|
358
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
throw new Error("请求正文必须是有效 JSON");
|
|
362
|
+
}
|
|
319
363
|
}
|
|
320
|
-
catch {
|
|
321
|
-
throw new Error("invalid JSON body");
|
|
322
|
-
} }
|
|
323
364
|
json(res, status, value) { res.statusCode = status; res.setHeader("content-type", "application/json; charset=utf-8"); res.end(JSON.stringify(value)); }
|
|
324
365
|
}
|
|
325
366
|
exports.ControlServer = ControlServer;
|
|
@@ -18,6 +18,7 @@ const version_manager_1 = require("./version-manager");
|
|
|
18
18
|
const plugin_catalog_1 = require("./plugin-catalog");
|
|
19
19
|
const log_store_1 = require("./log-store");
|
|
20
20
|
const channel_sessions_1 = require("./channel-sessions");
|
|
21
|
+
const session_management_1 = require("./session-management");
|
|
21
22
|
const _2020_1 = __importDefault(require("ajv/dist/2020"));
|
|
22
23
|
const attachments_1 = require("./attachments");
|
|
23
24
|
const logger = { info() { }, warn() { }, error() { } };
|
|
@@ -30,6 +31,7 @@ class PluginManager {
|
|
|
30
31
|
httpRoutes = new Map();
|
|
31
32
|
root;
|
|
32
33
|
messages = [];
|
|
34
|
+
activeIngress = new Map();
|
|
33
35
|
trustConfig = defaultPluginTrustConfig();
|
|
34
36
|
trustLoaded = false;
|
|
35
37
|
publisherLookups = new Map();
|
|
@@ -37,12 +39,14 @@ class PluginManager {
|
|
|
37
39
|
versions;
|
|
38
40
|
logs;
|
|
39
41
|
sessions;
|
|
42
|
+
sessionManagement;
|
|
40
43
|
constructor(options) {
|
|
41
44
|
this.options = options;
|
|
42
45
|
this.root = (0, node_path_1.resolve)(options.pluginsDir);
|
|
43
46
|
this.versions = options.versions ?? new version_manager_1.VersionManager({ stateDir: options.stateDir });
|
|
44
47
|
this.logs = new log_store_1.LogStore((0, node_path_1.join)(options.stateDir, "logs"));
|
|
45
48
|
this.sessions = new channel_sessions_1.ChannelSessions(options.stateDir);
|
|
49
|
+
this.sessionManagement = new session_management_1.SessionManagement(options.stateDir, (operation, fields) => this.log("info", operation, fields, "会话管理"), { list: () => this.sessions.list(), isBusy: source => this.sessions.isBusy(source) || this.activeIngress.has(source) }, () => [...this.plugins.entries()].flatMap(([pluginId, loaded]) => loaded.instance?.sessionHistory ? [{ pluginId, provider: loaded.instance.sessionHistory, identity: loaded.instance, status: loaded.record.status }] : []));
|
|
46
50
|
}
|
|
47
51
|
log(level, message, fields, source = "mab") { this.logs.append({ time: new Date().toISOString(), level, source, message, ...(fields ? { fields } : {}) }); }
|
|
48
52
|
setControlUrl(url) { this.controlUrl = url; }
|
|
@@ -375,8 +379,27 @@ class PluginManager {
|
|
|
375
379
|
pluginStatus: (targetId) => this.plugins.get(targetId)?.record.status,
|
|
376
380
|
ingress: {
|
|
377
381
|
publish: async (message) => {
|
|
382
|
+
if (message.channel !== id)
|
|
383
|
+
throw new agent_1.AgentError("消息来源与通道插件身份不一致");
|
|
384
|
+
const trustedChannel = loaded.record.type === "channel" && (id === "aun" || id === "feishu");
|
|
385
|
+
if (trustedChannel && (typeof message.peerId !== "string" || !message.peerId.trim() || [message.peerId, message.recipientId, message.chatId, message.threadId].some(value => value !== undefined && (typeof value !== "string" || value.length > 512 || /[\u0000-\u001f*]/.test(value)))))
|
|
386
|
+
throw new agent_1.AgentError("通道来源标识无效");
|
|
378
387
|
const target = message.agentPlugin || "qwenwork";
|
|
388
|
+
if (/^\/sessions(?:\s|$)/.test(message.text.trimStart())) {
|
|
389
|
+
if (!trustedChannel)
|
|
390
|
+
throw new agent_1.AgentError("当前通道不支持会话管理");
|
|
391
|
+
const current = await this.sessions.current(message, target);
|
|
392
|
+
const result = await this.sessionManagement.command(message.text, { source: current.source, sessionId: current.logicalSessionId });
|
|
393
|
+
await message.reply(result);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
if (trustedChannel)
|
|
397
|
+
await this.sessionManagement.ready();
|
|
398
|
+
const archiveToken = trustedChannel ? this.sessionManagement.archiveWriteToken((0, channel_sessions_1.channelSourceKey)(message)) : undefined;
|
|
379
399
|
this.recordMessage(target, "request", message.text, message.peerId);
|
|
400
|
+
const source = (0, channel_sessions_1.channelSourceKey)(message);
|
|
401
|
+
if (trustedChannel)
|
|
402
|
+
this.activeIngress.set(source, (this.activeIngress.get(source) ?? 0) + 1);
|
|
380
403
|
try {
|
|
381
404
|
const instance = this.plugins.get(target)?.instance;
|
|
382
405
|
const control = instance?.sessionOperations?.includes("new")
|
|
@@ -384,11 +407,13 @@ class PluginManager {
|
|
|
384
407
|
: undefined;
|
|
385
408
|
const task = !message.text.trim() && message.attachments?.length ? "请处理收到的附件" : message.text;
|
|
386
409
|
const forwarded = new Set();
|
|
410
|
+
const sent = [];
|
|
387
411
|
const onMessage = async (text) => {
|
|
388
412
|
if (!text.trim() || forwarded.has(text))
|
|
389
413
|
return;
|
|
390
414
|
await message.reply(text);
|
|
391
415
|
forwarded.add(text);
|
|
416
|
+
sent.push({ text });
|
|
392
417
|
this.recordMessage(target, "reply", text, message.peerId);
|
|
393
418
|
};
|
|
394
419
|
const response = await this.sessions.run(message, target, (sessionId, newSession) => this.handle(target, { task, sessionId, onMessage, ...(newSession ? { newSession: true } : {}), ...(message.attachments ? { attachments: message.attachments } : {}) }), control);
|
|
@@ -400,15 +425,37 @@ class PluginManager {
|
|
|
400
425
|
throw new agent_1.AgentError("当前通道不支持附件回传");
|
|
401
426
|
}
|
|
402
427
|
await message.replyAttachments(attachments, response.text);
|
|
428
|
+
sent.push({ text: response.text, attachments });
|
|
403
429
|
this.recordMessage(target, "reply", response.text, message.peerId);
|
|
404
430
|
}
|
|
405
431
|
else
|
|
406
432
|
await onMessage(response.text);
|
|
433
|
+
if (trustedChannel && response.source && response.logicalSessionId) {
|
|
434
|
+
const identity = { sessionId: response.logicalSessionId, source: response.source, principal: response.source, channel: message.channel, agentPlugin: target, completed: true, time: new Date().toISOString() };
|
|
435
|
+
const requestId = message.messageId || (0, node_crypto_1.randomUUID)();
|
|
436
|
+
const metadata = (items) => items?.map(item => ({ filename: item.filename, contentType: item.contentType, sizeBytes: item.data.byteLength }));
|
|
437
|
+
try {
|
|
438
|
+
await this.sessionManagement.recordIngressTurns(source, archiveToken, [
|
|
439
|
+
{ ...identity, role: "user", text: message.text, requestId, messageId: message.messageId ? `${message.messageId}:user` : undefined, attachments: metadata(message.attachments) },
|
|
440
|
+
...sent.map((item, index) => ({ ...identity, role: "assistant", text: item.text, requestId, messageId: message.messageId ? `${message.messageId}:assistant:${index}` : undefined, attachments: metadata(item.attachments) })),
|
|
441
|
+
]);
|
|
442
|
+
}
|
|
443
|
+
catch {
|
|
444
|
+
this.log("warn", "会话归档失败", { sessionId: identity.sessionId, status: "failed" }, "会话管理");
|
|
445
|
+
}
|
|
446
|
+
}
|
|
407
447
|
}
|
|
408
448
|
catch (error) {
|
|
409
449
|
this.recordMessage(target, "error", error instanceof Error ? error.message : String(error), message.peerId);
|
|
410
450
|
throw error;
|
|
411
451
|
}
|
|
452
|
+
finally {
|
|
453
|
+
const remaining = (this.activeIngress.get(source) ?? 1) - 1;
|
|
454
|
+
if (remaining)
|
|
455
|
+
this.activeIngress.set(source, remaining);
|
|
456
|
+
else
|
|
457
|
+
this.activeIngress.delete(source);
|
|
458
|
+
}
|
|
412
459
|
},
|
|
413
460
|
},
|
|
414
461
|
runtime: { controlUrl: this.controlUrl, ports: {
|
|
@@ -95,6 +95,78 @@ class ChatgptCdpClient {
|
|
|
95
95
|
})()`);
|
|
96
96
|
return (0, session_store_1.validSession)(entry) ? entry : undefined;
|
|
97
97
|
}
|
|
98
|
+
async historyBusy() {
|
|
99
|
+
return Boolean(await this.evaluate(`Boolean(document.querySelector(${JSON.stringify(stopButtonSelector)}))`));
|
|
100
|
+
}
|
|
101
|
+
async readSessionHistory(session) {
|
|
102
|
+
if (!this.connected())
|
|
103
|
+
return { supported: false, reason: "ChatGPT CDP 当前未连接" };
|
|
104
|
+
const before = await this.activeSession();
|
|
105
|
+
if (!before || before.id !== session.id || before.host !== session.host)
|
|
106
|
+
return { supported: false, reason: "当前活动会话身份与请求不一致" };
|
|
107
|
+
if (await this.historyBusy())
|
|
108
|
+
return { supported: false, reason: "ChatGPT 正在生成回复,暂不读取历史" };
|
|
109
|
+
const raw = await this.evaluate(`(() => {
|
|
110
|
+
const selected = document.querySelector('[data-app-action-sidebar-thread-selected="true"], [data-app-action-sidebar-thread-active="true"]');
|
|
111
|
+
if (selected?.getAttribute('data-app-action-sidebar-thread-id') !== ${JSON.stringify(session.id)}
|
|
112
|
+
|| selected?.getAttribute('data-app-action-sidebar-thread-host-id') !== ${JSON.stringify(session.host)}) return { supported: false, reason: '读取期间活动会话身份发生变化' };
|
|
113
|
+
if (document.querySelector(${JSON.stringify(stopButtonSelector)})) return { supported: false, reason: 'ChatGPT 正在生成回复,暂不读取历史' };
|
|
114
|
+
const turns = [...document.querySelectorAll(${JSON.stringify(exports.turnSelector)})];
|
|
115
|
+
const output = [];
|
|
116
|
+
const missingReasons = [];
|
|
117
|
+
let chars = 0;
|
|
118
|
+
const collect = (role, text) => {
|
|
119
|
+
chars += text.length;
|
|
120
|
+
if (chars > 60000) throw new Error('ChatGPT 会话正文超过 60,000 字符上限');
|
|
121
|
+
output.push({ role, text });
|
|
122
|
+
};
|
|
123
|
+
for (const turn of turns) {
|
|
124
|
+
const turnId = turn.getAttribute(${JSON.stringify(turnIdAttribute)});
|
|
125
|
+
if (!turnId) {
|
|
126
|
+
missingReasons.push('部分消息缺少可验证的 turn 身份');
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const users = [...turn.querySelectorAll(${JSON.stringify(exports.userBubbleSelector)})];
|
|
130
|
+
for (const item of users) {
|
|
131
|
+
const body = item.querySelector('[data-markdown-text-tone="user-message"]');
|
|
132
|
+
if (!body) {
|
|
133
|
+
missingReasons.push('部分用户消息缺少专用正文标记,已跳过');
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const text = body.innerText?.trim();
|
|
137
|
+
if (text) collect('user', text);
|
|
138
|
+
}
|
|
139
|
+
const assistants = [...turn.querySelectorAll(${JSON.stringify(assistantMessageSelector)})];
|
|
140
|
+
for (const item of assistants) {
|
|
141
|
+
const marker = item.closest('[data-response-annotation-target][data-response-annotation-conversation]');
|
|
142
|
+
const excluded = item.closest('[data-message-author-role="tool"], [data-message-author-role="system"], [data-message-channel="analysis"], [data-message-channel="commentary"], [data-reasoning], [data-is-streaming="true"], [data-streaming="true"], [aria-busy="true"]');
|
|
143
|
+
const text = item.innerText?.trim();
|
|
144
|
+
if (marker?.getAttribute('data-response-annotation-conversation') === ${JSON.stringify(session.id.replace(/^local:/, ""))}
|
|
145
|
+
&& marker.getAttribute('data-response-annotation-target') && !excluded && text) collect('assistant', text);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return { supported: true, turns: output, missingReasons };
|
|
149
|
+
})()`);
|
|
150
|
+
const after = await this.activeSession();
|
|
151
|
+
if (!after || after.id !== session.id || after.host !== session.host)
|
|
152
|
+
return { supported: false, reason: "读取期间活动会话身份发生变化" };
|
|
153
|
+
if (await this.historyBusy())
|
|
154
|
+
return { supported: false, reason: "ChatGPT 正在生成回复,暂不读取历史" };
|
|
155
|
+
const result = raw;
|
|
156
|
+
if (result?.supported === false && typeof result.reason === "string")
|
|
157
|
+
return { supported: false, reason: result.reason };
|
|
158
|
+
if (!result || !Array.isArray(result.turns) || !result.turns.every((turn) => {
|
|
159
|
+
if (!turn || typeof turn !== "object")
|
|
160
|
+
return false;
|
|
161
|
+
const value = turn;
|
|
162
|
+
return (value.role === "user" || value.role === "assistant") && typeof value.text === "string";
|
|
163
|
+
})) {
|
|
164
|
+
return { supported: false, reason: "ChatGPT 会话正文格式无法验证" };
|
|
165
|
+
}
|
|
166
|
+
const missingReasons = Array.isArray(result.missingReasons) ? result.missingReasons.filter((item) => typeof item === "string") : [];
|
|
167
|
+
missingReasons.push("仅包含当前页面已加载的可验证正文,页面虚拟化或未加载历史导致完整性无法确认");
|
|
168
|
+
return { supported: true, turns: result.turns, complete: false, missingReasons };
|
|
169
|
+
}
|
|
98
170
|
async switchSession(session) {
|
|
99
171
|
await this.recoverDraft();
|
|
100
172
|
const locate = `[...document.querySelectorAll(${JSON.stringify(sidebarSelector)})].find(item =>
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.ChatgptCdpClient = exports.ChatgptPlugin = exports.chatgptManifest = void 0;
|
|
4
|
+
const node_crypto_1 = require("node:crypto");
|
|
4
5
|
const cdp_client_1 = require("./cdp-client");
|
|
5
6
|
const session_store_1 = require("./session-store");
|
|
6
7
|
exports.chatgptManifest = {
|
|
7
8
|
id: "chatgpt",
|
|
8
|
-
version: "1.0.
|
|
9
|
+
version: "1.0.9",
|
|
9
10
|
displayName: "ChatGPT(Codex)",
|
|
10
11
|
type: "agent",
|
|
11
12
|
entry: "dist/index.js",
|
|
@@ -28,11 +29,27 @@ class ChatgptPlugin {
|
|
|
28
29
|
failure;
|
|
29
30
|
remote = false;
|
|
30
31
|
queue = Promise.resolve();
|
|
32
|
+
queuedOperations = 0;
|
|
31
33
|
store;
|
|
32
34
|
logger;
|
|
35
|
+
historyScope = "";
|
|
36
|
+
historyGeneration = 0;
|
|
37
|
+
historyProvider;
|
|
33
38
|
constructor(options = {}) {
|
|
34
39
|
this.options = options;
|
|
35
40
|
}
|
|
41
|
+
get sessionHistory() {
|
|
42
|
+
const client = this.client;
|
|
43
|
+
const unsupported = !client?.listSessions || !client.activeSession || !client.readSessionHistory;
|
|
44
|
+
const generation = this.historyGeneration;
|
|
45
|
+
return this.historyProvider ??= {
|
|
46
|
+
scope: this.historyScope,
|
|
47
|
+
mode: unsupported ? "unsupported" : "sidebar",
|
|
48
|
+
limitation: unsupported ? "当前 ChatGPT 客户端不提供只读历史接口" : "仅列举已加载侧栏;仅读取当前活动会话中已加载且可验证的正文,完整性可能受页面虚拟化影响",
|
|
49
|
+
listSessions: query => generation === this.historyGeneration ? this.listNativeSessions(query) : Promise.resolve({ supported: false, reason: "ChatGPT 插件实例已变化,请重新列举历史" }),
|
|
50
|
+
readSession: query => generation === this.historyGeneration ? this.readNativeSession(query) : Promise.resolve({ supported: false, reason: "ChatGPT 插件实例已变化,请重新列举历史" }),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
36
53
|
async activate(context) {
|
|
37
54
|
const config = context.config;
|
|
38
55
|
const hasSshHost = config.sshHost !== undefined;
|
|
@@ -53,6 +70,11 @@ class ChatgptPlugin {
|
|
|
53
70
|
localPort: port(config.localCdpPort, 9230),
|
|
54
71
|
} : undefined;
|
|
55
72
|
this.remote = Boolean(ssh);
|
|
73
|
+
this.historyGeneration++;
|
|
74
|
+
this.historyProvider = undefined;
|
|
75
|
+
this.historyScope = (0, node_crypto_1.createHash)("sha256").update(JSON.stringify(ssh
|
|
76
|
+
? [ssh.host, ssh.user, ssh.port, ssh.remotePort]
|
|
77
|
+
: ["local", port(config.localCdpPort, 9230)])).digest("hex").slice(0, 24);
|
|
56
78
|
this.logger = context.logger;
|
|
57
79
|
this.store = new session_store_1.ChatgptSessionStore(context.dataDir, JSON.stringify(ssh
|
|
58
80
|
? [ssh.host, ssh.user, ssh.port, ssh.remotePort]
|
|
@@ -93,8 +115,7 @@ class ChatgptPlugin {
|
|
|
93
115
|
throw new Error("消息内容不能为空");
|
|
94
116
|
if (!this.client || !this.store)
|
|
95
117
|
throw new Error("ChatGPT 插件未启动");
|
|
96
|
-
const run = this.
|
|
97
|
-
this.queue = run.then(() => undefined, () => undefined);
|
|
118
|
+
const run = this.enqueue(() => this.run(request));
|
|
98
119
|
const result = await run;
|
|
99
120
|
return { ...(typeof result === "string" ? { text: result } : result), sessionId: request.sessionId };
|
|
100
121
|
}
|
|
@@ -106,7 +127,7 @@ class ChatgptPlugin {
|
|
|
106
127
|
throw new Error("ChatGPT 新会话缺少 sessionId");
|
|
107
128
|
if (!this.client?.createSession || !this.client.listSessions || !this.store)
|
|
108
129
|
throw new Error("ChatGPT 当前传输不支持新建会话");
|
|
109
|
-
const run = this.
|
|
130
|
+
const run = this.enqueue(async () => {
|
|
110
131
|
const existing = new Set((await this.client.listSessions()).map(item => JSON.stringify([item.host, item.id])));
|
|
111
132
|
const created = await this.client.createSession(request.name);
|
|
112
133
|
if (created) {
|
|
@@ -120,9 +141,106 @@ class ChatgptPlugin {
|
|
|
120
141
|
this.persist();
|
|
121
142
|
return { text: "ChatGPT 已创建新会话", sessionId };
|
|
122
143
|
});
|
|
144
|
+
return run;
|
|
145
|
+
}
|
|
146
|
+
enqueue(work) {
|
|
147
|
+
this.queuedOperations++;
|
|
148
|
+
const run = this.queue.then(work).finally(() => { this.queuedOperations--; });
|
|
123
149
|
this.queue = run.then(() => undefined, () => undefined);
|
|
124
150
|
return run;
|
|
125
151
|
}
|
|
152
|
+
async listNativeSessions(query) {
|
|
153
|
+
const client = this.client;
|
|
154
|
+
const reject = (reason) => ({ supported: false, reason });
|
|
155
|
+
if (!client?.listSessions || !client.activeSession || !client.readSessionHistory)
|
|
156
|
+
return reject("当前 ChatGPT 客户端不支持只读历史");
|
|
157
|
+
const listSessions = client.listSessions.bind(client);
|
|
158
|
+
if (!client.connected())
|
|
159
|
+
return reject("ChatGPT CDP 当前未连接");
|
|
160
|
+
if (this.queuedOperations)
|
|
161
|
+
return reject("ChatGPT 正在处理其他操作,历史读取已拒绝");
|
|
162
|
+
if (!Number.isInteger(query.limit) || query.limit < 1)
|
|
163
|
+
throw new Error("ChatGPT 历史页条数无效");
|
|
164
|
+
if (query.cursor !== undefined && !/^list:[a-f0-9]{64}:\d+$/.test(query.cursor))
|
|
165
|
+
throw new Error("ChatGPT 历史游标无效");
|
|
166
|
+
return this.enqueue(async () => {
|
|
167
|
+
if (!client.connected())
|
|
168
|
+
return reject("ChatGPT CDP 当前未连接");
|
|
169
|
+
if (await client.historyBusy?.())
|
|
170
|
+
return reject("ChatGPT 正在生成回复,暂不读取历史");
|
|
171
|
+
const entries = await listSessions();
|
|
172
|
+
const fingerprint = (0, node_crypto_1.createHash)("sha256").update(JSON.stringify([this.historyScope, this.historyGeneration, entries.map(({ host, id, title }) => [host, id, title])])).digest("hex");
|
|
173
|
+
const offset = query.cursor ? Number(query.cursor.split(":").at(-1)) : 0;
|
|
174
|
+
if (query.cursor !== undefined && !query.cursor.startsWith(`list:${fingerprint}:`))
|
|
175
|
+
throw new Error("ChatGPT 侧栏已变化,历史游标失效");
|
|
176
|
+
if (!Number.isSafeInteger(offset) || (query.cursor !== undefined && offset >= entries.length))
|
|
177
|
+
throw new Error("ChatGPT 历史游标位置无效");
|
|
178
|
+
const limit = Math.min(query.limit, 50);
|
|
179
|
+
const sessions = entries.slice(offset, offset + limit).map(item => ({ id: JSON.stringify([item.host, item.id]), title: item.title, ...(item.updatedAt ? { updatedAt: item.updatedAt } : {}) }));
|
|
180
|
+
return { supported: true, sessions, ...(offset + limit < entries.length ? { nextCursor: `list:${fingerprint}:${offset + limit}` } : {}), complete: false, missingReasons: ["仅包含 ChatGPT 当前已加载的侧栏项目,不代表全部历史会话"], readAt: new Date().toISOString() };
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
async readNativeSession(query) {
|
|
184
|
+
const client = this.client;
|
|
185
|
+
const reject = (reason) => ({ supported: false, reason });
|
|
186
|
+
if (!client?.listSessions || !client.activeSession || !client.readSessionHistory)
|
|
187
|
+
return reject("当前 ChatGPT 客户端不支持只读历史");
|
|
188
|
+
const listSessions = client.listSessions.bind(client);
|
|
189
|
+
const activeSession = client.activeSession.bind(client);
|
|
190
|
+
const readSessionHistory = client.readSessionHistory.bind(client);
|
|
191
|
+
if (!client.connected())
|
|
192
|
+
return reject("ChatGPT CDP 当前未连接");
|
|
193
|
+
if (this.queuedOperations)
|
|
194
|
+
return reject("ChatGPT 正在处理其他操作,历史读取已拒绝");
|
|
195
|
+
if (!Number.isInteger(query.limit) || query.limit < 1)
|
|
196
|
+
throw new Error("ChatGPT 历史页条数无效");
|
|
197
|
+
if (query.cursor !== undefined && !/^read:[a-f0-9]{64}:\d+$/.test(query.cursor))
|
|
198
|
+
throw new Error("ChatGPT 历史游标无效");
|
|
199
|
+
let requested;
|
|
200
|
+
try {
|
|
201
|
+
requested = JSON.parse(query.sessionId);
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return reject("ChatGPT 会话 ID 无效");
|
|
205
|
+
}
|
|
206
|
+
if (!Array.isArray(requested) || requested.length !== 2 || requested.some(part => typeof part !== "string" || !part))
|
|
207
|
+
return reject("ChatGPT 会话 ID 无效");
|
|
208
|
+
return this.enqueue(async () => {
|
|
209
|
+
if (!client.connected())
|
|
210
|
+
return reject("ChatGPT CDP 当前未连接");
|
|
211
|
+
if (await client.historyBusy?.())
|
|
212
|
+
return reject("ChatGPT 正在生成回复,暂不读取历史");
|
|
213
|
+
const sessions = await listSessions();
|
|
214
|
+
const session = sessions.find(item => item.host === requested[0] && item.id === requested[1]);
|
|
215
|
+
if (!session)
|
|
216
|
+
return reject("目标会话不在当前 ChatGPT 侧栏中");
|
|
217
|
+
const active = await activeSession();
|
|
218
|
+
if (!active || active.host !== session.host || active.id !== session.id)
|
|
219
|
+
return reject("ChatGPT 只支持读取当前活动会话");
|
|
220
|
+
if (await client.historyBusy?.())
|
|
221
|
+
return reject("ChatGPT 正在生成回复,暂不读取历史");
|
|
222
|
+
const result = await readSessionHistory(session);
|
|
223
|
+
if (!result.supported)
|
|
224
|
+
return reject(result.reason);
|
|
225
|
+
const after = await activeSession();
|
|
226
|
+
if (!after || after.host !== session.host || after.id !== session.id)
|
|
227
|
+
return reject("读取期间活动会话身份发生变化");
|
|
228
|
+
const fingerprint = (0, node_crypto_1.createHash)("sha256").update(JSON.stringify([this.historyScope, this.historyGeneration, session.host, session.id, result.turns])).digest("hex");
|
|
229
|
+
const offset = query.cursor ? Number(query.cursor.split(":").at(-1)) : 0;
|
|
230
|
+
if (query.cursor !== undefined && !query.cursor.startsWith(`read:${fingerprint}:`))
|
|
231
|
+
throw new Error("ChatGPT 会话内容已变化,历史游标失效");
|
|
232
|
+
if (!Number.isSafeInteger(offset) || (query.cursor !== undefined && offset >= result.turns.length))
|
|
233
|
+
throw new Error("ChatGPT 历史游标位置无效");
|
|
234
|
+
const turns = result.turns.map(turn => ({ ...turn }));
|
|
235
|
+
const chars = turns.reduce((total, turn) => total + turn.text.length, 0);
|
|
236
|
+
if (chars > 60_000)
|
|
237
|
+
throw new Error("ChatGPT 会话正文超过 60,000 字符上限");
|
|
238
|
+
const limit = Math.min(query.limit, 100);
|
|
239
|
+
const pageTurns = turns.slice(offset, offset + limit);
|
|
240
|
+
const reasons = [...result.missingReasons, "仅包含当前页面已加载的可验证正文,页面虚拟化或未加载历史导致完整性无法确认"];
|
|
241
|
+
return { supported: true, session: { id: query.sessionId, title: session.title, ...(session.updatedAt ? { updatedAt: session.updatedAt } : {}) }, turns: pageTurns, ...(offset + limit < turns.length ? { nextCursor: `read:${fingerprint}:${offset + limit}` } : {}), complete: false, missingReasons: reasons, readAt: new Date().toISOString() };
|
|
242
|
+
});
|
|
243
|
+
}
|
|
126
244
|
persist() {
|
|
127
245
|
void this.store.save().catch(error => this.logger?.warn("ChatGPT 会话缓存保存失败", { error: String(error) }));
|
|
128
246
|
}
|