qq-codex-bridge 0.1.3 → 0.1.4
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/.env.example +62 -0
- package/README.md +232 -287
- package/bin/chatgpt-desktop.js +2 -0
- package/bin/qq-codex-weixin-gateway.js +14 -0
- package/dist/apps/bridge-daemon/src/bootstrap.js +161 -31
- package/dist/apps/bridge-daemon/src/cli.js +5 -1
- package/dist/apps/bridge-daemon/src/config.js +168 -37
- package/dist/apps/bridge-daemon/src/http-server.js +23 -11
- package/dist/apps/bridge-daemon/src/main.js +163 -29
- package/dist/apps/bridge-daemon/src/thread-command-handler.js +309 -26
- package/dist/apps/chatgpt-desktop-cli/src/cli.js +191 -0
- package/dist/apps/weixin-gateway/src/cli.js +446 -0
- package/dist/apps/weixin-gateway/src/config.js +135 -0
- package/dist/apps/weixin-gateway/src/dev.js +2 -0
- package/dist/apps/weixin-gateway/src/message-store.js +50 -0
- package/dist/apps/weixin-gateway/src/server.js +216 -0
- package/dist/apps/weixin-gateway/src/state.js +163 -0
- package/dist/apps/weixin-gateway/src/weixin-client.js +520 -0
- package/dist/packages/adapters/chatgpt-desktop/src/ax-client.js +472 -0
- package/dist/packages/adapters/chatgpt-desktop/src/bridge-provider.js +82 -0
- package/dist/packages/adapters/chatgpt-desktop/src/driver.js +161 -0
- package/dist/packages/adapters/chatgpt-desktop/src/image-cache.js +155 -0
- package/dist/packages/adapters/chatgpt-desktop/src/session-registry.js +48 -0
- package/dist/packages/adapters/chatgpt-desktop/src/types.js +1 -0
- package/dist/packages/adapters/codex-desktop/src/codex-app-server-driver.js +810 -0
- package/dist/packages/adapters/codex-desktop/src/codex-app-ui-notification-forwarder.js +33 -0
- package/dist/packages/adapters/codex-desktop/src/codex-desktop-driver.js +727 -123
- package/dist/packages/adapters/codex-desktop/src/codex-local-rollout-reader.js +227 -0
- package/dist/packages/adapters/codex-desktop/src/codex-local-submission-reader.js +142 -0
- package/dist/packages/adapters/weixin/src/weixin-channel-adapter.js +15 -0
- package/dist/packages/adapters/weixin/src/weixin-http-client.js +42 -0
- package/dist/packages/adapters/weixin/src/weixin-sender.js +200 -0
- package/dist/packages/adapters/weixin/src/weixin-webhook.js +35 -0
- package/dist/packages/orchestrator/src/bridge-orchestrator.js +72 -25
- package/dist/packages/orchestrator/src/weixin-outbound-format.js +55 -0
- package/dist/packages/ports/src/chat.js +1 -0
- package/dist/packages/store/src/session-repo.js +16 -3
- package/dist/packages/store/src/sqlite.js +3 -0
- package/package.json +8 -2
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
const BUNDLE_ID = "com.openai.chat";
|
|
3
|
+
// ── Swift runner ──────────────────────────────────────────────────────────────
|
|
4
|
+
// ChatGPT Desktop does NOT support AppleScript/JXA. All AX operations go
|
|
5
|
+
// through Swift + ApplicationServices / AX API via `swift -` stdin pipe.
|
|
6
|
+
function runSwift(code, timeoutMs = 15_000) {
|
|
7
|
+
const result = spawnSync("swift", ["-"], {
|
|
8
|
+
input: code,
|
|
9
|
+
encoding: "utf-8",
|
|
10
|
+
timeout: timeoutMs,
|
|
11
|
+
maxBuffer: 4 * 1024 * 1024
|
|
12
|
+
});
|
|
13
|
+
if (result.error)
|
|
14
|
+
throw new Error(`swift spawn error: ${result.error.message}`);
|
|
15
|
+
if (result.status !== 0) {
|
|
16
|
+
const stderr = result.stderr?.trim() ?? "";
|
|
17
|
+
throw new Error(`swift exit ${result.status}: ${stderr.slice(0, 400)}`);
|
|
18
|
+
}
|
|
19
|
+
return (result.stdout ?? "").trim();
|
|
20
|
+
}
|
|
21
|
+
// Shared Swift preamble (AX helpers + BUNDLE_ID)
|
|
22
|
+
const SWIFT_PREAMBLE = `
|
|
23
|
+
import Cocoa
|
|
24
|
+
import ApplicationServices
|
|
25
|
+
|
|
26
|
+
let BUNDLE_ID = "${BUNDLE_ID}"
|
|
27
|
+
|
|
28
|
+
func getAttr(_ el: AXUIElement, _ attr: String) -> CFTypeRef? {
|
|
29
|
+
var v: CFTypeRef?
|
|
30
|
+
AXUIElementCopyAttributeValue(el, attr as CFString, &v)
|
|
31
|
+
return v
|
|
32
|
+
}
|
|
33
|
+
func getChildren(_ el: AXUIElement) -> [AXUIElement] {
|
|
34
|
+
(getAttr(el, kAXChildrenAttribute) as? [AXUIElement]) ?? []
|
|
35
|
+
}
|
|
36
|
+
func getRole(_ el: AXUIElement) -> String {
|
|
37
|
+
(getAttr(el, kAXRoleAttribute) as? String) ?? ""
|
|
38
|
+
}
|
|
39
|
+
func getDesc(_ el: AXUIElement) -> String {
|
|
40
|
+
(getAttr(el, kAXDescriptionAttribute) as? String) ?? ""
|
|
41
|
+
}
|
|
42
|
+
func findAll(_ el: AXUIElement, role: String, depth: Int = 0, max: Int = 14) -> [AXUIElement] {
|
|
43
|
+
guard depth <= max else { return [] }
|
|
44
|
+
var out: [AXUIElement] = []
|
|
45
|
+
if getRole(el) == role { out.append(el) }
|
|
46
|
+
for child in getChildren(el) { out += findAll(child, role: role, depth: depth+1, max: max) }
|
|
47
|
+
return out
|
|
48
|
+
}
|
|
49
|
+
func axApp() -> AXUIElement? {
|
|
50
|
+
guard let app = NSWorkspace.shared.runningApplications
|
|
51
|
+
.first(where: { $0.bundleIdentifier == BUNDLE_ID }) else { return nil }
|
|
52
|
+
return AXUIElementCreateApplication(app.processIdentifier)
|
|
53
|
+
}
|
|
54
|
+
func jsonEscape(_ s: String) -> String {
|
|
55
|
+
s.replacingOccurrences(of: "\\\\", with: "\\\\\\\\")
|
|
56
|
+
.replacingOccurrences(of: "\\"", with: "\\\\\\"")
|
|
57
|
+
.replacingOccurrences(of: "\\n", with: "\\\\n")
|
|
58
|
+
.replacingOccurrences(of: "\\r", with: "\\\\r")
|
|
59
|
+
.replacingOccurrences(of: "\\t", with: "\\\\t")
|
|
60
|
+
}
|
|
61
|
+
`;
|
|
62
|
+
export function checkAccessibility() {
|
|
63
|
+
try {
|
|
64
|
+
const out = runSwift(`
|
|
65
|
+
import ApplicationServices
|
|
66
|
+
print(AXIsProcessTrusted())
|
|
67
|
+
`);
|
|
68
|
+
return out === "true";
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
export function healthCheck() {
|
|
75
|
+
try {
|
|
76
|
+
const out = runSwift(`
|
|
77
|
+
import Cocoa
|
|
78
|
+
import ApplicationServices
|
|
79
|
+
let BUNDLE_ID = "${BUNDLE_ID}"
|
|
80
|
+
let app = NSWorkspace.shared.runningApplications.first(where: { $0.bundleIdentifier == BUNDLE_ID })
|
|
81
|
+
let running = app != nil
|
|
82
|
+
let trusted = AXIsProcessTrusted()
|
|
83
|
+
let front = app?.isActive ?? false
|
|
84
|
+
print("\\(running ? "true" : "false") \\(trusted ? "true" : "false") \\(front ? "true" : "false")")
|
|
85
|
+
`);
|
|
86
|
+
const parts = out.split(" ");
|
|
87
|
+
return {
|
|
88
|
+
appRunning: parts[0] === "true",
|
|
89
|
+
accessibility: parts[1] === "true",
|
|
90
|
+
frontmost: parts[2] === "true"
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return { appRunning: false, accessibility: false, frontmost: false };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
export function ensureAppVisible() {
|
|
98
|
+
const code = `
|
|
99
|
+
${SWIFT_PREAMBLE}
|
|
100
|
+
guard let app = NSWorkspace.shared.runningApplications
|
|
101
|
+
.first(where: { $0.bundleIdentifier == BUNDLE_ID }) else {
|
|
102
|
+
print("ERROR: not running"); exit(1)
|
|
103
|
+
}
|
|
104
|
+
app.activate(options: [])
|
|
105
|
+
Thread.sleep(forTimeInterval: 0.8)
|
|
106
|
+
print("ok")
|
|
107
|
+
`;
|
|
108
|
+
const out = runSwift(code);
|
|
109
|
+
if (out.hasPrefix("ERROR"))
|
|
110
|
+
throw new Error("ChatGPT Desktop is not running");
|
|
111
|
+
}
|
|
112
|
+
export function snapshotReplyTexts() {
|
|
113
|
+
const code = `
|
|
114
|
+
${SWIFT_PREAMBLE}
|
|
115
|
+
guard let ax = axApp() else { print("[]"); exit(0) }
|
|
116
|
+
let sts = findAll(ax, role: "AXStaticText")
|
|
117
|
+
var result: [String] = []
|
|
118
|
+
for st in sts {
|
|
119
|
+
let d = getDesc(st)
|
|
120
|
+
if d.count > 8 { result.append("\\"\\(jsonEscape(d))\\"") }
|
|
121
|
+
}
|
|
122
|
+
print("[\\(result.joined(separator: ","))]")
|
|
123
|
+
`;
|
|
124
|
+
try {
|
|
125
|
+
const raw = runSwift(code);
|
|
126
|
+
return JSON.parse(raw);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
export function diffTexts(before, after) {
|
|
133
|
+
const beforeSet = new Set(before);
|
|
134
|
+
return after.filter((t) => !beforeSet.has(t));
|
|
135
|
+
}
|
|
136
|
+
export function sendMessage(prompt, opts = {}) {
|
|
137
|
+
const confirmTimeout = Math.round((opts.confirmTimeoutMs ?? 8_000) / 1000);
|
|
138
|
+
const completionTimeout = Math.round((opts.completionTimeoutMs ?? 120_000) / 1000);
|
|
139
|
+
const swiftPrompt = prompt
|
|
140
|
+
.replace(/\\/g, "\\\\")
|
|
141
|
+
.replace(/"/g, '\\"')
|
|
142
|
+
.replace(/\n/g, "\\n");
|
|
143
|
+
const swiftAttachmentPaths = `[${(opts.attachmentPaths ?? [])
|
|
144
|
+
.map((path) => `"${path.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`)
|
|
145
|
+
.join(",")}]`;
|
|
146
|
+
const code = `
|
|
147
|
+
${SWIFT_PREAMBLE}
|
|
148
|
+
import Foundation
|
|
149
|
+
import CoreGraphics
|
|
150
|
+
|
|
151
|
+
let prompt = "${swiftPrompt}"
|
|
152
|
+
let attachmentPaths: [String] = ${swiftAttachmentPaths}
|
|
153
|
+
let confirmSecs = ${confirmTimeout}
|
|
154
|
+
let completeSecs = ${completionTimeout}
|
|
155
|
+
let sendButtonPolls = attachmentPaths.isEmpty ? 40 : 120
|
|
156
|
+
|
|
157
|
+
guard let ax = axApp() else {
|
|
158
|
+
print("not_confirmed not_completed 0")
|
|
159
|
+
print("[]")
|
|
160
|
+
exit(0)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// activate (use empty options — activateIgnoringOtherApps is deprecated/no-op on macOS 14+)
|
|
164
|
+
if let nsApp = NSWorkspace.shared.runningApplications
|
|
165
|
+
.first(where: { $0.bundleIdentifier == BUNDLE_ID }) {
|
|
166
|
+
nsApp.activate(options: [])
|
|
167
|
+
Thread.sleep(forTimeInterval: 0.8)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// collect AX static text nodes — returns ordered array (preserves duplicates)
|
|
171
|
+
func collectTextNodes() -> [String] {
|
|
172
|
+
var out: [String] = []
|
|
173
|
+
for st in findAll(ax, role: "AXStaticText") {
|
|
174
|
+
// try kAXValueAttribute first (richer), fall back to kAXDescriptionAttribute
|
|
175
|
+
var v: CFTypeRef?
|
|
176
|
+
AXUIElementCopyAttributeValue(st, kAXValueAttribute as CFString, &v)
|
|
177
|
+
if let s = v as? String, !s.isEmpty { out.append(s); continue }
|
|
178
|
+
AXUIElementCopyAttributeValue(st, kAXDescriptionAttribute as CFString, &v)
|
|
179
|
+
if let s = v as? String, !s.isEmpty { out.append(s) }
|
|
180
|
+
}
|
|
181
|
+
return out
|
|
182
|
+
}
|
|
183
|
+
let beforeNodes = collectTextNodes()
|
|
184
|
+
let beforeCount = beforeNodes.count
|
|
185
|
+
|
|
186
|
+
func currentComposer() -> AXUIElement? {
|
|
187
|
+
findAll(ax, role: "AXTextArea").first
|
|
188
|
+
}
|
|
189
|
+
func pasteAttachmentFiles(_ paths: [String]) -> Bool {
|
|
190
|
+
if paths.isEmpty { return true }
|
|
191
|
+
var urls: [NSURL] = []
|
|
192
|
+
for path in paths {
|
|
193
|
+
guard FileManager.default.fileExists(atPath: path) else { return false }
|
|
194
|
+
urls.append(NSURL(fileURLWithPath: path))
|
|
195
|
+
}
|
|
196
|
+
let pasteboard = NSPasteboard.general
|
|
197
|
+
pasteboard.clearContents()
|
|
198
|
+
guard pasteboard.writeObjects(urls) else { return false }
|
|
199
|
+
|
|
200
|
+
let src = CGEventSource(stateID: .hidSystemState)
|
|
201
|
+
let down = CGEvent(keyboardEventSource: src, virtualKey: 0x09, keyDown: true)!
|
|
202
|
+
let up = CGEvent(keyboardEventSource: src, virtualKey: 0x09, keyDown: false)!
|
|
203
|
+
down.flags = .maskCommand
|
|
204
|
+
up.flags = .maskCommand
|
|
205
|
+
down.post(tap: .cghidEventTap)
|
|
206
|
+
Thread.sleep(forTimeInterval: 0.05)
|
|
207
|
+
up.post(tap: .cghidEventTap)
|
|
208
|
+
Thread.sleep(forTimeInterval: 2.0)
|
|
209
|
+
return true
|
|
210
|
+
}
|
|
211
|
+
func isEnabled(_ el: AXUIElement) -> Bool {
|
|
212
|
+
(getAttr(el, kAXEnabledAttribute) as? Bool) ?? true
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// set composer and optional attachments
|
|
216
|
+
guard let initialComposer = currentComposer() else {
|
|
217
|
+
print("not_confirmed not_completed 0")
|
|
218
|
+
print("[]")
|
|
219
|
+
exit(1)
|
|
220
|
+
}
|
|
221
|
+
AXUIElementSetAttributeValue(initialComposer, kAXFocusedAttribute as CFString, true as CFBoolean)
|
|
222
|
+
Thread.sleep(forTimeInterval: 0.3)
|
|
223
|
+
guard pasteAttachmentFiles(attachmentPaths) else {
|
|
224
|
+
print("not_confirmed not_completed 0")
|
|
225
|
+
print("[]")
|
|
226
|
+
exit(0)
|
|
227
|
+
}
|
|
228
|
+
Thread.sleep(forTimeInterval: 0.3)
|
|
229
|
+
guard let composer = currentComposer() else {
|
|
230
|
+
print("not_confirmed not_completed 0")
|
|
231
|
+
print("[]")
|
|
232
|
+
exit(1)
|
|
233
|
+
}
|
|
234
|
+
AXUIElementSetAttributeValue(composer, kAXFocusedAttribute as CFString, true as CFBoolean)
|
|
235
|
+
AXUIElementSetAttributeValue(composer, kAXValueAttribute as CFString, prompt as CFString)
|
|
236
|
+
Thread.sleep(forTimeInterval: 0.3)
|
|
237
|
+
|
|
238
|
+
// click send
|
|
239
|
+
var sendBtn: AXUIElement? = nil
|
|
240
|
+
for _ in 0..<sendButtonPolls {
|
|
241
|
+
if let candidate = findAll(ax, role: "AXButton").first(where: { getDesc($0) == "发送" && isEnabled($0) }) {
|
|
242
|
+
sendBtn = candidate
|
|
243
|
+
break
|
|
244
|
+
}
|
|
245
|
+
Thread.sleep(forTimeInterval: 0.25)
|
|
246
|
+
}
|
|
247
|
+
guard let btn = sendBtn else {
|
|
248
|
+
print("not_confirmed not_completed 0")
|
|
249
|
+
print("[]")
|
|
250
|
+
exit(1)
|
|
251
|
+
}
|
|
252
|
+
AXUIElementPerformAction(btn, kAXPressAction as CFString)
|
|
253
|
+
let t0 = Date()
|
|
254
|
+
|
|
255
|
+
// wait for stop-generating (confirmed)
|
|
256
|
+
var confirmed = false
|
|
257
|
+
let stopLabel = "停止生成"
|
|
258
|
+
for _ in 0..<(confirmSecs * 4) {
|
|
259
|
+
Thread.sleep(forTimeInterval: 0.25)
|
|
260
|
+
if findAll(ax, role: "AXButton").contains(where: { getDesc($0) == stopLabel }) {
|
|
261
|
+
confirmed = true; break
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
guard confirmed else {
|
|
266
|
+
let ms = Int(Date().timeIntervalSince(t0) * 1000)
|
|
267
|
+
print("not_confirmed not_completed \\(ms)")
|
|
268
|
+
print("[]")
|
|
269
|
+
exit(0)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// wait for stop-generating to disappear AND stay gone for 1.5s (handles Thinking model gaps)
|
|
273
|
+
var completed = false
|
|
274
|
+
var goneStreak = 0
|
|
275
|
+
for _ in 0..<(completeSecs * 4) {
|
|
276
|
+
Thread.sleep(forTimeInterval: 0.25)
|
|
277
|
+
let hasStop = findAll(ax, role: "AXButton").contains(where: { getDesc($0) == stopLabel })
|
|
278
|
+
if hasStop {
|
|
279
|
+
goneStreak = 0
|
|
280
|
+
} else {
|
|
281
|
+
goneStreak += 1
|
|
282
|
+
if goneStreak >= 6 { // 6 * 0.25s = 1.5s gone → truly finished
|
|
283
|
+
completed = true; break
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
// extra settle time for AX tree to finalize
|
|
288
|
+
if completed { Thread.sleep(forTimeInterval: 0.5) }
|
|
289
|
+
|
|
290
|
+
// collect new texts: nodes appended after beforeCount (index-based, handles repeated text)
|
|
291
|
+
let afterNodes = collectTextNodes()
|
|
292
|
+
var newTexts: [String] = []
|
|
293
|
+
// primary: nodes that appeared after the snapshot position
|
|
294
|
+
if afterNodes.count > beforeCount {
|
|
295
|
+
for t in afterNodes[beforeCount...] {
|
|
296
|
+
if !t.isEmpty && t != prompt && !t.hasPrefix(prompt) {
|
|
297
|
+
newTexts.append(t)
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
// fallback: if no new nodes by position, use Set diff (e.g. ChatGPT rewrote existing nodes)
|
|
302
|
+
if newTexts.isEmpty {
|
|
303
|
+
let beforeSet = Set(beforeNodes)
|
|
304
|
+
for t in afterNodes {
|
|
305
|
+
if !beforeSet.contains(t) && !t.isEmpty && t != prompt && !t.hasPrefix(prompt) {
|
|
306
|
+
newTexts.append(t)
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
let jsonItems = newTexts.map { "\\"\\(jsonEscape($0))\\"" }
|
|
311
|
+
let jsonArr = "[\\(jsonItems.joined(separator: ","))]"
|
|
312
|
+
|
|
313
|
+
let elapsed = Int(Date().timeIntervalSince(t0) * 1000)
|
|
314
|
+
print("\\(confirmed ? "confirmed" : "not_confirmed") \\(completed ? "completed" : "not_completed") \\(elapsed)")
|
|
315
|
+
print(jsonArr)
|
|
316
|
+
`;
|
|
317
|
+
const t0 = Date.now();
|
|
318
|
+
try {
|
|
319
|
+
const out = runSwift(code, (confirmTimeout + completionTimeout + 10) * 1000);
|
|
320
|
+
const lines = out.split("\n");
|
|
321
|
+
const statusLine = lines[0] ?? "";
|
|
322
|
+
const jsonLine = lines.slice(1).join("\n").trim();
|
|
323
|
+
const parts = statusLine.split(" ");
|
|
324
|
+
let replyTexts = [];
|
|
325
|
+
try {
|
|
326
|
+
replyTexts = JSON.parse(jsonLine);
|
|
327
|
+
}
|
|
328
|
+
catch { }
|
|
329
|
+
return {
|
|
330
|
+
confirmed: parts[0] === "confirmed",
|
|
331
|
+
completed: parts[1] === "completed",
|
|
332
|
+
elapsedMs: parseInt(parts[2] ?? "0", 10) || Date.now() - t0,
|
|
333
|
+
replyTexts
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
catch (err) {
|
|
337
|
+
throw new Error(`sendMessage swift error: ${err instanceof Error ? err.message : String(err)}`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
export function clickNewChat() {
|
|
341
|
+
const code = `
|
|
342
|
+
${SWIFT_PREAMBLE}
|
|
343
|
+
guard let ax = axApp() else { exit(1) }
|
|
344
|
+
let btn = findAll(ax, role: "AXButton").first { getDesc($0) == "新聊天" }
|
|
345
|
+
if let b = btn { AXUIElementPerformAction(b, kAXPressAction as CFString) }
|
|
346
|
+
Thread.sleep(forTimeInterval: 0.6)
|
|
347
|
+
print("ok")
|
|
348
|
+
`;
|
|
349
|
+
runSwift(code);
|
|
350
|
+
}
|
|
351
|
+
export function getCurrentWindowTitle() {
|
|
352
|
+
const code = `
|
|
353
|
+
${SWIFT_PREAMBLE}
|
|
354
|
+
guard let ax = axApp() else { print(""); exit(0) }
|
|
355
|
+
let title = (getAttr(ax, kAXFocusedWindowAttribute) as AXUIElement?)
|
|
356
|
+
.flatMap { getAttr($0, kAXTitleAttribute) as? String }
|
|
357
|
+
?? (getAttr(ax, kAXWindowsAttribute) as? [AXUIElement])?.first
|
|
358
|
+
.flatMap { getAttr($0, kAXTitleAttribute) as? String }
|
|
359
|
+
?? ""
|
|
360
|
+
print(title)
|
|
361
|
+
`;
|
|
362
|
+
try {
|
|
363
|
+
const r = runSwift(code);
|
|
364
|
+
return r.length > 0 ? r : null;
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
export function listRecentChats(maxCount = 20) {
|
|
371
|
+
const limitLiteral = String(maxCount);
|
|
372
|
+
const code = `
|
|
373
|
+
${SWIFT_PREAMBLE}
|
|
374
|
+
import Foundation
|
|
375
|
+
|
|
376
|
+
guard let ax = axApp() else { print("[]"); exit(0) }
|
|
377
|
+
|
|
378
|
+
let allBtns = findAll(ax, role: "AXButton")
|
|
379
|
+
|
|
380
|
+
let uiLabels: Set<String> = ["新聊天", "New chat", "ChatGPT", "ChatGPT Auto", "发送", "停止生成",
|
|
381
|
+
"Search", "搜索", "Explore GPTs", "探索 GPTs", "设置", "Settings",
|
|
382
|
+
"分享", "移至新窗口", "附件", "使用应用", "选项", "录制会议", "听写",
|
|
383
|
+
"切换边栏", "项目", "使用 Windsurf、 选项卡"]
|
|
384
|
+
func btnLabel(_ el: AXUIElement) -> String {
|
|
385
|
+
// ChatGPT Desktop sidebar buttons expose title via kAXDescriptionAttribute (kAXTitleAttribute is empty)
|
|
386
|
+
var v: CFTypeRef?
|
|
387
|
+
AXUIElementCopyAttributeValue(el, kAXDescriptionAttribute as CFString, &v)
|
|
388
|
+
if let s = v as? String, !s.isEmpty { return s }
|
|
389
|
+
AXUIElementCopyAttributeValue(el, kAXTitleAttribute as CFString, &v)
|
|
390
|
+
if let s = v as? String, !s.isEmpty { return s }
|
|
391
|
+
return ""
|
|
392
|
+
}
|
|
393
|
+
var chatBtns: [String] = []
|
|
394
|
+
var seen = Set<String>()
|
|
395
|
+
for btn in allBtns {
|
|
396
|
+
let label = btnLabel(btn)
|
|
397
|
+
if label.count >= 2 && !uiLabels.contains(label) && !seen.contains(label) {
|
|
398
|
+
chatBtns.append(label)
|
|
399
|
+
seen.insert(label)
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
let limited = Array(chatBtns.prefix(${limitLiteral}))
|
|
404
|
+
let jsonItems = limited.enumerated().map { (i, title) in
|
|
405
|
+
"\\"\\(jsonEscape(title))\\""
|
|
406
|
+
}
|
|
407
|
+
print("[\\(jsonItems.joined(separator: ","))]")
|
|
408
|
+
`;
|
|
409
|
+
try {
|
|
410
|
+
const raw = runSwift(code, 12_000);
|
|
411
|
+
const titles = JSON.parse(raw);
|
|
412
|
+
return titles.map((title, i) => ({ index: i + 1, title, windowTitle: null }));
|
|
413
|
+
}
|
|
414
|
+
catch {
|
|
415
|
+
return [];
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
export function clickChatByTitle(title) {
|
|
419
|
+
const escaped = title.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
420
|
+
const code = `
|
|
421
|
+
${SWIFT_PREAMBLE}
|
|
422
|
+
import CoreGraphics
|
|
423
|
+
|
|
424
|
+
guard let nsApp = NSWorkspace.shared.runningApplications
|
|
425
|
+
.first(where: { $0.bundleIdentifier == BUNDLE_ID }) else { print("not_found"); exit(0) }
|
|
426
|
+
nsApp.activate(options: [])
|
|
427
|
+
Thread.sleep(forTimeInterval: 0.8)
|
|
428
|
+
guard let ax = axApp() else { print("not_found"); exit(0) }
|
|
429
|
+
let target = "${escaped}"
|
|
430
|
+
func btnLabel2(_ el: AXUIElement) -> String {
|
|
431
|
+
var v: CFTypeRef?
|
|
432
|
+
AXUIElementCopyAttributeValue(el, kAXDescriptionAttribute as CFString, &v)
|
|
433
|
+
if let s = v as? String, !s.isEmpty { return s }
|
|
434
|
+
AXUIElementCopyAttributeValue(el, kAXTitleAttribute as CFString, &v)
|
|
435
|
+
if let s = v as? String, !s.isEmpty { return s }
|
|
436
|
+
return ""
|
|
437
|
+
}
|
|
438
|
+
func getFrame(_ el: AXUIElement) -> CGRect? {
|
|
439
|
+
var posVal: CFTypeRef?; var sizeVal: CFTypeRef?
|
|
440
|
+
AXUIElementCopyAttributeValue(el, kAXPositionAttribute as CFString, &posVal)
|
|
441
|
+
AXUIElementCopyAttributeValue(el, kAXSizeAttribute as CFString, &sizeVal)
|
|
442
|
+
guard let pv = posVal as! AXValue?, let sv = sizeVal as! AXValue? else { return nil }
|
|
443
|
+
var pos = CGPoint.zero; var size = CGSize.zero
|
|
444
|
+
AXValueGetValue(pv, .cgPoint, &pos); AXValueGetValue(sv, .cgSize, &size)
|
|
445
|
+
return CGRect(origin: pos, size: size)
|
|
446
|
+
}
|
|
447
|
+
func mouseClick(_ pt: CGPoint) {
|
|
448
|
+
let dn = CGEvent(mouseEventSource: nil, mouseType: .leftMouseDown, mouseCursorPosition: pt, mouseButton: .left)!
|
|
449
|
+
let up = CGEvent(mouseEventSource: nil, mouseType: .leftMouseUp, mouseCursorPosition: pt, mouseButton: .left)!
|
|
450
|
+
dn.post(tap: .cghidEventTap); Thread.sleep(forTimeInterval: 0.05); up.post(tap: .cghidEventTap)
|
|
451
|
+
}
|
|
452
|
+
let allBtns = findAll(ax, role: "AXButton")
|
|
453
|
+
if let btn = allBtns.first(where: { btnLabel2($0) == target }),
|
|
454
|
+
let frame = getFrame(btn) {
|
|
455
|
+
let center = CGPoint(x: frame.midX, y: frame.midY)
|
|
456
|
+
mouseClick(center)
|
|
457
|
+
Thread.sleep(forTimeInterval: 0.8)
|
|
458
|
+
print("ok")
|
|
459
|
+
} else {
|
|
460
|
+
print("not_found")
|
|
461
|
+
}
|
|
462
|
+
`;
|
|
463
|
+
try {
|
|
464
|
+
return runSwift(code, 8_000).trim() === "ok";
|
|
465
|
+
}
|
|
466
|
+
catch {
|
|
467
|
+
return false;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
String.prototype.hasPrefix = function (prefix) {
|
|
471
|
+
return this.startsWith(prefix);
|
|
472
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
import { MediaArtifactKind } from "../../../domain/src/message.js";
|
|
4
|
+
import { ChatgptDesktopDriver } from "./driver.js";
|
|
5
|
+
const DEFAULT_OUT_DIR = "runtime/media/chatgpt";
|
|
6
|
+
const CHINESE_IMAGE_KEYWORDS = /(画|绘|生成图|生图|作图|图片|照片|摄影|插图|海报|壁纸|合影|全家福)/;
|
|
7
|
+
const ENGLISH_IMAGE_KEYWORDS = /\b(draw|paint|generate\s+image|create\s+image|make\s+image|image\s+of|picture\s+of|photo|photograph|illustration|poster|wallpaper|dalle|dall-e)\b/i;
|
|
8
|
+
export function detectMode(text) {
|
|
9
|
+
return CHINESE_IMAGE_KEYWORDS.test(text) || ENGLISH_IMAGE_KEYWORDS.test(text) ? "image" : "text";
|
|
10
|
+
}
|
|
11
|
+
function isImageArtifact(artifact) {
|
|
12
|
+
return artifact.kind === MediaArtifactKind.Image || artifact.mimeType.startsWith("image/");
|
|
13
|
+
}
|
|
14
|
+
function buildPrompt(message, imageArtifacts) {
|
|
15
|
+
const text = message.text.trim();
|
|
16
|
+
if (text) {
|
|
17
|
+
return text;
|
|
18
|
+
}
|
|
19
|
+
if (imageArtifacts.length > 0) {
|
|
20
|
+
return "请分析这张图片,并根据图片内容回复我。";
|
|
21
|
+
}
|
|
22
|
+
return message.text;
|
|
23
|
+
}
|
|
24
|
+
export class ChatgptDesktopProvider {
|
|
25
|
+
driver;
|
|
26
|
+
outDir;
|
|
27
|
+
constructor(opts = {}) {
|
|
28
|
+
this.outDir = opts.outDir ?? DEFAULT_OUT_DIR;
|
|
29
|
+
this.driver = new ChatgptDesktopDriver({ destDir: this.outDir });
|
|
30
|
+
}
|
|
31
|
+
get desktopDriver() {
|
|
32
|
+
return this.driver;
|
|
33
|
+
}
|
|
34
|
+
async runTurn(message, options) {
|
|
35
|
+
const imageArtifacts = (message.mediaArtifacts ?? []).filter(isImageArtifact);
|
|
36
|
+
const prompt = buildPrompt(message, imageArtifacts);
|
|
37
|
+
const mode = detectMode(prompt);
|
|
38
|
+
const input = {
|
|
39
|
+
sessionKey: message.sessionKey,
|
|
40
|
+
mode,
|
|
41
|
+
prompt,
|
|
42
|
+
attachmentPaths: imageArtifacts.map((artifact) => artifact.localPath).filter(Boolean),
|
|
43
|
+
timeoutMs: mode === "image" ? 180_000 : 120_000
|
|
44
|
+
};
|
|
45
|
+
const result = await this.driver.run(input);
|
|
46
|
+
if (!result.ok) {
|
|
47
|
+
const errorDraft = {
|
|
48
|
+
draftId: randomUUID(),
|
|
49
|
+
turnId: undefined,
|
|
50
|
+
sessionKey: message.sessionKey,
|
|
51
|
+
text: `[ChatGPT Desktop 错误] ${result.errorCode}: ${result.message}`,
|
|
52
|
+
createdAt: new Date().toISOString(),
|
|
53
|
+
replyToMessageId: message.messageId
|
|
54
|
+
};
|
|
55
|
+
if (options?.onDraft) {
|
|
56
|
+
await options.onDraft(errorDraft);
|
|
57
|
+
}
|
|
58
|
+
return [errorDraft];
|
|
59
|
+
}
|
|
60
|
+
const mediaArtifacts = result.media.map((m) => ({
|
|
61
|
+
kind: MediaArtifactKind.Image,
|
|
62
|
+
sourceUrl: pathToFileURL(m.localPath).href,
|
|
63
|
+
localPath: m.localPath,
|
|
64
|
+
mimeType: m.mimeType,
|
|
65
|
+
fileSize: m.fileSize,
|
|
66
|
+
originalName: m.originalName
|
|
67
|
+
}));
|
|
68
|
+
const draft = {
|
|
69
|
+
draftId: randomUUID(),
|
|
70
|
+
turnId: result.turnId,
|
|
71
|
+
sessionKey: message.sessionKey,
|
|
72
|
+
text: result.text,
|
|
73
|
+
mediaArtifacts: mediaArtifacts.length > 0 ? mediaArtifacts : undefined,
|
|
74
|
+
createdAt: new Date().toISOString(),
|
|
75
|
+
replyToMessageId: message.messageId
|
|
76
|
+
};
|
|
77
|
+
if (options?.onDraft) {
|
|
78
|
+
await options.onDraft(draft);
|
|
79
|
+
}
|
|
80
|
+
return [draft];
|
|
81
|
+
}
|
|
82
|
+
}
|