Files
wehub-resource-sync 9b395f5cc3
Build Chrome Extension / build (push) Waiting to run
Trigger Website Rebuild (Docs Updated) / dispatch (push) Waiting to run
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

275 lines
12 KiB
JavaScript

import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
import {
GEMINI_DOMAIN,
ensureGeminiPage,
getCurrentGeminiModel,
readGeminiSnapshot,
selectGeminiModel,
selectGeminiThinking,
sendGeminiMessage,
startNewGeminiChat,
waitForGeminiResponse,
waitForGeminiSubmission,
} from './utils.js';
import { pickModelPickerScript, readMenuModelsScript } from './models.js';
function normalizeBooleanFlag(value) {
if (typeof value === 'boolean')
return value;
const normalized = String(value ?? '').trim().toLowerCase();
return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on';
}
const NO_RESPONSE_PREFIX = '[NO RESPONSE]';
/**
* Validate a --model value for gemini ask.
* Throws ArgumentError for short aliases or invalid formats.
*/
function unwrapBrowserBridgeEnvelope(value) {
if (value && typeof value === 'object' && !Array.isArray(value) && 'session' in value) {
if ('data' in value) return value.data;
throw new CommandExecutionError('Gemini model discovery returned a malformed Browser Bridge envelope');
}
return value;
}
function requireDiscoveredModels(value) {
const unwrapped = unwrapBrowserBridgeEnvelope(value);
if (!Array.isArray(unwrapped)) {
throw new CommandExecutionError('Gemini model discovery returned a malformed result');
}
for (const row of unwrapped) {
if (!row || typeof row.model !== 'string' || !Array.isArray(row.thinkingValues)) {
throw new CommandExecutionError('Gemini model discovery returned a malformed row');
}
}
return unwrapped;
}
function validateAskModelValue(value) {
if (!value) {
throw new ArgumentError(
'--model requires a canonical model id (e.g. "2.5-flash"). ' +
'Use "opencli gemini models" to list available values.'
);
}
// Reject short aliases like "pro", "flash", "flash-lite" that lack a version number.
if (!/\d+\.\d+/.test(value)) {
throw new ArgumentError(
'--model "' + value + '" is not accepted. ' +
'Short aliases like "pro", "flash", or "flash-lite" are not supported. ' +
'Use a canonical model id (e.g. "2.5-flash"). ' +
'Use "opencli gemini models" to list available values.'
);
}
// Must match canonical format: X.Y-variant
if (!/^\d+\.\d+-[a-z][a-z-]*$/.test(value)) {
throw new ArgumentError(
'--model "' + value + '" is not a valid canonical model id. ' +
'Expected format: version-variant (e.g. "2.5-flash", "3.1-pro"). ' +
'Use "opencli gemini models" to list available values.'
);
}
}
export const __test__ = {
validateAskModelValue,
};
export const askCommand = cli({
site: 'gemini',
name: 'ask',
access: 'write',
description: 'Send a prompt to Gemini and return only the assistant response',
domain: GEMINI_DOMAIN,
strategy: Strategy.COOKIE,
browser: true,
siteSession: 'persistent',
navigateBefore: false,
defaultFormat: 'plain',
args: [
{ name: 'prompt', required: true, positional: true, help: 'Prompt to send' },
{ name: 'model', type: 'string', required: false, help: 'Gemini model to use (e.g. "2.5-flash"). Use "opencli gemini models" to list available values.' },
{ name: 'timeout', type: 'int', required: false, help: 'Max seconds to wait (default: 60)', default: 60 },
{ name: 'new', required: false, help: 'Start a new chat first (true/false, default: false)', default: 'false' },
{ name: 'thinking', required: false, help: 'Thinking level: standard or extended (omitted = leave unchanged)', default: null },
],
columns: ['response'],
func: async (page, kwargs) => {
const prompt = kwargs.prompt;
const timeout = kwargs.timeout;
if (!Number.isInteger(timeout) || timeout < 1) {
throw new ArgumentError('--timeout must be a positive integer (seconds)');
}
// ── New chat (must happen before model/thinking selection) ──────
const startFresh = normalizeBooleanFlag(kwargs.new);
if (startFresh)
await startNewGeminiChat(page);
// ── Early validation: model format & thinking value ────────────
const hasModel = kwargs.model !== undefined && kwargs.model !== null;
const hasThinking = kwargs.thinking != null;
let modelValue = null;
if (hasModel) {
modelValue = String(kwargs.model).trim();
validateAskModelValue(modelValue);
}
if (hasThinking) {
const thinkingRaw = String(kwargs.thinking ?? '').trim();
const thinkingValue = thinkingRaw.toLowerCase();
if (thinkingValue !== 'standard' && thinkingValue !== 'extended') {
throw new ArgumentError(
`--thinking must be 'standard' or 'extended', got '${thinkingRaw}'`,
'Run `opencli gemini models` to see available thinking levels.',
);
}
}
// ── Model and thinking discovery (shared by both model and
// thinking selection) ──────────────────────────────────────
let discoveredModels = null;
if (hasModel || hasThinking) {
await ensureGeminiPage(page);
// Open the picker menu (click the model-picker button).
const pickerRaw = await page.evaluate(`
(() => {
${pickModelPickerScript()}
const picker = findModelPicker();
if (!picker) return { ok: false, reason: 'Gemini model picker button was not found' };
try { picker.click(); } catch (_) { return { ok: false, reason: 'Failed to click Gemini model picker button' }; }
return { ok: true };
})()
`);
const pickerResult = unwrapBrowserBridgeEnvelope(pickerRaw);
if (!pickerResult || typeof pickerResult !== 'object' || !pickerResult.ok) {
throw new CommandExecutionError(
pickerResult?.reason || 'Failed to open Gemini model picker for model discovery'
);
}
// Wait for React to render the menu.
await page.wait(1.0);
// Read model entries from the open menu.
const raw = await page.evaluate(readMenuModelsScript());
discoveredModels = requireDiscoveredModels(raw);
// Close the menu. Thinking support is intentionally not copied from
// the currently-visible UI into every model row: Gemini exposes
// thinking controls for the current selection, not a reliable
// per-model capability matrix.
await page.evaluate(`(() => { try { document.body.click(); } catch (_) {} })()`);
}
// ── Model selection ──────────────────────────────────────────────
if (hasModel) {
const availableModels = discoveredModels || [];
if (!Array.isArray(availableModels) || availableModels.length === 0) {
throw new CommandExecutionError(
'Gemini model discovery returned no selectable models. Gemini Web may have changed its model selector UI.'
);
}
const availableIds = availableModels.map((m) => m?.model).filter(Boolean);
if (!availableIds.includes(modelValue)) {
throw new ArgumentError(
'Unknown model "' + modelValue + '". ' +
'Available models: ' + availableIds.join(', ') + '. ' +
'Use "opencli gemini models" to see available values.'
);
}
await selectGeminiModel(page, modelValue);
}
// ── Thinking validation and selection ──────────────────────────
if (hasThinking) {
// Reuse the pre-discovered models from the shared discovery call.
const discovered = discoveredModels || [];
// When --model was supplied, scope thinking to the selected model;
// otherwise scope to the current model from the web UI.
let targetModelId;
if (hasModel) {
targetModelId = modelValue;
} else {
targetModelId = await getCurrentGeminiModel(page);
}
// Build a map of model → thinkingValues for lookup.
const modelThinkingMap = new Map();
const allThinking = new Set();
for (const entry of discovered) {
if (entry && typeof entry.model === 'string' && Array.isArray(entry.thinkingValues)) {
const tvs = entry.thinkingValues.filter((tv) => typeof tv === 'string');
if (tvs.length > 0) {
modelThinkingMap.set(entry.model, tvs);
for (const tv of tvs) allThinking.add(tv);
}
}
}
// Resolve thinkingValue from early validation.
const thinkingValue = String(kwargs.thinking ?? '').trim().toLowerCase();
// Validate: if we can identify the target model, scope to its
// thinking values; otherwise fall back to the union across all models.
const targetModelThinking =
targetModelId && modelThinkingMap.has(targetModelId)
? modelThinkingMap.get(targetModelId)
: null;
if (targetModelThinking) {
// Scoped to the target model.
if (!targetModelThinking.includes(thinkingValue)) {
const availableForModel = targetModelThinking.sort().join(', ');
throw new ArgumentError(
`--thinking '${thinkingValue}' is not available for the ${hasModel ? 'selected' : 'current'} model ('${targetModelId}')`,
`Model '${targetModelId}' supports: ${availableForModel}. Run \`opencli gemini models\` for all models.`,
);
}
} else if (allThinking.size > 0 && !allThinking.has(thinkingValue)) {
// Union fallback when the target model cannot be identified.
const availableList = [...allThinking].sort().join(', ');
throw new ArgumentError(
`--thinking '${thinkingValue}' is not currently available`,
`Available thinking values: ${availableList}. Run \`opencli gemini models\` for details.`,
);
}
// Select the requested thinking level before snapshot.
const selected = await selectGeminiThinking(page, thinkingValue);
if (!selected) {
// Build an informative hint from what we know.
const hintParts = [];
if (targetModelThinking && targetModelThinking.length > 0) {
hintParts.push(`Model '${targetModelId}' supports: ${targetModelThinking.sort().join(', ')}.`);
} else if (allThinking.size > 0) {
hintParts.push(`Available thinking values: ${[...allThinking].sort().join(', ')}.`);
}
hintParts.push('Run `opencli gemini models` for details.');
throw new ArgumentError(
`Could not select thinking level '${thinkingValue}' in the Gemini web UI`,
hintParts.join(' '),
);
}
}
const before = await readGeminiSnapshot(page);
await sendGeminiMessage(page, prompt);
const submissionStartedAt = Date.now();
const submitted = await waitForGeminiSubmission(page, before, timeout);
if (!submitted) {
return [{ response: `💬 ${NO_RESPONSE_PREFIX} No Gemini response within ${timeout}s.` }];
}
const remainingTimeoutSeconds = Math.max(0, timeout - Math.ceil((Date.now() - submissionStartedAt) / 1000));
const response = await waitForGeminiResponse(page, submitted, prompt, remainingTimeoutSeconds);
if (!response) {
return [{ response: `💬 ${NO_RESPONSE_PREFIX} No Gemini response within ${timeout}s.` }];
}
return [{ response: `💬 ${response}` }];
},
});