Files
wehub-resource-sync 9b395f5cc3
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
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

499 lines
18 KiB
JavaScript

import { cli, Strategy } from '@jackwener/opencli/registry';
import { CommandExecutionError } from '@jackwener/opencli/errors';
import { GEMINI_DOMAIN, ensureGeminiPage } from './utils.js';
function isObjectRecord(value) {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function unwrapBrowserBridgeEnvelope(value, context) {
if (isObjectRecord(value) && Object.prototype.hasOwnProperty.call(value, 'session')) {
if (Object.prototype.hasOwnProperty.call(value, 'data')) {
return value.data;
}
throw new CommandExecutionError(`${context} returned a malformed Browser Bridge envelope`);
}
return value;
}
// ── Shared helpers (embedded in browser scripts) ──────────────────────────
function sharedHelpers() {
return `
const isVisible = (el) => {
if (!el) return false;
if (!(el instanceof HTMLElement) && !(el instanceof Element)) return false;
if (el.hidden || el.closest('[hidden]')) return false;
const ariaHidden = el.getAttribute('aria-hidden');
if (ariaHidden && ariaHidden.toLowerCase() === 'true') return false;
if (el.closest('[aria-hidden="true"]')) return false;
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return false;
if (Number(style.opacity) === 0 || style.pointerEvents === 'none') return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim().toLowerCase();
const VERSION_LABEL_RE = /\\d+\\.\\d+/;
`;
}
function findModelPickerLogic() {
return `
// Patterns for detecting model/mode selector buttons by aria-label.
const MODE_SELECTOR_PATTERNS = [
/模式选择器/i,
/mode[\\s-]*selector/i,
/model[\\s-]*selector/i,
/model[\\s-]*picker/i,
/选择模型/i,
/select[\\s-]+model/i,
/choose[\\s-]+model/i,
/switch[\\s-]+model/i,
/change[\\s-]+model/i,
];
const findModelPicker = () => {
const buttons = Array.from(
document.querySelectorAll('button, [role="button"]')
).filter(isVisible);
// Method 1: Detect model/mode selector via aria-label patterns.
for (const button of buttons) {
const aria = normalize(button.getAttribute('aria-label') || '');
for (const pattern of MODE_SELECTOR_PATTERNS) {
if (pattern.test(aria)) return button;
}
}
// Method 2: Detect buttons whose text contains a model-version pattern.
const versionCandidates = buttons.filter((b) => {
const text = normalize(b.textContent || '') || normalize(b.getAttribute('aria-label') || '');
return VERSION_LABEL_RE.test(text) && text.length < 80;
});
versionCandidates.sort((a, b) => {
const aRect = a.getBoundingClientRect();
const bRect = b.getBoundingClientRect();
return aRect.top - bRect.top || aRect.left - bRect.left;
});
if (versionCandidates.length > 0) return versionCandidates[0];
// Method 3: Detect buttons showing a known model variant as their
// sole text (e.g. "Pro", "Flash", "Flash-Lite").
const MODEL_VARIANT_RE = /^(?:gemini\\s+)?(flash|lite|pro|ultra|nano|flash-lite|flash[\\s-]*thinking)$/i;
const variantCandidates = buttons.filter((b) => {
const text = normalize(b.textContent || '');
return MODEL_VARIANT_RE.test(text) && text.length < 30;
});
variantCandidates.sort((a, b) => {
const aRect = a.getBoundingClientRect();
const bRect = b.getBoundingClientRect();
return aRect.top - bRect.top || aRect.left - bRect.left;
});
if (variantCandidates.length > 0) return variantCandidates[0];
// Method 4: Fallback — look for any element with model-related attributes.
const attrEls = Array.from(
document.querySelectorAll('[data-model-selector], [aria-label*="model" i], [aria-label*="模式" i]')
).filter(isVisible);
if (attrEls.length > 0) return attrEls[0];
return null;
};
`;
}
function modelParsingLogic() {
return `
/**
* Best-effort extraction of a canonical model id from display text.
* Handles patterns like "3.1 flash-lite", "3.5 flash", "3.1 pro",
* "2.5-flash-thinking", "gemini 3.0 pro experimental", and entries with
* trailing Chinese descriptions (e.g. "3.1 Flash-Lite 极速回答").
*/
const canonicalModelId = (raw) => {
const text = normalize(raw);
if (!text) return '';
// Remove leading decorative characters (e.g. checkmark icons).
const cleaned = text.replace(/^[^a-z0-9]+/i, '').trim();
// Known model-version patterns: "X.Y variant" or "X.Y-variant".
const versionRe = /^((?:gemini[\\s-]*)?(\\d+(?:\\.\\d+)?)(?:[\\s-]+(flash|pro|lite|ultra|nano|thinking|experimental))(?:[\\s-]*(flash|pro|lite|ultra|nano|thinking|experimental))?)/i;
const match = cleaned.match(versionRe);
if (match) {
const version = match[2];
let variant = (match[3] || '').toLowerCase();
const extra = (match[4] || '').toLowerCase();
if (extra) variant = variant + '-' + extra;
return version + '-' + variant;
}
// Fallback: if the text contains a version number and a known keyword.
const fallbackRe = /(\\d+(?:\\.\\d+)?)\\s*(flash|pro|lite|ultra|nano|thinking|experimental)/i;
const fallbackMatch = cleaned.match(fallbackRe);
if (fallbackMatch) {
return fallbackMatch[1] + '-' + fallbackMatch[2].toLowerCase();
}
// For experimental / custom models: use a cleaned version of the label.
if (/\\d+(?:\\.\\d+)?/.test(cleaned) && cleaned.length < 60) {
return cleaned.replace(/\\s+/g, '-').replace(/[^a-z0-9.-]/g, '');
}
return '';
};
`;
}
function thinkingExtractionLogic() {
return `
/**
* Extract thinking values from the full set of visible menu items.
* Gemini Web shows thinking levels as separate menu items
* (e.g. "标准 最适合回答大多数问题", "扩展 擅长解决复杂问题").
* Returns a deduplicated, stable-order array.
*/
const extractAllThinkingValues = (menuItems) => {
const found = new Map();
const THINKING_PATTERNS = [
{ value: 'standard', patterns: [/\\bstandard\\b/i, /标准/i], priority: 1 },
{ value: 'extended', patterns: [/\\bextended\\b/i, /扩展/i], priority: 2 },
];
for (const item of menuItems) {
const itemText = normalize(item.textContent || '');
const itemAria = normalize(item.getAttribute('aria-label') || '');
const combined = itemText + ' ' + itemAria;
// Skip model entries (contain a version like "3.1").
if (/\\d+\\.\\d+/.test(combined)) continue;
// Skip the thinking-section header itself (e.g. "思考等级").
if (/^思考等级|^thinking level|^thinking mode/i.test(combined.trim())) continue;
for (const { value, patterns } of THINKING_PATTERNS) {
for (const re of patterns) {
if (re.test(combined)) {
found.set(value, true);
break;
}
}
}
}
const result = [];
for (const { value } of THINKING_PATTERNS) {
if (found.has(value)) result.push(value);
}
return result;
};
`;
}
function menuEnumerationLogic() {
return `
const MENU_SELECTORS = [
'[role="menu"] [role="menuitem"]',
'[role="menu"] [role="menuitemradio"]',
'[role="listbox"] [role="option"]',
'[role="menu"] button',
'[role="listbox"] button',
'[role="menu"] li',
'[role="listbox"] li',
'[role="dialog"] [role="menuitem"]',
'[role="dialog"] [role="option"]',
'[aria-modal="true"] [role="menuitem"]',
'[aria-modal="true"] [role="option"]',
'gem-menu-item',
'GEM-MENU-ITEM',
'[role="menu"] gem-menu-item',
'[role="menu"] GEM-MENU-ITEM',
'[role="listbox"] gem-menu-item',
'[role="listbox"] GEM-MENU-ITEM',
'[role="menu"] :not(script):not(style)',
'[role="listbox"] :not(script):not(style)',
];
const findVisibleMenuItems = () => {
let menuItems = [];
for (const sel of MENU_SELECTORS) {
const items = Array.from(document.querySelectorAll(sel)).filter(isVisible);
if (items.length >= 2) { menuItems = items; break; }
}
if (menuItems.length === 0) {
const containers = Array.from(
document.querySelectorAll('[role="menu"], [role="listbox"], [role="dialog"], [aria-modal="true"]')
).filter(isVisible);
for (const container of containers) {
const children = Array.from(
container.querySelectorAll('button, [role="button"], li, [role="menuitem"], [role="option"], gem-menu-item, GEM-MENU-ITEM, :not(script):not(style)')
).filter(isVisible);
if (children.length >= 2) { menuItems = children; break; }
}
}
return menuItems;
};
`;
}
function readMenuAndCloseLogic() {
return `
const menuItems = findVisibleMenuItems();
// ── Parse models from menu items ──────────────────────────────────
const results = [];
const seen = new Set();
for (const item of menuItems) {
if (!item || (!(item instanceof HTMLElement) && !(item instanceof Element))) continue;
const itemText = (item.textContent || '').replace(/\\s+/g, ' ').trim();
const modelId = canonicalModelId(itemText);
if (!modelId) continue;
if (seen.has(modelId)) continue;
seen.add(modelId);
results.push({ model: modelId, thinkingValues: [] });
}
// Guard: only thinking options, no model entries → bail out.
const hasModelEntries = results.some((r) => {
return /\\d+\\.\\d+/.test(r.model) || /flash|pro|lite/i.test(r.model);
});
if (!hasModelEntries) {
try { document.body.click(); } catch (_) {}
return [];
}
// Do not copy the thinking controls visible for the current Gemini UI
// selection onto every model row. They are not a reliable per-model
// capability matrix, so rows only report thinkingValues when a future UI
// exposes them directly on that model entry.
// ── Close the menu ────────────────────────────────────────────────
try { document.body.click(); } catch (_) {}
return results;
`;
}
// ── Exported script builders ──────────────────────────────────────────────
/**
* Complete discovery script that finds the model picker, opens it,
* reads model rows, and closes the menu.
* Used by gemini ask for model/thinking discovery.
* Uses .click() to trigger React event handlers on Gemini Web.
*/
export function discoverModelsScript() {
return `
(() => {
${sharedHelpers()}
${findModelPickerLogic()}
${modelParsingLogic()}
${thinkingExtractionLogic()}
${menuEnumerationLogic()}
const picker = findModelPicker();
if (!picker) return [];
// Use native .click() — Gemini Web is a React app and synthetic
// event dispatch may not trigger React's event handlers.
try { picker.click(); } catch (_) { throw new Error('Failed to click Gemini model picker button'); }
${readMenuAndCloseLogic()}
})()
`;
}
/**
* Script that only finds and clicks the model-picker button.
* Does NOT read the menu — only locates and clicks the picker.
*/
export function pickModelPickerScript() {
return `${sharedHelpers()}${findModelPickerLogic()}`;
}
/**
* Script that reads model rows from an already-open Gemini Web
* model-picker menu, then closes the menu.
*/
export function readMenuScript() {
return `
(() => {
${sharedHelpers()}
${modelParsingLogic()}
${thinkingExtractionLogic()}
${menuEnumerationLogic()}
${readMenuAndCloseLogic()}
})()
`;
}
/**
* Script that reads model entries from an open menu (no thinking extraction,
* no menu close). Returns {models: [...], hasThinkingToggle: bool}.
*/
export function readMenuModelsScript() {
return `
(() => {
${sharedHelpers()}
${modelParsingLogic()}
${menuEnumerationLogic()}
const menuItems = findVisibleMenuItems();
const results = [];
const seen = new Set();
for (const item of menuItems) {
if (!item || (!(item instanceof HTMLElement) && !(item instanceof Element))) continue;
const itemText = (item.textContent || '').replace(/\\s+/g, ' ').trim();
const modelId = canonicalModelId(itemText);
if (!modelId) continue;
if (seen.has(modelId)) continue;
seen.add(modelId);
results.push({ model: modelId, thinkingValues: [] });
}
const hasModelEntries = results.some((r) => {
return /\\d+\\.\\d+/.test(r.model) || /flash|pro|lite/i.test(r.model);
});
if (!hasModelEntries) return [];
return results;
})()
`;
}
/**
* Script that clicks the "思考等级" / "Thinking level" toggle in the
* currently-open menu to expand thinking options.
* Returns true if the toggle was found and clicked.
*/
export function clickThinkingToggleScript() {
return `
(() => {
const isVisible = (el) => {
if (!el) return false;
if (!(el instanceof HTMLElement) && !(el instanceof Element)) return false;
if (el.hidden || el.closest('[hidden]')) return false;
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
const menuItems = Array.from(
document.querySelectorAll('[role="menuitem"], gem-menu-item, GEM-MENU-ITEM')
).filter(isVisible);
const thinkingToggle = menuItems.find((item) => {
const text = (item.textContent || '').trim();
return /思考等级|thinking level|thinking mode/i.test(text);
});
if (thinkingToggle) {
try { thinkingToggle.click(); } catch (_) { return false; }
return true;
}
return false;
})()
`;
}
/**
* Script that extracts thinking values from the currently-open menu.
* Looks for menu items whose text matches known thinking-level strings.
*/
export function extractThinkingScript() {
return `
(() => {
${sharedHelpers()}
${thinkingExtractionLogic()}
${menuEnumerationLogic()}
const menuItems = findVisibleMenuItems();
return extractAllThinkingValues(menuItems);
})()
`;
}
export const __test__ = {
discoverModelsScript,
pickModelPickerScript,
readMenuScript,
readMenuModelsScript,
clickThinkingToggleScript,
extractThinkingScript,
};
// ── Command registration ──────────────────────────────────────────────────
export const modelsCommand = cli({
site: 'gemini',
name: 'models',
access: 'read',
description: 'List available Gemini models from the web UI',
domain: GEMINI_DOMAIN,
strategy: Strategy.COOKIE,
browser: true,
siteSession: 'persistent',
navigateBefore: false,
args: [],
columns: ['model', 'thinkingValues'],
func: async (page) => {
await ensureGeminiPage(page);
// Step 1: 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, 'Gemini model picker');
if (!pickerResult || !pickerResult.ok) {
throw new CommandExecutionError(
(pickerResult && pickerResult.reason)
? `${pickerResult.reason}. Gemini Web may have changed its model selector UI.`
: 'Failed to open Gemini model picker. Gemini Web may have changed its model selector UI.'
);
}
// Step 2: Wait for React to render the menu.
await page.wait(1.0);
// Step 3: Read model entries from the open menu.
const raw = await page.evaluate(readMenuModelsScript());
const result = unwrapBrowserBridgeEnvelope(raw, 'Gemini models discovery');
if (!Array.isArray(result)) {
throw new CommandExecutionError('Gemini models discovery returned unexpected data');
}
for (const row of result) {
if (!row || typeof row.model !== 'string' || !Array.isArray(row.thinkingValues)) {
throw new CommandExecutionError('Gemini models discovery returned a malformed row');
}
}
// Step 4: Close the menu. The current Gemini UI can show thinking
// controls for the active model, but that is not a reliable per-model
// support matrix, so the command does not copy those values to every
// model row.
// Step 5: Close the menu.
await page.evaluate(`(() => { try { document.body.click(); } catch (_) {} })()`);
return result;
},
});