surf-cli 2.5.2 → 2.6.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 +43 -2
- package/dist/manifest.json +1 -1
- package/dist/service-worker/index.js +5 -5
- package/dist/service-worker/index.js.map +1 -1
- package/native/aistudio-build.cjs +562 -0
- package/native/aistudio-client.cjs +502 -0
- package/native/aistudio-model.cjs +225 -0
- package/native/aistudio-parser.cjs +424 -0
- package/native/aistudio-response.cjs +336 -0
- package/native/cli.cjs +83 -14
- package/native/do-executor.cjs +1 -1
- package/native/host-helpers.cjs +29 -0
- package/native/host.cjs +210 -14
- package/native/mcp-server.cjs +1 -1
- package/native/network-store.cjs +3 -1
- package/package.json +3 -2
- package/scripts/install-native-host.cjs +14 -2
- package/scripts/uninstall-native-host.cjs +7 -1
- package/skills/README.md +21 -0
- package/skills/surf/SKILL.md +545 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI Studio model selection for surf-cli
|
|
3
|
+
*
|
|
4
|
+
* Handles reading, verifying, and selecting models in the AI Studio UI.
|
|
5
|
+
* Uses a three-tier strategy: URL param → wait for UI to reflect → click selector.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const {
|
|
9
|
+
normalizeModelString,
|
|
10
|
+
extractModelKeywords,
|
|
11
|
+
buildClickDispatcher,
|
|
12
|
+
delay,
|
|
13
|
+
} = require("./aistudio-parser.cjs");
|
|
14
|
+
|
|
15
|
+
async function evaluate(cdp, expression) {
|
|
16
|
+
const result = await cdp(expression);
|
|
17
|
+
if (result.exceptionDetails) {
|
|
18
|
+
const desc = result.exceptionDetails.exception?.description ||
|
|
19
|
+
result.exceptionDetails.text ||
|
|
20
|
+
"Evaluation failed";
|
|
21
|
+
throw new Error(desc);
|
|
22
|
+
}
|
|
23
|
+
if (result.error) {
|
|
24
|
+
throw new Error(result.error);
|
|
25
|
+
}
|
|
26
|
+
return result.result?.value;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function readCurrentModelInfo(cdp) {
|
|
30
|
+
return evaluate(cdp, `(() => {
|
|
31
|
+
const normalize = (text) => (text || '').replace(/\\s+/g, ' ').trim();
|
|
32
|
+
|
|
33
|
+
const selector = document.querySelector('button.model-selector-card, .model-selector-card');
|
|
34
|
+
if (!selector) {
|
|
35
|
+
return { found: false, label: '', modelId: '' };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const raw = normalize(selector.textContent || '');
|
|
39
|
+
const lower = raw.toLowerCase();
|
|
40
|
+
const compact = lower.replace(/\\s+/g, '');
|
|
41
|
+
const modelIdMatch = compact.match(/gemini-[a-z0-9.\-]*(?:preview|latest)/) ||
|
|
42
|
+
lower.match(/gemini-[a-z0-9.\-]*(?:preview|latest)/);
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
found: true,
|
|
46
|
+
label: lower,
|
|
47
|
+
modelId: modelIdMatch ? modelIdMatch[0] : '',
|
|
48
|
+
};
|
|
49
|
+
})()`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function waitForModelToApply(cdp, requestedModel, log, timeoutMs = 15000) {
|
|
53
|
+
const normalizedRequested = normalizeModelString(requestedModel);
|
|
54
|
+
const keywords = extractModelKeywords(normalizedRequested);
|
|
55
|
+
if (!normalizedRequested) return true;
|
|
56
|
+
|
|
57
|
+
const deadline = Date.now() + timeoutMs;
|
|
58
|
+
while (Date.now() < deadline) {
|
|
59
|
+
const info = await readCurrentModelInfo(cdp).catch(() => ({ found: false, label: '', modelId: '' }));
|
|
60
|
+
|
|
61
|
+
const modelIdMatches = info.modelId && info.modelId === normalizedRequested;
|
|
62
|
+
const keywordMatches = info.label && keywords.length > 0 && keywords.every(k => info.label.includes(k));
|
|
63
|
+
|
|
64
|
+
if (modelIdMatches || keywordMatches) {
|
|
65
|
+
log(
|
|
66
|
+
`Model appears applied: requested=${normalizedRequested}` +
|
|
67
|
+
`${info.modelId ? `, detected=${info.modelId}` : ''}` +
|
|
68
|
+
`${info.label ? `, label=${info.label.slice(0, 120)}` : ''}`
|
|
69
|
+
);
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
await delay(250);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
log(`Model did not appear to apply within ${timeoutMs}ms (requested=${normalizedRequested})`);
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function closeModelSelectorIfOpen(cdp, log = () => {}) {
|
|
81
|
+
const closed = await evaluate(cdp, `(() => {
|
|
82
|
+
const dialog = document.querySelector('[role="dialog"]');
|
|
83
|
+
if (!dialog) return false;
|
|
84
|
+
|
|
85
|
+
const hasModelOptions = dialog.querySelector('button.content-button, [role="option"], mat-option, mat-list-item');
|
|
86
|
+
if (!hasModelOptions) return false;
|
|
87
|
+
|
|
88
|
+
const esc = new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', bubbles: true });
|
|
89
|
+
document.dispatchEvent(esc);
|
|
90
|
+
return true;
|
|
91
|
+
})()`).catch(() => false);
|
|
92
|
+
|
|
93
|
+
if (closed) {
|
|
94
|
+
log('Closed model selector dialog before continuing');
|
|
95
|
+
await delay(150);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return Boolean(closed);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function selectModel(cdp, desiredModel, log, timeoutMs = 10000) {
|
|
102
|
+
const normalizedTargetModel = normalizeModelString(desiredModel);
|
|
103
|
+
if (!normalizedTargetModel) return desiredModel;
|
|
104
|
+
|
|
105
|
+
const openSelector = await evaluate(cdp, `(() => {
|
|
106
|
+
${buildClickDispatcher()}
|
|
107
|
+
|
|
108
|
+
const normalize = (text) => (text || '').replace(/\\s+/g, ' ').trim();
|
|
109
|
+
const isVisible = (el) => Boolean(el && (el.offsetParent !== null || el.getClientRects().length > 0));
|
|
110
|
+
|
|
111
|
+
const direct = document.querySelector('button.model-selector-card, .model-selector-card');
|
|
112
|
+
if (isVisible(direct)) {
|
|
113
|
+
dispatchClickSequence(direct);
|
|
114
|
+
return { success: true, method: 'model-selector-card', currentModel: normalize(direct.textContent || '').slice(0, 120) };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const fallbackButtons = Array.from(document.querySelectorAll('button')).filter((b) => {
|
|
118
|
+
if (!isVisible(b)) return false;
|
|
119
|
+
const cls = (b.className || '').toString().toLowerCase();
|
|
120
|
+
const aria = (b.getAttribute('aria-label') || '').toLowerCase();
|
|
121
|
+
const text = normalize(b.textContent || '').toLowerCase();
|
|
122
|
+
|
|
123
|
+
if (cls.includes('model-selector-card')) return true;
|
|
124
|
+
if (aria.includes('model') && text.includes('gemini')) return true;
|
|
125
|
+
return false;
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
if (fallbackButtons.length > 0) {
|
|
129
|
+
const target = fallbackButtons[0];
|
|
130
|
+
dispatchClickSequence(target);
|
|
131
|
+
return { success: true, method: 'fallback-model-button', currentModel: normalize(target.textContent || '').slice(0, 120) };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return { success: false, error: 'Model selector button not found' };
|
|
135
|
+
})()`);
|
|
136
|
+
|
|
137
|
+
if (!openSelector || !openSelector.success) {
|
|
138
|
+
log(`Model selector not found: ${openSelector?.error || 'unknown'}`);
|
|
139
|
+
return desiredModel;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
log(`Opened model selector via ${openSelector.method}: ${openSelector.currentModel || '(unknown)'}`);
|
|
143
|
+
|
|
144
|
+
const deadline = Date.now() + timeoutMs;
|
|
145
|
+
const targetToken = normalizedTargetModel.replace(/[^a-z0-9]/g, '');
|
|
146
|
+
|
|
147
|
+
while (Date.now() < deadline) {
|
|
148
|
+
const result = await evaluate(cdp, `(() => {
|
|
149
|
+
${buildClickDispatcher()}
|
|
150
|
+
|
|
151
|
+
const target = ${JSON.stringify(normalizedTargetModel)};
|
|
152
|
+
const targetToken = ${JSON.stringify(targetToken)};
|
|
153
|
+
const normalize = (text) => (text || '').replace(/\\s+/g, ' ').trim();
|
|
154
|
+
const isVisible = (el) => Boolean(el && (el.offsetParent !== null || el.getClientRects().length > 0));
|
|
155
|
+
const normalizeToken = (text) => (text || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
156
|
+
|
|
157
|
+
const candidates = Array.from(document.querySelectorAll(
|
|
158
|
+
'button.content-button, [role="dialog"] button, [role="option"], mat-option, mat-list-item, [role="menuitem"]'
|
|
159
|
+
))
|
|
160
|
+
.filter(isVisible)
|
|
161
|
+
.map((el) => {
|
|
162
|
+
const raw = normalize(el.textContent || '');
|
|
163
|
+
const lower = raw.toLowerCase();
|
|
164
|
+
return {
|
|
165
|
+
el,
|
|
166
|
+
raw,
|
|
167
|
+
lower,
|
|
168
|
+
token: normalizeToken(raw),
|
|
169
|
+
};
|
|
170
|
+
})
|
|
171
|
+
.filter((item) => {
|
|
172
|
+
return item.lower.includes('gemini') || item.lower.includes('nano banana') || item.lower.includes('-preview');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
if (candidates.length === 0) {
|
|
176
|
+
return { found: false, waiting: true };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const exact = candidates.find((item) => item.lower.includes(target));
|
|
180
|
+
if (exact) {
|
|
181
|
+
dispatchClickSequence(exact.el);
|
|
182
|
+
return { found: true, success: true, model: exact.raw.slice(0, 160), match: 'exact' };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const fuzzy = candidates.find((item) => item.token.includes(targetToken));
|
|
186
|
+
if (fuzzy) {
|
|
187
|
+
dispatchClickSequence(fuzzy.el);
|
|
188
|
+
return { found: true, success: true, model: fuzzy.raw.slice(0, 160), match: 'fuzzy' };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
found: true,
|
|
193
|
+
success: false,
|
|
194
|
+
models: candidates.slice(0, 8).map((item) => item.raw.slice(0, 80)),
|
|
195
|
+
};
|
|
196
|
+
})()`);
|
|
197
|
+
|
|
198
|
+
if (result && result.found) {
|
|
199
|
+
if (result.success) {
|
|
200
|
+
log(`Selected model (${result.match}): ${result.model}`);
|
|
201
|
+
await delay(300);
|
|
202
|
+
return result.model;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
log(`Model "${desiredModel}" not found in selector options: ${JSON.stringify(result.models || [])}`);
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
await delay(200);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
await evaluate(cdp, `(() => {
|
|
213
|
+
const esc = new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', bubbles: true });
|
|
214
|
+
document.dispatchEvent(esc);
|
|
215
|
+
})()`).catch(() => {});
|
|
216
|
+
|
|
217
|
+
return desiredModel;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
module.exports = {
|
|
221
|
+
readCurrentModelInfo,
|
|
222
|
+
waitForModelToApply,
|
|
223
|
+
closeModelSelectorIfOpen,
|
|
224
|
+
selectModel,
|
|
225
|
+
};
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
const AISTUDIO_URL = "https://aistudio.google.com/prompts/new_chat";
|
|
2
|
+
const GENERATE_CONTENT_URL_FRAGMENT =
|
|
3
|
+
"google.internal.alkali.applications.makersuite.v1.MakerSuiteService/GenerateContent";
|
|
4
|
+
|
|
5
|
+
function normalizeModelString(model) {
|
|
6
|
+
return String(model || "").trim().toLowerCase();
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function buildAiStudioUrl(model) {
|
|
10
|
+
const normalized = normalizeModelString(model);
|
|
11
|
+
if (!normalized) return AISTUDIO_URL;
|
|
12
|
+
|
|
13
|
+
// Only use the URL param when the caller passes a literal AI Studio model id.
|
|
14
|
+
// If the model id is wrong/unknown, AI Studio will fall back to the last-selected
|
|
15
|
+
// model in the UI, which is acceptable.
|
|
16
|
+
const looksLikeUrlModelId =
|
|
17
|
+
/^[a-z0-9.\-]+$/.test(normalized) && (normalized.includes("preview") || normalized.includes("latest"));
|
|
18
|
+
|
|
19
|
+
if (!looksLikeUrlModelId) return AISTUDIO_URL;
|
|
20
|
+
|
|
21
|
+
return `${AISTUDIO_URL}?model=${encodeURIComponent(normalized)}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function delay(ms) {
|
|
25
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function getNestedValue(value, pathParts, fallback) {
|
|
29
|
+
let current = value;
|
|
30
|
+
for (const part of pathParts) {
|
|
31
|
+
if (current == null) return fallback;
|
|
32
|
+
if (typeof part === 'number') {
|
|
33
|
+
if (!Array.isArray(current)) return fallback;
|
|
34
|
+
current = current[part];
|
|
35
|
+
} else {
|
|
36
|
+
if (typeof current !== 'object') return fallback;
|
|
37
|
+
current = current[part];
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return current ?? fallback;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function buildClickDispatcher() {
|
|
44
|
+
return `function dispatchClickSequence(target) {
|
|
45
|
+
if (!target || !(target instanceof EventTarget)) return false;
|
|
46
|
+
const types = ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click'];
|
|
47
|
+
for (const type of types) {
|
|
48
|
+
const common = { bubbles: true, cancelable: true, view: window };
|
|
49
|
+
let event;
|
|
50
|
+
if (type.startsWith('pointer') && 'PointerEvent' in window) {
|
|
51
|
+
event = new PointerEvent(type, { ...common, pointerId: 1, pointerType: 'mouse' });
|
|
52
|
+
} else {
|
|
53
|
+
event = new MouseEvent(type, common);
|
|
54
|
+
}
|
|
55
|
+
target.dispatchEvent(event);
|
|
56
|
+
}
|
|
57
|
+
return true;
|
|
58
|
+
}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function cleanAiStudioResponse(rawText, userPrompt = '') {
|
|
62
|
+
if (!rawText) return '';
|
|
63
|
+
|
|
64
|
+
// Lines that match exactly (full trimmed line, case-insensitive) are stripped
|
|
65
|
+
// These are AI Studio UI chrome artifacts that can leak into DOM text extraction
|
|
66
|
+
const bannedExact = new Set([
|
|
67
|
+
'user',
|
|
68
|
+
'model',
|
|
69
|
+
'info',
|
|
70
|
+
'warning',
|
|
71
|
+
'close',
|
|
72
|
+
'edit',
|
|
73
|
+
'more_vert',
|
|
74
|
+
'thumb_up',
|
|
75
|
+
'thumb_down',
|
|
76
|
+
'good response',
|
|
77
|
+
'bad response',
|
|
78
|
+
'rerun this turn',
|
|
79
|
+
'open options',
|
|
80
|
+
'running...',
|
|
81
|
+
|
|
82
|
+
// Code block UI chrome from AI Studio (can leak from rendered mode)
|
|
83
|
+
'code',
|
|
84
|
+
'download',
|
|
85
|
+
'content_copy',
|
|
86
|
+
'expand_less',
|
|
87
|
+
'expand_more',
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
const promptTrimmed = String(userPrompt || '').trim();
|
|
91
|
+
|
|
92
|
+
let lines = String(rawText).split(/\r?\n/);
|
|
93
|
+
|
|
94
|
+
// If the raw extraction includes both roles, keep only the last model segment
|
|
95
|
+
// Raw Mode commonly renders as:
|
|
96
|
+
// User
|
|
97
|
+
// <prompt>
|
|
98
|
+
// Model
|
|
99
|
+
// <response>
|
|
100
|
+
// plus occasional UI banners
|
|
101
|
+
const lastModelIdx = (() => {
|
|
102
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
103
|
+
if (String(lines[i] || '').trim().toLowerCase() === 'model') return i;
|
|
104
|
+
}
|
|
105
|
+
return -1;
|
|
106
|
+
})();
|
|
107
|
+
|
|
108
|
+
if (lastModelIdx !== -1 && lastModelIdx + 1 < lines.length) {
|
|
109
|
+
lines = lines.slice(lastModelIdx + 1);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let inCodeFence = false;
|
|
113
|
+
let previousWasBlank = false;
|
|
114
|
+
|
|
115
|
+
const cleanedLines = [];
|
|
116
|
+
|
|
117
|
+
for (const line of lines) {
|
|
118
|
+
const trimmed = line.trim();
|
|
119
|
+
const lower = trimmed.toLowerCase();
|
|
120
|
+
|
|
121
|
+
const isFenceLine = trimmed.startsWith('```');
|
|
122
|
+
|
|
123
|
+
// Fence lines: preserve exactly (minus trailing whitespace)
|
|
124
|
+
if (isFenceLine) {
|
|
125
|
+
inCodeFence = !inCodeFence;
|
|
126
|
+
cleanedLines.push(line.replace(/[\t ]+$/g, ''));
|
|
127
|
+
previousWasBlank = false;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Inside code fences: preserve indentation and blank lines
|
|
132
|
+
if (inCodeFence) {
|
|
133
|
+
cleanedLines.push(line.replace(/[\t ]+$/g, ''));
|
|
134
|
+
previousWasBlank = false;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Outside code: drop UI-only lines and prompt echo
|
|
139
|
+
if (trimmed.length === 0) {
|
|
140
|
+
if (!previousWasBlank) {
|
|
141
|
+
cleanedLines.push('');
|
|
142
|
+
previousWasBlank = true;
|
|
143
|
+
}
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (bannedExact.has(lower)) continue;
|
|
148
|
+
if (promptTrimmed && trimmed === promptTrimmed) continue;
|
|
149
|
+
|
|
150
|
+
// Common AI Studio footer/disclaimer
|
|
151
|
+
if (lower.includes('google ai models may make mistakes')) continue;
|
|
152
|
+
if (lower.includes('double-check outputs')) continue;
|
|
153
|
+
if (lower.startsWith('response ready')) continue;
|
|
154
|
+
|
|
155
|
+
// Drive enable prompt (AI Studio UI banner)
|
|
156
|
+
if (lower.includes('turn drive on for future conversations')) continue;
|
|
157
|
+
if (lower.includes('your work is currently not being saved')) continue;
|
|
158
|
+
if (lower.includes('enable google drive')) continue;
|
|
159
|
+
|
|
160
|
+
// Remove inline UI icon tokens, but only outside code
|
|
161
|
+
const withoutIcons = trimmed
|
|
162
|
+
.replace(/\bthumb_up\b/g, '')
|
|
163
|
+
.replace(/\bthumb_down\b/g, '')
|
|
164
|
+
.replace(/\bmore_vert\b/g, '')
|
|
165
|
+
.trim();
|
|
166
|
+
|
|
167
|
+
if (withoutIcons.length === 0) continue;
|
|
168
|
+
|
|
169
|
+
cleanedLines.push(withoutIcons);
|
|
170
|
+
previousWasBlank = false;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Trim leading/trailing blank lines
|
|
174
|
+
while (cleanedLines.length > 0 && cleanedLines[0].trim().length === 0) cleanedLines.shift();
|
|
175
|
+
while (cleanedLines.length > 0 && cleanedLines[cleanedLines.length - 1].trim().length === 0) cleanedLines.pop();
|
|
176
|
+
|
|
177
|
+
return cleanedLines.join('\n');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function hasRequiredCookies(cookies) {
|
|
181
|
+
if (!cookies || !Array.isArray(cookies)) return false;
|
|
182
|
+
const sid = cookies.find(c => c.name === "__Secure-1PSID" && c.value);
|
|
183
|
+
return Boolean(sid);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function extractModelKeywords(modelId) {
|
|
187
|
+
const normalized = normalizeModelString(modelId);
|
|
188
|
+
if (!normalized) return [];
|
|
189
|
+
|
|
190
|
+
const ignored = new Set(["gemini", "preview", "latest"]);
|
|
191
|
+
|
|
192
|
+
const tokens = normalized
|
|
193
|
+
.split("-")
|
|
194
|
+
.map(t => t.trim())
|
|
195
|
+
.filter(Boolean)
|
|
196
|
+
.filter(t => !ignored.has(t))
|
|
197
|
+
.filter(t => !/^\d+(?:\.\d+)?$/.test(t));
|
|
198
|
+
|
|
199
|
+
// Keep short-but-meaningful tokens like "pro"
|
|
200
|
+
const keywords = tokens.filter(t => t.length >= 3);
|
|
201
|
+
|
|
202
|
+
return Array.from(new Set(keywords));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function normalizeAiStudioRpcJson(rawText) {
|
|
206
|
+
let text = String(rawText || '').trim();
|
|
207
|
+
if (!text) return text;
|
|
208
|
+
|
|
209
|
+
// Strip Google's common XSSI prefix
|
|
210
|
+
// )]}'\n<json>
|
|
211
|
+
if (text.startsWith(")]}'")) {
|
|
212
|
+
const newlineIndex = text.indexOf('\n');
|
|
213
|
+
text = (newlineIndex === -1 ? '' : text.slice(newlineIndex + 1)).trim();
|
|
214
|
+
if (!text) return text;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Some RPC errors are returned in JS-ish array form with a leading elision:
|
|
218
|
+
// [,[7,"The caller does not have permission"]]
|
|
219
|
+
// Normalize this into valid JSON before parsing
|
|
220
|
+
if (text.startsWith('[,')) {
|
|
221
|
+
return `[null${text.slice(1)}`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return text;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function parseAiStudioRpcError(rawText) {
|
|
228
|
+
const normalized = normalizeAiStudioRpcJson(rawText);
|
|
229
|
+
if (!normalized) return null;
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
const parsed = JSON.parse(normalized);
|
|
233
|
+
const code = getNestedValue(parsed, [1, 0], null);
|
|
234
|
+
const message = getNestedValue(parsed, [1, 1], null);
|
|
235
|
+
|
|
236
|
+
if (typeof message === 'string' && message.trim()) {
|
|
237
|
+
return {
|
|
238
|
+
code: typeof code === 'number' ? code : undefined,
|
|
239
|
+
message: message.trim(),
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
} catch {
|
|
243
|
+
// ignore parse failures
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function isThinkingModelChunk(chunk) {
|
|
250
|
+
if (!Array.isArray(chunk)) return false;
|
|
251
|
+
|
|
252
|
+
// Observed structure for thinking chunks:
|
|
253
|
+
// [null, "<thinking>", ..., 1]
|
|
254
|
+
if (chunk.length >= 16 && chunk[15] === 1) return true;
|
|
255
|
+
|
|
256
|
+
const last = chunk[chunk.length - 1];
|
|
257
|
+
return chunk.length > 2 && last === 1;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function collectModelTextSegments(node, out) {
|
|
261
|
+
if (!Array.isArray(node)) return;
|
|
262
|
+
|
|
263
|
+
// Stream chunk patterns seen in GenerateContent response payload:
|
|
264
|
+
// [ [[null, "<chunk>"]], "model" ]
|
|
265
|
+
// [ [[[null, "<chunk>"]]], "model" ]
|
|
266
|
+
if (node.length >= 2 && node[1] === 'model') {
|
|
267
|
+
const payloadLevel2 = getNestedValue(node, [0, 0], null);
|
|
268
|
+
const payloadLevel3 = getNestedValue(node, [0, 0, 0], null);
|
|
269
|
+
|
|
270
|
+
const segment = typeof payloadLevel2?.[1] === 'string'
|
|
271
|
+
? payloadLevel2[1]
|
|
272
|
+
: typeof payloadLevel3?.[1] === 'string'
|
|
273
|
+
? payloadLevel3[1]
|
|
274
|
+
: null;
|
|
275
|
+
|
|
276
|
+
if (typeof segment === 'string' && segment.length > 0) {
|
|
277
|
+
out.push({
|
|
278
|
+
text: segment,
|
|
279
|
+
thinking: isThinkingModelChunk(payloadLevel2) || isThinkingModelChunk(payloadLevel3),
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
for (const child of node) {
|
|
287
|
+
if (Array.isArray(child)) {
|
|
288
|
+
collectModelTextSegments(child, out);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function extractFinalResponseText(value) {
|
|
294
|
+
const text = String(value || '').trim();
|
|
295
|
+
if (!text) return '';
|
|
296
|
+
|
|
297
|
+
const lines = text.split(/\r?\n/);
|
|
298
|
+
const headingIndex = lines.findIndex((line, index) => index > 0 && /^#{1,6}\s+/.test(String(line || '').trim()));
|
|
299
|
+
|
|
300
|
+
if (headingIndex <= 0) {
|
|
301
|
+
return text;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const preambleText = lines.slice(0, headingIndex).join('\n').trim();
|
|
305
|
+
const finalText = lines.slice(headingIndex).join('\n').trim();
|
|
306
|
+
|
|
307
|
+
if (!preambleText || !finalText) {
|
|
308
|
+
return text;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const lower = preambleText.toLowerCase();
|
|
312
|
+
// Heuristic can false-positive on conversational preambles when a heading follows.
|
|
313
|
+
const looksLikeThinking =
|
|
314
|
+
lower.includes('considering') ||
|
|
315
|
+
lower.includes('focusing') ||
|
|
316
|
+
lower.includes('reasoning') ||
|
|
317
|
+
lower.includes("i'm") ||
|
|
318
|
+
lower.includes('i am');
|
|
319
|
+
|
|
320
|
+
return looksLikeThinking ? finalText : text;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function parseAiStudioGenerateContentText(rawText) {
|
|
324
|
+
const normalized = normalizeAiStudioRpcJson(rawText);
|
|
325
|
+
if (!normalized) return '';
|
|
326
|
+
|
|
327
|
+
let parsed;
|
|
328
|
+
try {
|
|
329
|
+
parsed = JSON.parse(normalized);
|
|
330
|
+
} catch (e) {
|
|
331
|
+
throw new Error(`Invalid GenerateContent JSON (${normalized.length} chars): ${e.message}`);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const segments = [];
|
|
335
|
+
collectModelTextSegments(parsed, segments);
|
|
336
|
+
|
|
337
|
+
const finalText = segments
|
|
338
|
+
.filter((segment) => !segment.thinking)
|
|
339
|
+
.map((segment) => segment.text)
|
|
340
|
+
.join('')
|
|
341
|
+
.trim();
|
|
342
|
+
|
|
343
|
+
if (finalText) {
|
|
344
|
+
return extractFinalResponseText(finalText);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const combinedText = segments
|
|
348
|
+
.map((segment) => segment.text)
|
|
349
|
+
.join('')
|
|
350
|
+
.trim();
|
|
351
|
+
|
|
352
|
+
return extractFinalResponseText(combinedText);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function extractGenerateEntries(entries) {
|
|
356
|
+
if (!Array.isArray(entries)) return [];
|
|
357
|
+
|
|
358
|
+
return entries
|
|
359
|
+
.filter((entry) => {
|
|
360
|
+
const url = String(entry?.url || '');
|
|
361
|
+
return entry && typeof entry === 'object' && url.includes(GENERATE_CONTENT_URL_FRAGMENT);
|
|
362
|
+
})
|
|
363
|
+
.sort((a, b) => (a.ts || 0) - (b.ts || 0));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function extractLastUserPromptFromGenerateRequestBody(rawBody) {
|
|
367
|
+
if (!rawBody || typeof rawBody !== 'string') return null;
|
|
368
|
+
|
|
369
|
+
try {
|
|
370
|
+
const parsed = JSON.parse(rawBody);
|
|
371
|
+
const turns = Array.isArray(parsed?.[1]) ? parsed[1] : [];
|
|
372
|
+
|
|
373
|
+
for (let i = turns.length - 1; i >= 0; i--) {
|
|
374
|
+
const turn = turns[i];
|
|
375
|
+
if (!Array.isArray(turn) || turn[1] !== 'user') continue;
|
|
376
|
+
|
|
377
|
+
const prompt = getNestedValue(turn, [0, 0, 1], null);
|
|
378
|
+
if (typeof prompt === 'string' && prompt.trim()) {
|
|
379
|
+
return prompt.trim();
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
} catch {
|
|
383
|
+
// ignore parse failures
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function doesGenerateEntryMatchPrompt(entry, expectedPrompt) {
|
|
390
|
+
const expected = String(expectedPrompt || '').trim();
|
|
391
|
+
if (!expected) return true;
|
|
392
|
+
|
|
393
|
+
const requestBody = typeof entry?.requestBody === 'string' ? entry.requestBody : '';
|
|
394
|
+
if (!requestBody) return false;
|
|
395
|
+
|
|
396
|
+
const extractedPrompt = extractLastUserPromptFromGenerateRequestBody(requestBody);
|
|
397
|
+
if (extractedPrompt) {
|
|
398
|
+
return extractedPrompt === expected;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Fallback heuristic if request body parsing fails
|
|
402
|
+
const probe = expected.slice(0, 120);
|
|
403
|
+
return probe.length > 0 && requestBody.includes(probe);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
module.exports = {
|
|
407
|
+
normalizeModelString,
|
|
408
|
+
buildAiStudioUrl,
|
|
409
|
+
getNestedValue,
|
|
410
|
+
delay,
|
|
411
|
+
buildClickDispatcher,
|
|
412
|
+
cleanAiStudioResponse,
|
|
413
|
+
hasRequiredCookies,
|
|
414
|
+
extractModelKeywords,
|
|
415
|
+
normalizeAiStudioRpcJson,
|
|
416
|
+
parseAiStudioRpcError,
|
|
417
|
+
isThinkingModelChunk,
|
|
418
|
+
collectModelTextSegments,
|
|
419
|
+
extractFinalResponseText,
|
|
420
|
+
parseAiStudioGenerateContentText,
|
|
421
|
+
extractGenerateEntries,
|
|
422
|
+
extractLastUserPromptFromGenerateRequestBody,
|
|
423
|
+
doesGenerateEntryMatchPrompt,
|
|
424
|
+
};
|