agent-bridge-chatgpt 0.0.0-stage → 1.0.1
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 +46 -2
- package/config.schema.json +1 -0
- package/dist/src-ts/agent.js +10 -0
- package/dist/src-ts/plugin.js +13 -0
- package/dist/src-ts/plugins/chatgpt/cdp-client.js +351 -0
- package/dist/src-ts/plugins/chatgpt/index.js +194 -0
- package/dist/src-ts/plugins/chatgpt/session-store.js +91 -0
- package/package.json +1 -6
- package/plugin.json +1 -0
package/README.md
CHANGED
|
@@ -1,3 +1,47 @@
|
|
|
1
|
-
#
|
|
1
|
+
# ChatGPT(Codex)插件
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
插件通过 SSH 本地转发和 CDP,向远程 Windows 上已登录的 ChatGPT 桌面端发送消息并读取回复。插件不会保存 ChatGPT 账号或密码。
|
|
4
|
+
|
|
5
|
+
## 远程桌面准备
|
|
6
|
+
|
|
7
|
+
在远程 Windows 的已登录桌面会话中,先正常退出 ChatGPT,再打开 PowerShell 执行以下命令:
|
|
8
|
+
|
|
9
|
+
```powershell
|
|
10
|
+
$package = Get-AppxPackage -Name OpenAI.CodexBeta
|
|
11
|
+
$executable = Join-Path $package.InstallLocation 'app\ChatGPT (Beta).exe'
|
|
12
|
+
Start-Process -FilePath $executable -ArgumentList '--remote-debugging-port=9230'
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
启动后在桌面端确认账号仍处于登录状态。CDP 端口由 SSH 隧道访问,不需要开放到局域网。
|
|
16
|
+
|
|
17
|
+
## 插件配置
|
|
18
|
+
|
|
19
|
+
在 Agent Bridge 的 `chatgpt` 插件配置中填写:
|
|
20
|
+
|
|
21
|
+
```json
|
|
22
|
+
{
|
|
23
|
+
"sshHost": "192.168.31.151",
|
|
24
|
+
"sshUser": "m2lih",
|
|
25
|
+
"sshKeyPath": "/本机/SSH/私钥路径",
|
|
26
|
+
"remoteCdpPort": 9230,
|
|
27
|
+
"localCdpPort": 9230
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
插件启动时会建立 SSH 转发,并连接本机 `127.0.0.1:9230`。不配置 SSH 时,可直接连接本机已开启 CDP 的 ChatGPT 页面。
|
|
32
|
+
|
|
33
|
+
## 最近任务与继续会话
|
|
34
|
+
|
|
35
|
+
插件会在自己的数据目录保存最多 20 条最近 ChatGPT 会话索引(不保存账号、密码或页面 HTML)。Channel 查询以下内容时优先读取本地缓存,缓存过期后才同步一次远程侧栏:
|
|
36
|
+
|
|
37
|
+
- `最近任务`
|
|
38
|
+
- `列出最近会话`
|
|
39
|
+
- `有哪些已保存的 ChatGPT 任务`
|
|
40
|
+
|
|
41
|
+
可以使用 `切换会话:任务标题` 或 `继续处理任务标题` 切换到已有会话。切换成功后,同一 Channel 的后续消息会继续发送到该会话;标题重复时插件会返回候选列表,避免误切换。发送“刷新最近任务”可以强制同步侧栏。
|
|
42
|
+
|
|
43
|
+
缓存默认有效期为 10 分钟,可跨插件重启恢复,并按远程连接隔离。列表覆盖当前已加载的侧栏项目,不代表全部历史会话,也无法推断远程所有任务的精确更新时间。需要其他项目时,先在桌面侧栏展开后刷新列表。
|
|
44
|
+
|
|
45
|
+
`切换会话:任务标题` 只切换;`切换会话:任务标题;检查进度` 会切换并发送后半句;`继续处理任务标题` 会切换并向该会话发送这条继续指令。也可用列表中的完整会话 ID 指定目标。当前会话有未发送草稿或正在生成回复时,插件拒绝切换。
|
|
46
|
+
|
|
47
|
+
索引文件为插件数据目录下的 `chatgpt-sessions.json`,记录最近使用时间和最多 500 字符的最近消息/回复片段。它用于检索会话,不自动生成 Skill;远程暂时不可用时仍可查询已有缓存。
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"type":"object","properties":{"sshHost":{"type":"string","description":"Windows 主机地址"},"sshUser":{"type":"string","description":"SSH 用户名"},"sshPort":{"type":"integer","minimum":1,"maximum":65535,"default":22},"sshKeyPath":{"type":"string","description":"SSH 私钥路径"},"remoteCdpPort":{"type":"integer","minimum":1,"maximum":65535,"default":9230},"localCdpPort":{"type":"integer","minimum":1,"maximum":65535,"default":9230},"timeoutMs":{"type":"integer","minimum":1,"default":30000}},"additionalProperties":false}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.AgentError = void 0;
|
|
4
|
+
class AgentError extends Error {
|
|
5
|
+
constructor(message, options) {
|
|
6
|
+
super(message, options);
|
|
7
|
+
this.name = "AgentError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
exports.AgentError = AgentError;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.resolveRuntimePort = resolveRuntimePort;
|
|
4
|
+
function resolveRuntimePort(config, runtime, pluginId, envName, fallback) {
|
|
5
|
+
const injected = runtime?.ports?.[pluginId];
|
|
6
|
+
if (typeof injected === "number" && Number.isInteger(injected) && injected > 0)
|
|
7
|
+
return injected;
|
|
8
|
+
const configured = config.cdpPort;
|
|
9
|
+
if (typeof configured === "number" && Number.isInteger(configured) && configured > 0)
|
|
10
|
+
return configured;
|
|
11
|
+
const environment = Number(process.env[envName] ?? "");
|
|
12
|
+
return Number.isInteger(environment) && environment > 0 ? environment : fallback;
|
|
13
|
+
}
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ChatgptCdpClient = exports.userBubbleSelector = exports.turnSelector = exports.editorSelector = void 0;
|
|
4
|
+
const node_child_process_1 = require("node:child_process");
|
|
5
|
+
const session_store_1 = require("./session-store");
|
|
6
|
+
exports.editorSelector = '[data-codex-composer="true"][contenteditable="true"]';
|
|
7
|
+
exports.turnSelector = '[data-chatgpt-conversation-turn="true"]';
|
|
8
|
+
exports.userBubbleSelector = "[data-user-message-bubble]";
|
|
9
|
+
const assistantMessageSelector = '[data-markdown-text-style="assistant-message"]';
|
|
10
|
+
const turnIdAttribute = "data-chatgpt-conversation-turn-id";
|
|
11
|
+
const contentUnitSelector = "[data-content-search-unit-key]";
|
|
12
|
+
const sendButtonSelector = 'button[aria-label="Send"], button[aria-label="发送"]';
|
|
13
|
+
const stopButtonSelector = 'button[aria-label="Stop"], button[aria-label="停止"]';
|
|
14
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
15
|
+
const sidebarSelector = "[data-app-action-sidebar-thread-id]";
|
|
16
|
+
class ChatgptCdpClient {
|
|
17
|
+
socket;
|
|
18
|
+
tunnel;
|
|
19
|
+
sequence = 0;
|
|
20
|
+
pending = new Map();
|
|
21
|
+
port;
|
|
22
|
+
timeoutMs;
|
|
23
|
+
fetchImpl;
|
|
24
|
+
webSocketFactory;
|
|
25
|
+
ssh;
|
|
26
|
+
constructor(options = {}) {
|
|
27
|
+
this.port = options.port ?? options.ssh?.localPort ?? 9230;
|
|
28
|
+
this.timeoutMs = options.timeoutMs ?? 30_000;
|
|
29
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
30
|
+
this.webSocketFactory = options.webSocketFactory ?? ((url) => new WebSocket(url));
|
|
31
|
+
this.ssh = options.ssh;
|
|
32
|
+
}
|
|
33
|
+
connected() { return Boolean(this.socket); }
|
|
34
|
+
async listSessions() {
|
|
35
|
+
const entries = await this.evaluate(`(() => {
|
|
36
|
+
return [...document.querySelectorAll(${JSON.stringify(sidebarSelector)})].map(item => ({
|
|
37
|
+
id: item.getAttribute('data-app-action-sidebar-thread-id'),
|
|
38
|
+
host: item.getAttribute('data-app-action-sidebar-thread-host-id'),
|
|
39
|
+
title: item.getAttribute('data-app-action-sidebar-thread-title')
|
|
40
|
+
}));
|
|
41
|
+
})()`);
|
|
42
|
+
if (!Array.isArray(entries))
|
|
43
|
+
throw new Error("ChatGPT 侧栏会话列表格式无效");
|
|
44
|
+
return entries.filter(session_store_1.validSession);
|
|
45
|
+
}
|
|
46
|
+
async activeSession() {
|
|
47
|
+
const entry = await this.evaluate(`(() => {
|
|
48
|
+
const item = document.querySelector('[data-app-action-sidebar-thread-selected="true"]');
|
|
49
|
+
return item ? {
|
|
50
|
+
id: item.getAttribute('data-app-action-sidebar-thread-id'),
|
|
51
|
+
host: item.getAttribute('data-app-action-sidebar-thread-host-id'),
|
|
52
|
+
title: item.getAttribute('data-app-action-sidebar-thread-title')
|
|
53
|
+
} : null;
|
|
54
|
+
})()`);
|
|
55
|
+
return (0, session_store_1.validSession)(entry) ? entry : undefined;
|
|
56
|
+
}
|
|
57
|
+
async switchSession(session) {
|
|
58
|
+
const locate = `[...document.querySelectorAll(${JSON.stringify(sidebarSelector)})].find(item =>
|
|
59
|
+
item.getAttribute('data-app-action-sidebar-thread-id') === ${JSON.stringify(session.id)}
|
|
60
|
+
&& item.getAttribute('data-app-action-sidebar-thread-host-id') === ${JSON.stringify(session.host)})`;
|
|
61
|
+
const clicked = await this.evaluate(`(() => {
|
|
62
|
+
const editor = document.querySelector(${JSON.stringify(exports.editorSelector)});
|
|
63
|
+
if (editor?.innerText.trim() || document.querySelector(${JSON.stringify(stopButtonSelector)})) {
|
|
64
|
+
throw new Error('当前会话有未发送内容或正在生成回复,不能切换');
|
|
65
|
+
}
|
|
66
|
+
const item = ${locate};
|
|
67
|
+
if (!item) return false;
|
|
68
|
+
item.click();
|
|
69
|
+
return true;
|
|
70
|
+
})()`);
|
|
71
|
+
if (!clicked)
|
|
72
|
+
throw new Error("ChatGPT 侧栏中找不到目标会话");
|
|
73
|
+
await this.waitFor(`(() => {
|
|
74
|
+
const item = ${locate};
|
|
75
|
+
return item?.getAttribute('data-app-action-sidebar-thread-selected') === 'true'
|
|
76
|
+
&& Boolean(document.querySelector(${JSON.stringify(exports.editorSelector)}));
|
|
77
|
+
})()`, "ChatGPT 会话切换未确认,未发送消息");
|
|
78
|
+
}
|
|
79
|
+
async connect() {
|
|
80
|
+
if (this.socket)
|
|
81
|
+
return;
|
|
82
|
+
try {
|
|
83
|
+
if (this.ssh) {
|
|
84
|
+
await this.startTunnel();
|
|
85
|
+
}
|
|
86
|
+
const page = await this.findPage();
|
|
87
|
+
if (!page.webSocketDebuggerUrl)
|
|
88
|
+
throw new Error("ChatGPT CDP target 缺少 WebSocket 地址");
|
|
89
|
+
const socketUrl = new URL(page.webSocketDebuggerUrl);
|
|
90
|
+
if (this.ssh) {
|
|
91
|
+
socketUrl.hostname = "127.0.0.1";
|
|
92
|
+
socketUrl.port = String(this.port);
|
|
93
|
+
}
|
|
94
|
+
const socket = this.webSocketFactory(socketUrl);
|
|
95
|
+
this.socket = socket;
|
|
96
|
+
let opened = false;
|
|
97
|
+
let rejectOpening;
|
|
98
|
+
socket.onmessage = (event) => this.onMessage(String(event.data));
|
|
99
|
+
socket.onclose = () => {
|
|
100
|
+
if (this.socket === socket) {
|
|
101
|
+
this.socket = undefined;
|
|
102
|
+
this.tunnel?.kill();
|
|
103
|
+
this.tunnel = undefined;
|
|
104
|
+
}
|
|
105
|
+
if (!opened)
|
|
106
|
+
rejectOpening?.(new Error("ChatGPT CDP WebSocket 连接已关闭"));
|
|
107
|
+
this.rejectPending(new Error("ChatGPT CDP 连接已关闭"));
|
|
108
|
+
};
|
|
109
|
+
await new Promise((resolve, reject) => {
|
|
110
|
+
rejectOpening = reject;
|
|
111
|
+
const timer = setTimeout(() => reject(new Error("ChatGPT CDP WebSocket 连接超时")), this.timeoutMs);
|
|
112
|
+
socket.onopen = () => {
|
|
113
|
+
opened = true;
|
|
114
|
+
clearTimeout(timer);
|
|
115
|
+
resolve();
|
|
116
|
+
};
|
|
117
|
+
socket.onerror = () => {
|
|
118
|
+
clearTimeout(timer);
|
|
119
|
+
reject(new Error("ChatGPT CDP WebSocket 连接失败"));
|
|
120
|
+
};
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
this.close();
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
async findPage() {
|
|
129
|
+
const deadline = Date.now() + this.timeoutMs;
|
|
130
|
+
let last;
|
|
131
|
+
while (Date.now() < deadline) {
|
|
132
|
+
try {
|
|
133
|
+
const response = await this.fetchImpl(`http://127.0.0.1:${this.port}/json`, {
|
|
134
|
+
signal: AbortSignal.timeout(Math.min(this.timeoutMs, 3_000)),
|
|
135
|
+
});
|
|
136
|
+
if (!response.ok)
|
|
137
|
+
throw new Error(`ChatGPT CDP /json HTTP ${response.status}`);
|
|
138
|
+
const targets = await response.json();
|
|
139
|
+
const page = targets.find((target) => target.type === "page" && (/chatgpt|codex/i.test(`${target.title} ${target.url}`)
|
|
140
|
+
|| target.url === "app://-/index.html"));
|
|
141
|
+
if (page?.webSocketDebuggerUrl)
|
|
142
|
+
return page;
|
|
143
|
+
last = new Error(`未找到 ChatGPT/Codex 页面 target(端口 ${this.port})`);
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
last = error;
|
|
147
|
+
}
|
|
148
|
+
await sleep(500);
|
|
149
|
+
}
|
|
150
|
+
throw new Error(`ChatGPT CDP 连接超时:${last instanceof Error ? last.message : String(last ?? "端口不可达")}`, { cause: last });
|
|
151
|
+
}
|
|
152
|
+
close() {
|
|
153
|
+
this.socket?.close();
|
|
154
|
+
this.socket = undefined;
|
|
155
|
+
this.tunnel?.kill();
|
|
156
|
+
this.tunnel = undefined;
|
|
157
|
+
this.rejectPending(new Error("ChatGPT CDP 已关闭"));
|
|
158
|
+
}
|
|
159
|
+
async direct(text, timeoutMs = 240_000) {
|
|
160
|
+
await this.waitFor(`Boolean(document.querySelector(${JSON.stringify(exports.editorSelector)}))`, "ChatGPT 桌面端输入框未就绪");
|
|
161
|
+
const beforeTurnsJson = await this.evaluate(`JSON.stringify([...document.querySelectorAll(${JSON.stringify(exports.turnSelector)})].map(turn => ({ id: turn.getAttribute(${JSON.stringify(turnIdAttribute)}), users: turn.querySelectorAll(${JSON.stringify(exports.userBubbleSelector)}).length })))`);
|
|
162
|
+
const beforeTurns = JSON.parse(String(beforeTurnsJson || "[]"));
|
|
163
|
+
const beforeAssistantCount = Number(await this.evaluate(`document.querySelectorAll(${JSON.stringify(assistantMessageSelector)}).length`));
|
|
164
|
+
const ready = await this.evaluate(`(() => {
|
|
165
|
+
const editor = document.querySelector(${JSON.stringify(exports.editorSelector)});
|
|
166
|
+
if (!editor || editor.innerText.trim()) return false;
|
|
167
|
+
const findButton = () => {
|
|
168
|
+
for (let parent = editor; parent && parent !== document.body; parent = parent.parentElement) {
|
|
169
|
+
const buttons = [...parent.querySelectorAll(${JSON.stringify(sendButtonSelector)})];
|
|
170
|
+
if (buttons.length === 1) return buttons[0];
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
};
|
|
174
|
+
const button = findButton();
|
|
175
|
+
if (!button) return false;
|
|
176
|
+
editor.focus();
|
|
177
|
+
return document.activeElement === editor;
|
|
178
|
+
})()`);
|
|
179
|
+
if (!ready)
|
|
180
|
+
throw new Error("ChatGPT 输入框含有未发送内容,或桌面端发送按钮未就绪");
|
|
181
|
+
await this.cdp("Input.insertText", { text });
|
|
182
|
+
const inputMatches = await this.evaluate(`document.querySelector(${JSON.stringify(exports.editorSelector)})?.innerText.trim() === ${JSON.stringify(text.trim())}`);
|
|
183
|
+
if (!inputMatches)
|
|
184
|
+
throw new Error("ChatGPT 输入内容校验失败,未发送消息");
|
|
185
|
+
const sent = await this.evaluate(`(() => {
|
|
186
|
+
const editor = document.querySelector(${JSON.stringify(exports.editorSelector)});
|
|
187
|
+
const findButton = () => {
|
|
188
|
+
for (let parent = editor; parent && parent !== document.body; parent = parent.parentElement) {
|
|
189
|
+
const buttons = [...parent.querySelectorAll(${JSON.stringify(sendButtonSelector)})];
|
|
190
|
+
if (buttons.length === 1) return buttons[0];
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
};
|
|
194
|
+
const button = findButton();
|
|
195
|
+
if (!button || button.disabled) return false;
|
|
196
|
+
button.click();
|
|
197
|
+
return true;
|
|
198
|
+
})()`);
|
|
199
|
+
if (!sent)
|
|
200
|
+
throw new Error("ChatGPT 桌面端发送按钮不可用,未确认消息已发送");
|
|
201
|
+
const deadline = Date.now() + timeoutMs;
|
|
202
|
+
let previous = "";
|
|
203
|
+
let stable = 0;
|
|
204
|
+
while (Date.now() < deadline) {
|
|
205
|
+
await sleep(800);
|
|
206
|
+
const raw = await this.evaluate(`(() => {
|
|
207
|
+
const before = ${JSON.stringify(beforeTurns)};
|
|
208
|
+
const turns = [...document.querySelectorAll(${JSON.stringify(exports.turnSelector)})];
|
|
209
|
+
const turn = turns.findLast((item, index) => {
|
|
210
|
+
const id = item.getAttribute(${JSON.stringify(turnIdAttribute)});
|
|
211
|
+
const oldIndex = before.findIndex(entry => entry.id === id);
|
|
212
|
+
const old = oldIndex < 0 ? undefined : before[oldIndex];
|
|
213
|
+
const users = item.querySelectorAll(${JSON.stringify(exports.userBubbleSelector)});
|
|
214
|
+
const fresh = oldIndex < 0 || index >= before.length || users.length > (old?.users ?? 0);
|
|
215
|
+
return fresh && users.item(users.length - 1)?.innerText.trim() === ${JSON.stringify(text.trim())};
|
|
216
|
+
});
|
|
217
|
+
if (!turn) {
|
|
218
|
+
const assistants = [...document.querySelectorAll(${JSON.stringify(assistantMessageSelector)})];
|
|
219
|
+
const fallback = assistants.length > ${beforeAssistantCount}
|
|
220
|
+
? assistants.at(-1)?.innerText.trim() ?? ""
|
|
221
|
+
: "";
|
|
222
|
+
return JSON.stringify({ reply: fallback, active: Boolean(document.querySelector(${JSON.stringify(stopButtonSelector)})) });
|
|
223
|
+
}
|
|
224
|
+
const user = turn.querySelectorAll(${JSON.stringify(exports.userBubbleSelector)}).item(turn.querySelectorAll(${JSON.stringify(exports.userBubbleSelector)}).length - 1);
|
|
225
|
+
const unit = user?.closest(${JSON.stringify(contentUnitSelector)});
|
|
226
|
+
const units = [...turn.querySelectorAll(${JSON.stringify(contentUnitSelector)})];
|
|
227
|
+
const index = unit ? units.indexOf(unit) : -1;
|
|
228
|
+
const replies = index < 0 ? [] : units.slice(index + 1).map(item => item.innerText.trim()).filter(Boolean);
|
|
229
|
+
const legacyReplies = [...turn.querySelectorAll('[data-message-author-role="assistant"]')].map(item => item.innerText.trim()).filter(Boolean);
|
|
230
|
+
const reply = replies.at(-1) ?? legacyReplies.at(-1) ?? "";
|
|
231
|
+
const active = Boolean(document.querySelector(${JSON.stringify(stopButtonSelector)}));
|
|
232
|
+
return JSON.stringify({ reply, active });
|
|
233
|
+
})()`);
|
|
234
|
+
const state = JSON.parse(String(raw));
|
|
235
|
+
const reply = state.reply.trim();
|
|
236
|
+
stable = reply && reply === previous ? stable + 1 : 0;
|
|
237
|
+
previous = reply;
|
|
238
|
+
if (reply && stable >= 2 && !state.active)
|
|
239
|
+
return reply;
|
|
240
|
+
}
|
|
241
|
+
throw new Error("ChatGPT UI 回复超时");
|
|
242
|
+
}
|
|
243
|
+
async startTunnel() {
|
|
244
|
+
const ssh = this.ssh;
|
|
245
|
+
const args = [
|
|
246
|
+
"-N", "-o", "BatchMode=yes", "-o", "ExitOnForwardFailure=yes", "-o", "ConnectTimeout=10",
|
|
247
|
+
"-L", `${ssh.localPort}:127.0.0.1:${ssh.remotePort}`, "-p", String(ssh.port ?? 22),
|
|
248
|
+
];
|
|
249
|
+
if (ssh.keyPath)
|
|
250
|
+
args.push("-i", ssh.keyPath);
|
|
251
|
+
args.push(`${ssh.user}@${ssh.host}`);
|
|
252
|
+
this.tunnel = (0, node_child_process_1.spawn)("ssh", args, { stdio: "ignore", windowsHide: true });
|
|
253
|
+
const tunnel = this.tunnel;
|
|
254
|
+
await new Promise((resolve, reject) => {
|
|
255
|
+
let ready = false;
|
|
256
|
+
const fail = (error) => {
|
|
257
|
+
if (!ready)
|
|
258
|
+
reject(error);
|
|
259
|
+
else if (this.tunnel === tunnel) {
|
|
260
|
+
this.tunnel = undefined;
|
|
261
|
+
this.socket?.close();
|
|
262
|
+
this.socket = undefined;
|
|
263
|
+
this.rejectPending(error);
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
const timer = setTimeout(() => {
|
|
267
|
+
ready = true;
|
|
268
|
+
resolve();
|
|
269
|
+
}, 500);
|
|
270
|
+
tunnel.once("error", (error) => {
|
|
271
|
+
clearTimeout(timer);
|
|
272
|
+
fail(error);
|
|
273
|
+
});
|
|
274
|
+
tunnel.once("exit", () => {
|
|
275
|
+
clearTimeout(timer);
|
|
276
|
+
fail(new Error("SSH CDP 隧道已关闭,请检查远程连接"));
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
async waitFor(expression, message) {
|
|
281
|
+
const deadline = Date.now() + this.timeoutMs;
|
|
282
|
+
while (Date.now() < deadline) {
|
|
283
|
+
if (await this.evaluate(expression))
|
|
284
|
+
return;
|
|
285
|
+
await sleep(200);
|
|
286
|
+
}
|
|
287
|
+
throw new Error(message);
|
|
288
|
+
}
|
|
289
|
+
rejectPending(error) {
|
|
290
|
+
for (const pending of this.pending.values())
|
|
291
|
+
pending.reject(error);
|
|
292
|
+
this.pending.clear();
|
|
293
|
+
}
|
|
294
|
+
onMessage(raw) {
|
|
295
|
+
let message;
|
|
296
|
+
try {
|
|
297
|
+
message = JSON.parse(raw);
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
if (message.id === undefined)
|
|
303
|
+
return;
|
|
304
|
+
const pending = this.pending.get(message.id);
|
|
305
|
+
if (!pending)
|
|
306
|
+
return;
|
|
307
|
+
this.pending.delete(message.id);
|
|
308
|
+
if (message.error)
|
|
309
|
+
pending.reject(new Error(message.error.message ?? "CDP 调用失败"));
|
|
310
|
+
else
|
|
311
|
+
pending.resolve(message);
|
|
312
|
+
}
|
|
313
|
+
async cdp(method, params = {}) {
|
|
314
|
+
if (!this.socket)
|
|
315
|
+
throw new Error("ChatGPT CDP 尚未连接");
|
|
316
|
+
const id = ++this.sequence;
|
|
317
|
+
const socket = this.socket;
|
|
318
|
+
return new Promise((resolve, reject) => {
|
|
319
|
+
const timer = setTimeout(() => {
|
|
320
|
+
this.pending.delete(id);
|
|
321
|
+
reject(new Error(`CDP ${method} 超时`));
|
|
322
|
+
}, this.timeoutMs);
|
|
323
|
+
this.pending.set(id, {
|
|
324
|
+
resolve: (value) => {
|
|
325
|
+
clearTimeout(timer);
|
|
326
|
+
resolve(value);
|
|
327
|
+
},
|
|
328
|
+
reject: (error) => {
|
|
329
|
+
clearTimeout(timer);
|
|
330
|
+
reject(error);
|
|
331
|
+
},
|
|
332
|
+
});
|
|
333
|
+
try {
|
|
334
|
+
socket.send(JSON.stringify({ id, method, params }));
|
|
335
|
+
}
|
|
336
|
+
catch (error) {
|
|
337
|
+
this.pending.delete(id);
|
|
338
|
+
clearTimeout(timer);
|
|
339
|
+
reject(error);
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
async evaluate(expression) {
|
|
344
|
+
const response = await this.cdp("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true });
|
|
345
|
+
if (response.result?.exceptionDetails) {
|
|
346
|
+
throw new Error(`ChatGPT 页面 JS 异常:${JSON.stringify(response.result.exceptionDetails).slice(0, 300)}`);
|
|
347
|
+
}
|
|
348
|
+
return response.result?.result?.value;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
exports.ChatgptCdpClient = ChatgptCdpClient;
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ChatgptCdpClient = exports.ChatgptPlugin = exports.chatgptManifest = void 0;
|
|
4
|
+
const cdp_client_1 = require("./cdp-client");
|
|
5
|
+
const session_store_1 = require("./session-store");
|
|
6
|
+
exports.chatgptManifest = {
|
|
7
|
+
id: "chatgpt",
|
|
8
|
+
version: "1.0.1",
|
|
9
|
+
displayName: "ChatGPT(Codex)",
|
|
10
|
+
type: "agent",
|
|
11
|
+
entry: "dist/index.js",
|
|
12
|
+
apiVersion: "1",
|
|
13
|
+
process: "in-process",
|
|
14
|
+
capabilities: ["chat", "health"],
|
|
15
|
+
configSchema: "config.schema.json",
|
|
16
|
+
};
|
|
17
|
+
function port(value, fallback) {
|
|
18
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0 && value <= 65_535
|
|
19
|
+
? value
|
|
20
|
+
: fallback;
|
|
21
|
+
}
|
|
22
|
+
class ChatgptPlugin {
|
|
23
|
+
options;
|
|
24
|
+
manifest = exports.chatgptManifest;
|
|
25
|
+
client;
|
|
26
|
+
failure;
|
|
27
|
+
remote = false;
|
|
28
|
+
queue = Promise.resolve();
|
|
29
|
+
store;
|
|
30
|
+
logger;
|
|
31
|
+
constructor(options = {}) {
|
|
32
|
+
this.options = options;
|
|
33
|
+
}
|
|
34
|
+
async activate(context) {
|
|
35
|
+
const config = context.config;
|
|
36
|
+
const hasSshHost = config.sshHost !== undefined;
|
|
37
|
+
const hasSshUser = config.sshUser !== undefined;
|
|
38
|
+
if (hasSshHost !== hasSshUser || (hasSshHost && (typeof config.sshHost !== "string" || !config.sshHost.trim()
|
|
39
|
+
|| typeof config.sshUser !== "string" || !config.sshUser.trim()))) {
|
|
40
|
+
throw new Error("sshHost 和 sshUser 必须同时填写有效值");
|
|
41
|
+
}
|
|
42
|
+
if (config.sshKeyPath !== undefined && typeof config.sshKeyPath !== "string") {
|
|
43
|
+
throw new Error("sshKeyPath 必须是字符串");
|
|
44
|
+
}
|
|
45
|
+
const ssh = hasSshHost ? {
|
|
46
|
+
host: config.sshHost.trim(),
|
|
47
|
+
user: config.sshUser.trim(),
|
|
48
|
+
port: port(config.sshPort, 22),
|
|
49
|
+
keyPath: typeof config.sshKeyPath === "string" ? config.sshKeyPath : undefined,
|
|
50
|
+
remotePort: port(config.remoteCdpPort, 9230),
|
|
51
|
+
localPort: port(config.localCdpPort, 9230),
|
|
52
|
+
} : undefined;
|
|
53
|
+
this.remote = Boolean(ssh);
|
|
54
|
+
this.logger = context.logger;
|
|
55
|
+
this.store = new session_store_1.ChatgptSessionStore(context.dataDir, JSON.stringify(ssh
|
|
56
|
+
? [ssh.host, ssh.user, ssh.port, ssh.remotePort]
|
|
57
|
+
: ["local", port(config.localCdpPort, 9230)]));
|
|
58
|
+
await this.store.load();
|
|
59
|
+
this.failure = undefined;
|
|
60
|
+
this.client = this.options.client ?? new cdp_client_1.ChatgptCdpClient({
|
|
61
|
+
port: port(config.localCdpPort, 9230),
|
|
62
|
+
timeoutMs: typeof config.timeoutMs === "number" && config.timeoutMs > 0 ? config.timeoutMs : 30_000,
|
|
63
|
+
ssh,
|
|
64
|
+
});
|
|
65
|
+
try {
|
|
66
|
+
await this.client.connect();
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
this.failure = error instanceof Error ? error.message : String(error);
|
|
70
|
+
context.logger.warn(this.failure);
|
|
71
|
+
if (!this.store.sessions.length)
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
async deactivate() {
|
|
76
|
+
await this.queue;
|
|
77
|
+
await this.store?.drain();
|
|
78
|
+
this.client?.close();
|
|
79
|
+
this.client = undefined;
|
|
80
|
+
this.remote = false;
|
|
81
|
+
}
|
|
82
|
+
async health() {
|
|
83
|
+
return this.client?.connected()
|
|
84
|
+
? { status: "ready", details: { transport: "cdp", remote: this.remote } }
|
|
85
|
+
: { status: this.client && this.store?.sessions.length ? "degraded" : "failed", message: this.failure ?? "ChatGPT CDP 未连接", details: { transport: "cdp", remote: this.remote } };
|
|
86
|
+
}
|
|
87
|
+
async handle(request) {
|
|
88
|
+
if (!request.task.trim())
|
|
89
|
+
throw new Error("消息内容不能为空");
|
|
90
|
+
if (!this.client || !this.store)
|
|
91
|
+
throw new Error("ChatGPT 插件未启动");
|
|
92
|
+
const run = this.queue.then(() => this.run(request));
|
|
93
|
+
this.queue = run.then(() => undefined, () => undefined);
|
|
94
|
+
return { text: await run, sessionId: request.sessionId };
|
|
95
|
+
}
|
|
96
|
+
persist() {
|
|
97
|
+
void this.store.save().catch(error => this.logger?.warn("ChatGPT 会话缓存保存失败", { error: String(error) }));
|
|
98
|
+
}
|
|
99
|
+
async synchronize() {
|
|
100
|
+
if (!this.client.connected())
|
|
101
|
+
await this.client.connect();
|
|
102
|
+
if (!this.client.listSessions)
|
|
103
|
+
throw new Error("当前 ChatGPT 客户端不支持会话列表");
|
|
104
|
+
this.store.synchronize(await this.client.listSessions());
|
|
105
|
+
this.persist();
|
|
106
|
+
}
|
|
107
|
+
async select(session) {
|
|
108
|
+
if (!this.client.connected())
|
|
109
|
+
await this.client.connect();
|
|
110
|
+
if (!this.client.switchSession)
|
|
111
|
+
throw new Error("当前 ChatGPT 客户端不支持会话切换");
|
|
112
|
+
const active = await this.client.activeSession?.();
|
|
113
|
+
if (active?.id === session.id && active.host === session.host)
|
|
114
|
+
return;
|
|
115
|
+
try {
|
|
116
|
+
await this.client.switchSession(session);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
// 目标消失时只重新同步一次;不在其他会话中代替发送。
|
|
120
|
+
if (!String(error).includes("找不到目标会话"))
|
|
121
|
+
throw error;
|
|
122
|
+
await this.synchronize();
|
|
123
|
+
const updated = this.store.sessions.find(item => item.id === session.id && item.host === session.host);
|
|
124
|
+
if (!updated)
|
|
125
|
+
throw new Error("ChatGPT 目标会话已删除或不在当前侧栏,请刷新最近任务");
|
|
126
|
+
await this.client.switchSession(updated);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
async run(request) {
|
|
130
|
+
const store = this.store;
|
|
131
|
+
const text = request.task.trim();
|
|
132
|
+
const refresh = /^(?:刷新最近任务|刷新任务列表|刷新最近会话)$/.test(text);
|
|
133
|
+
const listing = /^(?:最近任务|列出最近会话|有哪些已保存的 ChatGPT 任务)[??。]?$/.test(text);
|
|
134
|
+
if (refresh || listing) {
|
|
135
|
+
if (refresh || !store.fresh()) {
|
|
136
|
+
try {
|
|
137
|
+
await this.synchronize();
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
if (refresh || !store.sessions.length)
|
|
141
|
+
throw error;
|
|
142
|
+
return `远程暂不可用,以下是本地缓存(同步时间:${store.updatedAt}):\n${(0, session_store_1.formatSessions)(store.sessions)}`;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return `最近会话(当前侧栏缓存;同步时间:${store.updatedAt}):\n${(0, session_store_1.formatSessions)(store.sessions)}`;
|
|
146
|
+
}
|
|
147
|
+
const explicit = text.match(/^切换会话\s*[::]\s*([^;;\n]+)(?:[;;\n]\s*([\s\S]+))?$/);
|
|
148
|
+
const natural = text.match(/^(?:继续处理|接着处理|继续任务|切换到)\s*(.+?)[。]?$/);
|
|
149
|
+
const query = explicit?.[1].trim() ?? natural?.[1].trim();
|
|
150
|
+
let selected;
|
|
151
|
+
if (query) {
|
|
152
|
+
if (!store.fresh())
|
|
153
|
+
await this.synchronize();
|
|
154
|
+
let matches = store.match(query);
|
|
155
|
+
if (!matches.length) {
|
|
156
|
+
await this.synchronize();
|
|
157
|
+
matches = store.match(query);
|
|
158
|
+
}
|
|
159
|
+
if (!matches.length)
|
|
160
|
+
return `没有找到会话:${query}。请发送“最近任务”查看可用标题。`;
|
|
161
|
+
if (matches.length > 1)
|
|
162
|
+
return `找到多个会话,请用完整标题或会话 ID 指定:\n${(0, session_store_1.formatSessions)(matches)}`;
|
|
163
|
+
selected = matches[0];
|
|
164
|
+
await this.select(selected);
|
|
165
|
+
if (request.sessionId)
|
|
166
|
+
store.bindings[request.sessionId] = selected;
|
|
167
|
+
this.persist();
|
|
168
|
+
if ((explicit && !explicit[2]) || text.startsWith("切换到"))
|
|
169
|
+
return `已切换到:${selected.title}。后续消息将继续此会话。`;
|
|
170
|
+
}
|
|
171
|
+
else if (request.sessionId && store.bindings[request.sessionId]) {
|
|
172
|
+
selected = store.bindings[request.sessionId];
|
|
173
|
+
await this.select(selected);
|
|
174
|
+
}
|
|
175
|
+
if (!this.client.connected())
|
|
176
|
+
await this.client.connect();
|
|
177
|
+
selected ??= await this.client.activeSession?.();
|
|
178
|
+
const message = explicit?.[2]?.trim() || request.task;
|
|
179
|
+
const reply = await this.client.direct(message, request.timeoutMs);
|
|
180
|
+
if (selected) {
|
|
181
|
+
const current = await this.client.activeSession?.().catch(() => undefined);
|
|
182
|
+
const entry = current?.id === selected.id && current.host === selected.host ? current : selected;
|
|
183
|
+
store.remember(entry, message, reply);
|
|
184
|
+
if (request.sessionId)
|
|
185
|
+
store.bindings[request.sessionId] = entry;
|
|
186
|
+
this.persist();
|
|
187
|
+
}
|
|
188
|
+
return reply;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
exports.ChatgptPlugin = ChatgptPlugin;
|
|
192
|
+
var cdp_client_2 = require("./cdp-client");
|
|
193
|
+
Object.defineProperty(exports, "ChatgptCdpClient", { enumerable: true, get: function () { return cdp_client_2.ChatgptCdpClient; } });
|
|
194
|
+
exports.default = ChatgptPlugin;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ChatgptSessionStore = void 0;
|
|
4
|
+
exports.validSession = validSession;
|
|
5
|
+
exports.formatSessions = formatSessions;
|
|
6
|
+
const promises_1 = require("node:fs/promises");
|
|
7
|
+
const node_path_1 = require("node:path");
|
|
8
|
+
function validSession(value) {
|
|
9
|
+
if (!value || typeof value !== "object")
|
|
10
|
+
return false;
|
|
11
|
+
const item = value;
|
|
12
|
+
return ["id", "host", "title"].every(key => typeof item[key] === "string" && Boolean(item[key]))
|
|
13
|
+
&& ["updatedAt", "lastUserMessage", "lastAssistantReply"].every(key => item[key] === undefined || typeof item[key] === "string");
|
|
14
|
+
}
|
|
15
|
+
class ChatgptSessionStore {
|
|
16
|
+
directory;
|
|
17
|
+
endpoint;
|
|
18
|
+
sessions = [];
|
|
19
|
+
bindings = {};
|
|
20
|
+
updatedAt = "";
|
|
21
|
+
writes = Promise.resolve();
|
|
22
|
+
constructor(directory, endpoint) {
|
|
23
|
+
this.directory = directory;
|
|
24
|
+
this.endpoint = endpoint;
|
|
25
|
+
}
|
|
26
|
+
async load() {
|
|
27
|
+
if (!this.directory)
|
|
28
|
+
return;
|
|
29
|
+
try {
|
|
30
|
+
const value = JSON.parse(await (0, promises_1.readFile)((0, node_path_1.join)(this.directory, "chatgpt-sessions.json"), "utf8"));
|
|
31
|
+
if (value.version !== 1 || value.endpoint !== this.endpoint || !Array.isArray(value.sessions)
|
|
32
|
+
|| !value.sessions.every(validSession) || typeof value.updatedAt !== "string"
|
|
33
|
+
|| !value.bindings || typeof value.bindings !== "object" || Array.isArray(value.bindings)
|
|
34
|
+
|| !Object.values(value.bindings).every(validSession))
|
|
35
|
+
return;
|
|
36
|
+
this.sessions = value.sessions.slice(0, 20);
|
|
37
|
+
this.bindings = Object.fromEntries(Object.entries(value.bindings));
|
|
38
|
+
this.updatedAt = value.updatedAt;
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
if (error.code !== "ENOENT" && !(error instanceof SyntaxError))
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
fresh() { return Date.now() - Date.parse(this.updatedAt) < 10 * 60_000; }
|
|
46
|
+
synchronize(entries) {
|
|
47
|
+
const previous = this.sessions;
|
|
48
|
+
const seen = new Set();
|
|
49
|
+
this.sessions = entries.filter(validSession).filter(item => {
|
|
50
|
+
const key = JSON.stringify([item.host, item.id]);
|
|
51
|
+
if (seen.has(key))
|
|
52
|
+
return false;
|
|
53
|
+
seen.add(key);
|
|
54
|
+
return true;
|
|
55
|
+
}).map(item => ({ ...previous.find(old => old.id === item.id && old.host === item.host), ...item }))
|
|
56
|
+
.sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? "")).slice(0, 20);
|
|
57
|
+
this.updatedAt = new Date().toISOString();
|
|
58
|
+
}
|
|
59
|
+
remember(entry, user, reply) {
|
|
60
|
+
const next = { ...entry, lastUserMessage: user.slice(0, 500), lastAssistantReply: reply.slice(0, 500), updatedAt: new Date().toISOString() };
|
|
61
|
+
this.sessions = [next, ...this.sessions.filter(old => old.id !== entry.id || old.host !== entry.host)].slice(0, 20);
|
|
62
|
+
}
|
|
63
|
+
match(query) {
|
|
64
|
+
const text = query.trim().toLocaleLowerCase();
|
|
65
|
+
if (!text)
|
|
66
|
+
return [];
|
|
67
|
+
const exact = this.sessions.filter(item => item.id === query || item.title.toLocaleLowerCase() === text);
|
|
68
|
+
return exact.length ? exact : this.sessions.filter(item => `${item.title}\n${item.lastUserMessage ?? ""}`.toLocaleLowerCase().includes(text));
|
|
69
|
+
}
|
|
70
|
+
save() {
|
|
71
|
+
const snapshot = { version: 1, endpoint: this.endpoint, updatedAt: this.updatedAt, sessions: this.sessions, bindings: this.bindings };
|
|
72
|
+
const text = `${JSON.stringify(snapshot, null, 2)}\n`;
|
|
73
|
+
const write = this.writes.catch(() => { }).then(async () => {
|
|
74
|
+
if (!this.directory)
|
|
75
|
+
return;
|
|
76
|
+
await (0, promises_1.mkdir)(this.directory, { recursive: true });
|
|
77
|
+
const file = (0, node_path_1.join)(this.directory, "chatgpt-sessions.json");
|
|
78
|
+
await (0, promises_1.writeFile)(`${file}.tmp`, text, { mode: 0o600 });
|
|
79
|
+
await (0, promises_1.rename)(`${file}.tmp`, file);
|
|
80
|
+
});
|
|
81
|
+
this.writes = write;
|
|
82
|
+
return write;
|
|
83
|
+
}
|
|
84
|
+
async drain() { await this.writes.catch(() => { }); }
|
|
85
|
+
}
|
|
86
|
+
exports.ChatgptSessionStore = ChatgptSessionStore;
|
|
87
|
+
function formatSessions(entries) {
|
|
88
|
+
if (!entries.length)
|
|
89
|
+
return "当前侧栏暂无可用会话。";
|
|
90
|
+
return entries.map((item, index) => `${index + 1}. ${item.title}\n 主机:${item.host};会话:${item.id}${item.updatedAt ? `;最近使用:${item.updatedAt}` : ""}`).join("\n");
|
|
91
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "agent-bridge-chatgpt",
|
|
3
|
-
"version": "0.0.0-stage",
|
|
4
|
-
"stub": true,
|
|
5
|
-
"description": "Temporary package placeholder for staged publishing"
|
|
6
|
-
}
|
|
1
|
+
{"name":"agent-bridge-chatgpt","version":"1.0.1","description":"agent-bridge 的 ChatGPT Codex CDP 插件。","keywords":["mu-agent-bridge-plugin","mu-agent-bridge-agent"],"main":"dist/src-ts/plugins/chatgpt/index.js","files":["dist/","plugin.json","config.schema.json","README.md"],"agentBridge":{"plugin":"./plugin.json","type":"agent"},"engines":{"node":">=22"}}
|
package/plugin.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"id":"chatgpt","version":"1.0.1","type":"agent","displayName":"ChatGPT(Codex)","entry":"dist/src-ts/plugins/chatgpt/index.js","apiVersion":"1","process":"in-process","capabilities":["chat","health"],"configSchema":"config.schema.json"}
|