diff --git a/SKILL.md b/SKILL.md index 6d91116..93acb8b 100644 --- a/SKILL.md +++ b/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 账号 | 无 | **诊断 & 恢复:** diff --git a/src/demo.js b/src/demo.js index fbc608b..62b0ac0 100644 --- a/src/demo.js +++ b/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] 未找到图片(可能本次回答不含图片)'); } } diff --git a/src/gemini-ops.js b/src/gemini-ops.js index 8446000..0b474e8 100644 --- a/src/gemini-ops.js +++ b/src/gemini-ops.js @@ -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 }; } diff --git a/src/mcp-server.js b/src/mcp-server.js index 51ecb91..8c7b0ea 100644 --- a/src/mcp-server.js +++ b/src/mcp-server.js @@ -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(