Add gemini-web skill
This commit is contained in:
parent
aeaf645bc0
commit
fc544bfeb2
|
|
@ -0,0 +1,110 @@
|
|||
---
|
||||
name: gemini-web
|
||||
description: Interacts with Gemini Web to generate text and images. Use when the user needs AI-generated content via Gemini, including text responses and image generation.
|
||||
---
|
||||
|
||||
# Gemini Web Client
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
npx -y bun scripts/main.ts "Hello, Gemini"
|
||||
npx -y bun scripts/main.ts --prompt "Explain quantum computing"
|
||||
npx -y bun scripts/main.ts --prompt "A cute cat" --image cat.png
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### Text generation
|
||||
|
||||
```bash
|
||||
# Simple prompt (positional)
|
||||
npx -y bun scripts/main.ts "Your prompt here"
|
||||
|
||||
# Explicit prompt flag
|
||||
npx -y bun scripts/main.ts --prompt "Your prompt here"
|
||||
npx -y bun scripts/main.ts -p "Your prompt here"
|
||||
|
||||
# With model selection
|
||||
npx -y bun scripts/main.ts -p "Hello" -m gemini-2.5-pro
|
||||
|
||||
# Pipe from stdin
|
||||
echo "Summarize this" | npx -y bun scripts/main.ts
|
||||
```
|
||||
|
||||
### Image generation
|
||||
|
||||
```bash
|
||||
# Generate image with default path (./generated.png)
|
||||
npx -y bun scripts/main.ts --prompt "A sunset over mountains" --image
|
||||
|
||||
# Generate image with custom path
|
||||
npx -y bun scripts/main.ts --prompt "A cute robot" --image robot.png
|
||||
|
||||
# Shorthand
|
||||
npx -y bun scripts/main.ts "A dragon" --image=dragon.png
|
||||
```
|
||||
|
||||
### Output formats
|
||||
|
||||
```bash
|
||||
# Plain text (default)
|
||||
npx -y bun scripts/main.ts "Hello"
|
||||
|
||||
# JSON output
|
||||
npx -y bun scripts/main.ts "Hello" --json
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Short | Description |
|
||||
|--------|-------|-------------|
|
||||
| `--prompt <text>` | `-p` | Prompt text |
|
||||
| `--model <id>` | `-m` | Model: gemini-3-pro (default), gemini-2.5-pro, gemini-2.5-flash |
|
||||
| `--image [path]` | | Generate image, save to path (default: generated.png) |
|
||||
| `--json` | | Output as JSON |
|
||||
| `--login` | | Refresh cookies only, then exit |
|
||||
| `--cookie-path <path>` | | Custom cookie file path |
|
||||
| `--profile-dir <path>` | | Chrome profile directory |
|
||||
| `--help` | `-h` | Show help |
|
||||
|
||||
## Models
|
||||
|
||||
- `gemini-3-pro` - Default, latest model
|
||||
- `gemini-2.5-pro` - Previous generation pro
|
||||
- `gemini-2.5-flash` - Fast, lightweight
|
||||
|
||||
## Authentication
|
||||
|
||||
First run opens Chrome to authenticate with Google. Cookies are cached for subsequent runs.
|
||||
|
||||
```bash
|
||||
# Force cookie refresh
|
||||
npx -y bun scripts/main.ts --login
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `GEMINI_WEB_DATA_DIR` | Data directory |
|
||||
| `GEMINI_WEB_COOKIE_PATH` | Cookie file path |
|
||||
| `GEMINI_WEB_CHROME_PROFILE_DIR` | Chrome profile directory |
|
||||
| `GEMINI_WEB_CHROME_PATH` | Chrome executable path |
|
||||
|
||||
## Examples
|
||||
|
||||
### Generate text response
|
||||
```bash
|
||||
npx -y bun scripts/main.ts "What is the capital of France?"
|
||||
```
|
||||
|
||||
### Generate image
|
||||
```bash
|
||||
npx -y bun scripts/main.ts "A photorealistic image of a golden retriever puppy" --image puppy.png
|
||||
```
|
||||
|
||||
### Get JSON output for parsing
|
||||
```bash
|
||||
npx -y bun scripts/main.ts "Hello" --json | jq '.text'
|
||||
```
|
||||
|
|
@ -0,0 +1,338 @@
|
|||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import net from 'node:net';
|
||||
import process from 'node:process';
|
||||
|
||||
import { fetchGeminiAccessToken } from './client.js';
|
||||
import type { GeminiWebLog } from './cookie-store.js';
|
||||
import { buildGeminiCookieMap, hasRequiredGeminiCookies } from './cookie-store.js';
|
||||
import { resolveGeminiWebChromeProfileDir } from './paths.js';
|
||||
|
||||
const GEMINI_URL = 'https://gemini.google.com/app';
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function getFreePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.unref();
|
||||
server.on('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
server.close(() => reject(new Error('Unable to allocate a free TCP port for Chrome debugging.')));
|
||||
return;
|
||||
}
|
||||
const port = address.port;
|
||||
server.close((err) => {
|
||||
if (err) reject(err);
|
||||
else resolve(port);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function findChromeExecutable(): string | undefined {
|
||||
const override = process.env.GEMINI_WEB_CHROME_PATH?.trim();
|
||||
if (override && fs.existsSync(override)) return override;
|
||||
|
||||
const candidates: string[] = [];
|
||||
switch (process.platform) {
|
||||
case 'darwin':
|
||||
candidates.push(
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||
'/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
||||
);
|
||||
break;
|
||||
case 'win32':
|
||||
candidates.push(
|
||||
'C:\\\\Program Files\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe',
|
||||
'C:\\\\Program Files (x86)\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe',
|
||||
'C:\\\\Program Files\\\\Microsoft\\\\Edge\\\\Application\\\\msedge.exe',
|
||||
'C:\\\\Program Files (x86)\\\\Microsoft\\\\Edge\\\\Application\\\\msedge.exe',
|
||||
);
|
||||
break;
|
||||
default:
|
||||
candidates.push(
|
||||
'/usr/bin/google-chrome',
|
||||
'/usr/bin/google-chrome-stable',
|
||||
'/usr/bin/chromium',
|
||||
'/usr/bin/chromium-browser',
|
||||
'/snap/bin/chromium',
|
||||
'/usr/bin/microsoft-edge',
|
||||
'/usr/bin/microsoft-edge-stable',
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
for (const p of candidates) {
|
||||
if (fs.existsSync(p)) return p;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function fetchJson<T = unknown>(url: string): Promise<T> {
|
||||
const res = await fetch(url, { redirect: 'follow' });
|
||||
if (!res.ok) {
|
||||
throw new Error(`Request failed: ${res.status} ${res.statusText} (${url})`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
async function waitForChromeDebugPort(
|
||||
port: number,
|
||||
timeoutMs: number,
|
||||
): Promise<{ webSocketDebuggerUrl: string }> {
|
||||
const start = Date.now();
|
||||
let lastError: unknown = null;
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(
|
||||
`http://127.0.0.1:${port}/json/version`,
|
||||
);
|
||||
if (version.webSocketDebuggerUrl) {
|
||||
return { webSocketDebuggerUrl: version.webSocketDebuggerUrl };
|
||||
}
|
||||
lastError = new Error('Missing webSocketDebuggerUrl');
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
|
||||
await sleep(200);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Chrome debugging endpoint did not become ready within ${timeoutMs}ms: ${lastError instanceof Error ? lastError.message : String(lastError)}`,
|
||||
);
|
||||
}
|
||||
|
||||
class CdpConnection {
|
||||
private ws: WebSocket;
|
||||
private nextId = 0;
|
||||
private pending = new Map<
|
||||
number,
|
||||
{ resolve: (value: unknown) => void; reject: (reason: Error) => void; timer: ReturnType<typeof setTimeout> | null }
|
||||
>();
|
||||
|
||||
private constructor(ws: WebSocket) {
|
||||
this.ws = ws;
|
||||
this.ws.addEventListener('message', (event) => {
|
||||
try {
|
||||
const data = (() => {
|
||||
if (typeof event.data === 'string') return event.data;
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
return new TextDecoder().decode(new Uint8Array(event.data));
|
||||
}
|
||||
if (ArrayBuffer.isView(event.data)) {
|
||||
return new TextDecoder().decode(event.data);
|
||||
}
|
||||
return String(event.data);
|
||||
})();
|
||||
const msg = JSON.parse(data) as { id?: number; result?: unknown; error?: { message?: string } };
|
||||
if (!msg.id) return;
|
||||
const pending = this.pending.get(msg.id);
|
||||
if (!pending) return;
|
||||
this.pending.delete(msg.id);
|
||||
if (pending.timer) clearTimeout(pending.timer);
|
||||
if (msg.error?.message) pending.reject(new Error(msg.error.message));
|
||||
else pending.resolve(msg.result);
|
||||
} catch {
|
||||
// ignore malformed events
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.addEventListener('close', () => {
|
||||
for (const [id, pending] of this.pending.entries()) {
|
||||
this.pending.delete(id);
|
||||
if (pending.timer) clearTimeout(pending.timer);
|
||||
pending.reject(new Error('Chrome DevTools connection closed.'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static async connect(url: string, timeoutMs: number): Promise<CdpConnection> {
|
||||
const ws = new WebSocket(url);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('Timed out connecting to Chrome DevTools.')), timeoutMs);
|
||||
ws.addEventListener('open', () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
ws.addEventListener('error', () => {
|
||||
clearTimeout(timer);
|
||||
reject(new Error('Failed to connect to Chrome DevTools.'));
|
||||
});
|
||||
});
|
||||
return new CdpConnection(ws);
|
||||
}
|
||||
|
||||
async send<T = unknown>(
|
||||
method: string,
|
||||
params?: Record<string, unknown>,
|
||||
options?: { sessionId?: string; timeoutMs?: number },
|
||||
): Promise<T> {
|
||||
const id = (this.nextId += 1);
|
||||
const message: Record<string, unknown> = { id, method };
|
||||
if (params) message.params = params;
|
||||
if (options?.sessionId) message.sessionId = options.sessionId;
|
||||
|
||||
const timeoutMs = options?.timeoutMs ?? 15_000;
|
||||
|
||||
const result = await new Promise<unknown>((resolve, reject) => {
|
||||
const timer =
|
||||
timeoutMs > 0
|
||||
? setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
reject(new Error(`CDP command timeout (${method}) after ${timeoutMs}ms.`));
|
||||
}, timeoutMs)
|
||||
: null;
|
||||
this.pending.set(id, {
|
||||
resolve,
|
||||
reject: (reason) => reject(reason),
|
||||
timer,
|
||||
});
|
||||
this.ws.send(JSON.stringify(message));
|
||||
});
|
||||
|
||||
return result as T;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
try {
|
||||
this.ws.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getGeminiCookieMapViaChrome(options?: {
|
||||
timeoutMs?: number;
|
||||
debugConnectTimeoutMs?: number;
|
||||
pollIntervalMs?: number;
|
||||
log?: GeminiWebLog;
|
||||
userDataDir?: string;
|
||||
chromePath?: string;
|
||||
}): Promise<Record<string, string>> {
|
||||
const log = options?.log;
|
||||
const timeoutMs = options?.timeoutMs ?? 5 * 60_000;
|
||||
const debugConnectTimeoutMs = options?.debugConnectTimeoutMs ?? 30_000;
|
||||
const pollIntervalMs = options?.pollIntervalMs ?? 2_000;
|
||||
const userDataDir = options?.userDataDir ?? resolveGeminiWebChromeProfileDir();
|
||||
|
||||
const chromePath = options?.chromePath ?? findChromeExecutable();
|
||||
if (!chromePath) {
|
||||
throw new Error(
|
||||
'Unable to locate a Chrome/Chromium executable. Install Google Chrome or set GEMINI_WEB_CHROME_PATH.',
|
||||
);
|
||||
}
|
||||
|
||||
await mkdir(userDataDir, { recursive: true });
|
||||
|
||||
const port = await getFreePort();
|
||||
log?.(`[gemini-web] Launching Chrome for cookie sync (profile: ${userDataDir})`);
|
||||
|
||||
const chrome = spawn(
|
||||
chromePath,
|
||||
[
|
||||
`--remote-debugging-port=${port}`,
|
||||
`--user-data-dir=${userDataDir}`,
|
||||
'--no-first-run',
|
||||
'--no-default-browser-check',
|
||||
'--disable-blink-features=AutomationControlled',
|
||||
'--start-maximized',
|
||||
GEMINI_URL,
|
||||
],
|
||||
{ stdio: 'ignore' },
|
||||
);
|
||||
|
||||
let cdp: CdpConnection | null = null;
|
||||
try {
|
||||
const { webSocketDebuggerUrl } = await waitForChromeDebugPort(port, debugConnectTimeoutMs);
|
||||
cdp = await CdpConnection.connect(webSocketDebuggerUrl, debugConnectTimeoutMs);
|
||||
|
||||
const { targetId } = await cdp.send<{ targetId: string }>('Target.createTarget', { url: GEMINI_URL });
|
||||
const { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', {
|
||||
targetId,
|
||||
flatten: true,
|
||||
});
|
||||
|
||||
await cdp.send('Page.enable', {}, { sessionId });
|
||||
await cdp.send('Network.enable', {}, { sessionId });
|
||||
|
||||
log?.('[gemini-web] Please log in to Gemini in the opened browser window.');
|
||||
log?.('[gemini-web] Waiting for cookies to become available...');
|
||||
|
||||
const start = Date.now();
|
||||
let lastTokenError: string | null = null;
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const response = await cdp.send<{ cookies?: unknown[] }>(
|
||||
'Network.getCookies',
|
||||
{ urls: [GEMINI_URL, 'https://google.com/'] },
|
||||
{ sessionId, timeoutMs: 10_000 },
|
||||
);
|
||||
|
||||
const rawCookies = Array.isArray(response.cookies) ? response.cookies : [];
|
||||
const cookieMap = buildGeminiCookieMap(
|
||||
rawCookies.filter(
|
||||
(cookie): cookie is { name?: string; value?: string; domain?: string; path?: string; url?: string } =>
|
||||
Boolean(cookie && typeof cookie === 'object'),
|
||||
),
|
||||
);
|
||||
|
||||
if (hasRequiredGeminiCookies(cookieMap)) {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 10_000);
|
||||
try {
|
||||
await fetchGeminiAccessToken(cookieMap, controller.signal);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
log?.('[gemini-web] Gemini cookies detected.');
|
||||
return cookieMap;
|
||||
} catch (error) {
|
||||
lastTokenError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
|
||||
await sleep(pollIntervalMs);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Timed out waiting for Gemini cookies after ${timeoutMs}ms${lastTokenError ? ` (last error: ${lastTokenError})` : ''}.`,
|
||||
);
|
||||
} finally {
|
||||
if (cdp) {
|
||||
try {
|
||||
await cdp.send('Browser.close', {}, { timeoutMs: 5_000 });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
cdp.close();
|
||||
}
|
||||
|
||||
const killTimer = setTimeout(() => {
|
||||
if (!chrome.killed) {
|
||||
try {
|
||||
chrome.kill('SIGKILL');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}, 2_000);
|
||||
killTimer.unref?.();
|
||||
try {
|
||||
chrome.kill('SIGTERM');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,413 @@
|
|||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
export type GeminiWebModelId = 'gemini-3-pro' | 'gemini-2.5-pro' | 'gemini-2.5-flash';
|
||||
|
||||
export interface GeminiWebRunInput {
|
||||
prompt: string;
|
||||
files?: string[];
|
||||
model: GeminiWebModelId;
|
||||
cookieMap: Record<string, string>;
|
||||
chatMetadata?: unknown;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface GeminiWebCandidateImage {
|
||||
url: string;
|
||||
title?: string;
|
||||
alt?: string;
|
||||
kind: 'web' | 'generated' | 'raw';
|
||||
}
|
||||
|
||||
export interface GeminiWebRunOutput {
|
||||
rawResponseText: string;
|
||||
text: string;
|
||||
thoughts: string | null;
|
||||
metadata: unknown;
|
||||
images: GeminiWebCandidateImage[];
|
||||
errorCode?: number;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
const MODEL_HEADER_NAME = 'x-goog-ext-525001261-jspb';
|
||||
const MODEL_HEADERS: Record<GeminiWebModelId, string> = {
|
||||
'gemini-3-pro': '[1,null,null,null,"9d8ca3786ebdfbea",null,null,0,[4]]',
|
||||
'gemini-2.5-pro': '[1,null,null,null,"4af6c7f5da75d65d",null,null,0,[4]]',
|
||||
'gemini-2.5-flash': '[1,null,null,null,"9ec249fc9ad08861",null,null,0,[4]]',
|
||||
};
|
||||
|
||||
const GEMINI_APP_URL = 'https://gemini.google.com/app';
|
||||
const GEMINI_STREAM_GENERATE_URL =
|
||||
'https://gemini.google.com/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate';
|
||||
const GEMINI_UPLOAD_URL = 'https://content-push.googleapis.com/upload';
|
||||
const GEMINI_UPLOAD_PUSH_ID = 'feeds/mcudyrk2a4khkz';
|
||||
|
||||
function getNestedValue<T>(value: unknown, pathParts: Array<string | number>, fallback: T): T {
|
||||
let current: unknown = value;
|
||||
for (const part of pathParts) {
|
||||
if (current == null) return fallback;
|
||||
if (typeof part === 'number') {
|
||||
if (!Array.isArray(current)) return fallback;
|
||||
current = current[part];
|
||||
} else {
|
||||
if (typeof current !== 'object') return fallback;
|
||||
current = (current as Record<string, unknown>)[part];
|
||||
}
|
||||
}
|
||||
return (current as T) ?? fallback;
|
||||
}
|
||||
|
||||
function buildCookieHeader(cookieMap: Record<string, string>): string {
|
||||
return Object.entries(cookieMap)
|
||||
.filter(([, value]) => typeof value === 'string' && value.length > 0)
|
||||
.map(([name, value]) => `${name}=${value}`)
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
export async function fetchGeminiAccessToken(
|
||||
cookieMap: Record<string, string>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> {
|
||||
const cookieHeader = buildCookieHeader(cookieMap);
|
||||
const res = await fetch(GEMINI_APP_URL, {
|
||||
redirect: 'follow',
|
||||
signal,
|
||||
headers: {
|
||||
cookie: cookieHeader,
|
||||
'user-agent': USER_AGENT,
|
||||
},
|
||||
});
|
||||
const html = await res.text();
|
||||
|
||||
const tokens = ['SNlM0e', 'thykhd'] as const;
|
||||
for (const key of tokens) {
|
||||
const match = html.match(new RegExp(`"${key}":"(.*?)"`));
|
||||
if (match?.[1]) return match[1];
|
||||
}
|
||||
throw new Error(
|
||||
'Unable to locate Gemini access token on gemini.google.com/app (missing SNlM0e/thykhd).',
|
||||
);
|
||||
}
|
||||
|
||||
function trimGeminiJsonEnvelope(text: string): string {
|
||||
const start = text.indexOf('[');
|
||||
const end = text.lastIndexOf(']');
|
||||
if (start === -1 || end === -1 || end <= start) {
|
||||
throw new Error('Gemini response did not contain a JSON payload.');
|
||||
}
|
||||
return text.slice(start, end + 1);
|
||||
}
|
||||
|
||||
function extractErrorCode(responseJson: unknown): number | undefined {
|
||||
const code = getNestedValue<number>(responseJson, [0, 5, 2, 0, 1, 0], -1);
|
||||
return typeof code === 'number' && code >= 0 ? code : undefined;
|
||||
}
|
||||
|
||||
function extractGgdlUrls(rawText: string): string[] {
|
||||
const matches = rawText.match(/https:\/\/lh3\.googleusercontent\.com\/gg-dl\/[^\s"']+/g) ?? [];
|
||||
const seen = new Set<string>();
|
||||
const urls: string[] = [];
|
||||
for (const match of matches) {
|
||||
if (seen.has(match)) continue;
|
||||
seen.add(match);
|
||||
urls.push(match);
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
|
||||
function ensureFullSizeImageUrl(url: string): string {
|
||||
if (url.includes('=s2048')) return url;
|
||||
if (url.includes('=s')) return url;
|
||||
return `${url}=s2048`;
|
||||
}
|
||||
|
||||
async function fetchWithCookiePreservingRedirects(
|
||||
url: string,
|
||||
init: Omit<RequestInit, 'redirect'>,
|
||||
signal?: AbortSignal,
|
||||
maxRedirects = 10,
|
||||
): Promise<Response> {
|
||||
let current = url;
|
||||
for (let i = 0; i <= maxRedirects; i += 1) {
|
||||
const res = await fetch(current, { ...init, redirect: 'manual', signal });
|
||||
if (res.status >= 300 && res.status < 400) {
|
||||
const location = res.headers.get('location');
|
||||
if (!location) return res;
|
||||
current = new URL(location, current).toString();
|
||||
continue;
|
||||
}
|
||||
return res;
|
||||
}
|
||||
throw new Error(`Too many redirects while downloading image (>${maxRedirects}).`);
|
||||
}
|
||||
|
||||
export async function downloadGeminiImage(
|
||||
url: string,
|
||||
cookieMap: Record<string, string>,
|
||||
outputPath: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const cookieHeader = buildCookieHeader(cookieMap);
|
||||
const res = await fetchWithCookiePreservingRedirects(ensureFullSizeImageUrl(url), {
|
||||
headers: {
|
||||
cookie: cookieHeader,
|
||||
'user-agent': USER_AGENT,
|
||||
},
|
||||
}, signal);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to download image: ${res.status} ${res.statusText} (${res.url})`);
|
||||
}
|
||||
|
||||
const data = new Uint8Array(await res.arrayBuffer());
|
||||
await mkdir(path.dirname(outputPath), { recursive: true });
|
||||
await writeFile(outputPath, data);
|
||||
}
|
||||
|
||||
async function uploadGeminiFile(filePath: string, signal?: AbortSignal): Promise<{ id: string; name: string }> {
|
||||
const absPath = path.resolve(process.cwd(), filePath);
|
||||
const data = await readFile(absPath);
|
||||
const fileName = path.basename(absPath);
|
||||
const form = new FormData();
|
||||
form.append('file', new Blob([data]), fileName);
|
||||
|
||||
const res = await fetch(GEMINI_UPLOAD_URL, {
|
||||
method: 'POST',
|
||||
redirect: 'follow',
|
||||
signal,
|
||||
headers: {
|
||||
'push-id': GEMINI_UPLOAD_PUSH_ID,
|
||||
'user-agent': USER_AGENT,
|
||||
},
|
||||
body: form,
|
||||
});
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
throw new Error(`File upload failed: ${res.status} ${res.statusText} (${text.slice(0, 200)})`);
|
||||
}
|
||||
return { id: text, name: fileName };
|
||||
}
|
||||
|
||||
function buildGeminiFReqPayload(
|
||||
prompt: string,
|
||||
uploaded: Array<{ id: string; name: string }>,
|
||||
chatMetadata: unknown,
|
||||
): string {
|
||||
const promptPayload =
|
||||
uploaded.length > 0
|
||||
? [
|
||||
prompt,
|
||||
0,
|
||||
null,
|
||||
// Matches gemini-webapi payload format: [[[fileId, 1]]] for a single attachment.
|
||||
// Keep it extensible for multiple uploads by emitting one [[id, 1]] entry per file.
|
||||
uploaded.map((file) => [[file.id, 1]]),
|
||||
]
|
||||
: [prompt];
|
||||
|
||||
const innerList: unknown[] = [promptPayload, null, chatMetadata ?? null];
|
||||
return JSON.stringify([null, JSON.stringify(innerList)]);
|
||||
}
|
||||
|
||||
export function parseGeminiStreamGenerateResponse(rawText: string): {
|
||||
metadata: unknown;
|
||||
text: string;
|
||||
thoughts: string | null;
|
||||
images: GeminiWebCandidateImage[];
|
||||
errorCode?: number;
|
||||
} {
|
||||
const responseJson = JSON.parse(trimGeminiJsonEnvelope(rawText)) as unknown;
|
||||
const errorCode = extractErrorCode(responseJson);
|
||||
|
||||
const parts = Array.isArray(responseJson) ? responseJson : [];
|
||||
let bodyIndex = 0;
|
||||
let body: unknown = null;
|
||||
for (let i = 0; i < parts.length; i += 1) {
|
||||
const partBody = getNestedValue<string | null>(parts[i], [2], null);
|
||||
if (!partBody) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(partBody) as unknown;
|
||||
const candidateList = getNestedValue<unknown[]>(parsed, [4], []);
|
||||
if (Array.isArray(candidateList) && candidateList.length > 0) {
|
||||
bodyIndex = i;
|
||||
body = parsed;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const candidateList = getNestedValue<unknown[]>(body, [4], []);
|
||||
const firstCandidate = candidateList[0];
|
||||
const textRaw = getNestedValue<string>(firstCandidate, [1, 0], '');
|
||||
const cardContent = /^http:\/\/googleusercontent\.com\/card_content\/\d+/.test(textRaw);
|
||||
const text = cardContent
|
||||
? (getNestedValue<string | null>(firstCandidate, [22, 0], null) ?? textRaw)
|
||||
: textRaw;
|
||||
const thoughts = getNestedValue<string | null>(firstCandidate, [37, 0, 0], null);
|
||||
const metadata = getNestedValue<unknown>(body, [1], []);
|
||||
|
||||
const images: GeminiWebCandidateImage[] = [];
|
||||
|
||||
const webImages = getNestedValue<unknown[]>(firstCandidate, [12, 1], []);
|
||||
for (const webImage of webImages) {
|
||||
const url = getNestedValue<string | null>(webImage, [0, 0, 0], null);
|
||||
if (!url) continue;
|
||||
images.push({
|
||||
kind: 'web',
|
||||
url,
|
||||
title: getNestedValue<string | undefined>(webImage, [7, 0], undefined),
|
||||
alt: getNestedValue<string | undefined>(webImage, [0, 4], undefined),
|
||||
});
|
||||
}
|
||||
|
||||
const hasGenerated = Boolean(getNestedValue<unknown>(firstCandidate, [12, 7, 0], null));
|
||||
if (hasGenerated) {
|
||||
let imgBody: unknown = null;
|
||||
for (let i = bodyIndex; i < parts.length; i += 1) {
|
||||
const partBody = getNestedValue<string | null>(parts[i], [2], null);
|
||||
if (!partBody) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(partBody) as unknown;
|
||||
const candidateImages = getNestedValue<unknown | null>(parsed, [4, 0, 12, 7, 0], null);
|
||||
if (candidateImages != null) {
|
||||
imgBody = parsed;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const imgCandidate = getNestedValue<unknown>(imgBody ?? body, [4, 0], null);
|
||||
|
||||
const generated = getNestedValue<unknown[]>(imgCandidate, [12, 7, 0], []);
|
||||
for (const genImage of generated) {
|
||||
const url = getNestedValue<string | null>(genImage, [0, 3, 3], null);
|
||||
if (!url) continue;
|
||||
images.push({
|
||||
kind: 'generated',
|
||||
url,
|
||||
title: '[Generated Image]',
|
||||
alt: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { metadata, text, thoughts, images, errorCode };
|
||||
}
|
||||
|
||||
export function isGeminiModelUnavailable(errorCode: number | undefined): boolean {
|
||||
return errorCode === 1052;
|
||||
}
|
||||
|
||||
export async function runGeminiWebOnce(input: GeminiWebRunInput): Promise<GeminiWebRunOutput> {
|
||||
const cookieHeader = buildCookieHeader(input.cookieMap);
|
||||
const at = await fetchGeminiAccessToken(input.cookieMap, input.signal);
|
||||
|
||||
const uploaded: Array<{ id: string; name: string }> = [];
|
||||
for (const file of input.files ?? []) {
|
||||
if (input.signal?.aborted) {
|
||||
throw new Error('Gemini web run aborted before upload.');
|
||||
}
|
||||
uploaded.push(await uploadGeminiFile(file, input.signal));
|
||||
}
|
||||
|
||||
const fReq = buildGeminiFReqPayload(input.prompt, uploaded, input.chatMetadata ?? null);
|
||||
const params = new URLSearchParams();
|
||||
params.set('at', at);
|
||||
params.set('f.req', fReq);
|
||||
|
||||
const res = await fetch(GEMINI_STREAM_GENERATE_URL, {
|
||||
method: 'POST',
|
||||
redirect: 'follow',
|
||||
signal: input.signal,
|
||||
headers: {
|
||||
'content-type': 'application/x-www-form-urlencoded;charset=utf-8',
|
||||
origin: 'https://gemini.google.com',
|
||||
referer: 'https://gemini.google.com/',
|
||||
'x-same-domain': '1',
|
||||
'user-agent': USER_AGENT,
|
||||
cookie: cookieHeader,
|
||||
[MODEL_HEADER_NAME]: MODEL_HEADERS[input.model],
|
||||
},
|
||||
body: params.toString(),
|
||||
});
|
||||
|
||||
const rawResponseText = await res.text();
|
||||
if (!res.ok) {
|
||||
return {
|
||||
rawResponseText,
|
||||
text: '',
|
||||
thoughts: null,
|
||||
metadata: input.chatMetadata ?? null,
|
||||
images: [],
|
||||
errorMessage: `Gemini request failed: ${res.status} ${res.statusText}`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = parseGeminiStreamGenerateResponse(rawResponseText);
|
||||
return {
|
||||
rawResponseText,
|
||||
text: parsed.text ?? '',
|
||||
thoughts: parsed.thoughts,
|
||||
metadata: parsed.metadata,
|
||||
images: parsed.images,
|
||||
errorCode: parsed.errorCode,
|
||||
};
|
||||
} catch (error) {
|
||||
let responseJson: unknown = null;
|
||||
try {
|
||||
responseJson = JSON.parse(trimGeminiJsonEnvelope(rawResponseText)) as unknown;
|
||||
} catch {
|
||||
responseJson = null;
|
||||
}
|
||||
const errorCode = extractErrorCode(responseJson);
|
||||
|
||||
return {
|
||||
rawResponseText,
|
||||
text: '',
|
||||
thoughts: null,
|
||||
metadata: input.chatMetadata ?? null,
|
||||
images: [],
|
||||
errorCode: typeof errorCode === 'number' ? errorCode : undefined,
|
||||
errorMessage: error instanceof Error ? error.message : String(error ?? ''),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function runGeminiWebWithFallback(
|
||||
input: Omit<GeminiWebRunInput, 'model'> & { model: GeminiWebModelId },
|
||||
): Promise<GeminiWebRunOutput & { effectiveModel: GeminiWebModelId }> {
|
||||
const attempt = await runGeminiWebOnce(input);
|
||||
if (isGeminiModelUnavailable(attempt.errorCode) && input.model !== 'gemini-2.5-flash') {
|
||||
const fallback = await runGeminiWebOnce({ ...input, model: 'gemini-2.5-flash' });
|
||||
return { ...fallback, effectiveModel: 'gemini-2.5-flash' };
|
||||
}
|
||||
return { ...attempt, effectiveModel: input.model };
|
||||
}
|
||||
|
||||
export async function saveFirstGeminiImageFromOutput(
|
||||
output: GeminiWebRunOutput,
|
||||
cookieMap: Record<string, string>,
|
||||
outputPath: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ saved: boolean; imageCount: number }> {
|
||||
const generatedOrWeb = output.images.find((img) => img.kind === 'generated') ?? output.images[0];
|
||||
if (generatedOrWeb?.url) {
|
||||
await downloadGeminiImage(generatedOrWeb.url, cookieMap, outputPath, signal);
|
||||
return { saved: true, imageCount: output.images.length };
|
||||
}
|
||||
|
||||
const ggdl = extractGgdlUrls(output.rawResponseText);
|
||||
if (ggdl[0]) {
|
||||
await downloadGeminiImage(ggdl[0], cookieMap, outputPath, signal);
|
||||
return { saved: true, imageCount: ggdl.length };
|
||||
}
|
||||
|
||||
return { saved: false, imageCount: 0 };
|
||||
}
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { resolveGeminiWebCookiePath } from './paths.js';
|
||||
|
||||
export type GeminiWebLog = (message: string) => void;
|
||||
|
||||
export const GEMINI_COOKIE_NAMES = [
|
||||
'__Secure-1PSID',
|
||||
'__Secure-1PSIDTS',
|
||||
'__Secure-1PSIDCC',
|
||||
'__Secure-1PAPISID',
|
||||
'NID',
|
||||
'AEC',
|
||||
'SOCS',
|
||||
'__Secure-BUCKET',
|
||||
'__Secure-ENID',
|
||||
'SID',
|
||||
'HSID',
|
||||
'SSID',
|
||||
'APISID',
|
||||
'SAPISID',
|
||||
'__Secure-3PSID',
|
||||
'__Secure-3PSIDTS',
|
||||
'__Secure-3PAPISID',
|
||||
'SIDCC',
|
||||
] as const;
|
||||
|
||||
export const GEMINI_REQUIRED_COOKIES = ['__Secure-1PSID', '__Secure-1PSIDTS'] as const;
|
||||
|
||||
export interface GeminiCookieFileV1 {
|
||||
version: 1;
|
||||
updatedAt: string;
|
||||
cookieMap: Record<string, string>;
|
||||
}
|
||||
|
||||
export function hasRequiredGeminiCookies(cookieMap: Record<string, string>): boolean {
|
||||
return GEMINI_REQUIRED_COOKIES.every((name) => Boolean(cookieMap[name]));
|
||||
}
|
||||
|
||||
function resolveCookieDomain(cookie: { domain?: string; url?: string }): string | null {
|
||||
const rawDomain = cookie.domain?.trim();
|
||||
if (rawDomain) {
|
||||
return rawDomain.startsWith('.') ? rawDomain.slice(1) : rawDomain;
|
||||
}
|
||||
const rawUrl = cookie.url?.trim();
|
||||
if (rawUrl) {
|
||||
try {
|
||||
return new URL(rawUrl).hostname;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function pickCookieValue<T extends { name?: string; value?: string; domain?: string; path?: string; url?: string }>(
|
||||
cookies: T[],
|
||||
name: string,
|
||||
): string | undefined {
|
||||
const matches = cookies.filter((cookie) => cookie.name === name && typeof cookie.value === 'string');
|
||||
if (matches.length === 0) return undefined;
|
||||
|
||||
const preferredDomain = matches.find((cookie) => {
|
||||
const domain = resolveCookieDomain(cookie);
|
||||
return domain === 'google.com' && (cookie.path ?? '/') === '/';
|
||||
});
|
||||
const googleDomain = matches.find((cookie) => (resolveCookieDomain(cookie) ?? '').endsWith('google.com'));
|
||||
return (preferredDomain ?? googleDomain ?? matches[0])?.value;
|
||||
}
|
||||
|
||||
export function buildGeminiCookieMap<
|
||||
T extends { name?: string; value?: string; domain?: string; path?: string; url?: string },
|
||||
>(cookies: T[]): Record<string, string> {
|
||||
const cookieMap: Record<string, string> = {};
|
||||
for (const name of GEMINI_COOKIE_NAMES) {
|
||||
const value = pickCookieValue(cookies, name);
|
||||
if (value) cookieMap[name] = value;
|
||||
}
|
||||
return cookieMap;
|
||||
}
|
||||
|
||||
export async function readGeminiCookieMapFromDisk(options?: {
|
||||
cookiePath?: string;
|
||||
log?: GeminiWebLog;
|
||||
}): Promise<Record<string, string>> {
|
||||
const cookiePath = options?.cookiePath ?? resolveGeminiWebCookiePath();
|
||||
|
||||
try {
|
||||
const raw = await readFile(cookiePath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as Partial<GeminiCookieFileV1> | Record<string, unknown>;
|
||||
|
||||
const cookieMap =
|
||||
(parsed as Partial<GeminiCookieFileV1>).version === 1
|
||||
? (parsed as Partial<GeminiCookieFileV1>).cookieMap
|
||||
: (parsed as Record<string, unknown>);
|
||||
|
||||
if (!cookieMap || typeof cookieMap !== 'object') return {};
|
||||
const normalized: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(cookieMap)) {
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
normalized[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(normalized).length > 0) {
|
||||
options?.log?.(`[gemini-web] Loaded cookies from ${cookiePath}`);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
||||
if (code === 'ENOENT') return {};
|
||||
options?.log?.(
|
||||
`[gemini-web] Failed to read cookies from ${cookiePath}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeGeminiCookieMapToDisk(
|
||||
cookieMap: Record<string, string>,
|
||||
options?: { cookiePath?: string; log?: GeminiWebLog },
|
||||
): Promise<void> {
|
||||
const cookiePath = options?.cookiePath ?? resolveGeminiWebCookiePath();
|
||||
await mkdir(path.dirname(cookiePath), { recursive: true });
|
||||
|
||||
const payload: GeminiCookieFileV1 = {
|
||||
version: 1,
|
||||
updatedAt: new Date().toISOString(),
|
||||
cookieMap,
|
||||
};
|
||||
|
||||
await writeFile(cookiePath, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 });
|
||||
try {
|
||||
await chmod(cookiePath, 0o600);
|
||||
} catch {
|
||||
// ignore chmod failures (e.g. on Windows)
|
||||
}
|
||||
options?.log?.(`[gemini-web] Saved cookies to ${cookiePath}`);
|
||||
}
|
||||
|
|
@ -0,0 +1,262 @@
|
|||
import path from 'node:path';
|
||||
import type { BrowserRunOptions, BrowserRunResult, BrowserLogger, CookieParam } from '../browser/types.js';
|
||||
import { runGeminiWebWithFallback, saveFirstGeminiImageFromOutput } from './client.js';
|
||||
import type { GeminiWebModelId } from './client.js';
|
||||
import {
|
||||
buildGeminiCookieMap,
|
||||
hasRequiredGeminiCookies,
|
||||
readGeminiCookieMapFromDisk,
|
||||
} from './cookie-store.js';
|
||||
import type { GeminiWebOptions, GeminiWebResponse } from './types.js';
|
||||
|
||||
export { hasRequiredGeminiCookies } from './cookie-store.js';
|
||||
|
||||
function estimateTokenCount(text: string): number {
|
||||
return Math.ceil(text.length / 4);
|
||||
}
|
||||
|
||||
function resolveInvocationPath(value: string | undefined): string | undefined {
|
||||
if (!value) return undefined;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(process.cwd(), trimmed);
|
||||
}
|
||||
|
||||
function resolveGeminiWebModel(
|
||||
desiredModel: string | null | undefined,
|
||||
log?: BrowserLogger,
|
||||
): GeminiWebModelId {
|
||||
const desired = typeof desiredModel === 'string' ? desiredModel.trim() : '';
|
||||
if (!desired) return 'gemini-3-pro';
|
||||
|
||||
switch (desired) {
|
||||
case 'gemini-3-pro':
|
||||
case 'gemini-3.0-pro':
|
||||
return 'gemini-3-pro';
|
||||
case 'gemini-2.5-pro':
|
||||
return 'gemini-2.5-pro';
|
||||
case 'gemini-2.5-flash':
|
||||
return 'gemini-2.5-flash';
|
||||
default:
|
||||
if (desired.startsWith('gemini-')) {
|
||||
log?.(
|
||||
`[gemini-web] Unsupported Gemini web model "${desired}". Falling back to gemini-3-pro.`,
|
||||
);
|
||||
}
|
||||
return 'gemini-3-pro';
|
||||
}
|
||||
}
|
||||
|
||||
function buildInlineCookiesFromEnv(): CookieParam[] {
|
||||
const cookies: CookieParam[] = [];
|
||||
const psid = process.env.GEMINI_SECURE_1PSID?.trim();
|
||||
const psidts = process.env.GEMINI_SECURE_1PSIDTS?.trim();
|
||||
|
||||
if (psid) {
|
||||
cookies.push({ name: '__Secure-1PSID', value: psid, domain: 'google.com', path: '/' });
|
||||
}
|
||||
if (psidts) {
|
||||
cookies.push({ name: '__Secure-1PSIDTS', value: psidts, domain: 'google.com', path: '/' });
|
||||
}
|
||||
|
||||
return cookies;
|
||||
}
|
||||
|
||||
async function loadGeminiCookiesFromInline(
|
||||
browserConfig: BrowserRunOptions['config'],
|
||||
log?: BrowserLogger,
|
||||
): Promise<Record<string, string>> {
|
||||
const inline = browserConfig?.inlineCookies;
|
||||
if (!inline || inline.length === 0) return {};
|
||||
|
||||
const cookieMap = buildGeminiCookieMap(
|
||||
inline.filter((cookie): cookie is CookieParam => Boolean(cookie?.name && typeof cookie.value === 'string')),
|
||||
);
|
||||
|
||||
if (Object.keys(cookieMap).length > 0) {
|
||||
const source = browserConfig?.inlineCookiesSource ?? 'inline';
|
||||
log?.(`[gemini-web] Loaded Gemini cookies from inline payload (${source}): ${Object.keys(cookieMap).length} cookie(s).`);
|
||||
} else {
|
||||
log?.('[gemini-web] Inline cookie payload provided but no Gemini cookies matched.');
|
||||
}
|
||||
|
||||
return cookieMap;
|
||||
}
|
||||
|
||||
export async function loadGeminiCookies(
|
||||
browserConfig: BrowserRunOptions['config'],
|
||||
log?: BrowserLogger,
|
||||
): Promise<Record<string, string>> {
|
||||
const inlineMap = await loadGeminiCookiesFromInline(browserConfig, log);
|
||||
if (hasRequiredGeminiCookies(inlineMap)) return inlineMap;
|
||||
|
||||
const diskMap = await readGeminiCookieMapFromDisk({ log });
|
||||
const merged = { ...diskMap, ...inlineMap };
|
||||
if (hasRequiredGeminiCookies(merged)) return merged;
|
||||
|
||||
if (browserConfig?.cookieSync === false) {
|
||||
log?.('[gemini-web] Cookie sync disabled and inline cookies missing Gemini auth tokens.');
|
||||
return merged;
|
||||
}
|
||||
|
||||
log?.(
|
||||
'[gemini-web] Missing Gemini auth cookies. Run `npx -y bun skills/gemini-web/scripts/main.ts --login` to sign in and refresh cookies.',
|
||||
);
|
||||
return merged;
|
||||
}
|
||||
|
||||
export async function loadGeminiCookieMap(log?: BrowserLogger): Promise<Record<string, string>> {
|
||||
const diskMap = await readGeminiCookieMapFromDisk({ log });
|
||||
const inlineCookies = buildInlineCookiesFromEnv();
|
||||
const envMap = buildGeminiCookieMap(inlineCookies);
|
||||
return { ...diskMap, ...envMap };
|
||||
}
|
||||
|
||||
export function createGeminiWebExecutor(
|
||||
geminiOptions: GeminiWebOptions,
|
||||
): (runOptions: BrowserRunOptions) => Promise<BrowserRunResult> {
|
||||
return async (runOptions: BrowserRunOptions): Promise<BrowserRunResult> => {
|
||||
const startTime = Date.now();
|
||||
const log = runOptions.log;
|
||||
|
||||
log?.('[gemini-web] Starting Gemini web executor (TypeScript)');
|
||||
|
||||
const cookieMap = await loadGeminiCookies(runOptions.config, log);
|
||||
if (!hasRequiredGeminiCookies(cookieMap)) {
|
||||
throw new Error(
|
||||
'Gemini browser mode requires auth cookies (missing __Secure-1PSID/__Secure-1PSIDTS). Run `npx -y bun skills/gemini-web/scripts/main.ts --login` to sign in and save cookies.',
|
||||
);
|
||||
}
|
||||
|
||||
const configTimeout =
|
||||
typeof runOptions.config?.timeoutMs === 'number' && Number.isFinite(runOptions.config.timeoutMs)
|
||||
? Math.max(1_000, runOptions.config.timeoutMs)
|
||||
: null;
|
||||
|
||||
const defaultTimeoutMs = geminiOptions.youtube
|
||||
? 240_000
|
||||
: geminiOptions.generateImage || geminiOptions.editImage
|
||||
? 300_000
|
||||
: 120_000;
|
||||
|
||||
const timeoutMs = Math.min(configTimeout ?? defaultTimeoutMs, 600_000);
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
const generateImagePath = resolveInvocationPath(geminiOptions.generateImage);
|
||||
const editImagePath = resolveInvocationPath(geminiOptions.editImage);
|
||||
const outputPath = resolveInvocationPath(geminiOptions.outputPath);
|
||||
const attachmentPaths = (runOptions.attachments ?? []).map((attachment) => attachment.path);
|
||||
|
||||
let prompt = runOptions.prompt;
|
||||
if (geminiOptions.aspectRatio && (generateImagePath || editImagePath)) {
|
||||
prompt = `${prompt} (aspect ratio: ${geminiOptions.aspectRatio})`;
|
||||
}
|
||||
if (geminiOptions.youtube) {
|
||||
prompt = `${prompt}\n\nYouTube video: ${geminiOptions.youtube}`;
|
||||
}
|
||||
if (generateImagePath && !editImagePath) {
|
||||
prompt = `Generate an image: ${prompt}`;
|
||||
}
|
||||
|
||||
const model: GeminiWebModelId = resolveGeminiWebModel(runOptions.config?.desiredModel, log);
|
||||
let response: GeminiWebResponse;
|
||||
|
||||
try {
|
||||
if (editImagePath) {
|
||||
const intro = await runGeminiWebWithFallback({
|
||||
prompt: 'Here is an image to edit',
|
||||
files: [editImagePath],
|
||||
model,
|
||||
cookieMap,
|
||||
chatMetadata: null,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const editPrompt = `Use image generation tool to ${prompt}`;
|
||||
const out = await runGeminiWebWithFallback({
|
||||
prompt: editPrompt,
|
||||
files: attachmentPaths,
|
||||
model,
|
||||
cookieMap,
|
||||
chatMetadata: intro.metadata,
|
||||
signal: controller.signal,
|
||||
});
|
||||
response = {
|
||||
text: out.text ?? null,
|
||||
thoughts: geminiOptions.showThoughts ? out.thoughts : null,
|
||||
has_images: false,
|
||||
image_count: 0,
|
||||
};
|
||||
|
||||
const resolvedOutputPath = outputPath ?? generateImagePath ?? 'generated.png';
|
||||
const imageSave = await saveFirstGeminiImageFromOutput(out, cookieMap, resolvedOutputPath, controller.signal);
|
||||
response.has_images = imageSave.saved;
|
||||
response.image_count = imageSave.imageCount;
|
||||
if (!imageSave.saved) {
|
||||
throw new Error(`No images generated. Response text:\n${out.text || '(empty response)'}`);
|
||||
}
|
||||
} else if (generateImagePath) {
|
||||
const out = await runGeminiWebWithFallback({
|
||||
prompt,
|
||||
files: attachmentPaths,
|
||||
model,
|
||||
cookieMap,
|
||||
chatMetadata: null,
|
||||
signal: controller.signal,
|
||||
});
|
||||
response = {
|
||||
text: out.text ?? null,
|
||||
thoughts: geminiOptions.showThoughts ? out.thoughts : null,
|
||||
has_images: false,
|
||||
image_count: 0,
|
||||
};
|
||||
const imageSave = await saveFirstGeminiImageFromOutput(out, cookieMap, generateImagePath, controller.signal);
|
||||
response.has_images = imageSave.saved;
|
||||
response.image_count = imageSave.imageCount;
|
||||
if (!imageSave.saved) {
|
||||
throw new Error(`No images generated. Response text:\n${out.text || '(empty response)'}`);
|
||||
}
|
||||
} else {
|
||||
const out = await runGeminiWebWithFallback({
|
||||
prompt,
|
||||
files: attachmentPaths,
|
||||
model,
|
||||
cookieMap,
|
||||
chatMetadata: null,
|
||||
signal: controller.signal,
|
||||
});
|
||||
response = {
|
||||
text: out.text ?? null,
|
||||
thoughts: geminiOptions.showThoughts ? out.thoughts : null,
|
||||
has_images: out.images.length > 0,
|
||||
image_count: out.images.length,
|
||||
};
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
const answerText = response.text ?? '';
|
||||
let answerMarkdown = answerText;
|
||||
|
||||
if (geminiOptions.showThoughts && response.thoughts) {
|
||||
answerMarkdown = `## Thinking\n\n${response.thoughts}\n\n## Response\n\n${answerText}`;
|
||||
}
|
||||
|
||||
if (response.has_images && response.image_count > 0) {
|
||||
const imagePath = generateImagePath || outputPath || 'generated.png';
|
||||
answerMarkdown += `\n\n*Generated ${response.image_count} image(s). Saved to: ${imagePath}*`;
|
||||
}
|
||||
|
||||
const tookMs = Date.now() - startTime;
|
||||
log?.(`[gemini-web] Completed in ${tookMs}ms`);
|
||||
|
||||
return {
|
||||
answerText,
|
||||
answerMarkdown,
|
||||
tookMs,
|
||||
answerTokens: estimateTokenCount(answerText),
|
||||
answerChars: answerText.length,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
#!/usr/bin/env -S npx -y bun
|
||||
|
||||
import process from 'node:process';
|
||||
|
||||
import { getGeminiCookieMapViaChrome } from './chrome-auth.js';
|
||||
import { writeGeminiCookieMapToDisk } from './cookie-store.js';
|
||||
import { resolveGeminiWebChromeProfileDir, resolveGeminiWebCookiePath } from './paths.js';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const cookiePath = resolveGeminiWebCookiePath();
|
||||
const profileDir = resolveGeminiWebChromeProfileDir();
|
||||
|
||||
const log = (msg: string) => console.log(msg);
|
||||
const cookieMap = await getGeminiCookieMapViaChrome({ userDataDir: profileDir, log });
|
||||
await writeGeminiCookieMapToDisk(cookieMap, { cookiePath, log });
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { createGeminiWebExecutor } from './executor.js';
|
||||
export type { GeminiWebOptions, GeminiWebResponse } from './types.js';
|
||||
|
|
@ -0,0 +1,340 @@
|
|||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
|
||||
import { fetchGeminiAccessToken, runGeminiWebWithFallback, saveFirstGeminiImageFromOutput } from './client.js';
|
||||
import { getGeminiCookieMapViaChrome } from './chrome-auth.js';
|
||||
import {
|
||||
hasRequiredGeminiCookies,
|
||||
readGeminiCookieMapFromDisk,
|
||||
writeGeminiCookieMapToDisk,
|
||||
} from './cookie-store.js';
|
||||
import { resolveGeminiWebChromeProfileDir, resolveGeminiWebCookiePath } from './paths.js';
|
||||
|
||||
function printUsage(exitCode = 0): never {
|
||||
const cookiePath = resolveGeminiWebCookiePath();
|
||||
const profileDir = resolveGeminiWebChromeProfileDir();
|
||||
|
||||
console.log(`Usage:
|
||||
npx -y bun skills/gemini-web/scripts/main.ts --prompt "Hello"
|
||||
npx -y bun skills/gemini-web/scripts/main.ts "Hello"
|
||||
npx -y bun skills/gemini-web/scripts/main.ts --prompt "A cute cat" --image generated.png
|
||||
|
||||
Options:
|
||||
-p, --prompt <text> Prompt text
|
||||
-m, --model <id> gemini-3-pro | gemini-2.5-pro | gemini-2.5-flash (default: gemini-3-pro)
|
||||
--json Output JSON
|
||||
--image [path] Generate an image and save it (default: ./generated.png)
|
||||
--login Only refresh cookies, then exit
|
||||
--cookie-path <path> Cookie file path (default: ${cookiePath})
|
||||
--profile-dir <path> Chrome profile dir (default: ${profileDir})
|
||||
-h, --help Show help
|
||||
|
||||
Env overrides:
|
||||
GEMINI_WEB_DATA_DIR, GEMINI_WEB_COOKIE_PATH, GEMINI_WEB_CHROME_PROFILE_DIR, GEMINI_WEB_CHROME_PATH
|
||||
`);
|
||||
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function readPromptFromStdin(): Promise<string | null> {
|
||||
if (process.stdin.isTTY) return null;
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of process.stdin) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
const text = Buffer.concat(chunks).toString('utf8').trim();
|
||||
return text ? text : null;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): {
|
||||
prompt?: string;
|
||||
model?: string;
|
||||
json?: boolean;
|
||||
imagePath?: string;
|
||||
loginOnly?: boolean;
|
||||
cookiePath?: string;
|
||||
profileDir?: string;
|
||||
} {
|
||||
const out: ReturnType<typeof parseArgs> = {};
|
||||
const positional: string[] = [];
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i] ?? '';
|
||||
if (arg === '--help' || arg === '-h') printUsage(0);
|
||||
if (arg === '--json') {
|
||||
out.json = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--image' || arg === '--generate-image') {
|
||||
const next = argv[i + 1];
|
||||
if (next && !next.startsWith('-')) {
|
||||
out.imagePath = next;
|
||||
i += 1;
|
||||
} else {
|
||||
out.imagePath = 'generated.png';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('--image=')) {
|
||||
out.imagePath = arg.slice('--image='.length);
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('--generate-image=')) {
|
||||
out.imagePath = arg.slice('--generate-image='.length);
|
||||
continue;
|
||||
}
|
||||
if (arg === '--login') {
|
||||
out.loginOnly = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--prompt' || arg === '-p') {
|
||||
out.prompt = argv[i + 1] ?? '';
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('--prompt=')) {
|
||||
out.prompt = arg.slice('--prompt='.length);
|
||||
continue;
|
||||
}
|
||||
if (arg === '--model' || arg === '-m') {
|
||||
out.model = argv[i + 1] ?? '';
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('--model=')) {
|
||||
out.model = arg.slice('--model='.length);
|
||||
continue;
|
||||
}
|
||||
if (arg === '--cookie-path') {
|
||||
out.cookiePath = argv[i + 1] ?? '';
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('--cookie-path=')) {
|
||||
out.cookiePath = arg.slice('--cookie-path='.length);
|
||||
continue;
|
||||
}
|
||||
if (arg === '--profile-dir') {
|
||||
out.profileDir = argv[i + 1] ?? '';
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('--profile-dir=')) {
|
||||
out.profileDir = arg.slice('--profile-dir='.length);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith('-')) {
|
||||
throw new Error(`Unknown option: ${arg}`);
|
||||
}
|
||||
positional.push(arg);
|
||||
}
|
||||
|
||||
if (!out.prompt && positional.length > 0) {
|
||||
out.prompt = positional.join(' ').trim();
|
||||
}
|
||||
|
||||
if (out.prompt != null) out.prompt = out.prompt.trim();
|
||||
if (out.model != null) out.model = out.model.trim();
|
||||
if (out.imagePath != null) out.imagePath = out.imagePath.trim();
|
||||
if (out.cookiePath != null) out.cookiePath = out.cookiePath.trim();
|
||||
if (out.profileDir != null) out.profileDir = out.profileDir.trim();
|
||||
|
||||
if (out.imagePath === '') delete out.imagePath;
|
||||
if (out.cookiePath === '') delete out.cookiePath;
|
||||
if (out.profileDir === '') delete out.profileDir;
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
async function isCookieMapValid(cookieMap: Record<string, string>): Promise<boolean> {
|
||||
if (!hasRequiredGeminiCookies(cookieMap)) return false;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 10_000);
|
||||
try {
|
||||
await fetchGeminiAccessToken(cookieMap, controller.signal);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureGeminiCookieMap(options: {
|
||||
cookiePath: string;
|
||||
profileDir: string;
|
||||
}): Promise<Record<string, string>> {
|
||||
const log = (msg: string) => console.log(msg);
|
||||
|
||||
let cookieMap = await readGeminiCookieMapFromDisk({ cookiePath: options.cookiePath, log });
|
||||
if (await isCookieMapValid(cookieMap)) return cookieMap;
|
||||
|
||||
log('[gemini-web] No valid cookies found. Opening browser to sync Gemini cookies...');
|
||||
cookieMap = await getGeminiCookieMapViaChrome({ userDataDir: options.profileDir, log });
|
||||
await writeGeminiCookieMapToDisk(cookieMap, { cookiePath: options.cookiePath, log });
|
||||
return cookieMap;
|
||||
}
|
||||
|
||||
function resolveModel(value: string): 'gemini-3-pro' | 'gemini-2.5-pro' | 'gemini-2.5-flash' {
|
||||
const desired = value.trim();
|
||||
if (!desired) return 'gemini-3-pro';
|
||||
switch (desired) {
|
||||
case 'gemini-3-pro':
|
||||
case 'gemini-3.0-pro':
|
||||
return 'gemini-3-pro';
|
||||
case 'gemini-2.5-pro':
|
||||
return 'gemini-2.5-pro';
|
||||
case 'gemini-2.5-flash':
|
||||
return 'gemini-2.5-flash';
|
||||
default:
|
||||
console.error(`[gemini-web] Unsupported model "${desired}", falling back to gemini-3-pro.`);
|
||||
return 'gemini-3-pro';
|
||||
}
|
||||
}
|
||||
|
||||
function resolveImageOutputPath(value: string | undefined): string | null {
|
||||
if (value == null) return null;
|
||||
const trimmed = value.trim();
|
||||
const raw = trimmed || 'generated.png';
|
||||
const resolved = path.isAbsolute(raw) ? raw : path.resolve(process.cwd(), raw);
|
||||
|
||||
if (resolved.endsWith(path.sep)) return path.join(resolved, 'generated.png');
|
||||
try {
|
||||
if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {
|
||||
return path.join(resolved, 'generated.png');
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const cookiePath = args.cookiePath ?? resolveGeminiWebCookiePath();
|
||||
const profileDir = args.profileDir ?? resolveGeminiWebChromeProfileDir();
|
||||
|
||||
if (args.loginOnly) {
|
||||
await ensureGeminiCookieMap({ cookiePath, profileDir });
|
||||
return;
|
||||
}
|
||||
|
||||
const promptFromStdin = await readPromptFromStdin();
|
||||
const prompt = args.prompt || promptFromStdin;
|
||||
if (!prompt) printUsage(1);
|
||||
|
||||
let cookieMap = await ensureGeminiCookieMap({ cookiePath, profileDir });
|
||||
|
||||
const desiredModel = resolveModel(args.model || 'gemini-3-pro');
|
||||
const imagePath = resolveImageOutputPath(args.imagePath);
|
||||
|
||||
try {
|
||||
const effectivePrompt = imagePath ? `Generate an image: ${prompt}` : prompt;
|
||||
const out = await runGeminiWebWithFallback({
|
||||
prompt: effectivePrompt,
|
||||
files: [],
|
||||
model: desiredModel,
|
||||
cookieMap,
|
||||
chatMetadata: null,
|
||||
});
|
||||
|
||||
let imageSaved = false;
|
||||
let imageCount = 0;
|
||||
if (imagePath) {
|
||||
const save = await saveFirstGeminiImageFromOutput(out, cookieMap, imagePath);
|
||||
imageSaved = save.saved;
|
||||
imageCount = save.imageCount;
|
||||
if (!imageSaved) {
|
||||
throw new Error(`No images generated. Response text:\n${out.text || '(empty response)'}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (args.json) {
|
||||
process.stdout.write(
|
||||
`${JSON.stringify(
|
||||
imagePath ? { ...out, imageSaved, imageCount, imagePath } : out,
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
if (out.errorMessage) process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (out.errorMessage) {
|
||||
throw new Error(out.errorMessage);
|
||||
}
|
||||
|
||||
process.stdout.write(out.text ?? '');
|
||||
if (!out.text?.endsWith('\n')) process.stdout.write('\n');
|
||||
if (imagePath) {
|
||||
process.stdout.write(`Saved image (${imageCount || 1}) to: ${imagePath}\n`);
|
||||
}
|
||||
return;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
if (message.includes('Unable to locate Gemini access token')) {
|
||||
console.error('[gemini-web] Cookies may be expired. Re-opening browser to refresh cookies...');
|
||||
await sleep(500);
|
||||
cookieMap = await getGeminiCookieMapViaChrome({ userDataDir: profileDir, log: (m) => console.log(m) });
|
||||
await writeGeminiCookieMapToDisk(cookieMap, { cookiePath, log: (m) => console.log(m) });
|
||||
|
||||
const out = await runGeminiWebWithFallback({
|
||||
prompt: imagePath ? `Generate an image: ${prompt}` : prompt,
|
||||
files: [],
|
||||
model: desiredModel,
|
||||
cookieMap,
|
||||
chatMetadata: null,
|
||||
});
|
||||
|
||||
let imageSaved = false;
|
||||
let imageCount = 0;
|
||||
if (imagePath) {
|
||||
const save = await saveFirstGeminiImageFromOutput(out, cookieMap, imagePath);
|
||||
imageSaved = save.saved;
|
||||
imageCount = save.imageCount;
|
||||
if (!imageSaved) {
|
||||
throw new Error(`No images generated. Response text:\n${out.text || '(empty response)'}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (args.json) {
|
||||
process.stdout.write(
|
||||
`${JSON.stringify(
|
||||
imagePath ? { ...out, imageSaved, imageCount, imagePath } : out,
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
if (out.errorMessage) process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (out.errorMessage) {
|
||||
throw new Error(out.errorMessage);
|
||||
}
|
||||
|
||||
process.stdout.write(out.text ?? '');
|
||||
if (!out.text?.endsWith('\n')) process.stdout.write('\n');
|
||||
if (imagePath) {
|
||||
process.stdout.write(`Saved image (${imageCount || 1}) to: ${imagePath}\n`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
|
||||
const APP_DATA_DIR = 'baoyu-skills';
|
||||
const GEMINI_DATA_DIR = 'gemini-web';
|
||||
const COOKIE_FILE_NAME = 'cookies.json';
|
||||
const PROFILE_DIR_NAME = 'chrome-profile';
|
||||
|
||||
export function resolveUserDataRoot(): string {
|
||||
if (process.platform === 'win32') {
|
||||
return process.env.APPDATA ?? path.join(os.homedir(), 'AppData', 'Roaming');
|
||||
}
|
||||
if (process.platform === 'darwin') {
|
||||
return path.join(os.homedir(), 'Library', 'Application Support');
|
||||
}
|
||||
return process.env.XDG_DATA_HOME ?? path.join(os.homedir(), '.local', 'share');
|
||||
}
|
||||
|
||||
export function resolveGeminiWebDataDir(): string {
|
||||
const override = process.env.GEMINI_WEB_DATA_DIR?.trim();
|
||||
if (override) return path.resolve(override);
|
||||
return path.join(resolveUserDataRoot(), APP_DATA_DIR, GEMINI_DATA_DIR);
|
||||
}
|
||||
|
||||
export function resolveGeminiWebCookiePath(): string {
|
||||
const override = process.env.GEMINI_WEB_COOKIE_PATH?.trim();
|
||||
if (override) return path.resolve(override);
|
||||
return path.join(resolveGeminiWebDataDir(), COOKIE_FILE_NAME);
|
||||
}
|
||||
|
||||
export function resolveGeminiWebChromeProfileDir(): string {
|
||||
const override = process.env.GEMINI_WEB_CHROME_PROFILE_DIR?.trim();
|
||||
if (override) return path.resolve(override);
|
||||
return path.join(resolveGeminiWebDataDir(), PROFILE_DIR_NAME);
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
export interface GeminiWebOptions {
|
||||
youtube?: string;
|
||||
generateImage?: string;
|
||||
editImage?: string;
|
||||
outputPath?: string;
|
||||
showThoughts?: boolean;
|
||||
aspectRatio?: string;
|
||||
}
|
||||
|
||||
export interface GeminiWebResponse {
|
||||
text: string | null;
|
||||
thoughts: string | null;
|
||||
has_images: boolean;
|
||||
image_count: number;
|
||||
error?: string;
|
||||
}
|
||||
Loading…
Reference in New Issue