From c258a402f41a4f9f6bbb2d4e571f6bf38de35e6e Mon Sep 17 00:00:00 2001 From: knowen <1369727119@qq.com> Date: Thu, 19 Mar 2026 20:38:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20=E6=96=B0=E5=A2=9E=E5=A4=9A?= =?UTF-8?q?=E7=A7=8D=20Gemini=20=E5=B7=A5=E5=85=B7=EF=BC=8C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E6=96=87=E6=9C=AC=E4=BA=A4=E4=BA=92=E3=80=81=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E4=B8=8A=E4=BC=A0=E6=8F=90=E5=8F=96=E5=8F=8A=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E6=A8=A1=E5=9E=8B=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SKILL.md | 63 ++++++-- src/browser.js | 3 +- src/gemini-ops.js | 47 +++++- src/mcp-server.js | 355 +++++++++++++++++++++++++++++++++++++++++++++- src/operator.js | 5 +- src/util.js | 12 ++ 6 files changed, 466 insertions(+), 19 deletions(-) create mode 100644 src/util.js diff --git a/SKILL.md b/SKILL.md index 4791d75..f899372 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,6 +1,6 @@ --- name: gemini-skill -description: 通过 Gemini 官网(gemini.google.com)执行生图操作。用户提到"生图/画图/绘图/nano banana/nanobanana/生成图片"等关键词时触发。所有浏览器操作已封装为 MCP 工具,AI 无需手动操控浏览器,但必要时可以通过gemini_browser_info获取浏览器连接信息,如CDP连接端口,方便AI自行连接调试。 +description: 通过 Gemini 官网(gemini.google.com)执行生图操作。用户提到"生图/画图/绘图/nano banana/nanobanana/生成图片"等关键词时触发。所有浏览器操作已封装为 MCP 工具,AI 无需手动操控浏览器,但必要时可以通过gemini_browser_info获取浏览器连接信息,方便AI自行连接调试。 --- # Gemini Skill @@ -18,22 +18,63 @@ description: 通过 Gemini 官网(gemini.google.com)执行生图操作。用 ### 可用工具 +**核心生图(封装完整流程):** + | 工具名 | 说明 | 入参 | |--------|------|------| -| `gemini_generate_image` | 生成图片,返回本地文件路径 + base64 图片 | `prompt`(描述词),`newSession`(是否新建会话,默认 false) | -| `gemini_browser_info` | 获取浏览器连接信息(CDP 端口、wsEndpoint、Daemon 状态等) | 无 | +| `gemini_generate_image` | 完整生图流程:新建会话→发prompt→等待→提取图片→保存本地 | `prompt`,`newSession`(默认false),`referenceImages`(参考图路径数组,默认空) | -### 典型调用流程 +**会话管理:** -1. 用户说"帮我画一张猫咪的图" -2. 调用 `gemini_generate_image`,传入 prompt -3. 工具返回本地图片路径和 base64 数据 -4. 将图片展示给用户 +| 工具名 | 说明 | 入参 | +|--------|------|------| +| `gemini_new_chat` | 新建一个空白对话 | 无 | +| `gemini_temp_chat` | 进入临时对话模式(不保留历史记录) | 无 | -### 参数说明 +**模型切换:** -- `newSession: false`(默认)— 复用当前 Gemini 会话页,适合连续生图 -- `newSession: true` — 新建干净会话,适合全新主题 +| 工具名 | 说明 | 入参 | +|--------|------|------| +| `gemini_switch_model` | 切换 Gemini 模型 | `model`(`pro` / `quick` / `think`) | + +**文本对话:** + +| 工具名 | 说明 | 入参 | +|--------|------|------| +| `gemini_send_message` | 发送文本消息并等待回答完成 | `message`,`timeout`(默认120000ms) | + +**图片操作:** + +| 工具名 | 说明 | 入参 | +|--------|------|------| +| `gemini_upload_images` | 上传图片到输入框(仅上传不发送,可配合 send_message) | `images`(路径数组) | +| `gemini_get_images` | 获取会话中所有已加载图片的元信息 | 无 | +| `gemini_extract_image` | 提取指定图片的 base64 并保存到本地 | `imageUrl`(从 get_images 获取) | + +**诊断 & 恢复:** + +| 工具名 | 说明 | 入参 | +|--------|------|------| +| `gemini_probe` | 探测页面各元素状态(输入框、按钮、模型等) | 无 | +| `gemini_reload_page` | 刷新页面(卡住或异常时使用) | `timeout`(默认30000ms) | +| `gemini_browser_info` | 获取浏览器连接信息(CDP 端口、wsEndpoint 等) | 无 | + +### 典型用法 + +**快速生图(一步到位):** +1. 调用 `gemini_generate_image`,传入 prompt → 返回本地图片路径 + +**灵活组合(细粒度控制):** +1. `gemini_new_chat` — 新建会话 +2. `gemini_switch_model` → `pro` — 切换到高质量模型 +3. `gemini_upload_images` — 上传参考图 +4. `gemini_send_message` — 发送描述词 +5. `gemini_get_images` → `gemini_extract_image` — 获取并保存图片 + +**排障:** +1. `gemini_probe` — 看看页面元素有没有就位 +2. `gemini_reload_page` — 页面卡了就刷新 +3. `gemini_browser_info` — 获取 CDP 信息自行连接调试 ## MCP 客户端配置 diff --git a/src/browser.js b/src/browser.js index 1930d9a..8ab7beb 100644 --- a/src/browser.js +++ b/src/browser.js @@ -21,6 +21,7 @@ import puppeteerCore from 'puppeteer-core'; import { addExtra } from 'puppeteer-extra'; import StealthPlugin from 'puppeteer-extra-plugin-stealth'; import config from './config.js'; +import { sleep } from './util.js'; // connect 也套上 Stealth,双保险 const puppeteer = addExtra(puppeteerCore); @@ -93,7 +94,7 @@ async function ensureDaemon() { // 轮询等待就绪 const deadline = Date.now() + DAEMON_READY_TIMEOUT; while (Date.now() < deadline) { - await new Promise(r => setTimeout(r, DAEMON_POLL_INTERVAL)); + await sleep(DAEMON_POLL_INTERVAL); if (await isDaemonAlive()) { console.log('[browser] ✅ Daemon 就绪'); return; diff --git a/src/gemini-ops.js b/src/gemini-ops.js index 7e9c7b1..8073022 100644 --- a/src/gemini-ops.js +++ b/src/gemini-ops.js @@ -409,6 +409,49 @@ export function createOps(page) { return isImageLoaded(op); }, + /** + * 获取当前会话中所有 Gemini 的文字回复 + * + * 选择器:div.response-content + * 直接使用 innerText 提取渲染后的文本,浏览器排版引擎会自动处理换行和格式 + * + * @returns {Promise<{ok: boolean, responses: Array<{index: number, text: string}>, total: number, error?: string}>} + */ + async getAllTextResponses() { + return op.query(() => { + const divs = [...document.querySelectorAll('div.response-content')]; + if (!divs.length) { + return { ok: false, responses: [], total: 0, error: 'no_responses' }; + } + + const responses = divs.map((div, i) => ({ + index: i, + text: (div.innerText || '').trim(), + })); + + return { ok: true, responses, total: responses.length }; + }); + }, + + /** + * 获取最新一条 Gemini 文字回复 + * + * 取最后一个 div.response-content,使用 innerText 提取渲染后的文本 + * + * @returns {Promise<{ok: boolean, text?: string, index?: number, error?: string}>} + */ + async getLatestTextResponse() { + return op.query(() => { + const divs = [...document.querySelectorAll('div.response-content')]; + if (!divs.length) { + return { ok: false, error: 'no_responses' }; + } + + const last = divs[divs.length - 1]; + return { ok: true, text: (last.innerText || '').trim(), index: divs.length - 1 }; + }); + }, + /** * 获取本次会话中所有已加载的图片 * @@ -877,6 +920,4 @@ function isImageLoaded(op) { }); } -function sleep(ms) { - return new Promise(r => setTimeout(r, ms)); -} + diff --git a/src/mcp-server.js b/src/mcp-server.js index 097517e..5b71462 100644 --- a/src/mcp-server.js +++ b/src/mcp-server.js @@ -23,12 +23,39 @@ server.registerTool( newSession: z.boolean().default(false).describe( "是否新建会话。true= 开启全新对话; false = 复用当前已有的 Gemini 会话页" ), + referenceImages: z.array(z.string()).default([]).describe( + "参考图片的本地文件路径数组,例如 [\"/path/to/ref1.png\", \"/path/to/ref2.jpg\"]。图片会在发送 prompt 前上传到 Gemini 输入框" + ), }, }, - async ({ prompt, newSession }) => { + async ({ prompt, newSession, referenceImages }) => { try { const { ops } = await createGeminiSession(); - const result = await ops.generateImage(prompt, { newChat: newSession }); + + // 如果有参考图,先上传 + if (referenceImages.length > 0) { + // 需要先处理新建会话(如果需要),因为 generateImage 内部的 newChat 会在上传之后才执行 + if (newSession) { + await ops.click('newChatBtn'); + await sleep(500); + } + + for (const imgPath of referenceImages) { + console.error(`[mcp] 正在上传参考图: ${imgPath}`); + const uploadResult = await ops.uploadImage(imgPath); + if (!uploadResult.ok) { + return { + content: [{ type: "text", text: `参考图上传失败: ${imgPath}\n错误: ${uploadResult.error}` }], + isError: true, + }; + } + } + console.error(`[mcp] ${referenceImages.length} 张参考图上传完成`); + } + + // 如果上传了参考图且已手动新建会话,则 generateImage 内部不再新建 + const needNewChat = referenceImages.length > 0 ? false : newSession; + const result = await ops.generateImage(prompt, { newChat: needNewChat }); // 执行完毕立刻断开,交还给 Daemon 倒计时 disconnect(); @@ -71,6 +98,330 @@ server.registerTool( } ); +// ─── 会话管理 ─── + +// 新建会话 +server.registerTool( + "gemini_new_chat", + { + description: "在 Gemini 中新建一个空白对话", + inputSchema: {}, + }, + async () => { + try { + const { ops } = await createGeminiSession(); + const result = await ops.click('newChatBtn'); + disconnect(); + + if (!result.ok) { + return { content: [{ type: "text", text: `新建会话失败: ${result.error}` }], isError: true }; + } + return { content: [{ type: "text", text: "已新建 Gemini 会话" }] }; + } catch (err) { + return { content: [{ type: "text", text: `执行崩溃: ${err.message}` }], isError: true }; + } + } +); + +// 临时会话 +server.registerTool( + "gemini_temp_chat", + { + description: "进入 Gemini 临时对话模式(不保留历史记录,适合隐私场景)。注意:临时会话按钮仅在空白新会话页面可见,本工具会自动先新建会话再进入临时模式", + inputSchema: {}, + }, + async () => { + try { + const { ops } = await createGeminiSession(); + + // 临时会话按钮仅在空白新会话页可见,当前会话有内容时会被隐藏 + // 因此必须先新建会话,确保页面回到空白状态 + const newChatResult = await ops.click('newChatBtn'); + if (!newChatResult.ok) { + disconnect(); + return { content: [{ type: "text", text: `前置步骤失败:无法新建会话(临时会话按钮仅在空白页可见): ${newChatResult.error}` }], isError: true }; + } + // 等待新会话页面稳定 + await sleep(250); + + const result = await ops.clickTempChat(); + disconnect(); + + if (!result.ok) { + return { content: [{ type: "text", text: `进入临时会话失败: ${result.error}` }], isError: true }; + } + return { content: [{ type: "text", text: "已进入临时对话模式(自动先新建了空白会话)" }] }; + } catch (err) { + return { content: [{ type: "text", text: `执行崩溃: ${err.message}` }], isError: true }; + } + } +); + +// ─── 模型切换 ─── + +server.registerTool( + "gemini_switch_model", + { + description: "切换 Gemini 模型(pro / quick / think)", + inputSchema: { + model: z.enum(["pro", "quick", "think"]).describe("目标模型:pro=高质量, quick=快速, think=深度思考"), + }, + }, + async ({ model }) => { + try { + const { ops } = await createGeminiSession(); + const result = await ops.switchToModel(model); + disconnect(); + + if (!result.ok) { + return { content: [{ type: "text", text: `切换模型失败: ${result.error}` }], isError: true }; + } + return { + content: [{ type: "text", text: `模型已切换到 ${model}${result.previousModel ? `(之前是 ${result.previousModel})` : ''}` }], + }; + } catch (err) { + return { content: [{ type: "text", text: `执行崩溃: ${err.message}` }], isError: true }; + } + } +); + +// ─── 文本对话 ─── + +server.registerTool( + "gemini_send_message", + { + description: "向 Gemini 发送文本消息并等待回答完成(不提取图片,纯文本交互)", + inputSchema: { + message: z.string().describe("要发送给 Gemini 的文本内容"), + timeout: z.number().default(120000).describe("等待回答完成的超时时间(毫秒),默认 120000"), + }, + }, + async ({ message, timeout }) => { + try { + const { ops } = await createGeminiSession(); + const result = await ops.sendAndWait(message, { timeout }); + disconnect(); + + if (!result.ok) { + return { content: [{ type: "text", text: `发送失败: ${result.error},耗时 ${result.elapsed}ms` }], isError: true }; + } + return { + content: [{ type: "text", text: `消息已发送并等待完成,耗时 ${result.elapsed}ms` }], + }; + } catch (err) { + return { content: [{ type: "text", text: `执行崩溃: ${err.message}` }], isError: true }; + } + } +); + +// ─── 图片上传 ─── + +server.registerTool( + "gemini_upload_images", + { + description: "向 Gemini 当前输入框上传图片(仅上传,不发送消息),可配合 gemini_send_message 组合使用", + inputSchema: { + images: z.array(z.string()).min(1).describe("本地图片文件路径数组"), + }, + }, + async ({ images }) => { + try { + const { ops } = await createGeminiSession(); + + const results = []; + for (const imgPath of images) { + console.error(`[mcp] 正在上传: ${imgPath}`); + const r = await ops.uploadImage(imgPath); + results.push({ path: imgPath, ...r }); + if (!r.ok) { + disconnect(); + return { + content: [{ type: "text", text: `上传失败: ${imgPath}\n错误: ${r.error}\n\n已成功上传 ${results.filter(x => x.ok).length}/${images.length} 张` }], + isError: true, + }; + } + } + + disconnect(); + return { + content: [{ type: "text", text: `全部 ${images.length} 张图片上传成功` }], + }; + } catch (err) { + return { content: [{ type: "text", text: `执行崩溃: ${err.message}` }], isError: true }; + } + } +); + +// ─── 图片获取 ─── + +server.registerTool( + "gemini_get_images", + { + description: "获取当前 Gemini 会话中所有已加载的图片列表(不下载,仅返回元信息)", + inputSchema: {}, + }, + async () => { + try { + const { ops } = await createGeminiSession(); + const result = await ops.getAllImages(); + disconnect(); + + if (!result.ok) { + return { content: [{ type: "text", text: `未找到图片: ${result.error}` }], isError: true }; + } + + return { + content: [{ type: "text", text: JSON.stringify({ total: result.total, newCount: result.newCount, images: result.images }, null, 2) }], + }; + } catch (err) { + return { content: [{ type: "text", text: `执行崩溃: ${err.message}` }], isError: true }; + } + } +); + +server.registerTool( + "gemini_extract_image", + { + description: "提取指定图片的 base64 数据并保存到本地文件。可从 gemini_get_images 获取图片 src URL", + inputSchema: { + imageUrl: z.string().describe("图片的 src URL(从 gemini_get_images 结果中获取)"), + }, + }, + async ({ imageUrl }) => { + try { + const { ops } = await createGeminiSession(); + const result = await ops.extractImageBase64(imageUrl); + disconnect(); + + if (!result.ok) { + return { content: [{ type: "text", text: `图片提取失败: ${result.error}${result.detail ? ' — ' + result.detail : ''}` }], isError: true }; + } + + // 保存到本地 + const base64Data = result.dataUrl.split(',')[1]; + const mimeMatch = result.dataUrl.match(/^data:(image\/\w+);/); + const ext = mimeMatch ? mimeMatch[1].split('/')[1] : 'png'; + + mkdirSync(config.outputDir, { recursive: true }); + const filename = `gemini_${Date.now()}.${ext}`; + const filePath = join(config.outputDir, filename); + writeFileSync(filePath, Buffer.from(base64Data, 'base64')); + + console.error(`[mcp] 图片已保存至 ${filePath}`); + + return { + content: [ + { type: "text", text: `图片提取成功,已保存至: ${filePath}` }, + { type: "image", data: base64Data, mimeType: mimeMatch ? mimeMatch[1] : "image/png" }, + ], + }; + } catch (err) { + return { content: [{ type: "text", text: `执行崩溃: ${err.message}` }], isError: true }; + } + } +); + +// ─── 文字回复获取 ─── + +server.registerTool( + "gemini_get_all_text_responses", + { + description: "获取当前 Gemini 会话中所有文字回复内容(仅文字,不含图片等其他类型回复)", + inputSchema: {}, + }, + async () => { + try { + const { ops } = await createGeminiSession(); + const result = await ops.getAllTextResponses(); + disconnect(); + + if (!result.ok) { + return { content: [{ type: "text", text: `未找到回复: ${result.error}` }], isError: true }; + } + + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + } catch (err) { + return { content: [{ type: "text", text: `执行崩溃: ${err.message}` }], isError: true }; + } + } +); + +server.registerTool( + "gemini_get_latest_text_response", + { + description: "获取当前 Gemini 会话中最新一条文字回复(仅文字,不含图片等其他类型回复)", + inputSchema: {}, + }, + async () => { + try { + const { ops } = await createGeminiSession(); + const result = await ops.getLatestTextResponse(); + disconnect(); + + if (!result.ok) { + return { content: [{ type: "text", text: `未找到回复: ${result.error}` }], isError: true }; + } + + return { + content: [{ type: "text", text: result.text }], + }; + } catch (err) { + return { content: [{ type: "text", text: `执行崩溃: ${err.message}` }], isError: true }; + } + } +); + +// ─── 页面状态 & 恢复 ─── + +server.registerTool( + "gemini_probe", + { + description: "探测 Gemini 页面各元素状态(输入框、按钮、当前模型等),用于调试和排查问题", + inputSchema: {}, + }, + async () => { + try { + const { ops } = await createGeminiSession(); + const result = await ops.probe(); + disconnect(); + + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + } catch (err) { + return { content: [{ type: "text", text: `执行崩溃: ${err.message}` }], isError: true }; + } + } +); + +server.registerTool( + "gemini_reload_page", + { + description: "刷新 Gemini 页面(页面卡住或状态异常时使用)", + inputSchema: { + timeout: z.number().default(30000).describe("等待页面重新加载完成的超时(毫秒),默认 30000"), + }, + }, + async ({ timeout }) => { + try { + const { ops } = await createGeminiSession(); + const result = await ops.reloadPage({ timeout }); + disconnect(); + + if (!result.ok) { + return { content: [{ type: "text", text: `页面刷新失败: ${result.error}` }], isError: true }; + } + return { content: [{ type: "text", text: `页面刷新完成,耗时 ${result.elapsed}ms` }] }; + } catch (err) { + return { content: [{ type: "text", text: `执行崩溃: ${err.message}` }], isError: true }; + } + } +); + +// ─── 浏览器信息 ─── + // 查询浏览器信息 server.registerTool( "gemini_browser_info", diff --git a/src/operator.js b/src/operator.js index 52c5459..dccc27d 100644 --- a/src/operator.js +++ b/src/operator.js @@ -10,6 +10,7 @@ * - 鼠标 / 键盘事件通过 CDP Input 域发送,生成 isTrusted=true 的原生事件 * - 每个方法都是独立的原子操作,上层 gemini-ops.js 负责编排组合 */ +import { sleep } from './util.js'; /** * 创建 operator 实例 @@ -92,7 +93,7 @@ export function createOperator(page) { */ function randomDelay(min, max) { const ms = min + Math.random() * (max - min); - return new Promise(r => setTimeout(r, ms)); + return sleep(ms); } // ─── 公开 API ─── @@ -264,7 +265,7 @@ export function createOperator(page) { return { ok: true, result, elapsed: Date.now() - start }; } } catch { /* 页面可能还在加载 */ } - await new Promise(r => setTimeout(r, interval)); + await sleep(interval); } return { ok: false, error: 'timeout', elapsed: Date.now() - start }; diff --git a/src/util.js b/src/util.js new file mode 100644 index 0000000..51cd3b0 --- /dev/null +++ b/src/util.js @@ -0,0 +1,12 @@ +/** + * util.js — 公共工具函数 + */ + +/** + * 异步等待指定毫秒数 + * @param {number} ms + * @returns {Promise} + */ +export function sleep(ms) { + return new Promise(r => setTimeout(r, ms)); +}