surf-cli 2.0.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/LICENSE +21 -0
- package/README.md +426 -0
- package/dist/content/accessibility-tree.js +11 -0
- package/dist/content/accessibility-tree.js.map +1 -0
- package/dist/content/visual-indicator.js +111 -0
- package/dist/content/visual-indicator.js.map +1 -0
- package/dist/icons/icon-128.png +0 -0
- package/dist/icons/icon-16.png +0 -0
- package/dist/icons/icon-48.png +0 -0
- package/dist/manifest.json +70 -0
- package/dist/options/options.html +30 -0
- package/dist/options/options.js +30 -0
- package/dist/options/options.js.map +1 -0
- package/dist/service-worker/index.js +156 -0
- package/dist/service-worker/index.js.map +1 -0
- package/dist/service-worker-loader.js +1 -0
- package/native/CHANGELOG.md +136 -0
- package/native/README.md +141 -0
- package/native/chatgpt-client.cjs +455 -0
- package/native/cli.cjs +2424 -0
- package/native/config.cjs +87 -0
- package/native/device-presets.cjs +211 -0
- package/native/formatters/network.cjs +402 -0
- package/native/gemini-client.cjs +637 -0
- package/native/host-helpers.cjs +989 -0
- package/native/host-wrapper.py +15 -0
- package/native/host.cjs +1271 -0
- package/native/host.sh +2 -0
- package/native/mcp-server.cjs +511 -0
- package/native/network-store.cjs +851 -0
- package/native/perplexity-client.cjs +561 -0
- package/native/protocol.cjs +27 -0
- package/native/test-host.py +41 -0
- package/native/tests/cli-tests.sh +115 -0
- package/package.json +70 -0
- package/scripts/install-native-host.cjs +308 -0
- package/scripts/uninstall-native-host.cjs +194 -0
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gemini Web Client for surf-cli
|
|
3
|
+
*
|
|
4
|
+
* Cookie-based client for gemini.google.com (no API key required).
|
|
5
|
+
* Adapted from Oracle's gemini-web module.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const https = require("https");
|
|
9
|
+
const fs = require("fs");
|
|
10
|
+
const path = require("path");
|
|
11
|
+
|
|
12
|
+
// ============================================================================
|
|
13
|
+
// Constants
|
|
14
|
+
// ============================================================================
|
|
15
|
+
|
|
16
|
+
const GEMINI_APP_URL = "https://gemini.google.com/app";
|
|
17
|
+
const GEMINI_STREAM_GENERATE_URL = "https://gemini.google.com/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate";
|
|
18
|
+
const GEMINI_UPLOAD_URL = "https://content-push.googleapis.com/upload";
|
|
19
|
+
const GEMINI_UPLOAD_PUSH_ID = "feeds/mcudyrk2a4khkz";
|
|
20
|
+
|
|
21
|
+
const USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
|
22
|
+
|
|
23
|
+
const MODEL_HEADER_NAME = "x-goog-ext-525001261-jspb";
|
|
24
|
+
const MODEL_HEADERS = {
|
|
25
|
+
"gemini-3-pro": '[1,null,null,null,"9d8ca3786ebdfbea",null,null,0,[4]]',
|
|
26
|
+
"gemini-2.5-pro": '[1,null,null,null,"4af6c7f5da75d65d",null,null,0,[4]]',
|
|
27
|
+
"gemini-2.5-flash": '[1,null,null,null,"9ec249fc9ad08861",null,null,0,[4]]',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const REQUIRED_COOKIES = ["__Secure-1PSID", "__Secure-1PSIDTS"];
|
|
31
|
+
|
|
32
|
+
const ALL_COOKIE_NAMES = [
|
|
33
|
+
"__Secure-1PSID",
|
|
34
|
+
"__Secure-1PSIDTS",
|
|
35
|
+
"__Secure-1PSIDCC",
|
|
36
|
+
"__Secure-1PAPISID",
|
|
37
|
+
"NID",
|
|
38
|
+
"AEC",
|
|
39
|
+
"SOCS",
|
|
40
|
+
"__Secure-BUCKET",
|
|
41
|
+
"__Secure-ENID",
|
|
42
|
+
"SID",
|
|
43
|
+
"HSID",
|
|
44
|
+
"SSID",
|
|
45
|
+
"APISID",
|
|
46
|
+
"SAPISID",
|
|
47
|
+
"__Secure-3PSID",
|
|
48
|
+
"__Secure-3PSIDTS",
|
|
49
|
+
"__Secure-3PAPISID",
|
|
50
|
+
"SIDCC",
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
// ============================================================================
|
|
54
|
+
// Utility Functions
|
|
55
|
+
// ============================================================================
|
|
56
|
+
|
|
57
|
+
function getNestedValue(value, pathParts, fallback) {
|
|
58
|
+
let current = value;
|
|
59
|
+
for (const part of pathParts) {
|
|
60
|
+
if (current == null) return fallback;
|
|
61
|
+
if (typeof part === "number") {
|
|
62
|
+
if (!Array.isArray(current)) return fallback;
|
|
63
|
+
current = current[part];
|
|
64
|
+
} else {
|
|
65
|
+
if (typeof current !== "object") return fallback;
|
|
66
|
+
current = current[part];
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return current ?? fallback;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function buildCookieHeader(cookieMap) {
|
|
73
|
+
return Object.entries(cookieMap)
|
|
74
|
+
.filter(([, value]) => typeof value === "string" && value.length > 0)
|
|
75
|
+
.map(([name, value]) => `${name}=${value}`)
|
|
76
|
+
.join("; ");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function buildCookieMap(cookies) {
|
|
80
|
+
const cookieMap = {};
|
|
81
|
+
for (const name of ALL_COOKIE_NAMES) {
|
|
82
|
+
const cookie = cookies.find(c => c.name === name && c.value);
|
|
83
|
+
if (cookie) {
|
|
84
|
+
cookieMap[name] = cookie.value;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return cookieMap;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function hasRequiredCookies(cookieMap) {
|
|
91
|
+
return REQUIRED_COOKIES.every(name => Boolean(cookieMap[name]));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function delay(ms) {
|
|
95
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ============================================================================
|
|
99
|
+
// HTTP Helpers
|
|
100
|
+
// ============================================================================
|
|
101
|
+
|
|
102
|
+
function httpsGet(url, headers, binary = false) {
|
|
103
|
+
return new Promise((resolve, reject) => {
|
|
104
|
+
const urlObj = new URL(url);
|
|
105
|
+
const options = {
|
|
106
|
+
hostname: urlObj.hostname,
|
|
107
|
+
port: 443,
|
|
108
|
+
path: urlObj.pathname + urlObj.search,
|
|
109
|
+
method: "GET",
|
|
110
|
+
headers: {
|
|
111
|
+
"user-agent": USER_AGENT,
|
|
112
|
+
...headers,
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const req = https.request(options, (res) => {
|
|
117
|
+
const chunks = [];
|
|
118
|
+
res.on("data", chunk => chunks.push(chunk));
|
|
119
|
+
res.on("end", () => {
|
|
120
|
+
const buffer = Buffer.concat(chunks);
|
|
121
|
+
resolve({
|
|
122
|
+
status: res.statusCode,
|
|
123
|
+
headers: res.headers,
|
|
124
|
+
text: binary ? null : buffer.toString("utf-8"),
|
|
125
|
+
buffer: binary ? buffer : null,
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
req.on("error", reject);
|
|
131
|
+
req.end();
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function httpsPost(url, headers, body) {
|
|
136
|
+
return new Promise((resolve, reject) => {
|
|
137
|
+
const urlObj = new URL(url);
|
|
138
|
+
const options = {
|
|
139
|
+
hostname: urlObj.hostname,
|
|
140
|
+
port: 443,
|
|
141
|
+
path: urlObj.pathname + urlObj.search,
|
|
142
|
+
method: "POST",
|
|
143
|
+
headers: {
|
|
144
|
+
"user-agent": USER_AGENT,
|
|
145
|
+
...headers,
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const req = https.request(options, (res) => {
|
|
150
|
+
let data = "";
|
|
151
|
+
res.on("data", chunk => data += chunk);
|
|
152
|
+
res.on("end", () => resolve({ status: res.statusCode, headers: res.headers, text: data }));
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
req.on("error", reject);
|
|
156
|
+
if (body) req.write(body);
|
|
157
|
+
req.end();
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function fetchWithRedirects(url, headers, maxRedirects = 10, binary = false) {
|
|
162
|
+
let current = url;
|
|
163
|
+
for (let i = 0; i <= maxRedirects; i++) {
|
|
164
|
+
const res = await httpsGet(current, headers, binary);
|
|
165
|
+
if (res.status >= 300 && res.status < 400 && res.headers.location) {
|
|
166
|
+
current = new URL(res.headers.location, current).toString();
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
return res;
|
|
170
|
+
}
|
|
171
|
+
throw new Error(`Too many redirects (>${maxRedirects})`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ============================================================================
|
|
175
|
+
// Gemini API Functions
|
|
176
|
+
// ============================================================================
|
|
177
|
+
|
|
178
|
+
async function fetchGeminiAccessToken(cookieMap) {
|
|
179
|
+
const cookieHeader = buildCookieHeader(cookieMap);
|
|
180
|
+
const res = await fetchWithRedirects(GEMINI_APP_URL, { cookie: cookieHeader });
|
|
181
|
+
const html = res.text;
|
|
182
|
+
|
|
183
|
+
const tokens = ["SNlM0e", "thykhd"];
|
|
184
|
+
for (const key of tokens) {
|
|
185
|
+
const match = html.match(new RegExp(`"${key}":"(.*?)"`));
|
|
186
|
+
if (match?.[1]) return match[1];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
throw new Error("Unable to authenticate with Gemini. Make sure you're signed into gemini.google.com in Chrome.");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function trimGeminiJsonEnvelope(text) {
|
|
193
|
+
const start = text.indexOf("[");
|
|
194
|
+
const end = text.lastIndexOf("]");
|
|
195
|
+
if (start === -1 || end === -1 || end <= start) {
|
|
196
|
+
throw new Error("Gemini response did not contain a JSON payload.");
|
|
197
|
+
}
|
|
198
|
+
return text.slice(start, end + 1);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function extractErrorCode(responseJson) {
|
|
202
|
+
const code = getNestedValue(responseJson, [0, 5, 2, 0, 1, 0], -1);
|
|
203
|
+
return typeof code === "number" && code >= 0 ? code : undefined;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function isModelUnavailable(errorCode) {
|
|
207
|
+
return errorCode === 1052;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function extractGgdlUrls(rawText) {
|
|
211
|
+
const matches = rawText.match(/https:\/\/lh3\.googleusercontent\.com\/gg-dl\/[^\s"']+/g) ?? [];
|
|
212
|
+
const seen = new Set();
|
|
213
|
+
const urls = [];
|
|
214
|
+
for (const match of matches) {
|
|
215
|
+
if (seen.has(match)) continue;
|
|
216
|
+
seen.add(match);
|
|
217
|
+
urls.push(match);
|
|
218
|
+
}
|
|
219
|
+
return urls;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function ensureFullSizeImageUrl(url) {
|
|
223
|
+
if (url.includes("=s2048")) return url;
|
|
224
|
+
if (url.includes("=s")) return url;
|
|
225
|
+
return `${url}=s2048`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function parseGeminiStreamGenerateResponse(rawText) {
|
|
229
|
+
const responseJson = JSON.parse(trimGeminiJsonEnvelope(rawText));
|
|
230
|
+
const errorCode = extractErrorCode(responseJson);
|
|
231
|
+
|
|
232
|
+
const parts = Array.isArray(responseJson) ? responseJson : [];
|
|
233
|
+
let bodyIndex = 0;
|
|
234
|
+
let body = null;
|
|
235
|
+
|
|
236
|
+
for (let i = 0; i < parts.length; i++) {
|
|
237
|
+
const partBody = getNestedValue(parts[i], [2], null);
|
|
238
|
+
if (!partBody) continue;
|
|
239
|
+
try {
|
|
240
|
+
const parsed = JSON.parse(partBody);
|
|
241
|
+
const candidateList = getNestedValue(parsed, [4], []);
|
|
242
|
+
if (Array.isArray(candidateList) && candidateList.length > 0) {
|
|
243
|
+
bodyIndex = i;
|
|
244
|
+
body = parsed;
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
} catch {
|
|
248
|
+
// ignore
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const candidateList = getNestedValue(body, [4], []);
|
|
253
|
+
const firstCandidate = candidateList[0];
|
|
254
|
+
const textRaw = getNestedValue(firstCandidate, [1, 0], "");
|
|
255
|
+
const cardContent = /^http:\/\/googleusercontent\.com\/card_content\/\d+/.test(textRaw);
|
|
256
|
+
const text = cardContent
|
|
257
|
+
? (getNestedValue(firstCandidate, [22, 0], null) ?? textRaw)
|
|
258
|
+
: textRaw;
|
|
259
|
+
const thoughts = getNestedValue(firstCandidate, [37, 0, 0], null);
|
|
260
|
+
const metadata = getNestedValue(body, [1], []);
|
|
261
|
+
|
|
262
|
+
const images = [];
|
|
263
|
+
|
|
264
|
+
// Web images
|
|
265
|
+
const webImages = getNestedValue(firstCandidate, [12, 1], []);
|
|
266
|
+
for (const webImage of webImages) {
|
|
267
|
+
const url = getNestedValue(webImage, [0, 0, 0], null);
|
|
268
|
+
if (!url) continue;
|
|
269
|
+
images.push({
|
|
270
|
+
kind: "web",
|
|
271
|
+
url,
|
|
272
|
+
title: getNestedValue(webImage, [7, 0], undefined),
|
|
273
|
+
alt: getNestedValue(webImage, [0, 4], undefined),
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Generated images
|
|
278
|
+
const hasGenerated = Boolean(getNestedValue(firstCandidate, [12, 7, 0], null));
|
|
279
|
+
if (hasGenerated) {
|
|
280
|
+
let imgBody = null;
|
|
281
|
+
for (let i = bodyIndex; i < parts.length; i++) {
|
|
282
|
+
const partBody = getNestedValue(parts[i], [2], null);
|
|
283
|
+
if (!partBody) continue;
|
|
284
|
+
try {
|
|
285
|
+
const parsed = JSON.parse(partBody);
|
|
286
|
+
const candidateImages = getNestedValue(parsed, [4, 0, 12, 7, 0], null);
|
|
287
|
+
if (candidateImages != null) {
|
|
288
|
+
imgBody = parsed;
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
} catch {
|
|
292
|
+
// ignore
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const imgCandidate = getNestedValue(imgBody ?? body, [4, 0], null);
|
|
297
|
+
const generated = getNestedValue(imgCandidate, [12, 7, 0], []);
|
|
298
|
+
for (const genImage of generated) {
|
|
299
|
+
const url = getNestedValue(genImage, [0, 3, 3], null);
|
|
300
|
+
if (!url) continue;
|
|
301
|
+
images.push({
|
|
302
|
+
kind: "generated",
|
|
303
|
+
url,
|
|
304
|
+
title: "[Generated Image]",
|
|
305
|
+
alt: "",
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return { metadata, text, thoughts, images, errorCode };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ============================================================================
|
|
314
|
+
// File Upload
|
|
315
|
+
// ============================================================================
|
|
316
|
+
|
|
317
|
+
async function uploadGeminiFile(filePath) {
|
|
318
|
+
const absPath = path.resolve(process.cwd(), filePath);
|
|
319
|
+
const data = fs.readFileSync(absPath);
|
|
320
|
+
const fileName = path.basename(absPath);
|
|
321
|
+
|
|
322
|
+
// Build multipart form data manually
|
|
323
|
+
const boundary = "----FormBoundary" + Math.random().toString(36).slice(2);
|
|
324
|
+
const header = `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${fileName}"\r\nContent-Type: application/octet-stream\r\n\r\n`;
|
|
325
|
+
const footer = `\r\n--${boundary}--\r\n`;
|
|
326
|
+
|
|
327
|
+
const body = Buffer.concat([
|
|
328
|
+
Buffer.from(header, "utf-8"),
|
|
329
|
+
data,
|
|
330
|
+
Buffer.from(footer, "utf-8"),
|
|
331
|
+
]);
|
|
332
|
+
|
|
333
|
+
const res = await httpsPost(GEMINI_UPLOAD_URL, {
|
|
334
|
+
"content-type": `multipart/form-data; boundary=${boundary}`,
|
|
335
|
+
"push-id": GEMINI_UPLOAD_PUSH_ID,
|
|
336
|
+
}, body);
|
|
337
|
+
|
|
338
|
+
if (res.status < 200 || res.status >= 300) {
|
|
339
|
+
throw new Error(`File upload failed: ${res.status} (${res.text.slice(0, 200)})`);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return { id: res.text, name: fileName };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ============================================================================
|
|
346
|
+
// Image Download
|
|
347
|
+
// ============================================================================
|
|
348
|
+
|
|
349
|
+
async function downloadGeminiImage(url, cookieMap, outputPath) {
|
|
350
|
+
const cookieHeader = buildCookieHeader(cookieMap);
|
|
351
|
+
const fullUrl = ensureFullSizeImageUrl(url);
|
|
352
|
+
|
|
353
|
+
// Use binary mode for image download
|
|
354
|
+
const res = await fetchWithRedirects(fullUrl, { cookie: cookieHeader }, 10, true);
|
|
355
|
+
|
|
356
|
+
if (res.status < 200 || res.status >= 300) {
|
|
357
|
+
throw new Error(`Failed to download image: ${res.status}`);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const dir = path.dirname(outputPath);
|
|
361
|
+
if (dir && !fs.existsSync(dir)) {
|
|
362
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Write binary buffer directly
|
|
366
|
+
fs.writeFileSync(outputPath, res.buffer);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function saveFirstGeminiImage(output, cookieMap, outputPath) {
|
|
370
|
+
// Try generated or web images first
|
|
371
|
+
const genOrWeb = output.images.find(img => img.kind === "generated") ?? output.images[0];
|
|
372
|
+
if (genOrWeb?.url) {
|
|
373
|
+
await downloadGeminiImage(genOrWeb.url, cookieMap, outputPath);
|
|
374
|
+
return { saved: true, imageCount: output.images.length };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Fall back to gg-dl URLs in raw response
|
|
378
|
+
const ggdl = extractGgdlUrls(output.rawResponseText);
|
|
379
|
+
if (ggdl[0]) {
|
|
380
|
+
await downloadGeminiImage(ggdl[0], cookieMap, outputPath);
|
|
381
|
+
return { saved: true, imageCount: ggdl.length };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return { saved: false, imageCount: 0 };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ============================================================================
|
|
388
|
+
// Core Request Function
|
|
389
|
+
// ============================================================================
|
|
390
|
+
|
|
391
|
+
function buildGeminiFReqPayload(prompt, uploaded, chatMetadata) {
|
|
392
|
+
const promptPayload = uploaded.length > 0
|
|
393
|
+
? [prompt, 0, null, uploaded.map(file => [[file.id, 1]])]
|
|
394
|
+
: [prompt];
|
|
395
|
+
|
|
396
|
+
const innerList = [promptPayload, null, chatMetadata ?? null];
|
|
397
|
+
return JSON.stringify([null, JSON.stringify(innerList)]);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function runGeminiWebOnce(input) {
|
|
401
|
+
const { prompt, files, model, cookieMap, chatMetadata } = input;
|
|
402
|
+
const cookieHeader = buildCookieHeader(cookieMap);
|
|
403
|
+
|
|
404
|
+
// 1. Get access token
|
|
405
|
+
const at = await fetchGeminiAccessToken(cookieMap);
|
|
406
|
+
|
|
407
|
+
// 2. Upload files
|
|
408
|
+
const uploaded = [];
|
|
409
|
+
for (const file of files ?? []) {
|
|
410
|
+
uploaded.push(await uploadGeminiFile(file));
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// 3. Build request
|
|
414
|
+
const fReq = buildGeminiFReqPayload(prompt, uploaded, chatMetadata);
|
|
415
|
+
const params = new URLSearchParams();
|
|
416
|
+
params.set("at", at);
|
|
417
|
+
params.set("f.req", fReq);
|
|
418
|
+
|
|
419
|
+
// 4. Send request
|
|
420
|
+
const res = await httpsPost(GEMINI_STREAM_GENERATE_URL, {
|
|
421
|
+
"content-type": "application/x-www-form-urlencoded;charset=utf-8",
|
|
422
|
+
"origin": "https://gemini.google.com",
|
|
423
|
+
"referer": "https://gemini.google.com/",
|
|
424
|
+
"x-same-domain": "1",
|
|
425
|
+
"cookie": cookieHeader,
|
|
426
|
+
[MODEL_HEADER_NAME]: MODEL_HEADERS[model] || MODEL_HEADERS["gemini-3-pro"],
|
|
427
|
+
}, params.toString());
|
|
428
|
+
|
|
429
|
+
const rawResponseText = res.text;
|
|
430
|
+
|
|
431
|
+
if (res.status < 200 || res.status >= 300) {
|
|
432
|
+
return {
|
|
433
|
+
rawResponseText,
|
|
434
|
+
text: "",
|
|
435
|
+
thoughts: null,
|
|
436
|
+
metadata: chatMetadata ?? null,
|
|
437
|
+
images: [],
|
|
438
|
+
errorMessage: `Gemini request failed: ${res.status}`,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
try {
|
|
443
|
+
const parsed = parseGeminiStreamGenerateResponse(rawResponseText);
|
|
444
|
+
return {
|
|
445
|
+
rawResponseText,
|
|
446
|
+
text: parsed.text ?? "",
|
|
447
|
+
thoughts: parsed.thoughts,
|
|
448
|
+
metadata: parsed.metadata,
|
|
449
|
+
images: parsed.images,
|
|
450
|
+
errorCode: parsed.errorCode,
|
|
451
|
+
};
|
|
452
|
+
} catch (error) {
|
|
453
|
+
let responseJson = null;
|
|
454
|
+
try {
|
|
455
|
+
responseJson = JSON.parse(trimGeminiJsonEnvelope(rawResponseText));
|
|
456
|
+
} catch {
|
|
457
|
+
responseJson = null;
|
|
458
|
+
}
|
|
459
|
+
const errorCode = extractErrorCode(responseJson);
|
|
460
|
+
|
|
461
|
+
return {
|
|
462
|
+
rawResponseText,
|
|
463
|
+
text: "",
|
|
464
|
+
thoughts: null,
|
|
465
|
+
metadata: chatMetadata ?? null,
|
|
466
|
+
images: [],
|
|
467
|
+
errorCode: typeof errorCode === "number" ? errorCode : undefined,
|
|
468
|
+
errorMessage: error instanceof Error ? error.message : String(error ?? ""),
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
async function runGeminiWebWithFallback(input) {
|
|
474
|
+
const attempt = await runGeminiWebOnce(input);
|
|
475
|
+
|
|
476
|
+
// Auto-fallback to flash if model unavailable
|
|
477
|
+
if (isModelUnavailable(attempt.errorCode) && input.model !== "gemini-2.5-flash") {
|
|
478
|
+
const fallback = await runGeminiWebOnce({ ...input, model: "gemini-2.5-flash" });
|
|
479
|
+
return { ...fallback, effectiveModel: "gemini-2.5-flash" };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return { ...attempt, effectiveModel: input.model };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// ============================================================================
|
|
486
|
+
// Main Query Function
|
|
487
|
+
// ============================================================================
|
|
488
|
+
|
|
489
|
+
async function query(options) {
|
|
490
|
+
const {
|
|
491
|
+
prompt,
|
|
492
|
+
model = "gemini-3-pro",
|
|
493
|
+
file,
|
|
494
|
+
generateImage,
|
|
495
|
+
editImage,
|
|
496
|
+
output,
|
|
497
|
+
youtube,
|
|
498
|
+
aspectRatio,
|
|
499
|
+
timeout = 300000,
|
|
500
|
+
getCookies,
|
|
501
|
+
log = () => {},
|
|
502
|
+
} = options;
|
|
503
|
+
|
|
504
|
+
const startTime = Date.now();
|
|
505
|
+
log("Starting Gemini query");
|
|
506
|
+
|
|
507
|
+
// 1. Get cookies from Chrome
|
|
508
|
+
const cookieResponse = await getCookies();
|
|
509
|
+
const cookies = cookieResponse?.cookies;
|
|
510
|
+
if (!Array.isArray(cookies)) {
|
|
511
|
+
throw new Error("Failed to get cookies from Chrome. Make sure the extension is loaded and Chrome is running.");
|
|
512
|
+
}
|
|
513
|
+
const cookieMap = buildCookieMap(cookies);
|
|
514
|
+
|
|
515
|
+
if (!hasRequiredCookies(cookieMap)) {
|
|
516
|
+
throw new Error("Gemini login required. Sign into gemini.google.com in Chrome and try again.");
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
log(`Got ${Object.keys(cookieMap).length} Gemini cookies`);
|
|
520
|
+
|
|
521
|
+
// 2. Resolve model
|
|
522
|
+
const resolvedModel = MODEL_HEADERS[model] ? model : "gemini-3-pro";
|
|
523
|
+
|
|
524
|
+
// 3. Build prompt
|
|
525
|
+
let fullPrompt = prompt || "";
|
|
526
|
+
if (aspectRatio && (generateImage || editImage)) {
|
|
527
|
+
fullPrompt = `${fullPrompt} (aspect ratio: ${aspectRatio})`;
|
|
528
|
+
}
|
|
529
|
+
if (youtube) {
|
|
530
|
+
fullPrompt = `${fullPrompt}\n\nYouTube video: ${youtube}`;
|
|
531
|
+
}
|
|
532
|
+
if (generateImage && !editImage) {
|
|
533
|
+
fullPrompt = `Generate an image: ${fullPrompt}`;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// 4. Collect files
|
|
537
|
+
const files = file ? [file] : [];
|
|
538
|
+
|
|
539
|
+
// 5. Execute request
|
|
540
|
+
let response;
|
|
541
|
+
let imagePath = null;
|
|
542
|
+
|
|
543
|
+
try {
|
|
544
|
+
if (editImage) {
|
|
545
|
+
// Two-turn conversation for image editing
|
|
546
|
+
log("Uploading image for editing...");
|
|
547
|
+
const intro = await runGeminiWebWithFallback({
|
|
548
|
+
prompt: "Here is an image to edit",
|
|
549
|
+
files: [editImage],
|
|
550
|
+
model: resolvedModel,
|
|
551
|
+
cookieMap,
|
|
552
|
+
chatMetadata: null,
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
log("Sending edit request...");
|
|
556
|
+
const editPrompt = `Use image generation tool to ${fullPrompt}`;
|
|
557
|
+
const out = await runGeminiWebWithFallback({
|
|
558
|
+
prompt: editPrompt,
|
|
559
|
+
files,
|
|
560
|
+
model: resolvedModel,
|
|
561
|
+
cookieMap,
|
|
562
|
+
chatMetadata: intro.metadata,
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
response = out;
|
|
566
|
+
|
|
567
|
+
// Save output image
|
|
568
|
+
const outputPath = output || generateImage || "edited.png";
|
|
569
|
+
const imageSave = await saveFirstGeminiImage(out, cookieMap, outputPath);
|
|
570
|
+
if (!imageSave.saved) {
|
|
571
|
+
throw new Error(`No images generated. Response: ${out.text?.slice(0, 200) || "(empty)"}`);
|
|
572
|
+
}
|
|
573
|
+
imagePath = outputPath;
|
|
574
|
+
|
|
575
|
+
} else if (generateImage) {
|
|
576
|
+
// Image generation
|
|
577
|
+
log("Generating image...");
|
|
578
|
+
const out = await runGeminiWebWithFallback({
|
|
579
|
+
prompt: fullPrompt,
|
|
580
|
+
files,
|
|
581
|
+
model: resolvedModel,
|
|
582
|
+
cookieMap,
|
|
583
|
+
chatMetadata: null,
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
response = out;
|
|
587
|
+
|
|
588
|
+
// Save output image
|
|
589
|
+
const imageSave = await saveFirstGeminiImage(out, cookieMap, generateImage);
|
|
590
|
+
if (!imageSave.saved) {
|
|
591
|
+
throw new Error(`No images generated. Response: ${out.text?.slice(0, 200) || "(empty)"}`);
|
|
592
|
+
}
|
|
593
|
+
imagePath = generateImage;
|
|
594
|
+
|
|
595
|
+
} else {
|
|
596
|
+
// Text query
|
|
597
|
+
log("Sending text query...");
|
|
598
|
+
const out = await runGeminiWebWithFallback({
|
|
599
|
+
prompt: fullPrompt,
|
|
600
|
+
files,
|
|
601
|
+
model: resolvedModel,
|
|
602
|
+
cookieMap,
|
|
603
|
+
chatMetadata: null,
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
response = out;
|
|
607
|
+
}
|
|
608
|
+
} catch (error) {
|
|
609
|
+
throw new Error(`Gemini request failed: ${error.message}`);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const tookMs = Date.now() - startTime;
|
|
613
|
+
log(`Completed in ${tookMs}ms`);
|
|
614
|
+
|
|
615
|
+
return {
|
|
616
|
+
response: response.text || "",
|
|
617
|
+
model: response.effectiveModel || resolvedModel,
|
|
618
|
+
tookMs,
|
|
619
|
+
imagePath,
|
|
620
|
+
thoughts: response.thoughts,
|
|
621
|
+
imageCount: response.images?.length || 0,
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// ============================================================================
|
|
626
|
+
// Exports
|
|
627
|
+
// ============================================================================
|
|
628
|
+
|
|
629
|
+
module.exports = {
|
|
630
|
+
query,
|
|
631
|
+
hasRequiredCookies,
|
|
632
|
+
buildCookieMap,
|
|
633
|
+
parseGeminiStreamGenerateResponse,
|
|
634
|
+
REQUIRED_COOKIES,
|
|
635
|
+
ALL_COOKIE_NAMES,
|
|
636
|
+
GEMINI_APP_URL,
|
|
637
|
+
};
|