Merge branch 'main' of https://github.com/WJZ-P/gemini-skill
This commit is contained in:
36
SKILL.md
36
SKILL.md
@@ -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 账号 | 无 |
|
||||
|
||||
**诊断 & 恢复:**
|
||||
|
||||
|
||||
60
src/demo.js
60
src/demo.js
@@ -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] 未找到图片(可能本次回答不含图片)');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 拦截下载完整尺寸原图到 outputDir;false 时提取页面预览图 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 };
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user