refactor(mcp): 封装浏览器操作为 MCP 工具,移除手动浏览器操控逻辑
This commit is contained in:
108
SKILL.md
108
SKILL.md
@@ -1,94 +1,62 @@
|
||||
---
|
||||
name: gemini-skill
|
||||
description: 通过 Gemini 官网(gemini.google.com)执行问答与生图操作。用户提到"问问Gemini/让Gemini回答/去Gemini问",或出现"生图/画图/绘图/nano banana/nanobanana/生成图片"等关键词时触发。默认使用可用模型中最强档(优先 Gemini 3.1 Pro),按任务切换文本问答或图片生成流程,并把结果回传给用户。
|
||||
description: 通过 Gemini 官网(gemini.google.com)执行生图操作。用户提到"生图/画图/绘图/nano banana/nanobanana/生成图片"等关键词时触发。所有浏览器操作已封装为 MCP 工具,AI 无需手动操控浏览器,但必要时可以通过gemini_browser_info获取浏览器连接信息,如CDP连接端口,方便AI自行连接调试。
|
||||
---
|
||||
|
||||
# Gemini Web Ops
|
||||
# Gemini Skill
|
||||
|
||||
## 核心规则
|
||||
## 触发关键词
|
||||
|
||||
1. 使用 Browser Daemon 托管的浏览器(Daemon 未运行时会自动后台拉起,无需手动启动)。
|
||||
2. 涉及生图关键词(如:生图、绘图、画一张、nano banana)时,优先用无头浏览器流程执行。
|
||||
3. 文本问答任务(如"问问Gemini xxx")走 Gemini 文本提问链路。
|
||||
4. 默认模型:可用列表中最强模型,优先 `Gemini 3.1 Pro`。
|
||||
5. 执行生图后先向用户回报"正在绘图中",完成后回传图片。
|
||||
6. **禁止使用浏览器截图(screenshot)获取生成图片**。默认通过 `ops.extractImageBase64()` 从已渲染的 DOM 直接提取图片 Base64 数据,解码后保存到本地再发送给用户;仅当用户明确要求高清/原图时,才调用 `ops.downloadLatestImage()` 走原图下载流程。
|
||||
7. **只调封装好的方法,禁止自己写 `page.evaluate()`**。所有操作通过 `ops.xxx`(高层业务)或 `operator.xxx`(底层原子)完成。底层已全部走 CDP 协议,无需关心实现细节。直接写 evaluate 既浪费 token 又容易出错。
|
||||
- **生图任务**:`生图`、`画`、`绘图`、`海报`、`nano banana`、`nanobanana`、`image generation`、`生成图片`
|
||||
- 若请求含糊,先确认用户是否需要生图
|
||||
|
||||
## 任务分流
|
||||
## 使用方式
|
||||
|
||||
- **文本问答**触发词:`问问Gemini`、`让Gemini回答`、`去Gemini问`。
|
||||
- **生图任务**触发词:`生图`、`画`、`绘图`、`海报`、`nano banana`、`nanobanana`、`image generation`。
|
||||
- 若请求含糊,先确认:是文本回答还是要出图。
|
||||
本 Skill 通过 MCP Server 暴露工具,AI 直接调用即可,**不需要手动操作浏览器**。
|
||||
|
||||
## 标准执行流程
|
||||
浏览器启动、会话管理、图片提取、文件保存等流程已全部封装在工具内部。Daemon 未运行时会自动后台拉起,无需手动启动。
|
||||
|
||||
### 按钮状态机
|
||||
### 可用工具
|
||||
|
||||
Gemini 页面的操作按钮(`.send-button-container` 内)通过 `aria-label` 反映当前状态:
|
||||
| 工具名 | 说明 | 入参 |
|
||||
|--------|------|------|
|
||||
| `gemini_generate_image` | 生成图片,返回本地文件路径 + base64 图片 | `prompt`(描述词),`newSession`(是否新建会话,默认 false) |
|
||||
| `gemini_browser_info` | 获取浏览器连接信息(CDP 端口、wsEndpoint、Daemon 状态等) | 无 |
|
||||
|
||||
| aria-label | 状态 | 含义 |
|
||||
|---|---|---|
|
||||
| 麦克风 | `idle` | 输入框为空,空闲中 |
|
||||
| 发送 / Send | `ready` | 输入框有内容,可发送 |
|
||||
| 停止 / Stop | `loading` | 已发送,正在生成回答 |
|
||||
### 典型调用流程
|
||||
|
||||
可通过 `ops.getStatus()` 获取当前状态,通过 `ops.pollStatus()` 分段轮询等待生成完毕。
|
||||
1. 用户说"帮我画一张猫咪的图"
|
||||
2. 调用 `gemini_generate_image`,传入 prompt
|
||||
3. 工具返回本地图片路径和 base64 数据
|
||||
4. 将图片展示给用户
|
||||
|
||||
### A. 文本问答
|
||||
1. 打开 `https://gemini.google.com`。
|
||||
2. 校验登录态(头像/输入框可见)。
|
||||
3. 新建会话:`click('newChatBtn')`,确保干净上下文。
|
||||
4. 选择最强可用模型(优先 Gemini 3.1 Pro)。
|
||||
5. 将用户问题原样输入并发送。
|
||||
6. **分段轮询等待**(见下方"CDP 保活轮询策略")。
|
||||
7. 等待完整输出,提炼后回传(必要时附原文要点)。
|
||||
### 参数说明
|
||||
|
||||
### B. 生图流程
|
||||
1. 打开 Gemini 页面并确认登录。
|
||||
2. 新建会话:`click('newChatBtn')`,确保干净上下文。
|
||||
3. 选择最强可用模型(优先 Gemini 3.1 Pro)。
|
||||
4. 将用户提示词原样输入。
|
||||
5. 发送后立即通知用户:正在绘图中。
|
||||
6. **分段轮询等待**(见下方"CDP 保活轮询策略",生图超时上限 120s)。
|
||||
7. 结果出现后,调用 `ops.getLatestImage()` 获取最新生成的图片(Gemini 一次只生成一张):
|
||||
- 返回 `{ok, src, alt, width, height, hasDownloadBtn}`。
|
||||
- 定位依据:`<img class="image loaded">` — 只有同时具有 `image` 和 `loaded` 两个 class 的才是已渲染完成的生成图片;DOM 中取最后一个即为最新。
|
||||
- `src` 为 `https://lh3.googleusercontent.com/...` 格式的原图 URL。
|
||||
- 若 `ok === false`,等几秒再调一次;连续两次失败则做 screenshot 排查页面状态。
|
||||
- **默认**:调用 `ops.extractImageBase64()` 从 DOM 直接提取图片 Base64(Canvas 优先,跨域污染时 fallback 到 fetch),解码后保存为本地文件发送给用户。
|
||||
- **高清**:仅当用户明确要求高清/原图时,才调用 `ops.downloadLatestImage()` 走原图下载按钮流程。
|
||||
- 下载按钮定位:从 `img` 向上找到 `.image-container` 容器,容器内的 `mat-icon[fonticon="download"]` 即为下载原图按钮。
|
||||
- ⚠️ **严禁使用浏览器截图(screenshot)代替保存图片**。
|
||||
8. 将保存到本地的图片文件发送给用户。
|
||||
- `newSession: false`(默认)— 复用当前 Gemini 会话页,适合连续生图
|
||||
- `newSession: true` — 新建干净会话,适合全新主题
|
||||
|
||||
## CDP 保活轮询策略
|
||||
## MCP 客户端配置
|
||||
|
||||
> **核心原则**:通过 `ops.pollStatus()` 分段轮询,不要试图一次性长时间等待结果。
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"gemini": {
|
||||
"command": "node",
|
||||
"args": ["<项目绝对路径>/src/mcp-server.js"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
生图/问答发送后,按以下方式等待结果:
|
||||
也可通过 `npm run mcp` 手动启动。
|
||||
|
||||
1. 每隔 **8~10 秒**调用一次 `ops.pollStatus()`。
|
||||
2. 该函数立即返回 `{status, label, pageVisible, ts}`。
|
||||
3. 调用端根据 `status` 判断:
|
||||
- `loading` → 继续等待,累计已耗时。
|
||||
- `idle` → 生成完毕,进入结果获取阶段。
|
||||
- `unknown` → 页面可能异常,做一次 snapshot 兜底排查。
|
||||
4. 累计耗时超过上限(文本 60s / 生图 120s)→ 走超时回退逻辑。
|
||||
## 失败处理
|
||||
|
||||
**为什么这样做**:Skill 通过 CDP(Chrome DevTools Protocol)WebSocket 控制 Daemon 托管的浏览器。若长时间(>30s)无消息往来,网关/代理可能判定连接空闲并断开。分段短轮询保证 CDP 通道始终有心跳流量。
|
||||
工具内部已包含重试逻辑。若仍然失败,返回值的 `isError: true` 和错误信息会告知原因:
|
||||
|
||||
## 失败回退
|
||||
|
||||
1. 元素定位失败:刷新页面后重试一次。
|
||||
2. 模型不可用:降级到次优 Gemini 模型并告知。
|
||||
3. 生成超时:回报"仍在生成中",继续等待一次;再次超时则请用户换短提示词。
|
||||
|
||||
## 低 token 优先策略
|
||||
|
||||
- **只调封装好的 `ops.xxx` / `operator.xxx` 方法**,不要自己拼 `page.evaluate()` 代码——既省 token 又不容易出错。
|
||||
- 先调方法执行动作,再用 `operator.screenshot()` 精准兜底排查。
|
||||
- 避免高频全量快照。
|
||||
- **生成超时** — 建议用户简化描述词后重试
|
||||
- **Daemon 未启动** — 工具会自动拉起,若仍失败可手动 `npm run daemon`
|
||||
- **页面异常** — 可调用 `gemini_browser_info` 查看浏览器状态排查
|
||||
|
||||
## 参考
|
||||
|
||||
|
||||
1062
package-lock.json
generated
1062
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -5,8 +5,8 @@
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"demo": "node src/demo.js",
|
||||
"daemon": "node src/daemon/server.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
"mcp": "node src/mcp-server.js",
|
||||
"daemon": "node src/daemon/server.js"
|
||||
},
|
||||
"keywords": [
|
||||
"gemini",
|
||||
@@ -18,6 +18,7 @@
|
||||
"license": "ISC",
|
||||
"description": "通过 CDP 操控 Gemini 网页进行 AI 问答与生图",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"puppeteer-core": "^24.39.1",
|
||||
"puppeteer-extra": "^3.3.6",
|
||||
"puppeteer-extra-plugin-stealth": "^2.11.2"
|
||||
|
||||
@@ -125,7 +125,7 @@ const config = {
|
||||
browserProtocolTimeout: envInt('BROWSER_PROTOCOL_TIMEOUT', 60_000),
|
||||
|
||||
/** 截图 / 图片输出目录 */
|
||||
outputDir: envStr('OUTPUT_DIR', resolve('output')),
|
||||
outputDir: envStr('OUTPUT_DIR', join(projectRoot, 'gemini-image')),
|
||||
|
||||
// ── Daemon 配置 ──
|
||||
|
||||
|
||||
141
src/mcp-server.js
Normal file
141
src/mcp-server.js
Normal file
@@ -0,0 +1,141 @@
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { z } from "zod";
|
||||
import { writeFileSync, mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
// 复用已有的统一入口,不修改原有逻辑
|
||||
import { createGeminiSession, disconnect } from './index.js';
|
||||
import config from './config.js';
|
||||
|
||||
const server = new McpServer({
|
||||
name: "gemini-mcp-server",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
// 注册工具
|
||||
server.registerTool(
|
||||
"gemini_generate_image",
|
||||
{
|
||||
description: "调用后台的 Gemini 浏览器会话生成高质量图片",
|
||||
inputSchema: {
|
||||
prompt: z.string().describe("图片的详细描述词"),
|
||||
newSession: z.boolean().default(false).describe(
|
||||
"是否新建会话。true= 开启全新对话; false = 复用当前已有的 Gemini 会话页"
|
||||
),
|
||||
},
|
||||
},
|
||||
async ({ prompt, newSession }) => {
|
||||
try {
|
||||
const { ops } = await createGeminiSession();
|
||||
const result = await ops.generateImage(prompt, { newChat: newSession });
|
||||
|
||||
// 执行完毕立刻断开,交还给 Daemon 倒计时
|
||||
disconnect();
|
||||
|
||||
if (!result.ok) {
|
||||
return {
|
||||
content: [{ type: "text", text: `生成失败: ${result.error}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
// 将 base64 写入本地文件
|
||||
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_browser_info",
|
||||
{
|
||||
description: "获取 Gemini 浏览器会话的连接信息(CDP 端口、WebSocket 地址、Daemon 状态等),方便外部工具直连浏览器",
|
||||
inputSchema: {},
|
||||
},
|
||||
async () => {
|
||||
const daemonUrl = `http://127.0.0.1:${config.daemonPort}`;
|
||||
|
||||
try {
|
||||
// 1. 检查 Daemon 健康状态
|
||||
const healthRes = await fetch(`${daemonUrl}/health`, { signal: AbortSignal.timeout(3000) });
|
||||
const health = await healthRes.json();
|
||||
|
||||
if (!health.ok) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Daemon 未就绪,浏览器可能未启动。请先调用 gemini_generate_image 触发自动启动。" }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. 获取浏览器连接信息
|
||||
const acquireRes = await fetch(`${daemonUrl}/browser/acquire`, { signal: AbortSignal.timeout(5000) });
|
||||
const acquire = await acquireRes.json();
|
||||
|
||||
const info = {
|
||||
daemon: {
|
||||
url: daemonUrl,
|
||||
port: config.daemonPort,
|
||||
status: "running",
|
||||
},
|
||||
browser: {
|
||||
cdpPort: config.browserDebugPort,
|
||||
wsEndpoint: acquire.wsEndpoint || null,
|
||||
pid: acquire.pid || null,
|
||||
headless: config.browserHeadless,
|
||||
},
|
||||
config: {
|
||||
protocolTimeout: config.browserProtocolTimeout,
|
||||
outputDir: config.outputDir,
|
||||
daemonTTL: config.daemonTTL,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(info, null, 2) }],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: `无法连接 Daemon (${daemonUrl}),浏览器可能未启动。\n错误: ${err.message}\n\n提示: 请先调用 gemini_generate_image 触发自动启动,或手动运行 npm run daemon`,
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 启动标准输入输出通信
|
||||
async function run() {
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
console.error("Gemini MCP Server running on stdio"); // 必须用 console.error,避免污染 stdio
|
||||
}
|
||||
|
||||
run().catch(console.error);
|
||||
Reference in New Issue
Block a user