@youdie006/prodex 0.2.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 +36 -0
- package/dist/chatgpt-browser.d.ts +39 -0
- package/dist/chatgpt-browser.js +373 -4
- package/dist/chatgpt-browser.js.map +1 -1
- package/dist/cli.js +191 -18
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +52 -0
- package/dist/config.js +42 -0
- package/dist/config.js.map +1 -1
- package/dist/repo.d.ts +1 -0
- package/dist/repo.js +32 -1
- package/dist/repo.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
|
@@ -184,6 +184,42 @@ prodex sessions show latest
|
|
|
184
184
|
|
|
185
185
|
This uses the currently available ChatGPT web session and model selection. It is not a hidden API client, and it does not read cookies, tokens, localStorage, or sessionStorage.
|
|
186
186
|
|
|
187
|
+
#### Choosing the model, reasoning effort, and project
|
|
188
|
+
|
|
189
|
+
The visible-browser send drives the same composer picker you use by hand, so you can pick the model, reasoning effort, or a sidebar project per ask:
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
# Pro extended sub-mode, inside an existing sidebar project
|
|
193
|
+
prodex pro browser ask --model Pro --pro-mode 확장 --project "my-project" "Review the migration plan"
|
|
194
|
+
|
|
195
|
+
# A non-Pro model at a specific reasoning effort
|
|
196
|
+
prodex pro browser ask --effort "매우 높음" "Draft the release notes"
|
|
197
|
+
```
|
|
198
|
+
|
|
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.
|
|
200
|
+
|
|
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:
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
prodex setup --model Pro --pro-mode 확장 --project "my-project"
|
|
218
|
+
prodex setup --clear-project
|
|
219
|
+
```
|
|
220
|
+
|
|
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.
|
|
222
|
+
|
|
187
223
|
For a source checkout, keep the explicit send and inspection commands source-aware too:
|
|
188
224
|
|
|
189
225
|
```bash
|
|
@@ -34,11 +34,27 @@ export declare class ChatGptBrowserBlockerError extends Error {
|
|
|
34
34
|
readonly blocker: NonNullable<ChatGptBrowserStatus["blocker"]>;
|
|
35
35
|
constructor(blocker: NonNullable<ChatGptBrowserStatus["blocker"]>);
|
|
36
36
|
}
|
|
37
|
+
export type ChatGptReasoningEffort = "즉시" | "중간" | "높음" | "매우 높음";
|
|
38
|
+
export type ChatGptProMode = "기본" | "확장";
|
|
39
|
+
/** Normalize a CLI reasoning-effort value onto the exact ChatGPT menu label. */
|
|
40
|
+
export declare function parseReasoningEffort(raw: string): ChatGptReasoningEffort;
|
|
41
|
+
/** Normalize a CLI Pro sub-mode value onto the exact ChatGPT menu label. */
|
|
42
|
+
export declare function parseProMode(raw: string): ChatGptProMode;
|
|
37
43
|
export interface SendChatGptPromptOptions {
|
|
38
44
|
port?: number;
|
|
39
45
|
prompt: string;
|
|
40
46
|
targetUrl?: string;
|
|
41
47
|
timeoutMs?: number;
|
|
48
|
+
/** Switch into this sidebar project (by visible name) before sending. */
|
|
49
|
+
project?: string;
|
|
50
|
+
/** Create a new project with this name before sending. */
|
|
51
|
+
projectNew?: string;
|
|
52
|
+
/** Model to select in the composer picker, e.g. "Pro" or "GPT-5.5". */
|
|
53
|
+
model?: string;
|
|
54
|
+
/** Pro sub-mode, used when model is Pro. */
|
|
55
|
+
proMode?: ChatGptProMode;
|
|
56
|
+
/** Reasoning effort, used for non-Pro models. */
|
|
57
|
+
effort?: ChatGptReasoningEffort;
|
|
42
58
|
}
|
|
43
59
|
export interface SendChatGptPromptResult {
|
|
44
60
|
url: string;
|
|
@@ -112,7 +128,30 @@ export declare function getChatGptBrowserStatus(options?: {
|
|
|
112
128
|
port?: number;
|
|
113
129
|
timeoutMs?: number;
|
|
114
130
|
}): Promise<ChatGptBrowserStatus>;
|
|
131
|
+
export declare function menuOpenExpression(): string;
|
|
132
|
+
export declare function menuClosedExpression(): string;
|
|
133
|
+
export declare function menuItemPresentExpression(label: string): string;
|
|
134
|
+
export declare function modelButtonRectExpression(): string;
|
|
135
|
+
export declare function menuItemRectExpression(label: string): string;
|
|
136
|
+
export declare function submenuItemRectExpression(label: string): string;
|
|
137
|
+
export declare function proRadioRectExpression(): string;
|
|
138
|
+
export declare function proSubmenuExpanderRectExpression(): string;
|
|
139
|
+
export declare function projectItemRectExpression(name: string): string;
|
|
115
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>;
|
|
116
155
|
export declare function statusExpression(): string;
|
|
117
156
|
export declare function setComposerTextExpression(text: string): string;
|
|
118
157
|
export declare function submitExpression(): string;
|
package/dist/chatgpt-browser.js
CHANGED
|
@@ -10,6 +10,43 @@ export class ChatGptBrowserBlockerError extends Error {
|
|
|
10
10
|
this.blocker = blocker;
|
|
11
11
|
}
|
|
12
12
|
}
|
|
13
|
+
const REASONING_EFFORTS = ["즉시", "중간", "높음", "매우 높음"];
|
|
14
|
+
const PRO_MODES = ["기본", "확장"];
|
|
15
|
+
// Aliases map friendly CLI input onto the exact Korean menu labels the picker
|
|
16
|
+
// clicks by text. Keys are lowercased and space-stripped before lookup.
|
|
17
|
+
const REASONING_EFFORT_ALIASES = {
|
|
18
|
+
"매우높음": "매우 높음",
|
|
19
|
+
instant: "즉시",
|
|
20
|
+
medium: "중간",
|
|
21
|
+
high: "높음",
|
|
22
|
+
max: "매우 높음"
|
|
23
|
+
};
|
|
24
|
+
const PRO_MODE_ALIASES = {
|
|
25
|
+
standard: "기본",
|
|
26
|
+
extended: "확장"
|
|
27
|
+
};
|
|
28
|
+
/** Normalize a CLI reasoning-effort value onto the exact ChatGPT menu label. */
|
|
29
|
+
export function parseReasoningEffort(raw) {
|
|
30
|
+
const trimmed = raw.trim();
|
|
31
|
+
const match = REASONING_EFFORTS.find((effort) => effort === trimmed);
|
|
32
|
+
if (match)
|
|
33
|
+
return match;
|
|
34
|
+
const alias = REASONING_EFFORT_ALIASES[trimmed.toLowerCase().replace(/\s+/g, "")];
|
|
35
|
+
if (alias)
|
|
36
|
+
return alias;
|
|
37
|
+
throw new Error(`--effort must be one of ${REASONING_EFFORTS.join(", ")}`);
|
|
38
|
+
}
|
|
39
|
+
/** Normalize a CLI Pro sub-mode value onto the exact ChatGPT menu label. */
|
|
40
|
+
export function parseProMode(raw) {
|
|
41
|
+
const trimmed = raw.trim();
|
|
42
|
+
const match = PRO_MODES.find((mode) => mode === trimmed);
|
|
43
|
+
if (match)
|
|
44
|
+
return match;
|
|
45
|
+
const alias = PRO_MODE_ALIASES[trimmed.toLowerCase().replace(/\s+/g, "")];
|
|
46
|
+
if (alias)
|
|
47
|
+
return alias;
|
|
48
|
+
throw new Error(`--pro-mode must be one of ${PRO_MODES.join(", ")}`);
|
|
49
|
+
}
|
|
13
50
|
export const CHATGPT_RUNTIME_BLOCKER_TEXT_EXCLUDED_ANCESTORS = '[data-message-author-role],script,style,noscript,[aria-hidden="true"],div[role="textbox"],textarea,[contenteditable="true"]';
|
|
14
51
|
export const CHATGPT_COMPOSER_CANDIDATE_EXCLUDED_ANCESTORS = '[data-message-author-role],script,style,noscript,[aria-hidden="true"]';
|
|
15
52
|
export const PRODEX_ACTIVE_COMPOSER_ATTRIBUTE = "data-prodex-active-composer";
|
|
@@ -47,8 +84,15 @@ export function isUsableChatGptAnswer(answer) {
|
|
|
47
84
|
const normalized = answer.trim();
|
|
48
85
|
if (!normalized)
|
|
49
86
|
return false;
|
|
50
|
-
|
|
51
|
-
|
|
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;
|
|
52
96
|
}
|
|
53
97
|
return true;
|
|
54
98
|
}
|
|
@@ -359,6 +403,260 @@ export async function getChatGptBrowserStatus(options = {}) {
|
|
|
359
403
|
blocker
|
|
360
404
|
};
|
|
361
405
|
}
|
|
406
|
+
async function dispatchEscapeKey(cdp) {
|
|
407
|
+
await cdp.send("Input.dispatchKeyEvent", { type: "keyDown", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 });
|
|
408
|
+
await cdp.send("Input.dispatchKeyEvent", { type: "keyUp", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 });
|
|
409
|
+
}
|
|
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) {
|
|
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}
|
|
475
|
+
const c = document.querySelector('#prompt-textarea,[contenteditable="true"],textarea');
|
|
476
|
+
const scope = c ? (c.closest('form') || document) : document;
|
|
477
|
+
const b = [...scope.querySelectorAll('[aria-haspopup="menu"]')].find((el) => {
|
|
478
|
+
const t = (el.textContent || "").trim();
|
|
479
|
+
const aria = el.getAttribute("aria-label") || "";
|
|
480
|
+
return /\\S/.test(t) && !/파일|첨부|받아쓰기|음성|dictation|attach/i.test(t + aria);
|
|
481
|
+
});
|
|
482
|
+
if (!b) return { ok: false, reason: "model selector button not found" };
|
|
483
|
+
return clickPoint(b);
|
|
484
|
+
})()`;
|
|
485
|
+
}
|
|
486
|
+
export function menuItemRectExpression(label) {
|
|
487
|
+
return `(() => {${CLICK_POINT_SNIPPET}
|
|
488
|
+
const m = document.querySelector('[data-testid="composer-intelligence-picker-content"]');
|
|
489
|
+
if (!m) return { ok: false, reason: "reasoning/model menu did not open" };
|
|
490
|
+
const items = [...m.querySelectorAll('[role="menuitemradio"],[role="menuitem"]')];
|
|
491
|
+
const it = items.find((r) => (r.textContent || "").trim() === ${JSON.stringify(label)});
|
|
492
|
+
if (!it) return { ok: false, reason: "menu item not found", available: items.map((r) => (r.textContent || "").trim()).slice(0, 12) };
|
|
493
|
+
const point = clickPoint(it);
|
|
494
|
+
if (!point.ok) return point;
|
|
495
|
+
return { ...point, role: it.getAttribute("role"), haspopup: it.getAttribute("aria-haspopup") };
|
|
496
|
+
})()`;
|
|
497
|
+
}
|
|
498
|
+
export function submenuItemRectExpression(label) {
|
|
499
|
+
return `(() => {${CLICK_POINT_SNIPPET}
|
|
500
|
+
const items = [...document.querySelectorAll('[role="menuitemradio"],[role="menuitem"]')];
|
|
501
|
+
const it = items.find((r) => (r.textContent || "").trim() === ${JSON.stringify(label)});
|
|
502
|
+
if (!it) return { ok: false, reason: "submenu item not found" };
|
|
503
|
+
return clickPoint(it);
|
|
504
|
+
})()`;
|
|
505
|
+
}
|
|
506
|
+
// "Pro" itself is a plain radio; its sub-modes (Pro 기본 / Pro 확장) live behind an
|
|
507
|
+
// unlabeled aria-haspopup="menu" chevron sitting next to the Pro radio. Clicking
|
|
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
|
+
}
|
|
523
|
+
export function proSubmenuExpanderRectExpression() {
|
|
524
|
+
return `(() => {${CLICK_POINT_SNIPPET}${PRO_RADIO_FINDER_SNIPPET}
|
|
525
|
+
const m = document.querySelector('[data-testid="composer-intelligence-picker-content"],[role="menu"]');
|
|
526
|
+
if (!m) return { ok: false, reason: "model menu is not open" };
|
|
527
|
+
const proRadio = findProRadio(m);
|
|
528
|
+
if (!proRadio) return { ok: false, reason: "Pro option not found in the model menu" };
|
|
529
|
+
const proTop = proRadio.getBoundingClientRect().top;
|
|
530
|
+
const expanders = [...m.querySelectorAll('[role="menuitem"][aria-haspopup="menu"]')].filter((e) => {
|
|
531
|
+
const t = (e.textContent || "").trim();
|
|
532
|
+
return !/gpt|claude|gemini|o\\d|mini|thinking/i.test(t);
|
|
533
|
+
});
|
|
534
|
+
let best = null;
|
|
535
|
+
let bestDy = Infinity;
|
|
536
|
+
for (const e of expanders) {
|
|
537
|
+
const dy = Math.abs(e.getBoundingClientRect().top - proTop);
|
|
538
|
+
if (dy < bestDy) { best = e; bestDy = dy; }
|
|
539
|
+
}
|
|
540
|
+
if (!best || bestDy > 40) return { ok: false, reason: "Pro sub-mode expander not found next to Pro" };
|
|
541
|
+
return clickPoint(best);
|
|
542
|
+
})()`;
|
|
543
|
+
}
|
|
544
|
+
export function projectItemRectExpression(name) {
|
|
545
|
+
return `(() => {${CLICK_POINT_SNIPPET}
|
|
546
|
+
const opt = [...document.querySelectorAll('[aria-label*="프로젝트 옵션 열기"]')].find((b) => (b.getAttribute("aria-label") || "").startsWith(${JSON.stringify(name)}));
|
|
547
|
+
let target = opt ? (opt.closest('a,[role="link"],li') || opt.parentElement) : null;
|
|
548
|
+
if (!target) {
|
|
549
|
+
const icons = [...document.querySelectorAll('[data-testid="project-folder-icon"]')];
|
|
550
|
+
for (const ic of icons) {
|
|
551
|
+
const row = ic.closest('a,li,[role="link"]') || ic.parentElement?.parentElement;
|
|
552
|
+
if (row && (row.textContent || "").includes(${JSON.stringify(name)})) { target = row; break; }
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
if (!target) return { ok: false, reason: "project not found in sidebar" };
|
|
556
|
+
return clickPoint(target, 18);
|
|
557
|
+
})()`;
|
|
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
|
+
}
|
|
567
|
+
async function selectModelReasoning(cdp, options) {
|
|
568
|
+
if (!options.model && !options.proMode && !options.effort)
|
|
569
|
+
return;
|
|
570
|
+
const button = await cdp.evaluate(modelButtonRectExpression());
|
|
571
|
+
if (!button.ok || button.x === undefined || button.y === undefined) {
|
|
572
|
+
throw new Error(button.reason ?? "Could not open the ChatGPT model selector");
|
|
573
|
+
}
|
|
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;
|
|
598
|
+
}
|
|
599
|
+
const primaryLabel = options.model ?? options.effort;
|
|
600
|
+
if (!primaryLabel) {
|
|
601
|
+
await dispatchEscapeKey(cdp);
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
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);
|
|
624
|
+
}
|
|
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;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
async function selectProject(cdp, options) {
|
|
639
|
+
if (options.projectNew) {
|
|
640
|
+
throw new Error("projectNew (new-project modal) automation is not implemented yet; create the project manually and use --project");
|
|
641
|
+
}
|
|
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}"`);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
362
660
|
export async function sendChatGptPrompt(options) {
|
|
363
661
|
const port = options.port ?? 9333;
|
|
364
662
|
const timeoutMs = options.timeoutMs ?? 90_000;
|
|
@@ -394,6 +692,8 @@ export async function sendChatGptPrompt(options) {
|
|
|
394
692
|
const cdp = await connectCdp(page.webSocketDebuggerUrl);
|
|
395
693
|
try {
|
|
396
694
|
await cdp.send("Runtime.enable");
|
|
695
|
+
await selectProject(cdp, options);
|
|
696
|
+
await selectModelReasoning(cdp, options);
|
|
397
697
|
const inserted = await cdp.evaluate(setComposerTextExpression(options.prompt));
|
|
398
698
|
if (!inserted.ok)
|
|
399
699
|
throw new Error(inserted.reason ?? "Could not insert text into ChatGPT composer");
|
|
@@ -460,6 +760,71 @@ export async function sendChatGptPrompt(options) {
|
|
|
460
760
|
warnings: []
|
|
461
761
|
};
|
|
462
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
|
+
}
|
|
463
828
|
async function findChatGptPage(port, timeoutMs, targetUrl) {
|
|
464
829
|
try {
|
|
465
830
|
const response = await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(timeoutMs) });
|
|
@@ -632,7 +997,9 @@ export function statusExpression() {
|
|
|
632
997
|
}));
|
|
633
998
|
const assistant = messages.filter((message) => message.role === "assistant").at(-1);
|
|
634
999
|
const answer = assistant?.text || "";
|
|
635
|
-
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));
|
|
636
1003
|
const hasComposer = Boolean(findChatGptComposerCandidate());
|
|
637
1004
|
return {
|
|
638
1005
|
title: document.title,
|
|
@@ -782,7 +1149,9 @@ function answerExpression() {
|
|
|
782
1149
|
.map((node) => (node.innerText || node.getAttribute("aria-label") || node.getAttribute("data-testid") || "").trim())
|
|
783
1150
|
.filter(Boolean);
|
|
784
1151
|
const answer = assistant?.text || "";
|
|
785
|
-
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));
|
|
786
1155
|
return {
|
|
787
1156
|
title: document.title,
|
|
788
1157
|
url: location.href,
|