@youdie006/prodex 0.3.0 → 0.4.0
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 +17 -6
- package/dist/chatgpt-browser.d.ts +18 -0
- package/dist/chatgpt-browser.js +252 -61
- package/dist/chatgpt-browser.js.map +1 -1
- package/dist/cli.js +82 -23
- package/dist/cli.js.map +1 -1
- package/dist/store.js +10 -0
- package/dist/store.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -196,18 +196,29 @@ prodex pro browser ask --model Pro --pro-mode 확장 --project "my-project" "Rev
|
|
|
196
196
|
prodex pro browser ask --effort "매우 높음" "Draft the release notes"
|
|
197
197
|
```
|
|
198
198
|
|
|
199
|
-
|
|
200
|
-
- `--pro-mode 기본|확장` selects the Pro sub-mode; it applies only when the model is Pro.
|
|
201
|
-
- `--effort 즉시|중간|높음|"매우 높음"` sets the reasoning effort for non-Pro models. English aliases `instant`/`medium`/`high`/`max` are accepted. `--pro-mode` and `--effort` are different model axes and cannot be combined.
|
|
202
|
-
- `--project "name"` enters an existing sidebar project before sending. Creating a new project from the CLI is not supported yet; make it in ChatGPT first, then pass `--project`.
|
|
199
|
+
Prerequisite: selection matches menu items by their visible text in the **Korean ChatGPT UI** (즉시/중간/높음/매우 높음, Pro 기본/확장, "프로젝트" sidebar labels). If your ChatGPT display language is not Korean, the selection flags will fail with "menu item not found"; either switch the UI language or send without selection flags.
|
|
203
200
|
|
|
204
|
-
|
|
201
|
+
To see the labels your account currently shows, list them read-only (opens the menu, reads it, presses Escape — nothing is selected):
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
prodex pro browser models
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
- `--model` picks the composer model by its exact menu label. `Pro` is verified end-to-end. Models whose menu entry opens a submenu of variants (for example GPT-5.5) are rejected with a clear error instead of silently keeping the previous model; direct variant selection is planned.
|
|
208
|
+
- `--pro-mode 기본|확장` selects the Pro sub-mode; it applies only when the model is Pro. `확장` can think for minutes, so it raises the default `--timeout-ms` from 90000 to 300000 (an explicit `--timeout-ms` always wins).
|
|
209
|
+
- `--effort 즉시|중간|높음|"매우 높음"` sets the reasoning effort. English aliases `instant`/`medium`/`high`/`max` are accepted. The effort options and Pro share one radio group in ChatGPT, so picking an effort switches the composer to the standard reasoning model and deselects Pro; for the same reason `--pro-mode` and `--effort` cannot be combined.
|
|
210
|
+
- `--project "name"` enters an existing sidebar project before sending. It cannot be combined with `--target-url` (the project click would navigate away from the confirmed tab). Creating a new project from the CLI is not supported yet; make it in ChatGPT first, then pass `--project`.
|
|
211
|
+
|
|
212
|
+
Selection clicks are guarded: prodex refuses to click a control that is covered or out of view, waits for the menu to actually open instead of sleeping a fixed delay, and treats a menu that stays open after a pick as a failed selection. If any step fails, it backs out with Escape and reports a blocker instead of sending with the wrong model. Note that an applied selection stays active in your ChatGPT session after the send — switch back manually if you were on a different model.
|
|
213
|
+
|
|
214
|
+
Persist defaults so you can omit these flags on routine asks; a per-ask flag always overrides the saved default. View saved defaults with `prodex status`, and clear one with the matching `--clear-*` flag:
|
|
205
215
|
|
|
206
216
|
```bash
|
|
207
217
|
prodex setup --model Pro --pro-mode 확장 --project "my-project"
|
|
218
|
+
prodex setup --clear-project
|
|
208
219
|
```
|
|
209
220
|
|
|
210
|
-
Whatever selection is applied is recorded on the consult receipt (`metadata.selection`). `prodex` only clicks the picker you can see; it never selects a model, effort, or project silently outside the visible browser.
|
|
221
|
+
Whatever selection is applied is recorded on the consult receipt (`metadata.selection`); receipt display output redacts the project name, keeping only the model axes visible. `prodex` only clicks the picker you can see; it never selects a model, effort, or project silently outside the visible browser.
|
|
211
222
|
|
|
212
223
|
For a source checkout, keep the explicit send and inspection commands source-aware too:
|
|
213
224
|
|
|
@@ -128,12 +128,30 @@ export declare function getChatGptBrowserStatus(options?: {
|
|
|
128
128
|
port?: number;
|
|
129
129
|
timeoutMs?: number;
|
|
130
130
|
}): Promise<ChatGptBrowserStatus>;
|
|
131
|
+
export declare function menuOpenExpression(): string;
|
|
132
|
+
export declare function menuClosedExpression(): string;
|
|
133
|
+
export declare function menuItemPresentExpression(label: string): string;
|
|
131
134
|
export declare function modelButtonRectExpression(): string;
|
|
132
135
|
export declare function menuItemRectExpression(label: string): string;
|
|
133
136
|
export declare function submenuItemRectExpression(label: string): string;
|
|
137
|
+
export declare function proRadioRectExpression(): string;
|
|
134
138
|
export declare function proSubmenuExpanderRectExpression(): string;
|
|
135
139
|
export declare function projectItemRectExpression(name: string): string;
|
|
136
140
|
export declare function sendChatGptPrompt(options: SendChatGptPromptOptions): Promise<SendChatGptPromptResult>;
|
|
141
|
+
export interface ChatGptModelOption {
|
|
142
|
+
label: string;
|
|
143
|
+
kind: "radio" | "submenu";
|
|
144
|
+
checked: boolean;
|
|
145
|
+
}
|
|
146
|
+
export interface ListChatGptModelOptionsResult {
|
|
147
|
+
url: string;
|
|
148
|
+
options: ChatGptModelOption[];
|
|
149
|
+
}
|
|
150
|
+
export declare function modelMenuOptionsExpression(): string;
|
|
151
|
+
export declare function listChatGptModelOptions(input?: {
|
|
152
|
+
port?: number;
|
|
153
|
+
timeoutMs?: number;
|
|
154
|
+
}): Promise<ListChatGptModelOptionsResult>;
|
|
137
155
|
export declare function statusExpression(): string;
|
|
138
156
|
export declare function setComposerTextExpression(text: string): string;
|
|
139
157
|
export declare function submitExpression(): string;
|
package/dist/chatgpt-browser.js
CHANGED
|
@@ -84,8 +84,15 @@ export function isUsableChatGptAnswer(answer) {
|
|
|
84
84
|
const normalized = answer.trim();
|
|
85
85
|
if (!normalized)
|
|
86
86
|
return false;
|
|
87
|
-
|
|
88
|
-
|
|
87
|
+
const stripped = normalized.replace(/\.+$/, "");
|
|
88
|
+
const lineCount = normalized.split(/\r?\n/).filter(Boolean).length;
|
|
89
|
+
if (/^(생각 중|thinking|thought for|thought about)/i.test(stripped)) {
|
|
90
|
+
return lineCount > 1;
|
|
91
|
+
}
|
|
92
|
+
// Model-prefixed thinking status, e.g. "Pro 생각 중": a single short line
|
|
93
|
+
// ending in 생각 중 is the placeholder, not an answer.
|
|
94
|
+
if (lineCount <= 1 && /(^|\s)생각 중$/.test(stripped)) {
|
|
95
|
+
return false;
|
|
89
96
|
}
|
|
90
97
|
return true;
|
|
91
98
|
}
|
|
@@ -396,17 +403,75 @@ export async function getChatGptBrowserStatus(options = {}) {
|
|
|
396
403
|
blocker
|
|
397
404
|
};
|
|
398
405
|
}
|
|
399
|
-
async function dispatchMouseClickAt(cdp, x, y) {
|
|
400
|
-
await cdp.send("Input.dispatchMouseEvent", { type: "mouseMoved", x, y });
|
|
401
|
-
await cdp.send("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button: "left", clickCount: 1 });
|
|
402
|
-
await cdp.send("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button: "left", clickCount: 1 });
|
|
403
|
-
}
|
|
404
406
|
async function dispatchEscapeKey(cdp) {
|
|
405
407
|
await cdp.send("Input.dispatchKeyEvent", { type: "keyDown", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 });
|
|
406
408
|
await cdp.send("Input.dispatchKeyEvent", { type: "keyUp", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 });
|
|
407
409
|
}
|
|
408
|
-
|
|
410
|
+
// Poll a boolean page expression instead of sleeping a fixed duration, so slow
|
|
411
|
+
// renders wait longer and fast ones do not waste time.
|
|
412
|
+
async function waitForExpressionTrue(cdp, expression, timeoutMs, intervalMs = 150) {
|
|
413
|
+
const startedAt = Date.now();
|
|
414
|
+
for (;;) {
|
|
415
|
+
if (await cdp.evaluate(expression))
|
|
416
|
+
return true;
|
|
417
|
+
if (Date.now() - startedAt >= timeoutMs)
|
|
418
|
+
return false;
|
|
419
|
+
await sleep(intervalMs);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
const MENU_OPEN_TIMEOUT_MS = 5_000;
|
|
423
|
+
const MENU_SETTLE_TIMEOUT_MS = 5_000;
|
|
424
|
+
const PROJECT_NAVIGATION_TIMEOUT_MS = 8_000;
|
|
425
|
+
export function menuOpenExpression() {
|
|
426
|
+
return `Boolean(document.querySelector('[data-testid="composer-intelligence-picker-content"]'))`;
|
|
427
|
+
}
|
|
428
|
+
export function menuClosedExpression() {
|
|
429
|
+
return `!document.querySelector('[data-testid="composer-intelligence-picker-content"]')`;
|
|
430
|
+
}
|
|
431
|
+
export function menuItemPresentExpression(label) {
|
|
432
|
+
return `[...document.querySelectorAll('[role="menuitemradio"],[role="menuitem"]')].some((r) => (r.textContent || "").trim() === ${JSON.stringify(label)})`;
|
|
433
|
+
}
|
|
434
|
+
// Shared click-point resolver: scroll the target into view, tag it with a
|
|
435
|
+
// temporary attribute, and return its center. The coverage check happens later
|
|
436
|
+
// in verifiedClickAt AFTER the pointer hovers the point, because ChatGPT uses
|
|
437
|
+
// hover-revealed controls (e.g. the Pro sub-mode chevron) that only become
|
|
438
|
+
// hit-testable once the row is hovered.
|
|
439
|
+
const CLICK_POINT_SNIPPET = `
|
|
440
|
+
const clickPoint = (el, yCap) => {
|
|
441
|
+
document.querySelectorAll('[data-prodex-click]').forEach((n) => n.removeAttribute('data-prodex-click'));
|
|
442
|
+
el.scrollIntoView({ block: "center", inline: "nearest" });
|
|
443
|
+
const r = el.getBoundingClientRect();
|
|
444
|
+
if (r.width < 1 || r.height < 1) return { ok: false, reason: "target element has no visible area" };
|
|
445
|
+
el.setAttribute('data-prodex-click', '1');
|
|
446
|
+
const x = Math.round(r.x + r.width / 2);
|
|
447
|
+
const y = Math.round(r.y + (yCap ? Math.min(r.height / 2, yCap) : r.height / 2));
|
|
448
|
+
return { ok: true, x, y };
|
|
449
|
+
};`;
|
|
450
|
+
function hoverVerifyExpression(x, y) {
|
|
409
451
|
return `(() => {
|
|
452
|
+
const el = document.querySelector('[data-prodex-click]');
|
|
453
|
+
const hit = document.elementFromPoint(${x}, ${y});
|
|
454
|
+
const ok = Boolean(el && hit && (hit === el || el.contains(hit) || hit.contains(el)));
|
|
455
|
+
if (el) el.removeAttribute('data-prodex-click');
|
|
456
|
+
return ok;
|
|
457
|
+
})()`;
|
|
458
|
+
}
|
|
459
|
+
// Move the pointer first, give hover styles a beat to apply, then confirm the
|
|
460
|
+
// tagged target is what would actually receive the click before pressing.
|
|
461
|
+
// These clicks land in the user's real session, so a covered or scrolled-out
|
|
462
|
+
// target must fail loudly instead of clicking whatever sits at the point.
|
|
463
|
+
async function verifiedClickAt(cdp, x, y, label) {
|
|
464
|
+
await cdp.send("Input.dispatchMouseEvent", { type: "mouseMoved", x, y });
|
|
465
|
+
await sleep(150);
|
|
466
|
+
const onTarget = await cdp.evaluate(hoverVerifyExpression(x, y));
|
|
467
|
+
if (!onTarget) {
|
|
468
|
+
throw new Error(`Refusing to click "${label}": another element covers its click point (overlay, scroll, or layout change). Retry, or interact manually in the visible browser.`);
|
|
469
|
+
}
|
|
470
|
+
await cdp.send("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button: "left", clickCount: 1 });
|
|
471
|
+
await cdp.send("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button: "left", clickCount: 1 });
|
|
472
|
+
}
|
|
473
|
+
export function modelButtonRectExpression() {
|
|
474
|
+
return `(() => {${CLICK_POINT_SNIPPET}
|
|
410
475
|
const c = document.querySelector('#prompt-textarea,[contenteditable="true"],textarea');
|
|
411
476
|
const scope = c ? (c.closest('form') || document) : document;
|
|
412
477
|
const b = [...scope.querySelectorAll('[aria-haspopup="menu"]')].find((el) => {
|
|
@@ -415,38 +480,51 @@ export function modelButtonRectExpression() {
|
|
|
415
480
|
return /\\S/.test(t) && !/파일|첨부|받아쓰기|음성|dictation|attach/i.test(t + aria);
|
|
416
481
|
});
|
|
417
482
|
if (!b) return { ok: false, reason: "model selector button not found" };
|
|
418
|
-
|
|
419
|
-
return { ok: true, x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
|
483
|
+
return clickPoint(b);
|
|
420
484
|
})()`;
|
|
421
485
|
}
|
|
422
486
|
export function menuItemRectExpression(label) {
|
|
423
|
-
return `(() => {
|
|
487
|
+
return `(() => {${CLICK_POINT_SNIPPET}
|
|
424
488
|
const m = document.querySelector('[data-testid="composer-intelligence-picker-content"]');
|
|
425
489
|
if (!m) return { ok: false, reason: "reasoning/model menu did not open" };
|
|
426
490
|
const items = [...m.querySelectorAll('[role="menuitemradio"],[role="menuitem"]')];
|
|
427
491
|
const it = items.find((r) => (r.textContent || "").trim() === ${JSON.stringify(label)});
|
|
428
492
|
if (!it) return { ok: false, reason: "menu item not found", available: items.map((r) => (r.textContent || "").trim()).slice(0, 12) };
|
|
429
|
-
const
|
|
430
|
-
|
|
493
|
+
const point = clickPoint(it);
|
|
494
|
+
if (!point.ok) return point;
|
|
495
|
+
return { ...point, role: it.getAttribute("role"), haspopup: it.getAttribute("aria-haspopup") };
|
|
431
496
|
})()`;
|
|
432
497
|
}
|
|
433
498
|
export function submenuItemRectExpression(label) {
|
|
434
|
-
return `(() => {
|
|
499
|
+
return `(() => {${CLICK_POINT_SNIPPET}
|
|
435
500
|
const items = [...document.querySelectorAll('[role="menuitemradio"],[role="menuitem"]')];
|
|
436
501
|
const it = items.find((r) => (r.textContent || "").trim() === ${JSON.stringify(label)});
|
|
437
502
|
if (!it) return { ok: false, reason: "submenu item not found" };
|
|
438
|
-
|
|
439
|
-
return { ok: true, x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
|
503
|
+
return clickPoint(it);
|
|
440
504
|
})()`;
|
|
441
505
|
}
|
|
442
506
|
// "Pro" itself is a plain radio; its sub-modes (Pro 기본 / Pro 확장) live behind an
|
|
443
507
|
// unlabeled aria-haspopup="menu" chevron sitting next to the Pro radio. Clicking
|
|
444
508
|
// that chevron (hovering does not work) opens the sub-mode submenu.
|
|
509
|
+
// The Pro radio's visible label reflects the active sub-mode ("Pro", "Pro 기본",
|
|
510
|
+
// or "Pro 확장"), so it must be matched by prefix, never by exact text.
|
|
511
|
+
const PRO_RADIO_FINDER_SNIPPET = `
|
|
512
|
+
const findProRadio = (scope) =>
|
|
513
|
+
[...scope.querySelectorAll('[role="menuitemradio"]')].find((r) => /^Pro( |$)/.test((r.textContent || "").trim()));`;
|
|
514
|
+
export function proRadioRectExpression() {
|
|
515
|
+
return `(() => {${CLICK_POINT_SNIPPET}${PRO_RADIO_FINDER_SNIPPET}
|
|
516
|
+
const m = document.querySelector('[data-testid="composer-intelligence-picker-content"]');
|
|
517
|
+
if (!m) return { ok: false, reason: "reasoning/model menu did not open" };
|
|
518
|
+
const proRadio = findProRadio(m);
|
|
519
|
+
if (!proRadio) return { ok: false, reason: "Pro option not found in the model menu" };
|
|
520
|
+
return clickPoint(proRadio);
|
|
521
|
+
})()`;
|
|
522
|
+
}
|
|
445
523
|
export function proSubmenuExpanderRectExpression() {
|
|
446
|
-
return `(() => {
|
|
524
|
+
return `(() => {${CLICK_POINT_SNIPPET}${PRO_RADIO_FINDER_SNIPPET}
|
|
447
525
|
const m = document.querySelector('[data-testid="composer-intelligence-picker-content"],[role="menu"]');
|
|
448
526
|
if (!m) return { ok: false, reason: "model menu is not open" };
|
|
449
|
-
const proRadio =
|
|
527
|
+
const proRadio = findProRadio(m);
|
|
450
528
|
if (!proRadio) return { ok: false, reason: "Pro option not found in the model menu" };
|
|
451
529
|
const proTop = proRadio.getBoundingClientRect().top;
|
|
452
530
|
const expanders = [...m.querySelectorAll('[role="menuitem"][aria-haspopup="menu"]')].filter((e) => {
|
|
@@ -460,12 +538,11 @@ export function proSubmenuExpanderRectExpression() {
|
|
|
460
538
|
if (dy < bestDy) { best = e; bestDy = dy; }
|
|
461
539
|
}
|
|
462
540
|
if (!best || bestDy > 40) return { ok: false, reason: "Pro sub-mode expander not found next to Pro" };
|
|
463
|
-
|
|
464
|
-
return { ok: true, x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
|
541
|
+
return clickPoint(best);
|
|
465
542
|
})()`;
|
|
466
543
|
}
|
|
467
544
|
export function projectItemRectExpression(name) {
|
|
468
|
-
return `(() => {
|
|
545
|
+
return `(() => {${CLICK_POINT_SNIPPET}
|
|
469
546
|
const opt = [...document.querySelectorAll('[aria-label*="프로젝트 옵션 열기"]')].find((b) => (b.getAttribute("aria-label") || "").startsWith(${JSON.stringify(name)}));
|
|
470
547
|
let target = opt ? (opt.closest('a,[role="link"],li') || opt.parentElement) : null;
|
|
471
548
|
if (!target) {
|
|
@@ -476,10 +553,17 @@ export function projectItemRectExpression(name) {
|
|
|
476
553
|
}
|
|
477
554
|
}
|
|
478
555
|
if (!target) return { ok: false, reason: "project not found in sidebar" };
|
|
479
|
-
|
|
480
|
-
return { ok: true, x: Math.round(r.x + r.width / 2), y: Math.round(r.y + Math.min(r.height / 2, 18)) };
|
|
556
|
+
return clickPoint(target, 18);
|
|
481
557
|
})()`;
|
|
482
558
|
}
|
|
559
|
+
// Clicking a menuitemradio commits the choice and closes the picker; a menu
|
|
560
|
+
// that stays open means the click did not land where we intended.
|
|
561
|
+
async function assertSelectionCommitted(cdp, label) {
|
|
562
|
+
const closed = await waitForExpressionTrue(cdp, menuClosedExpression(), MENU_SETTLE_TIMEOUT_MS);
|
|
563
|
+
if (!closed) {
|
|
564
|
+
throw new Error(`ChatGPT selection "${label}" did not commit; the model menu stayed open. Retry, or pick it manually in the visible browser.`);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
483
567
|
async function selectModelReasoning(cdp, options) {
|
|
484
568
|
if (!options.model && !options.proMode && !options.effort)
|
|
485
569
|
return;
|
|
@@ -487,52 +571,90 @@ async function selectModelReasoning(cdp, options) {
|
|
|
487
571
|
if (!button.ok || button.x === undefined || button.y === undefined) {
|
|
488
572
|
throw new Error(button.reason ?? "Could not open the ChatGPT model selector");
|
|
489
573
|
}
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
const
|
|
496
|
-
if (
|
|
497
|
-
|
|
498
|
-
|
|
574
|
+
try {
|
|
575
|
+
await verifiedClickAt(cdp, button.x, button.y, "model selector");
|
|
576
|
+
const opened = await waitForExpressionTrue(cdp, menuOpenExpression(), MENU_OPEN_TIMEOUT_MS);
|
|
577
|
+
if (!opened)
|
|
578
|
+
throw new Error("ChatGPT model menu did not open after clicking the selector");
|
|
579
|
+
const wantsProMode = Boolean(options.proMode) && (!options.model || /pro/i.test(options.model));
|
|
580
|
+
if (wantsProMode && options.proMode) {
|
|
581
|
+
// Open the Pro sub-mode submenu via the chevron, then pick 기본/확장.
|
|
582
|
+
const expander = await cdp.evaluate(proSubmenuExpanderRectExpression());
|
|
583
|
+
if (!expander.ok || expander.x === undefined || expander.y === undefined) {
|
|
584
|
+
throw new Error(expander.reason ?? "Could not open the ChatGPT Pro sub-mode submenu");
|
|
585
|
+
}
|
|
586
|
+
await verifiedClickAt(cdp, expander.x, expander.y, "Pro sub-mode expander");
|
|
587
|
+
const subLabel = `Pro ${options.proMode}`;
|
|
588
|
+
const subVisible = await waitForExpressionTrue(cdp, menuItemPresentExpression(subLabel), MENU_OPEN_TIMEOUT_MS);
|
|
589
|
+
if (!subVisible)
|
|
590
|
+
throw new Error(`ChatGPT Pro sub-mode not found: ${subLabel}`);
|
|
591
|
+
const sub = await cdp.evaluate(submenuItemRectExpression(subLabel));
|
|
592
|
+
if (!sub.ok || sub.x === undefined || sub.y === undefined) {
|
|
593
|
+
throw new Error(sub.reason ?? `ChatGPT Pro sub-mode not clickable: ${subLabel}`);
|
|
594
|
+
}
|
|
595
|
+
await verifiedClickAt(cdp, sub.x, sub.y, subLabel);
|
|
596
|
+
await assertSelectionCommitted(cdp, subLabel);
|
|
597
|
+
return;
|
|
499
598
|
}
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
const subLabel = `Pro ${options.proMode}`;
|
|
503
|
-
const sub = await cdp.evaluate(submenuItemRectExpression(subLabel));
|
|
504
|
-
if (!sub.ok || sub.x === undefined || sub.y === undefined) {
|
|
599
|
+
const primaryLabel = options.model ?? options.effort;
|
|
600
|
+
if (!primaryLabel) {
|
|
505
601
|
await dispatchEscapeKey(cdp);
|
|
506
|
-
|
|
602
|
+
return;
|
|
507
603
|
}
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
604
|
+
// --model Pro without a sub-mode: the Pro radio's label carries the active
|
|
605
|
+
// sub-mode, so exact-label lookup would miss it.
|
|
606
|
+
if (options.model && /^pro$/i.test(options.model)) {
|
|
607
|
+
const proRadio = await cdp.evaluate(proRadioRectExpression());
|
|
608
|
+
if (!proRadio.ok || proRadio.x === undefined || proRadio.y === undefined) {
|
|
609
|
+
throw new Error(proRadio.reason ?? "Pro option not found in the model menu");
|
|
610
|
+
}
|
|
611
|
+
await verifiedClickAt(cdp, proRadio.x, proRadio.y, "Pro");
|
|
612
|
+
await assertSelectionCommitted(cdp, "Pro");
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
const primary = await cdp.evaluate(menuItemRectExpression(primaryLabel));
|
|
616
|
+
if (!primary.ok || primary.x === undefined || primary.y === undefined) {
|
|
617
|
+
throw new Error(`ChatGPT model/effort option not found: ${primaryLabel}${primary.available ? ` (available: ${primary.available.join(", ")})` : ""}`);
|
|
618
|
+
}
|
|
619
|
+
if (primary.role === "menuitem" && primary.haspopup === "menu") {
|
|
620
|
+
throw new Error(`ChatGPT model "${primaryLabel}" opens a submenu of variants instead of committing; selecting it is not supported yet. Supported today: reasoning efforts (--effort) and Pro via --pro-mode.`);
|
|
621
|
+
}
|
|
622
|
+
await verifiedClickAt(cdp, primary.x, primary.y, primaryLabel);
|
|
623
|
+
await assertSelectionCommitted(cdp, primaryLabel);
|
|
516
624
|
}
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
625
|
+
catch (error) {
|
|
626
|
+
// Leave the user's screen clean: back out of any open menu before rethrowing.
|
|
627
|
+
try {
|
|
628
|
+
await dispatchEscapeKey(cdp);
|
|
629
|
+
await sleep(150);
|
|
630
|
+
await dispatchEscapeKey(cdp);
|
|
631
|
+
}
|
|
632
|
+
catch {
|
|
633
|
+
// best effort only
|
|
634
|
+
}
|
|
635
|
+
throw error;
|
|
521
636
|
}
|
|
522
|
-
await dispatchMouseClickAt(cdp, primary.x, primary.y);
|
|
523
|
-
await sleep(400);
|
|
524
637
|
}
|
|
525
638
|
async function selectProject(cdp, options) {
|
|
526
639
|
if (options.projectNew) {
|
|
527
640
|
throw new Error("projectNew (new-project modal) automation is not implemented yet; create the project manually and use --project");
|
|
528
641
|
}
|
|
529
|
-
if (options.project)
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
642
|
+
if (!options.project)
|
|
643
|
+
return;
|
|
644
|
+
const hrefBefore = await cdp.evaluate("location.href");
|
|
645
|
+
const hit = await cdp.evaluate(projectItemRectExpression(options.project));
|
|
646
|
+
if (!hit.ok || hit.x === undefined || hit.y === undefined) {
|
|
647
|
+
const detail = hit.reason && hit.reason !== "project not found in sidebar" ? ` (${hit.reason})` : "";
|
|
648
|
+
throw new Error(`ChatGPT project not found in sidebar: ${options.project}${detail}`);
|
|
649
|
+
}
|
|
650
|
+
await verifiedClickAt(cdp, hit.x, hit.y, `project ${options.project}`);
|
|
651
|
+
const navigated = await waitForExpressionTrue(cdp, `location.href !== ${JSON.stringify(hrefBefore)}`, PROJECT_NAVIGATION_TIMEOUT_MS);
|
|
652
|
+
if (!navigated) {
|
|
653
|
+
throw new Error(`Clicking project "${options.project}" did not navigate the visible tab. If the tab is already inside this project, omit --project and retry.`);
|
|
654
|
+
}
|
|
655
|
+
const composerReady = await waitForExpressionTrue(cdp, `Boolean(document.querySelector('#prompt-textarea,[contenteditable="true"],textarea'))`, PROJECT_NAVIGATION_TIMEOUT_MS);
|
|
656
|
+
if (!composerReady) {
|
|
657
|
+
throw new Error(`ChatGPT composer did not appear after entering project "${options.project}"`);
|
|
536
658
|
}
|
|
537
659
|
}
|
|
538
660
|
export async function sendChatGptPrompt(options) {
|
|
@@ -638,6 +760,71 @@ export async function sendChatGptPrompt(options) {
|
|
|
638
760
|
warnings: []
|
|
639
761
|
};
|
|
640
762
|
}
|
|
763
|
+
export function modelMenuOptionsExpression() {
|
|
764
|
+
return `(() => {
|
|
765
|
+
const m = document.querySelector('[data-testid="composer-intelligence-picker-content"]');
|
|
766
|
+
if (!m) return [];
|
|
767
|
+
return [...m.querySelectorAll('[role="menuitemradio"],[role="menuitem"]')]
|
|
768
|
+
.map((it) => ({
|
|
769
|
+
label: (it.textContent || "").trim(),
|
|
770
|
+
kind: it.getAttribute("aria-haspopup") === "menu" ? "submenu" : "radio",
|
|
771
|
+
checked: it.getAttribute("aria-checked") === "true"
|
|
772
|
+
}))
|
|
773
|
+
.filter((o) => o.label.length > 0);
|
|
774
|
+
})()`;
|
|
775
|
+
}
|
|
776
|
+
// Read-only discovery: open the composer model menu, read the option labels,
|
|
777
|
+
// and press Escape. Nothing is clicked inside the menu, so the user's model
|
|
778
|
+
// selection is never changed.
|
|
779
|
+
export async function listChatGptModelOptions(input = {}) {
|
|
780
|
+
const port = input.port ?? 9333;
|
|
781
|
+
const timeoutMs = input.timeoutMs ?? 15_000;
|
|
782
|
+
const pageResult = await findChatGptPage(port, computePageDiscoveryTimeout(timeoutMs), undefined);
|
|
783
|
+
if (!pageResult.ok) {
|
|
784
|
+
throwBlockerOrError(pageResult.blocker, "ChatGPT browser page is not available");
|
|
785
|
+
}
|
|
786
|
+
if (!pageResult.page) {
|
|
787
|
+
if (pageResult.blocker)
|
|
788
|
+
throw new ChatGptBrowserBlockerError(pageResult.blocker);
|
|
789
|
+
assertChatGptPageAvailable();
|
|
790
|
+
}
|
|
791
|
+
const page = pageResult.page;
|
|
792
|
+
const status = await evaluateOnPage(page, statusExpression());
|
|
793
|
+
const blocker = detectChatGptPageBlocker(status);
|
|
794
|
+
if (blocker)
|
|
795
|
+
throw new ChatGptBrowserBlockerError(blocker);
|
|
796
|
+
assertChatGptReadyForPrompt(inferChatGptPageLoggedInLikely(status), status.hasComposer);
|
|
797
|
+
assertVisibleChatGptTab(status.visibilityState, status.url, undefined);
|
|
798
|
+
const cdp = await connectCdp(page.webSocketDebuggerUrl);
|
|
799
|
+
try {
|
|
800
|
+
await cdp.send("Runtime.enable");
|
|
801
|
+
const button = await cdp.evaluate(modelButtonRectExpression());
|
|
802
|
+
if (!button.ok || button.x === undefined || button.y === undefined) {
|
|
803
|
+
throw new Error(button.reason ?? "Could not open the ChatGPT model selector");
|
|
804
|
+
}
|
|
805
|
+
try {
|
|
806
|
+
await verifiedClickAt(cdp, button.x, button.y, "model selector");
|
|
807
|
+
const opened = await waitForExpressionTrue(cdp, menuOpenExpression(), MENU_OPEN_TIMEOUT_MS);
|
|
808
|
+
if (!opened)
|
|
809
|
+
throw new Error("ChatGPT model menu did not open after clicking the selector");
|
|
810
|
+
const options = await cdp.evaluate(modelMenuOptionsExpression());
|
|
811
|
+
return { url: status.url, options };
|
|
812
|
+
}
|
|
813
|
+
finally {
|
|
814
|
+
try {
|
|
815
|
+
await dispatchEscapeKey(cdp);
|
|
816
|
+
await sleep(150);
|
|
817
|
+
await dispatchEscapeKey(cdp);
|
|
818
|
+
}
|
|
819
|
+
catch {
|
|
820
|
+
// best effort only
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
finally {
|
|
825
|
+
cdp.close();
|
|
826
|
+
}
|
|
827
|
+
}
|
|
641
828
|
async function findChatGptPage(port, timeoutMs, targetUrl) {
|
|
642
829
|
try {
|
|
643
830
|
const response = await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(timeoutMs) });
|
|
@@ -810,7 +997,9 @@ export function statusExpression() {
|
|
|
810
997
|
}));
|
|
811
998
|
const assistant = messages.filter((message) => message.role === "assistant").at(-1);
|
|
812
999
|
const answer = assistant?.text || "";
|
|
813
|
-
const
|
|
1000
|
+
const ansStripped = answer.trim().replace(/\\.+$/, "");
|
|
1001
|
+
const ansLines = ansStripped.split(/\\r?\\n/).filter((l) => l.trim());
|
|
1002
|
+
const placeholder = /^(생각 중|thinking|thought for|thought about)/i.test(ansStripped) || (ansLines.length <= 1 && /(^|\\s)생각 중$/.test(ansStripped));
|
|
814
1003
|
const hasComposer = Boolean(findChatGptComposerCandidate());
|
|
815
1004
|
return {
|
|
816
1005
|
title: document.title,
|
|
@@ -960,7 +1149,9 @@ function answerExpression() {
|
|
|
960
1149
|
.map((node) => (node.innerText || node.getAttribute("aria-label") || node.getAttribute("data-testid") || "").trim())
|
|
961
1150
|
.filter(Boolean);
|
|
962
1151
|
const answer = assistant?.text || "";
|
|
963
|
-
const
|
|
1152
|
+
const ansStripped = answer.trim().replace(/\\.+$/, "");
|
|
1153
|
+
const ansLines = ansStripped.split(/\\r?\\n/).filter((l) => l.trim());
|
|
1154
|
+
const placeholder = /^(생각 중|thinking|thought for|thought about)/i.test(ansStripped) || (ansLines.length <= 1 && /(^|\\s)생각 중$/.test(ansStripped));
|
|
964
1155
|
return {
|
|
965
1156
|
title: document.title,
|
|
966
1157
|
url: location.href,
|