qq-codex-bridge 0.1.2 → 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.
Files changed (39) hide show
  1. package/.env.example +62 -0
  2. package/README.md +232 -287
  3. package/bin/chatgpt-desktop.js +2 -0
  4. package/bin/qq-codex-weixin-gateway.js +14 -0
  5. package/dist/apps/bridge-daemon/src/bootstrap.js +161 -31
  6. package/dist/apps/bridge-daemon/src/cli.js +5 -1
  7. package/dist/apps/bridge-daemon/src/config.js +168 -37
  8. package/dist/apps/bridge-daemon/src/http-server.js +23 -11
  9. package/dist/apps/bridge-daemon/src/main.js +163 -29
  10. package/dist/apps/bridge-daemon/src/thread-command-handler.js +320 -23
  11. package/dist/apps/chatgpt-desktop-cli/src/cli.js +191 -0
  12. package/dist/apps/weixin-gateway/src/cli.js +446 -0
  13. package/dist/apps/weixin-gateway/src/config.js +135 -0
  14. package/dist/apps/weixin-gateway/src/dev.js +2 -0
  15. package/dist/apps/weixin-gateway/src/message-store.js +50 -0
  16. package/dist/apps/weixin-gateway/src/server.js +216 -0
  17. package/dist/apps/weixin-gateway/src/state.js +163 -0
  18. package/dist/apps/weixin-gateway/src/weixin-client.js +520 -0
  19. package/dist/packages/adapters/chatgpt-desktop/src/ax-client.js +472 -0
  20. package/dist/packages/adapters/chatgpt-desktop/src/bridge-provider.js +82 -0
  21. package/dist/packages/adapters/chatgpt-desktop/src/driver.js +161 -0
  22. package/dist/packages/adapters/chatgpt-desktop/src/image-cache.js +155 -0
  23. package/dist/packages/adapters/chatgpt-desktop/src/session-registry.js +48 -0
  24. package/dist/packages/adapters/chatgpt-desktop/src/types.js +1 -0
  25. package/dist/packages/adapters/codex-desktop/src/codex-app-server-driver.js +810 -0
  26. package/dist/packages/adapters/codex-desktop/src/codex-app-ui-notification-forwarder.js +33 -0
  27. package/dist/packages/adapters/codex-desktop/src/codex-desktop-driver.js +727 -123
  28. package/dist/packages/adapters/codex-desktop/src/codex-local-rollout-reader.js +227 -0
  29. package/dist/packages/adapters/codex-desktop/src/codex-local-submission-reader.js +142 -0
  30. package/dist/packages/adapters/weixin/src/weixin-channel-adapter.js +15 -0
  31. package/dist/packages/adapters/weixin/src/weixin-http-client.js +42 -0
  32. package/dist/packages/adapters/weixin/src/weixin-sender.js +200 -0
  33. package/dist/packages/adapters/weixin/src/weixin-webhook.js +35 -0
  34. package/dist/packages/orchestrator/src/bridge-orchestrator.js +72 -25
  35. package/dist/packages/orchestrator/src/weixin-outbound-format.js +55 -0
  36. package/dist/packages/ports/src/chat.js +1 -0
  37. package/dist/packages/store/src/session-repo.js +16 -3
  38. package/dist/packages/store/src/sqlite.js +3 -0
  39. 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
+ }