surf-cli 2.10.0 → 2.12.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 +46 -0
- package/native/chatgpt-client-response.cjs +336 -0
- package/native/chatgpt-client-selection.cjs +119 -0
- package/native/chatgpt-client-ui.cjs +481 -0
- package/native/chatgpt-client.cjs +252 -664
- package/native/cli.cjs +86 -3
- package/native/host-helpers.cjs +38 -1
- package/native/host-sessions.cjs +1 -0
- package/native/host.cjs +30 -5
- package/native/oracle-cli.cjs +434 -0
- package/native/oracle-context.cjs +311 -0
- package/native/oracle-host.cjs +301 -0
- package/native/oracle-jobs.cjs +253 -0
- package/native/playbook-cli.cjs +3 -2
- package/native/playbook-runtime.cjs +29 -2
- package/native/playbooks.cjs +13 -0
- package/native/workflow-definition.cjs +1 -0
- package/native/workflow-script-runtime.cjs +294 -0
- package/package.json +20 -5
- package/pi-extension/surf.ts +230 -0
- package/skills/surf/SKILL.md +55 -6
package/README.md
CHANGED
|
@@ -230,9 +230,14 @@ surf read --compact # Remove empty structural elements
|
|
|
230
230
|
surf read --depth 3 --compact # Both (60% smaller output)
|
|
231
231
|
surf read --max-bytes 2000 # Cap visible text on a UTF-8 byte boundary
|
|
232
232
|
surf page.text # Raw text content only
|
|
233
|
+
surf page.html # Rendered document HTML
|
|
234
|
+
surf page.html --strip-scripts > artifact.html # Save a safe static Claude artifact
|
|
235
|
+
surf page.save --selector "#artifact" --strip-scripts --output artifact.html # Save one rendered element
|
|
233
236
|
surf page.state # Modals, loading state, scroll position
|
|
234
237
|
```
|
|
235
238
|
|
|
239
|
+
Use `surf page.html --strip-scripts` after the page loads when you need a static export of a Claude artifact or other rendered DOM. Use `--selector <css>` to export one element. Both commands target the active frame when `frame.switch` is active.
|
|
240
|
+
|
|
236
241
|
Element refs (`e1`, `e2`, `e3`...) are stable identifiers from the accessibility tree - semantic, predictable, and resilient to DOM changes.
|
|
237
242
|
|
|
238
243
|
### Semantic Locators
|
|
@@ -492,6 +497,19 @@ surf aistudio.build "crm dashboard" --output ./out # Extract zip to d
|
|
|
492
497
|
surf aistudio.build "game" --keep-open --timeout 600 # Keep tab open, 10min timeout
|
|
493
498
|
```
|
|
494
499
|
|
|
500
|
+
#### Oracle
|
|
501
|
+
|
|
502
|
+
Use `surf oracle` for a durable, local ChatGPT consult instead of a quick `surf chatgpt` one-shot. It persists jobs by conversation URL, supports repeatable file-context globs, and verifies requested model and reasoning effort before submission.
|
|
503
|
+
|
|
504
|
+
```bash
|
|
505
|
+
surf oracle ask "review this change" --files "src/**/*.ts" --model pro --effort extended --detach --json
|
|
506
|
+
surf oracle status <job-id> --json
|
|
507
|
+
surf oracle result <job-id> --wait --json
|
|
508
|
+
surf oracle follow <job-id> "challenge that recommendation" --detach --json
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
Only one oracle job can be in flight. Sensitive filename patterns and gitignored context are blocked unless `--allow-sensitive` is explicit.
|
|
512
|
+
|
|
495
513
|
Each AI tool uses your existing browser login - no API keys needed. Just be logged into the respective service in Chrome (chatgpt.com, gemini.google.com, perplexity.ai, x.com, or aistudio.google.com).
|
|
496
514
|
|
|
497
515
|
**Grok troubleshooting:** If queries fail, run `surf grok --validate` to check if the UI structure changed. Use `--save-models` to update the model cache in `surf.json`. Default model is `fast`.
|
|
@@ -694,6 +712,21 @@ surf use page read --json
|
|
|
694
712
|
surf use <site> <write-op> --write --resource-id 123
|
|
695
713
|
```
|
|
696
714
|
|
|
715
|
+
Playbook read ops can also use a trusted `script` strategy when fixed JSON steps are too rigid. Scripts require `--allow-script` at run time. Only run scripts from playbooks you trust. This is not a security sandbox.
|
|
716
|
+
|
|
717
|
+
The script gets `input`, `tools.run`, `tools.all`, `tools.ref`/`refs`, `emit`, and `console`. Tool calls still use Surf workflow step behavior, including auto-waits unless `autoWait` is `false`.
|
|
718
|
+
|
|
719
|
+
```json
|
|
720
|
+
{
|
|
721
|
+
"using": "script",
|
|
722
|
+
"script": [
|
|
723
|
+
"const page = await tools.run('page', { tool: 'page.text', args: {} });",
|
|
724
|
+
"const clicked = await tools.all(input.selectors.map((selector) => ({ key: selector.slice(1), tool: 'click', args: { selector } })));",
|
|
725
|
+
"return { page: page.output, clicked: clicked.map((link) => link.output) };"
|
|
726
|
+
]
|
|
727
|
+
}
|
|
728
|
+
```
|
|
729
|
+
|
|
697
730
|
Project playbooks in `./.surf/playbooks/` override user playbooks in `~/.surf/playbooks/`; built-ins are the final fallback. `show` reports the selected source. Provider compatibility commands continue to use their validated command paths until provider playbooks have real login-flow validation.
|
|
698
731
|
|
|
699
732
|
Author a playbook from redacted recent activity or an explicit evidence record:
|
|
@@ -919,6 +952,19 @@ cp -r skills/surf ~/.pi/agent/skills/
|
|
|
919
952
|
|
|
920
953
|
See [`skills/README.md`](skills/README.md) for details.
|
|
921
954
|
|
|
955
|
+
### Pi extension
|
|
956
|
+
|
|
957
|
+
Surf also includes an optional Pi extension. Install or load Surf as a Pi package, or load it from a checkout:
|
|
958
|
+
|
|
959
|
+
```bash
|
|
960
|
+
pi install npm:surf-cli
|
|
961
|
+
pi -e /path/to/surf-cli/pi-extension/surf.ts
|
|
962
|
+
```
|
|
963
|
+
|
|
964
|
+
It registers `surf_read`, `surf_screenshot`, `surf_click`, `surf_type`, `surf_tool`, and the `surf_oracle_*` tools. Browser calls use Surf's native-host socket, not shell commands. If `pi-subagents/background-work` is installed, the extension also reports active oracle jobs started by that Pi session. Pi still loads the browser tools when pi-subagents is not installed.
|
|
965
|
+
|
|
966
|
+
Surf agents share one browser session. Use read tools for parallel scouts when possible. `surf_click` and `surf_type` can interfere with another agent's browser actions. Browser leases are not available yet.
|
|
967
|
+
|
|
922
968
|
## Development
|
|
923
969
|
|
|
924
970
|
```bash
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
const { throwIfAborted } = require("./abort.cjs");
|
|
2
|
+
const { SELECTORS, delay, evaluate } = require("./chatgpt-client-ui.cjs");
|
|
3
|
+
|
|
4
|
+
function normalizePromptEcho(prompt) {
|
|
5
|
+
return String(prompt || "")
|
|
6
|
+
.replace(/\s+/g, " ")
|
|
7
|
+
.trim()
|
|
8
|
+
.slice(0, 200);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function matchesPromptEcho(text, promptEcho) {
|
|
12
|
+
const expected = normalizePromptEcho(promptEcho);
|
|
13
|
+
return expected.length > 0 && normalizePromptEcho(text) === expected;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function cleanChatGPTResponseText(rawText) {
|
|
17
|
+
if (!rawText) return "";
|
|
18
|
+
|
|
19
|
+
const chromeLines = new Set([
|
|
20
|
+
"copy",
|
|
21
|
+
"good response",
|
|
22
|
+
"bad response",
|
|
23
|
+
"read aloud",
|
|
24
|
+
"edit",
|
|
25
|
+
"retry",
|
|
26
|
+
"continue generating",
|
|
27
|
+
"share",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
const lines = [];
|
|
31
|
+
let inCodeFence = false;
|
|
32
|
+
|
|
33
|
+
for (const line of String(rawText).replace(/\r\n?/g, "\n").split("\n")) {
|
|
34
|
+
const trimmed = line.trim();
|
|
35
|
+
const isFenceLine = trimmed.startsWith("```");
|
|
36
|
+
const normalizedLine = inCodeFence || isFenceLine ? line.replace(/[\t ]+$/g, "") : line;
|
|
37
|
+
|
|
38
|
+
lines.push({
|
|
39
|
+
text: normalizedLine,
|
|
40
|
+
trimmed,
|
|
41
|
+
isChrome: trimmed.length > 0 && chromeLines.has(trimmed.toLowerCase()),
|
|
42
|
+
inCodeFence,
|
|
43
|
+
isFenceLine,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
if (isFenceLine) {
|
|
47
|
+
inCodeFence = !inCodeFence;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
while (lines.length > 0 && lines[0].trimmed.length === 0) {
|
|
52
|
+
lines.shift();
|
|
53
|
+
}
|
|
54
|
+
while (lines.length > 0 && lines[lines.length - 1].trimmed.length === 0) {
|
|
55
|
+
lines.pop();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let trailingChromeStart = lines.length;
|
|
59
|
+
while (trailingChromeStart > 0) {
|
|
60
|
+
const line = lines[trailingChromeStart - 1];
|
|
61
|
+
if (line.inCodeFence || line.isFenceLine || !line.isChrome) break;
|
|
62
|
+
trailingChromeStart--;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const trailingChromeCount = lines.length - trailingChromeStart;
|
|
66
|
+
if (trailingChromeCount >= 2) {
|
|
67
|
+
lines.splice(trailingChromeStart);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
while (lines.length > 0 && lines[0].trimmed.length === 0) {
|
|
71
|
+
lines.shift();
|
|
72
|
+
}
|
|
73
|
+
while (lines.length > 0 && lines[lines.length - 1].trimmed.length === 0) {
|
|
74
|
+
lines.pop();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return lines.map((line) => line.text).join("\n");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function extractLatestAssistantSnapshot(candidates) {
|
|
81
|
+
if (!Array.isArray(candidates)) return null;
|
|
82
|
+
|
|
83
|
+
let latestEmptyAssistant = null;
|
|
84
|
+
|
|
85
|
+
for (let i = candidates.length - 1; i >= 0; i--) {
|
|
86
|
+
const candidate = candidates[i];
|
|
87
|
+
if (!candidate?.isAssistant) continue;
|
|
88
|
+
|
|
89
|
+
const snapshot = {
|
|
90
|
+
...candidate,
|
|
91
|
+
text: cleanChatGPTResponseText(candidate?.text || ""),
|
|
92
|
+
turnIndex: i,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
if (snapshot.text) {
|
|
96
|
+
return snapshot;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!latestEmptyAssistant) {
|
|
100
|
+
latestEmptyAssistant = snapshot;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return latestEmptyAssistant;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function normalizeResponseSnapshot(rawSnapshot) {
|
|
108
|
+
const candidates = rawSnapshot?.candidates;
|
|
109
|
+
return {
|
|
110
|
+
latestAssistant: extractLatestAssistantSnapshot(candidates),
|
|
111
|
+
assistantCount: Array.isArray(candidates)
|
|
112
|
+
? candidates.filter((candidate) => candidate?.isAssistant).length
|
|
113
|
+
: 0,
|
|
114
|
+
stopVisible: Boolean(rawSnapshot?.stopVisible),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function isNewAssistantContent(
|
|
119
|
+
latestAssistant,
|
|
120
|
+
baselineAssistant,
|
|
121
|
+
assistantCount = 0,
|
|
122
|
+
baselineAssistantCount = 0,
|
|
123
|
+
) {
|
|
124
|
+
if (!latestAssistant) return false;
|
|
125
|
+
if (!baselineAssistant) return true;
|
|
126
|
+
if (
|
|
127
|
+
latestAssistant.messageId &&
|
|
128
|
+
baselineAssistant.messageId &&
|
|
129
|
+
latestAssistant.messageId !== baselineAssistant.messageId
|
|
130
|
+
) {
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const currentText = latestAssistant.text || "";
|
|
135
|
+
const baselineText = baselineAssistant.text || "";
|
|
136
|
+
|
|
137
|
+
if (assistantCount > baselineAssistantCount) {
|
|
138
|
+
if (latestAssistant.turnIndex !== baselineAssistant.turnIndex) {
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
if (currentText !== baselineText) {
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return currentText !== baselineText;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function isChatGPTResponseComplete(snapshot, stableCycles, stableMs) {
|
|
151
|
+
if (!snapshot?.text) return false;
|
|
152
|
+
if (snapshot.stopVisible) return false;
|
|
153
|
+
if (snapshot.hasFinishedActions) return true;
|
|
154
|
+
return stableCycles >= 6 && stableMs >= 1200;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function scopeSnapshotToPrompt(rawSnapshot, promptEcho) {
|
|
158
|
+
const candidates = Array.isArray(rawSnapshot?.candidates) ? rawSnapshot.candidates : [];
|
|
159
|
+
let userIndex = -1;
|
|
160
|
+
for (let index = candidates.length - 1; index >= 0; index--) {
|
|
161
|
+
const candidate = candidates[index];
|
|
162
|
+
if (candidate?.isUser && matchesPromptEcho(candidate.text, promptEcho)) {
|
|
163
|
+
userIndex = index;
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (userIndex === -1) return { ...rawSnapshot, candidates: [] };
|
|
168
|
+
|
|
169
|
+
const following = candidates.slice(userIndex + 1);
|
|
170
|
+
const nextUserIndex = following.findIndex((candidate) => candidate?.isUser);
|
|
171
|
+
return {
|
|
172
|
+
...rawSnapshot,
|
|
173
|
+
candidates: nextUserIndex === -1 ? following : following.slice(0, nextUserIndex),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function readChatGPTResponseSnapshot(cdp) {
|
|
178
|
+
return evaluate(
|
|
179
|
+
cdp,
|
|
180
|
+
`(() => {
|
|
181
|
+
const scope = document.querySelector('main') || document;
|
|
182
|
+
const CONVERSATION_SELECTOR = ${JSON.stringify(SELECTORS.conversationTurn)};
|
|
183
|
+
const ASSISTANT_SELECTOR = ${JSON.stringify(SELECTORS.assistantMessage)};
|
|
184
|
+
const CONTENT_SELECTORS = ${JSON.stringify(SELECTORS.assistantContent.split(", "))};
|
|
185
|
+
const STOP_SELECTOR = ${JSON.stringify(SELECTORS.stopButton)};
|
|
186
|
+
const FINISHED_SELECTOR = ${JSON.stringify(SELECTORS.finishedActions)};
|
|
187
|
+
|
|
188
|
+
const toCandidate = (turnNode, messageRoot = null) => {
|
|
189
|
+
const resolvedMessageRoot = messageRoot || (turnNode.matches?.(ASSISTANT_SELECTOR)
|
|
190
|
+
? turnNode
|
|
191
|
+
: turnNode.querySelector(ASSISTANT_SELECTOR));
|
|
192
|
+
const authorRoot = turnNode.matches?.('[data-message-author-role], [data-turn]')
|
|
193
|
+
? turnNode
|
|
194
|
+
: turnNode.querySelector('[data-message-author-role], [data-turn]');
|
|
195
|
+
const searchRoot = resolvedMessageRoot || authorRoot || turnNode;
|
|
196
|
+
let contentRoot = null;
|
|
197
|
+
|
|
198
|
+
for (const selector of CONTENT_SELECTORS) {
|
|
199
|
+
const match = selector === '[dir="auto"]'
|
|
200
|
+
? (searchRoot.matches?.(selector) ? searchRoot : null)
|
|
201
|
+
: (searchRoot.matches?.(selector) ? searchRoot : searchRoot.querySelector(selector));
|
|
202
|
+
if (match) {
|
|
203
|
+
contentRoot = match;
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const role =
|
|
209
|
+
resolvedMessageRoot?.getAttribute('data-message-author-role') ||
|
|
210
|
+
authorRoot?.getAttribute('data-message-author-role') ||
|
|
211
|
+
turnNode.getAttribute('data-message-author-role') ||
|
|
212
|
+
null;
|
|
213
|
+
const turn =
|
|
214
|
+
resolvedMessageRoot?.getAttribute('data-turn') ||
|
|
215
|
+
authorRoot?.getAttribute('data-turn') ||
|
|
216
|
+
turnNode.getAttribute('data-turn') ||
|
|
217
|
+
null;
|
|
218
|
+
const isAssistant =
|
|
219
|
+
role === 'assistant' ||
|
|
220
|
+
turn === 'assistant' ||
|
|
221
|
+
resolvedMessageRoot !== null;
|
|
222
|
+
const isUser = role === 'user' || turn === 'user';
|
|
223
|
+
const text = (contentRoot || turnNode).innerText || (contentRoot || turnNode).textContent || '';
|
|
224
|
+
const messageId =
|
|
225
|
+
resolvedMessageRoot?.getAttribute('data-message-id') ||
|
|
226
|
+
turnNode.getAttribute('data-message-id') ||
|
|
227
|
+
null;
|
|
228
|
+
const hasFinishedActions = Boolean(turnNode.querySelector(FINISHED_SELECTOR));
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
role,
|
|
232
|
+
turn,
|
|
233
|
+
isAssistant,
|
|
234
|
+
isUser,
|
|
235
|
+
text,
|
|
236
|
+
messageId,
|
|
237
|
+
hasFinishedActions,
|
|
238
|
+
};
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
let candidates = Array.from(scope.querySelectorAll(CONVERSATION_SELECTOR)).map((turnNode) =>
|
|
242
|
+
toCandidate(turnNode)
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
if (candidates.length === 0) {
|
|
246
|
+
candidates = Array.from(scope.querySelectorAll(ASSISTANT_SELECTOR)).map((messageRoot) =>
|
|
247
|
+
toCandidate(messageRoot, messageRoot)
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
candidates,
|
|
253
|
+
stopVisible: Boolean(scope.querySelector(STOP_SELECTOR)),
|
|
254
|
+
};
|
|
255
|
+
})()`,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function waitForResponse(
|
|
260
|
+
cdp,
|
|
261
|
+
timeoutMs = 2700000,
|
|
262
|
+
baselineAssistant,
|
|
263
|
+
baselineAssistantCount,
|
|
264
|
+
signal,
|
|
265
|
+
promptEcho,
|
|
266
|
+
) {
|
|
267
|
+
throwIfAborted(signal);
|
|
268
|
+
const deadline = Date.now() + timeoutMs;
|
|
269
|
+
let previousText = baselineAssistant?.text || "";
|
|
270
|
+
let stableCycles = 0;
|
|
271
|
+
let lastChangeAt = Date.now();
|
|
272
|
+
|
|
273
|
+
while (Date.now() < deadline) {
|
|
274
|
+
const rawSnapshot = await readChatGPTResponseSnapshot(cdp);
|
|
275
|
+
const snapshot = promptEcho ? scopeSnapshotToPrompt(rawSnapshot, promptEcho) : rawSnapshot;
|
|
276
|
+
|
|
277
|
+
if (!snapshot) {
|
|
278
|
+
await delay(400, signal);
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const { latestAssistant, assistantCount, stopVisible } = normalizeResponseSnapshot(snapshot);
|
|
283
|
+
const currentText = latestAssistant?.text || "";
|
|
284
|
+
const hasNewAssistantContent = isNewAssistantContent(
|
|
285
|
+
latestAssistant,
|
|
286
|
+
baselineAssistant,
|
|
287
|
+
assistantCount,
|
|
288
|
+
baselineAssistantCount,
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
if (!hasNewAssistantContent) {
|
|
292
|
+
await delay(400, signal);
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (currentText !== previousText) {
|
|
297
|
+
previousText = currentText;
|
|
298
|
+
stableCycles = 0;
|
|
299
|
+
lastChangeAt = Date.now();
|
|
300
|
+
} else if (currentText) {
|
|
301
|
+
stableCycles++;
|
|
302
|
+
} else {
|
|
303
|
+
stableCycles = 0;
|
|
304
|
+
lastChangeAt = Date.now();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const stableMs = Date.now() - lastChangeAt;
|
|
308
|
+
const completionSnapshot = latestAssistant
|
|
309
|
+
? { ...latestAssistant, stopVisible }
|
|
310
|
+
: { text: "", stopVisible, hasFinishedActions: false };
|
|
311
|
+
|
|
312
|
+
if (isChatGPTResponseComplete(completionSnapshot, stableCycles, stableMs)) {
|
|
313
|
+
return {
|
|
314
|
+
text: latestAssistant.text,
|
|
315
|
+
messageId: latestAssistant.messageId,
|
|
316
|
+
turnIndex: latestAssistant.turnIndex,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
await delay(400, signal);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
throw new Error("Response timeout");
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
module.exports = {
|
|
327
|
+
cleanChatGPTResponseText,
|
|
328
|
+
extractLatestAssistantSnapshot,
|
|
329
|
+
isChatGPTResponseComplete,
|
|
330
|
+
isNewAssistantContent,
|
|
331
|
+
matchesPromptEcho,
|
|
332
|
+
normalizePromptEcho,
|
|
333
|
+
normalizeResponseSnapshot,
|
|
334
|
+
readChatGPTResponseSnapshot,
|
|
335
|
+
waitForResponse,
|
|
336
|
+
};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
const CHATGPT_EFFORT_CHOICES = ["light", "standard", "extended", "heavy"];
|
|
2
|
+
|
|
3
|
+
function normalizeChatGPTModelChoice(desiredModel) {
|
|
4
|
+
const normalized = String(desiredModel || "")
|
|
5
|
+
.toLowerCase()
|
|
6
|
+
.replace(/[^a-z0-9]/g, "");
|
|
7
|
+
|
|
8
|
+
if (["instant", "gpt53"].includes(normalized)) return "instant";
|
|
9
|
+
if (["thinking", "gpt54thinking"].includes(normalized)) return "thinking";
|
|
10
|
+
if (["pro", "gpt54pro"].includes(normalized)) return "pro";
|
|
11
|
+
|
|
12
|
+
return normalized;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function normalizeChatGPTEffortChoice(desiredEffort) {
|
|
16
|
+
const normalized = String(desiredEffort || "").toLowerCase().trim();
|
|
17
|
+
return CHATGPT_EFFORT_CHOICES.includes(normalized) ? normalized : null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function normalizedWords(value) {
|
|
21
|
+
return String(value || "")
|
|
22
|
+
.toLowerCase()
|
|
23
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
24
|
+
.trim()
|
|
25
|
+
.split(/\s+/)
|
|
26
|
+
.filter(Boolean);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function modelCandidateMatches(item, targetModel) {
|
|
30
|
+
const values = [item?.label, item?.testId?.replace(/^model-switcher-/, "")].filter(Boolean);
|
|
31
|
+
return values.some((value) => {
|
|
32
|
+
if (normalizeChatGPTModelChoice(value) === targetModel) return true;
|
|
33
|
+
const variants = ["instant", "thinking", "pro"].filter((variant) =>
|
|
34
|
+
normalizedWords(value).includes(variant),
|
|
35
|
+
);
|
|
36
|
+
return variants.length === 1 && variants[0] === targetModel;
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function effortCandidateMatches(item, targetEffort) {
|
|
41
|
+
const variants = new Set(
|
|
42
|
+
[item?.label, item?.testId]
|
|
43
|
+
.flatMap((value) => normalizedWords(value))
|
|
44
|
+
.filter((word) => CHATGPT_EFFORT_CHOICES.includes(word)),
|
|
45
|
+
);
|
|
46
|
+
return variants.size === 1 && variants.has(targetEffort);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function uniqueMatch(items, matches) {
|
|
50
|
+
if (!Array.isArray(items)) return null;
|
|
51
|
+
const candidates = items.filter(matches);
|
|
52
|
+
return candidates.length === 1 ? candidates[0] : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function resolveChatGPTModelMenuOption(items, desiredModel) {
|
|
56
|
+
const targetModel = normalizeChatGPTModelChoice(desiredModel);
|
|
57
|
+
if (!targetModel) return null;
|
|
58
|
+
return uniqueMatch(
|
|
59
|
+
items,
|
|
60
|
+
(item) =>
|
|
61
|
+
item?.role === "menuitemradio" &&
|
|
62
|
+
typeof item?.testId === "string" &&
|
|
63
|
+
item.testId.startsWith("model-switcher-") &&
|
|
64
|
+
modelCandidateMatches(item, targetModel),
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function verifyChatGPTModelSelection(items, desiredModel) {
|
|
69
|
+
const targetModel = normalizeChatGPTModelChoice(desiredModel);
|
|
70
|
+
if (!targetModel) return null;
|
|
71
|
+
return uniqueMatch(
|
|
72
|
+
items,
|
|
73
|
+
(item) =>
|
|
74
|
+
(typeof item?.label === "string" || typeof item?.testId === "string") &&
|
|
75
|
+
modelCandidateMatches(item, targetModel),
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function resolveChatGPTEffortMenuOption(items, desiredEffort) {
|
|
80
|
+
const targetEffort = normalizeChatGPTEffortChoice(desiredEffort);
|
|
81
|
+
if (!targetEffort) return null;
|
|
82
|
+
return uniqueMatch(
|
|
83
|
+
items,
|
|
84
|
+
(item) =>
|
|
85
|
+
["button", "menuitem", "menuitemradio"].includes(item?.role) &&
|
|
86
|
+
effortCandidateMatches(item, targetEffort),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function verifyChatGPTEffortSelection(items, desiredEffort) {
|
|
91
|
+
const targetEffort = normalizeChatGPTEffortChoice(desiredEffort);
|
|
92
|
+
if (!targetEffort) return null;
|
|
93
|
+
return uniqueMatch(
|
|
94
|
+
items,
|
|
95
|
+
(item) =>
|
|
96
|
+
(typeof item?.label === "string" || typeof item?.testId === "string") &&
|
|
97
|
+
effortCandidateMatches(item, targetEffort),
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function boundedOptionLabels(items) {
|
|
102
|
+
if (!Array.isArray(items)) return [];
|
|
103
|
+
return items
|
|
104
|
+
.map((item) => String(item?.label || "").replace(/\s+/g, " ").trim().slice(0, 80))
|
|
105
|
+
.filter(Boolean)
|
|
106
|
+
.filter((label, index, labels) => labels.indexOf(label) === index)
|
|
107
|
+
.slice(0, 10);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = {
|
|
111
|
+
CHATGPT_EFFORT_CHOICES,
|
|
112
|
+
boundedOptionLabels,
|
|
113
|
+
normalizeChatGPTEffortChoice,
|
|
114
|
+
normalizeChatGPTModelChoice,
|
|
115
|
+
resolveChatGPTEffortMenuOption,
|
|
116
|
+
resolveChatGPTModelMenuOption,
|
|
117
|
+
verifyChatGPTEffortSelection,
|
|
118
|
+
verifyChatGPTModelSelection,
|
|
119
|
+
};
|