This commit is contained in:
WJZ_P
2026-03-21 15:16:39 +08:00
4 changed files with 252 additions and 42 deletions

View File

@@ -1,10 +1,26 @@
---
name: gemini-skill
description: 通过 Gemini 官网gemini.google.com执行生图操作。用户提到"生图/画图/绘图/nano banana/nanobanana/生成图片"等关键词时触发。所有浏览器操作已封装为 MCP 工具AI 无需手动操控浏览器但必要时可以通过gemini_browser_info获取浏览器连接信息方便AI自行连接调试
description: 通过 Gemini 官网gemini.google.com执行生图、对话等操作。用户提到"生图/画图/绘图/nano banana/nanobanana/生成图片"等关键词时触发。操作方式分三级优先级:首选 MCP 工具 → 次选 Skill 脚本 → 最次连接 Skill 浏览器手动操作(需用户授权)。禁止自行启动外部浏览器访问 Gemini
---
# Gemini Skill
## ⚠️ 操作优先级(必须遵守)
与 Gemini 的一切交互,按以下优先级选择方式:
1. **🥇 首选:调用 MCP 工具** — 直接调用本 Skill 暴露的 MCP 工具完成操作,覆盖绝大多数场景
2. **🥈 次选:运行 Skill 脚本** — 当 MCP 工具无法满足需求时,可运行本 Skill 项目中提供的脚本来完成
3. **🥉 最次:连接 Skill 管理的浏览器** — 仅当前两种方式都无法解决时,可通过 `gemini_browser_info` 获取 CDP 连接信息,主动连接到本 Skill 管理的浏览器进行操作。**此方式必须先征得用户同意**
**绝对禁止**:自行启动新的浏览器实例访问 Gemini 页面(如使用 OpenClaw 浏览器、另起 Puppeteer 等),这会导致会话冲突。
> 浏览器 Daemon 未运行时 MCP 工具会自动拉起,无需任何手动操作。
## 📡 进度同步
MCP 工具调用(尤其是生图、等待回复等)可能耗时较长。**每隔 15 秒必须主动向用户发送一条进度消息**,告知当前操作状态(如"正在等待 Gemini 生成图片…"、"图片仍在加载中,已等待 30 秒…"),避免用户长时间挂起收不到任何反馈。
## 触发关键词
- **生图任务**`生图``画``绘图``海报``nano banana``nanobanana``image generation``生成图片`
@@ -12,9 +28,9 @@ description: 通过 Gemini 官网gemini.google.com执行生图操作。用
## 使用方式
本 Skill 通过 MCP Server 暴露工具AI 直接调用即可**不需要手动操作浏览器**
本 Skill 通过 MCP Server 暴露工具AI 直接调用即可。
浏览器启动、会话管理、图片提取、文件保存等流程已全部封装在工具内部。Daemon 未运行时会自动后台拉起,无需手动启动。
浏览器启动、会话管理、图片提取、文件保存等流程已全部封装在工具内部。
### ⚠️ 强制规则
@@ -60,6 +76,20 @@ description: 通过 Gemini 官网gemini.google.com执行生图操作。用
| `gemini_upload_images` | 上传图片到输入框(仅上传不发送,可配合 send_message | `images`(路径数组) |
| `gemini_get_images` | 获取会话中所有已加载图片的元信息 | 无 |
| `gemini_extract_image` | 提取指定图片的 base64 并保存到本地 | `imageUrl`(从 get_images 获取) |
| `gemini_download_full_size_image` | 下载完整尺寸的高清图片,默认最新一张,可指定索引 | `index`可选从0开始从旧到新 |
**文字回复提取:**
| 工具名 | 说明 | 入参 |
|--------|------|------|
| `gemini_get_all_text_responses` | 获取会话中所有文字回复(仅文字,不含图片) | 无 |
| `gemini_get_latest_text_response` | 获取最新一条文字回复 | 无 |
**登录状态:**
| 工具名 | 说明 | 入参 |
|--------|------|------|
| `gemini_check_login` | 检查是否已登录 Google 账号 | 无 |
**诊断 & 恢复:**

View File

@@ -94,45 +94,45 @@ async function main() {
}
console.log(`[6] 图片加载完成 (${Date.now() - imgLoadStart}ms)`);
// 7. 获取最新图片并保存到本地
console.log('\n[7] 查找最新生成的图片...');
// 7. 下载完整尺寸的图片(通过 CDP 拦截下载到 outputDir
console.log('\n[7] 下载完整尺寸图片...');
const dlResult = await ops.downloadFullSizeImage();
if (dlResult.ok) {
console.log(`[7] ✅ 完整尺寸图片已保存: ${dlResult.filePath} (原始文件名: ${dlResult.suggestedFilename})`);
} else {
console.warn(`[7] ⚠ 完整尺寸下载失败: ${dlResult.error},回退到 base64 提取...`);
const imgInfo = await ops.getLatestImage();
console.log('imgInfo:', JSON.stringify(imgInfo, null, 2));
// 回退:用 base64 提取
const imgInfo = await ops.getLatestImage();
if (imgInfo.ok && imgInfo.src) {
console.log(`[7] 找到图片 (${imgInfo.width}x${imgInfo.height}, isNew=${imgInfo.isNew})`);
const b64Result = await ops.extractImageBase64(imgInfo.src);
if (imgInfo.ok && imgInfo.src) {
console.log(`[7] 找到图片 (${imgInfo.width}x${imgInfo.height}, isNew=${imgInfo.isNew})`);
if (b64Result.ok && b64Result.dataUrl) {
const matches = b64Result.dataUrl.match(/^data:image\/(\w+);base64,(.+)$/);
if (matches) {
const ext = matches[1] === 'jpeg' ? 'jpg' : matches[1];
const base64Data = matches[2];
const buffer = Buffer.from(base64Data, 'base64');
// 提取 base64 数据
console.log(`[7] 提取图片数据 (src=${imgInfo.src})...`);
const b64Result = await ops.extractImageBase64(imgInfo.src);
const outputDir = './gemini-image';
if (!existsSync(outputDir)) {
mkdirSync(outputDir, { recursive: true });
}
const filename = `gemini_${Date.now()}.${ext}`;
const filepath = join(outputDir, filename);
if (b64Result.ok && b64Result.dataUrl) {
// dataUrl 格式: data:image/png;base64,iVBOR...
const matches = b64Result.dataUrl.match(/^data:image\/(\w+);base64,(.+)$/);
if (matches) {
const ext = matches[1] === 'jpeg' ? 'jpg' : matches[1];
const base64Data = matches[2];
const buffer = Buffer.from(base64Data, 'base64');
// 保存到 ./gemini-image/
const outputDir = './gemini-image';
if (!existsSync(outputDir)) {
mkdirSync(outputDir, { recursive: true });
writeFileSync(filepath, buffer);
console.log(`[7] ✅ 图片已保存(base64回退): ${filepath} (${(buffer.length / 1024).toFixed(1)} KB, method=${b64Result.method})`);
} else {
console.warn('[7] ⚠ dataUrl 格式无法解析');
}
const filename = `gemini_${Date.now()}.${ext}`;
const filepath = join(outputDir, filename);
writeFileSync(filepath, buffer);
console.log(`[7] ✅ 图片已保存: ${filepath} (${(buffer.length / 1024).toFixed(1)} KB, method=${b64Result.method})`);
} else {
console.warn('[7] ⚠ dataUrl 格式无法解析');
console.warn(`[7] ⚠ 提取图片数据失败: ${b64Result.error || 'unknown'}`);
}
} else {
console.warn(`[7] ⚠ 提取图片数据失败: ${b64Result.error || 'unknown'}`);
console.log('[7] 未找到图片(可能本次回答不含图片)');
}
} else {
console.log('[7] 未找到图片(可能本次回答不含图片)');
}
}

View File

@@ -7,6 +7,8 @@
*/
import { createOperator } from './operator.js';
import { sleep } from './util.js';
import config from './config.js';
import { mkdirSync } from 'node:fs';
// ── Gemini 页面元素选择器 ──
const SELECTORS = {
@@ -691,6 +693,131 @@ export function createOps(page) {
});
},
/**
* 下载完整尺寸的图片
*
* 流程:
* 1. 定位目标图片,获取坐标用于 hover
* 2. 通过 CDP Browser.setDownloadBehavior 将下载目录重定向到 config.outputDir
* 3. hover 触发工具栏 → 点击"下载完整尺寸"按钮
* 4. 监听 CDP Browser.downloadWillBegin / Browser.downloadProgress 等待下载完成
* 5. 返回实际保存的文件路径
*
* 按钮选择器button[data-test-id="download-enhanced-image-button"]
*
* @param {object} [options]
* @param {number} [options.index] - 图片索引从0开始从旧到新不传则取最新一张
* @param {number} [options.timeout=30000] - 下载超时时间ms
* @returns {Promise<{ok: boolean, filePath?: string, suggestedFilename?: string, src?: string, index?: number, total?: number, error?: string}>}
*/
async downloadFullSizeImage({ index, timeout = 30_000 } = {}) {
// 1. 定位目标图片,获取其坐标用于 hover
const imgInfo = await op.query((targetIndex) => {
const imgs = [...document.querySelectorAll('img.image.loaded')];
if (!imgs.length) return { ok: false, error: 'no_loaded_images', total: 0 };
const i = targetIndex == null ? imgs.length - 1 : targetIndex;
if (i < 0 || i >= imgs.length) {
return { ok: false, error: 'index_out_of_range', total: imgs.length, requestedIndex: i };
}
const img = imgs[i];
const rect = img.getBoundingClientRect();
return {
ok: true,
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
src: img.src || '',
index: i,
total: imgs.length,
};
}, index);
if (!imgInfo.ok) return imgInfo;
// 2. 通过 CDP 设置下载路径到 config.outputDir
const downloadDir = config.outputDir;
mkdirSync(downloadDir, { recursive: true });
const client = page._client();
await client.send('Browser.setDownloadBehavior', {
behavior: 'allowAndName',
downloadPath: downloadDir,
eventsEnabled: true,
});
// 3. 设置下载监听(在点击前注册,避免遗漏事件)
const downloadPromise = new Promise((resolve, reject) => {
const timer = setTimeout(() => {
client.off('Browser.downloadWillBegin', onBegin);
client.off('Browser.downloadProgress', onProgress);
reject(new Error('download_timeout'));
}, timeout);
let guid = null;
let suggestedFilename = null;
function onBegin(evt) {
guid = evt.guid;
suggestedFilename = evt.suggestedFilename || null;
}
function onProgress(evt) {
if (evt.guid !== guid) return;
if (evt.state === 'completed') {
clearTimeout(timer);
client.off('Browser.downloadWillBegin', onBegin);
client.off('Browser.downloadProgress', onProgress);
resolve({ suggestedFilename });
} else if (evt.state === 'canceled') {
clearTimeout(timer);
client.off('Browser.downloadWillBegin', onBegin);
client.off('Browser.downloadProgress', onProgress);
reject(new Error('download_canceled'));
}
}
client.on('Browser.downloadWillBegin', onBegin);
client.on('Browser.downloadProgress', onProgress);
});
// 4. hover 到图片上,触发工具栏显示
await page.mouse.move(imgInfo.x, imgInfo.y);
await sleep(250);
// 5. 点击"下载完整尺寸"按钮
const btnSelector = 'button[data-test-id="download-enhanced-image-button"]';
const clickResult = await op.click(btnSelector);
if (!clickResult.ok) {
return { ok: false, error: 'full_size_download_btn_not_found', src: imgInfo.src, index: imgInfo.index, total: imgInfo.total };
}
// 6. 等待下载完成
try {
const { suggestedFilename } = await downloadPromise;
const { join } = await import('node:path');
const filePath = join(downloadDir, suggestedFilename || `gemini_fullsize_${Date.now()}.png`);
return {
ok: true,
filePath,
suggestedFilename,
src: imgInfo.src,
index: imgInfo.index,
total: imgInfo.total,
};
} catch (err) {
return {
ok: false,
error: err.message,
src: imgInfo.src,
index: imgInfo.index,
total: imgInfo.total,
};
}
},
// ─── 高层组合操作 ───
/**
@@ -829,11 +956,11 @@ export function createOps(page) {
* @param {object} [opts]
* @param {number} [opts.timeout=120000]
* @param {boolean} [opts.newChat=true]
* @param {boolean} [opts.highRes=false]
* @param {boolean} [opts.fullSize=false] - true 时通过 CDP 拦截下载完整尺寸原图到 outputDirfalse 时提取页面预览图 base64
* @param {(status: object) => void} [opts.onPoll]
*/
async generateImage(prompt, opts = {}) {
const { timeout = 120_000, newChat = true, highRes = false, onPoll } = opts;
const { timeout = 120_000, newChat = true, fullSize = false, onPoll } = opts;
// 1. 可选:新建会话
if (newChat) {
@@ -864,10 +991,12 @@ export function createOps(page) {
}
// 5. 提取 / 下载
if (highRes) {
const dlResult = await this.downloadLatestImage();
return { ok: dlResult.ok, method: 'download', elapsed: waitResult.elapsed, ...dlResult };
if (fullSize) {
// 完整尺寸下载:通过 CDP 拦截,文件保存到 config.outputDir
const dlResult = await this.downloadFullSizeImage();
return { ok: dlResult.ok, method: 'fullSize', elapsed: waitResult.elapsed, ...dlResult };
} else {
// 低分辨率:提取页面预览图的 base64
const b64Result = await this.extractImageBase64(imgInfo.src);
return { ok: b64Result.ok, method: b64Result.method, elapsed: waitResult.elapsed, ...b64Result };
}

View File

@@ -43,10 +43,17 @@ server.registerTool(
};
}
// 需要先处理新建会话(如果需要),因为 generateImage 内部的 newChat 会在上传之后才执行
if (newSession) {
if (newSession) {
await ops.click('newChatBtn');
await sleep(250);
}
}
// 确保是pro会话
const modelCheck = await ops.checkModel();
if (!modelCheck.ok || modelCheck.model !== 'pro') {
await ops.switchToModel('pro');
console.error(`[mcp] 已切换至 pro 模型`);
}
// 如果有参考图,先上传
if (referenceImages.length > 0) {
@@ -65,7 +72,7 @@ server.registerTool(
// 如果上传了参考图且已手动新建会话,则 generateImage 内部不再新建
const needNewChat = referenceImages.length > 0 ? false : newSession;
const result = await ops.generateImage(prompt, { newChat: needNewChat });
const result = await ops.generateImage(prompt, { newChat: needNewChat, fullSize });
// 执行完毕立刻断开,交还给 Daemon 倒计时
disconnect();
@@ -77,7 +84,17 @@ server.registerTool(
};
}
// 将 base64 写入本地文件
// 完整尺寸下载模式:文件已由 CDP 保存到 outputDir
if (result.method === 'fullSize') {
console.error(`[mcp] 完整尺寸图片已保存至 ${result.filePath}`);
return {
content: [
{ type: "text", text: `图片生成成功!完整尺寸原图已保存至: ${result.filePath}` },
],
};
}
// 低分辨率模式base64 提取,写入本地文件
const base64Data = result.dataUrl.split(',')[1];
const mimeMatch = result.dataUrl.match(/^data:(image\/\w+);/);
const ext = mimeMatch ? mimeMatch[1].split('/')[1] : 'png';
@@ -331,6 +348,40 @@ server.registerTool(
}
);
// ─── 完整尺寸图片下载 ───
server.registerTool(
"gemini_download_full_size_image",
{
description: "下载完整尺寸的图片(高清大图)。默认下载最新一张,也可通过 index 指定第几张从0开始从旧到新排列",
inputSchema: {
index: z.number().int().min(0).optional().describe(
"图片索引从0开始按从旧到新排列。不传则下载最新一张"
),
},
},
async ({ index }) => {
try {
const { ops } = await createGeminiSession();
const result = await ops.downloadFullSizeImage({ index });
disconnect();
if (!result.ok) {
let msg = `下载完整尺寸图片失败: ${result.error}`;
if (result.total != null) msg += `(共 ${result.total} 张图片)`;
if (result.error === 'index_out_of_range') msg += `,请求的索引: ${result.requestedIndex}`;
return { content: [{ type: "text", text: msg }], isError: true };
}
return {
content: [{ type: "text", text: `完整尺寸图片已下载(第 ${result.index + 1} 张,共 ${result.total} 张)\n文件路径: ${result.filePath}\n原始文件名: ${result.suggestedFilename || '未知'}` }],
};
} catch (err) {
return { content: [{ type: "text", text: `执行崩溃: ${err.message}` }], isError: true };
}
}
);
// ─── 文字回复获取 ───
server.registerTool(