38 KiB
GUI Agent MCP Server — Plan 计划文档
文档类型: Plan(计划文档)— 描述"如何实现",定义技术方案与架构设计
基于:
specs/gui-agent/gui-agent.mdSpec 规范文档模块路径:
crates/agent-gui-server/日期: 2026-03-18
1. 技术架构
1.1 模块划分
crates/agent-gui-server/
├── src/
│ ├── index.ts # CLI 入口: 参数解析 + 启动 MCP Server
│ ├── lib.ts # SDK 入口: 导出 createGuiAgentMcpServer()
│ ├── config.ts # 统一配置: 环境变量解析、校验、Fail Fast
│ │
│ ├── mcp/ # MCP 协议层(外部接口)
│ │ ├── server.ts # MCP Server 实例 (stdio + HTTP 双模式)
│ │ ├── atomicTools.ts # 13 个原子操作 tool handler
│ │ ├── taskTools.ts # gui_execute_task 互斥执行 + 进度通知
│ │ └── resources.ts # MCP Resources (status/permissions/audit)
│ │
│ ├── agent/ # Agent 循环引擎(gui_execute_task 内部)
│ │ ├── taskRunner.ts # 循环核心: pi-mono session + 截图→LLM→操作
│ │ ├── systemPrompt.ts # GUI Agent system prompt 模板
│ │ ├── memoryManager.ts # 三层记忆管理 (Summary/Recent/Pending) + LLM 摘要压缩
│ │ └── stuckDetector.ts # 卡死检测: 连续截图相似度比对
│ │
│ ├── desktop/ # 桌面操作层(底层能力封装)
│ │ ├── screenshot.ts # 截图管线: capture → scale → JPEG → base64
│ │ ├── mouse.ts # 鼠标: click/doubleClick/move/drag/scroll
│ │ ├── keyboard.ts # 键盘: type/pressKey/hotkey
│ │ ├── clipboard.ts # 剪贴板: CJK粘贴、备份恢复
│ │ ├── display.ts # 显示器: 列表、scaleFactor、全局偏移
│ │ └── imageSearch.ts # 图像查找 (nut.js template matcher)
│ │
│ ├── coordinates/ # 坐标系统(核心难点,独立目录)
│ │ ├── resolver.ts # CoordinateResolver: 模型坐标 → 逻辑坐标 → 全局坐标
│ │ └── modelProfiles.ts # 模型配置表: 坐标模式、坐标顺序
│ │
│ ├── safety/ # 安全层
│ │ ├── hotkeys.ts # 危险热键黑名单拦截
│ │ └── auditLog.ts # 环形缓冲审计日志
│ │
│ └── utils/
│ ├── logger.ts # 日志: stderr + 可选文件
│ ├── platform.ts # 平台检测、权限检查
│ └── errors.ts # 结构化错误类型
│
├── tests/ # Vitest 测试
├── package.json
├── tsconfig.json
└── vitest.config.ts
关键设计决策:
desktop/vstools/: Spec 用了tools/但会与 MCP 的 "tool" 概念冲突。底层桌面操作命名为desktop/,与 MCP 工具层 (mcp/) 明确分离。MCP handler 调 desktop 层,desktop 层不知道 MCP 存在(依赖反转)coordinates/独立目录: 坐标转换是核心难点(7+ 模型配置 + 三步转换 + 多屏偏移),独立提升可维护性和可测试性
1.2 模块依赖关系
index.ts → config.ts → mcp/server.ts
│
┌───────────┴───────────┐
▼ ▼
mcp/atomicTools.ts mcp/taskTools.ts
│ │
▼ ▼
safety/* (前置检查) agent/taskRunner.ts
│ │
▼ ├── agent/systemPrompt.ts
desktop/* ├── agent/memoryManager.ts (三层记忆 + LLM 摘要)
│ ├── agent/stuckDetector.ts
▼ └── pi-mono (LLM + tool calling)
coordinates/resolver.ts │
│ ▼
▼ desktop/* (复用)
coordinates/modelProfiles.ts
数据流 — 原子操作 (gui_click):
MCP tool call → atomicTools → hotkeys.validate()
→ resolver.resolve(x, y, mode, meta) → display.getGlobalOffset()
→ mouse.click(globalX, globalY) → auditLog.record() → MCP response
数据流 — 完整任务 (gui_execute_task):
MCP tool call → taskTools.executeTask(taskText, extra)
→ mutex.acquire()(确保同时只有一个 GUI 任务)
→ taskRunner.run(taskText, extra.signal) → Agent 循环 {
screenshot.capture() → agent.prompt(taskText + screenshot)
→ Agent 内部自动循环:
transformContext(messages) → 截图裁剪 + 记忆注入
→ convertToLlm(messages)
→ LLM 调用 → 返回 tool call
→ beforeToolCall → hotkeys.validate() 安全检查
→ tool.execute() → resolver + desktop 操作 → delay(stepDelayMs)
→ afterToolCall → auditLog.record()
→ turn_end 事件 → memory.finalizeStep() + stuckDetector.check()
每步通过 extra.sendNotification() 推送进度
监听 extra.signal.aborted 处理取消 → agent.abort()
} → mutex.release() → MCP response (steps + finalScreenshot + result)
连接断开 → AbortSignal 自动触发 → Agent 循环终止 → mutex 释放
2. 关键技术决策
2.1 pi-mono 的使用边界
| 场景 | 使用哪个包 | 具体 API |
|---|---|---|
| 原子操作 (gui_click 等) | 不使用 pi-mono | 纯工具执行,无需 LLM |
| gui_execute_task 的 Agent 循环 | @mariozechner/pi-agent-core |
Agent 类:内置循环、工具执行、事件流、abort |
| gui_execute_task 的 LLM 调用 | @mariozechner/pi-ai |
getModel(provider, modelId) 获取模型实例,Agent 内部自动调用 |
| gui_execute_task 的工具注册 | @mariozechner/pi-agent-core |
AgentTool 接口 + TypeBox schema + execute 函数 |
| 上下文压缩 | @mariozechner/pi-agent-core |
Agent 构造参数 transformContext hook |
| 安全层(热键拦截 + 审计) | @mariozechner/pi-agent-core |
Agent 构造参数 beforeToolCall / afterToolCall hook |
| 记忆摘要的 LLM 调用 | @mariozechner/pi-ai |
独立 complete(memoryModel, context) 调用(不走 Agent 循环) |
| 进度通知 | @mariozechner/pi-agent-core |
agent.subscribe(event) 事件系统 |
关键认识:pi-mono 的 Agent 类已经内置了完整的 Agent 循环(LLM 调用 → 解析 tool call → 执行工具 → 再次调 LLM),我们不需要手写循环。taskRunner 的职责是配置 Agent 实例、注册工具、接入钩子。
MCP Server 和 pi-mono Agent 是两个独立的框架,各司其职:
- MCP Server 负责外部协议(文本 Agent 调用)
- pi-mono Agent 负责内部 Agent 循环(LLM + tool calling)
2.2 nut.js 而非 robotjs
robotjs 已停止维护(2018),不支持 Apple Silicon 和 Node 22。nut.js 活跃维护,跨平台,async API,逻辑坐标空间与我们的坐标转换链路契合。
2.3 gui_execute_task 同步执行 + 互斥锁
同步阻塞:
gui_execute_task是标准的 MCP tool call,handler 内部 await Agent 循环完成后返回结果- MCP SDK 的 tool handler 支持长时间
Promise<Result>返回,无需异步队列
互斥锁:
- 桌面同一时间只能有一个 GUI 操作者,通过
Mutex确保同时只有一个gui_execute_task在执行 - 第二个调用会等待锁释放后再执行(不拒绝,排队等待)
进度通知(MCP SDK 原生):
- 客户端在
_meta.progressToken中传入 token - handler 通过
extra.sendNotification({ method: 'notifications/progress', params: { progressToken, progress, total, message } })推送每步进度
取消(MCP SDK 原生):
- handler 中
extra.signal(AbortSignal)由 SDK 自动注入 - 客户端取消请求或连接断开时,signal 自动触发
- handler 监听 signal → 调用 pi-mono
agent.abort()终止循环 - 无需手动管理 session 或自定义 abort 工具
2.4 截图管线: nut.js + sharp
- nut.js
screen.capture(region)获取原始 RGBA Buffer(物理分辨率) - sharp 做 resize(
kernel: 'lanczos3')+ JPEG 编码(quality: 75) - sharp 已在 monorepo 中验证可用
2.5 SDK 嵌入模式
lib.ts 导出 createGuiAgentMcpServer(config) 工厂函数,供 Electron 客户端未来直接 import 集成。package.json 中 "main": "./dist/lib.js" 导出 SDK,"bin" 导出 CLI。
3. 各模块详细设计
3.1 config.ts — 统一配置
- 解析所有
GUI_AGENT_*环境变量,返回类型安全的GuiAgentConfig对象 - 必填字段(如
API_KEY)缺失时直接 throw,进程退出(Fail Fast) - 数值参数做范围校验(jpegQuality 1-100,maxSteps 1-200)
coordinateMode为空时表示自动匹配(由 modelProfiles 决定)
3.2 coordinates/modelProfiles.ts — 模型配置表
内置模型配置表:
| 模型名匹配规则 | 坐标模式 | 坐标顺序 | 说明 |
|---|---|---|---|
claude-* |
image-absolute |
xy |
Anthropic Computer Use API 标准格式 |
gpt-4o*, gpt-5* |
image-absolute |
xy |
OpenAI CUA |
gemini* |
normalized-999 |
yx |
Google Gemini,坐标顺序是 [y, x] 而非 [x, y],这是 Google 训练数据的固有格式 |
ui-tars* |
normalized-1000 |
xy |
UI-TARS |
qwen2.5-vl*, qwen-vl* |
image-absolute |
xy |
通义千问 VL |
cogagent* |
image-absolute |
xy |
CogAgent |
seeclick*, showui* |
normalized-0-1 |
xy |
SeeClick/ShowUI |
| 未匹配(fallback) | image-absolute |
xy |
保守策略 |
截图分辨率策略(统一,不按模型区分):
不同模型的坐标转换都会经过归一化步骤(modelX / imageWidth 或 modelX / 1000 等),数学上与截图发送的分辨率无关。因此不需要按模型匹配不同截图分辨率,也不做 28/32 等倍数对齐(云端 API 服务端内部会处理 padding,本地部署场景暂不考虑)。
采用统一的分级缩放策略(参考 TuriX-CUA):
- 物理截图必须缩放到逻辑分辨率(吸收 scaleFactor),否则坐标会偏移 scaleFactor 倍
- 逻辑分辨率仍然过大时,按最长边分级等比缩放:
| 逻辑分辨率最长边 | 缩放策略 | 示例 |
|---|---|---|
| ≤ 1920 | 不缩放 | 1440×900 → 1440×900 |
| 1921 ~ 2560 | 等比缩放到最长边 1920 | 2560×1440 → 1920×1080 |
| > 2560 | 等比缩放到最长边 1920 | 3840×2160 → 1920×1080 |
- 转 JPEG quality=75 进一步压缩
Gemini 坐标顺序特殊处理:
Gemini 模型输出坐标格式为 [y_min, x_min, y_max, x_max](点坐标为 [y, x]),与其他所有模型的 [x, y] 相反。这是 Google 训练数据的固有格式,切换为 [x, y] 会导致性能显著下降。CoordinateResolver 必须在归一化前根据 coordinateOrder 做 swap。
实现逻辑:
getModelProfile(modelName)→ModelProfile { coordinateMode, coordinateOrder }coordinateOrder默认xy,Gemini 为yx- 环境变量
GUI_AGENT_COORDINATE_MODE可覆盖坐标模式 - 未匹配 fallback:
image-absolute+xy - 扩展新模型只需在数组中加一条正则规则
3.3 coordinates/resolver.ts — CoordinateResolver
核心四步转换(纯函数,零 I/O,高可测试性):
- 坐标顺序修正: 根据
coordinateOrder处理xy(默认): 不变,rawX = modelX, rawY = modelYyx(Gemini): swap,rawX = modelY, rawY = modelX
- 归一化 (0~1): 根据 coordinateMode 处理
- image-absolute:
normX = rawX / imageWidth - normalized-1000:
normX = rawX / 1000 - normalized-999:
normX = rawX / 999 - normalized-0-1:
normX = rawX(直接用)
- image-absolute:
- 逻辑坐标:
localX = normX × logicalWidth - 全局偏移:
globalX = localX + display.origin.x
边界校验:结果 clamp 到目标显示器范围内,超出记 warning。
3.4 desktop/screenshot.ts — 截图管线
- 获取目标显示器信息(bounds、scaleFactor)
- nut.js
screen.capture(region)截取目标显示器区域(物理分辨率) - 缩放到逻辑分辨率(吸收 scaleFactor);若逻辑分辨率最长边 > 1920,等比缩放到最长边 1920
- sharp resize(
kernel: 'lanczos3')+ JPEG encode(quality: 75) - 检查字节数是否超限,超限则降 quality 重试
- 返回
ScreenshotResult(base64 + 完整元数据)
3.5 desktop/keyboard.ts + clipboard.ts — 文本输入
typeText 智能路由:
- 包含非 ASCII (
/[^\x00-\x7F]/) 或长度 > 50 → clipboard.pasteText() - 否则 → nut.js keyboard.type()
clipboard.pasteText 流程:
- 读取当前剪贴板(通过
clipboardy库,跨平台) - 写入目标文本
- 模拟 Cmd+V (macOS) / Ctrl+V (Win/Linux)
- 等待 100ms
- 恢复原剪贴板(try-catch 包裹,失败不阻断主流程)
3.6 agent/taskRunner.ts — Agent 循环引擎
核心认识:pi-mono 的 Agent 类已经内置完整的 Agent 循环(LLM 调用 → 解析 tool call → 执行工具 → 再次调 LLM)。taskRunner 不需要手写循环,职责是:配置 Agent 实例、注册工具、接入钩子、管理生命周期。
TuriX-CUA 使用独立的 Brain + Actor 两个角色。我们 v1 简化为单角色:一个 LLM 同时分析截图 + 输出操作,但记忆管理参考 TuriX-CUA 的三层架构。
Agent 实例创建:
import { Agent } from '@mariozechner/pi-agent-core';
import { getModel, complete } from '@mariozechner/pi-ai';
import { Type } from '@sinclair/typebox';
// getModel() 是强类型泛型 API,动态配置需使用 as any 绕过编译期检查
// pi-mono 内部会校验 provider+model 组合是否有效,无效时 throw
const model = getModel(config.provider as any, config.model as any);
const memoryModel = getModel(
(config.memoryProvider ?? config.provider) as any,
(config.memoryModel ?? config.model) as any,
);
const memoryManager = new MemoryManager(memoryModel);
const agent = new Agent({
initialState: {
systemPrompt: buildSystemPrompt(taskText, memoryManager.compose()),
model,
thinkingLevel: 'off',
tools: guiTools, // AgentTool[] — 见下方工具定义
messages: [],
},
// 工具串行执行(GUI 操作同一屏幕不能并行)
toolExecution: 'sequential',
// 上下文转换:截图丢弃(注意:记忆文本通过 systemPrompt 注入,不在这里)
transformContext: async (messages, signal) => {
return memoryManager.pruneScreenshots(messages);
},
// 消息格式转换:AgentMessage[] → LLM Message[]
convertToLlm: (messages) =>
messages.filter(m => ['user', 'assistant', 'toolResult'].includes(m.role)),
// 安全层:工具执行前拦截危险热键
beforeToolCall: async ({ toolCall, args }) => {
if (toolCall.name === 'computer_hotkey') {
const blocked = hotkeys.validate(args.keys);
if (blocked) return { block: true, reason: `Blocked dangerous hotkey: ${args.keys}` };
}
},
// 审计层:工具执行后记录日志
// 注意:isError 是独立字段,不是 result.isError
afterToolCall: async ({ toolCall, args, result, isError }) => {
auditLog.record({ tool: toolCall.name, args, success: !isError });
},
});
运行任务:
async function runTask(taskText: string, signal: AbortSignal): Promise<TaskResult> {
// 1. 截取初始截图
const screenshot = await desktop.screenshot.capture(displayIndex);
// 2. 订阅事件 → 推送 MCP 进度通知 + 记忆管理
// 注意:subscribe 回调是同步的,异步操作不能在回调中 await
agent.subscribe((event) => {
switch (event.type) {
case 'turn_end':
stepCount++;
// finalizeStep 是异步的(可能触发 LLM 摘要),放入 Promise 队列
pendingMemoryWork = memoryManager.finalizeStep(stepCount, evaluateStep(event))
.then(() => {
// 记忆更新后,刷新 systemPrompt(包含最新的 compose() 文本)
agent.state.systemPrompt = buildSystemPrompt(taskText, memoryManager.compose());
});
stuckDetector.check(latestScreenshot);
onProgress({ step: stepCount, status: 'running' });
// maxSteps 限制
if (stepCount >= config.maxSteps) agent.abort();
break;
}
});
// 3. 发起 prompt — Agent 内部自动循环
// 循环终止条件:LLM 返回纯文本(无 tool call)→ stopReason: "stop"
// 或 agent.abort() → stopReason: "aborted"
await agent.prompt(taskText, [
{ type: 'image', data: screenshot.image, mimeType: screenshot.mimeType }
]);
// 4. 等待最后一次记忆压缩完成
await pendingMemoryWork;
// 5. 返回结果
return buildTaskResult(agent.state.messages);
}
// 外部 abort → agent.abort()
function abort() { agent.abort(); }
内部工具定义(AgentTool 接口):
const guiTools: AgentTool[] = [
{
name: 'computer_screenshot',
label: 'Screenshot',
description: '截取当前屏幕',
parameters: Type.Object({}),
execute: async (toolCallId, params, signal) => {
const shot = await desktop.screenshot.capture(displayIndex);
latestScreenshot = shot;
return {
content: [{ type: 'image', data: shot.image, mimeType: shot.mimeType }],
details: { imageWidth: shot.imageWidth, imageHeight: shot.imageHeight },
};
},
},
{
name: 'computer_click',
label: 'Click',
description: '鼠标点击指定坐标',
parameters: Type.Object({
x: Type.Number(),
y: Type.Number(),
button: Type.Optional(Type.String()),
}),
execute: async (toolCallId, params, signal) => {
const { globalX, globalY } = resolver.resolve(params.x, params.y, profile, screenshotMeta);
await desktop.mouse.click(globalX, globalY, params.button);
// 操作后等待 UI 渲染(延迟放在工具内部,而非 subscribe 回调)
await delay(config.stepDelayMs);
return { content: [{ type: 'text', text: `Clicked (${globalX}, ${globalY})` }], details: {} };
},
},
// computer_type, computer_scroll, computer_hotkey, computer_wait 同理...
// 每个操作类工具的 execute 末尾都包含 await delay(config.stepDelayMs)
{
name: 'computer_done',
label: 'Done',
description: '任务完成,调用此工具表示任务已完成',
parameters: Type.Object({
result: Type.String({ description: '任务完成的结果描述' }),
}),
execute: async (toolCallId, params, signal) => {
// computer_done 的 tool result 返回给 LLM 后,
// LLM 应输出纯文本总结(无 tool call),Agent 循环自然终止(stopReason: "stop")。
// system prompt 中明确指导:调用 computer_done 后不要再调其他工具。
return { content: [{ type: 'text', text: params.result }], details: { done: true } };
},
},
];
Agent 循环终止机制:
pi-mono Agent 的循环在以下条件终止:
- LLM 返回纯文本(无 tool call)→
stopReason: "stop"—computer_done走这条路:工具返回结果后,LLM 看到 "任务完成" 的 tool result,输出纯文本总结,循环自然结束 agent.abort()→stopReason: "aborted"— maxSteps 超限或外部 abort 走这条路- context overflow →
stopReason: "error"— 可通过isContextOverflow()检测
记忆文本注入方式:
记忆文本(compose() 输出)通过 systemPrompt 动态更新注入,而非通过 transformContext 注入消息。原因:
transformContext操作的是AgentMessage[],注入合成消息会干扰 Agent 内部的消息追踪agent.state.systemPrompt可直接赋值更新,在下一次 LLM 调用时生效- 在
turn_end事件回调中更新 systemPrompt,时机正确(当前轮结束、下一轮开始之前)
pi-mono Agent 事件流(单轮示例):
agent.prompt(taskText + screenshot)
├─ agent_start
├─ turn_start
│ ├─ message_start { userMessage }
│ ├─ message_end { userMessage }
│ ├─ message_start { assistantMessage }
│ ├─ message_update { toolcall_delta: "computer_click..." }
│ ├─ message_end { assistantMessage with toolCall }
│ ├─ tool_execution_start { computer_click, args }
│ ├─ tool_execution_end { result }
│ ├─ message_start { toolResultMessage }
│ └─ message_end { toolResultMessage }
├─ turn_end { message, toolResults }
├─ turn_start ← 自动下一轮(因为有 tool call)
│ └─ ... (LLM 看到 tool result → 继续决策)
└─ agent_end { messages }
3.7 agent/memoryManager.ts — 三层记忆管理
参考 TuriX-CUA 的三层记忆架构,实现 LLM 驱动的上下文压缩。
3.7.1 三层记忆结构
┌──────────────────────────────────────────────────────┐
│ summaryMemory(摘要记忆) │
│ 预算: summaryBudget = 2000 字符 │
│ 内容: 更早步骤的高度压缩摘要 │
│ 超限时: 调 memory_model 做"摘要的摘要" │
├──────────────────────────────────────────────────────┤
│ recentMemory(近期记忆) │
│ 预算: recentBudget = 500 字符 │
│ 内容: 最近完成的步骤记录 + 评估结果 │
│ 超限时: 调 memory_model 总结 → 移入 summaryMemory │
├──────────────────────────────────────────────────────┤
│ pendingMemory(进行中) │
│ 不计入预算 │
│ 内容: 当前正在执行的步骤 │
│ 完成后: 移入 recentMemory │
└──────────────────────────────────────────────────────┘
3.7.2 核心 API
class MemoryManager {
// 三层记忆
private summaryMemory: string = '';
private recentMemory: string = '';
private pendingMemory: string = '';
// 预算配置
private recentBudget: number = 500; // 字符数
private summaryBudget: number = 2000; // 字符数 (4x recentBudget)
private screenshotKeepCount: number = 3; // 保留最近 N 步的完整截图
// 记忆模型(用于 LLM 摘要,独立于主 Agent)
private memoryModel: Model;
/** 当前步骤开始 — 记录到 pendingMemory */
addPendingStep(stepId: number, goal: string): void;
/** 当前步骤完成 — 从 pending 移入 recent,触发压缩检查 */
async finalizeStep(stepId: number, evaluation: 'success' | 'failed'): Promise<void>;
/** 组合三层记忆为文本,用于注入 systemPrompt */
compose(): string;
/**
* 接入 Agent 的 transformContext hook
* 职责:仅处理截图 base64 裁剪(记忆文本通过 systemPrompt 注入,不在这里)
* 1. 丢弃超过 screenshotKeepCount 步的截图 base64
* 2. token 硬限制兜底(从最旧消息开始强制移除图片)
*/
pruneScreenshots(messages: AgentMessage[]): AgentMessage[];
}
与 pi-mono 的集成方式:
pruneScreenshots作为transformContexthook 传入 Agent,每次 LLM 调用前自动执行,只负责截图裁剪- 记忆文本通过
agent.state.systemPrompt注入:在turn_end事件回调中调用buildSystemPrompt(taskText, memoryManager.compose())更新 systemPrompt,下一轮 LLM 调用时生效 finalizeStep在turn_end事件回调中调用(异步,可能触发 LLM 摘要)- 记忆摘要使用独立的
complete(memoryModel, context)调用,不走 Agent 循环 - pi-mono 的
Usage对象提供了 provider 返回的实际 token 数(usage.input),可用于更准确的 token 预算判断(替代text.length / 3估算)
3.7.3 压缩触发流程(参考 TuriX-CUA)
finalizeStep(stepId, evaluation)
│
│ 将 pending 行移入 recentMemory:
│ "Step {stepId} | Eval: {evaluation} | Goal: {goal}"
│
│ 检查: recentMemory.length > recentBudget (500)?
│ ├── 否 → 返回
│ └── 是 ↓
│
│ 调 memory_model 生成摘要:
│ 输入: recentMemory 全文
│ 输出: { summary: string } ← 结构化 JSON
│ │
│ summaryMemory += summary
│ recentMemory = '' ← 清空
│ │
│ 检查: summaryMemory.length > summaryBudget (2000)?
│ ├── 否 → 返回
│ └── 是 ↓
│
│ 调 memory_model 做二次压缩:
│ 输入: summaryMemory 全文
│ 输出: { summary: string } ← 更高层摘要
│ │
│ summaryMemory = summary ← 替换
3.7.4 compose() — 组合输出
compose(): string {
const parts: string[] = [];
if (this.summaryMemory) {
parts.push(`[Summarized history]\n${this.summaryMemory}`);
}
if (this.recentMemory) {
parts.push(`[Recent steps]\n${this.recentMemory}`);
}
if (this.pendingMemory) {
parts.push(`[Current step]\n${this.pendingMemory}`);
}
return parts.join('\n\n');
}
3.7.5 截图 base64 的管理(pruneScreenshots,在 transformContext 中执行)
记忆压缩管理的是文字上下文(通过 systemPrompt 注入)。截图 base64 在 pruneScreenshots 中单独管理:
pruneScreenshots(messages: AgentMessage[])
│
│ 1. 遍历消息,识别包含 ImageContent 的 toolResultMessage
│ 2. 按时间排序,保留最近 screenshotKeepCount (默认 3) 步的截图
│ 3. 更早步骤:移除 ImageContent,替换为文字描述
│ { type: 'text', text: '[Screenshot removed - Step 5: browser opened]' }
│ 4. token 硬限制兜底:
│ 估算总 token(文字 text.length/3 + 图片 ~800/张)
│ 超过 model.contextWindow * 0.9 时,从最旧消息强制移除图片
│
返回裁剪后的 AgentMessage[]
pi-mono 的 transformContext 在 convertToLlm 之前执行,操作的是 AgentMessage[],裁剪结果只影响当次 LLM 调用的输入,不修改 Agent 内部存储的完整消息历史。
3.7.6 memory_model 配置
GUI_AGENT_MEMORY_MODEL — 记忆摘要用的模型(可选,默认复用 GUI_AGENT_MODEL)
GUI_AGENT_MEMORY_PROVIDER — 记忆模型 Provider(可选,默认复用 GUI_AGENT_PROVIDER)
记忆摘要是纯文本输入/输出,不需要视觉能力,可以用更便宜的模型(如 haiku / gpt-4o-mini)降低成本。
3.7.7 记忆摘要 system prompt
You are a memory summarization assistant for a GUI automation agent.
Your task is to condense step-by-step action records into concise memory entries.
Output JSON:
{
"summary": "Concise summary of the actions taken and their outcomes..."
}
Guidelines:
- Preserve key information: what was done, what succeeded/failed, current state
- Remove redundant details and repetitive patterns
- Keep the summary actionable — the agent needs to know what happened to plan next steps
3.8 agent/stuckDetector.ts — 卡死检测
- 将截图缩放到 32×32 → 计算与前 N 步(默认 3)的像素均值差异
- 连续 N 步差异 < 阈值(5%)→ 判定卡死,自动终止
- 简化方案,不需要 SSIM 或感知哈希
3.9 safety — 安全层
| 模块 | 实现 | 接入方式 |
|---|---|---|
| hotkeys | 组合键黑名单匹配,按平台区分(macOS: Cmd+Q, Win: Alt+F4 等) | 原子操作:atomicTools 中直接调用;Agent 循环:通过 beforeToolCall hook 拦截 |
| auditLog | 固定大小数组 (1000),环形写入,通过 MCP Resource 暴露 | 原子操作:atomicTools 执行后记录;Agent 循环:通过 afterToolCall hook 记录 |
3.10 mcp/server.ts — MCP Server
Streamable HTTP 模式(主模式):
参考 qiming-mcp-stdio-proxy 的 PersistentMcpBridge 模式:
http.createServer()监听127.0.0.1:<port>(默认 60008),长期运行- 请求路由:
/mcp路径处理 MCP 协议请求 - 每个客户端连接创建独立的
StreamableHTTPServerTransport+Server实例 - Session 通过
mcp-session-idHTTP header 跟踪 - Session 管理:
Map<sessionId, { server, transport }>,定期清理过期 session - 每个 session 的 Server 注册相同的 tool handler(atomicTools / taskTools)
- DELETE 请求关闭指定 session
stdio 模式(备选):
new Server()+StdioServerTransport,单客户端,适合简单场景
通用:
- 注册
ListToolsRequestSchema和CallToolRequestSchemahandler - CallTool handler 内部路由到 atomicTools / taskTools
- 连接断开:Transport 关闭时 AbortSignal 自动触发,正在执行的
gui_execute_task会收到 abort 信号并终止 Agent 循环
3.11 mcp/taskTools.ts — gui_execute_task 处理
executeTask(taskText, extra)函数:MCP tool handler 的核心逻辑Mutex互斥锁:确保同时只有一个 GUI 任务在执行- 流程:
mutex.acquire()— 获取锁(如已有任务在执行则等待)taskRunner.run(taskText, extra.signal)— 启动 Agent 循环- 循环中通过
extra.sendNotification()推送 progress - 监听
extra.signal处理取消(AbortSignal →agent.abort()) mutex.release()— 释放锁(finally 块中确保释放)- 返回
{ success, result, finalScreenshot, steps }给 MCP 调用方
4. Electron 客户端改造
4.1 改造范围
| 操作 | 文件 | 说明 |
|---|---|---|
| 修改 | src/main/services/computerServer.ts |
新增 4 个 /computer/gui-agent/* 路由 |
| 修改 | src/shared/types/computerTypes.ts |
新增 GuiVisionModelConfig 类型 |
| 新增 | src/renderer/components/GUIAgentSettings.tsx |
显示器选择 + 视觉模型配置 UI |
| 修改 | src/renderer/components/SettingsPage.tsx |
集成 GUIAgentSettings 标签页 |
4.2 computerServer.ts 新增路由
在 handleRequest 中新增 4 个路由(复用现有的 JSON envelope 响应格式):
| 路径 | 方法 | 逻辑 |
|---|---|---|
/computer/gui-agent/vision-model |
POST | 验证 body → 存 SQLite |
/computer/gui-agent/vision-model |
GET | 读 SQLite → 附加推断参数 |
/computer/gui-agent/displays |
GET | screen.getAllDisplays() |
/computer/gui-agent/display |
POST | 校验 displayIndex → 存 SQLite |
4.3 配置传递
启动 GUI Agent MCP Server 子进程时,从 SQLite 读取配置,注入为环境变量:
GUI_AGENT_PROVIDER, GUI_AGENT_MODEL, GUI_AGENT_API_KEY, GUI_AGENT_BASE_URL, GUI_AGENT_DISPLAY_INDEX, GUI_AGENT_COORDINATE_MODE
5. 实现阶段
Phase 1: 项目脚手架 + 原子操作
目标: MCP Server 启动(Streamable HTTP),13 个原子操作可调用。
- 创建
crates/agent-gui-server/,配置 package.json / tsconfig / vitest(参考 qiming-mcp-stdio-proxy 约定) - config.ts — 环境变量解析
- utils/ — logger, platform, errors
- coordinates/ — modelProfiles + resolver
- desktop/ — display, screenshot, mouse, keyboard, clipboard, imageSearch
- safety/ — hotkeys, auditLog
- mcp/ — atomicTools, resources, server(Streamable HTTP 主模式 + stdio 备选,参考 qiming-mcp-stdio-proxy 的 HTTP server + session 管理模式)
- index.ts — CLI 入口(
--port、--transport stdio)
验收: tools/list 返回 13 个工具;gui_screenshot 返回正确截图和元数据;多个 MCP 客户端可同时连接。
Phase 2: 坐标转换验证 + 多屏
目标: 坐标转换准确,多屏正确。
- CoordinateResolver 单元测试:覆盖所有 7 种模型 + Retina/HiDPI + 多屏偏移
- 截图管线单元测试:不同分辨率缩放后 metadata 正确
- 端到端:MCP 调
gui_screenshot+gui_click,验证点击位置
Phase 3: Agent 循环 (gui_execute_task)
目标: gui_execute_task 能执行自然语言 GUI 任务。
- 安装 pi-mono 依赖(
@mariozechner/pi-ai,@mariozechner/pi-agent-core,@sinclair/typebox) - agent/systemPrompt.ts — 专用 system prompt
- agent/taskRunner.ts — 创建 pi-mono
Agent实例,注册AgentTool[],接入 hooks:transformContext→ memoryManager 截图裁剪 + 记忆注入beforeToolCall→ hotkeys 安全拦截afterToolCall→ auditLog 审计记录convertToLlm→ 消息格式过滤toolExecution: 'sequential'(GUI 操作串行)
- agent/memoryManager.ts — 三层记忆 + LLM 摘要压缩(独立
complete()调用) - agent/stuckDetector.ts — 卡死检测
- mcp/taskTools.ts — 注册 gui_execute_task(同步阻塞 + Mutex + AbortSignal + progress notification)
- 进度通知:
agent.subscribe()事件 →extra.sendNotification({ method: 'notifications/progress', params: { progressToken, progress, total, message } })
验收: gui_execute_task("打开 Finder") 自动完成,返回步骤日志。
Phase 4: SDK 导出
目标: 提供 SDK 入口。
- lib.ts — SDK 工厂函数导出
Phase 5: Electron 客户端改造
目标: 显示器选择 + 视觉模型配置接口。
按第 4 节执行。
Phase 6: 集成测试 + 文档
- 与 claude-code / qimingcode 的 MCP 集成验证
- 跨平台基本验证
- README.md
6. 测试策略
重点单元测试
| 模块 | 覆盖重点 |
|---|---|
| coordinates/resolver | 所有坐标家族转换、Gemini yx 坐标顺序 swap、Retina/HiDPI、多屏偏移、边界 clamp |
| coordinates/modelProfiles | 模型名匹配、coordinateOrder 区分、fallback、环境变量覆盖 |
| desktop/screenshot | 统一缩放策略(逻辑分辨率 + 最长边 1920 上限)、JPEG quality 降级、metadata 完整性 |
| desktop/clipboard | CJK 检测、长文本路由、剪贴板备份恢复 |
| safety/* | 黑名单匹配、环形缓冲 |
| agent/memoryManager | 三层记忆预算触发、LLM 摘要调用、compose() 输出、截图丢弃策略 |
| agent/stuckDetector | 相同/不同截图判定 |
| config | 必填校验、默认值 |
Mock 策略
- nut.js: mock 避免实际操作桌面
- sharp: mock resize/jpeg/toBuffer 链,验证调用参数
- pi-mono: mock
Agent类(subscribe 发预设事件序列)、mockgetModel、mockcomplete(记忆摘要调用) - 框架: Vitest,配置参考 qiming-mcp-stdio-proxy
7. 风险与应对
| 风险 | 应对 |
|---|---|
| nut.js prebuilt binary 某平台不可用 | CI 验证三平台;备选: platform-specific CLI (screencapture, cliclick) |
| pi-mono 某 Provider 的 tool calling 不兼容 | Anthropic + OpenAI 是核心场景,其他验证后再加入 modelProfiles |
| macOS 权限弹窗阻塞首次使用 | gui://permissions Resource 报告状态;README 提供授权步骤 |
| Agent 循环 token 消耗过快 | 三层记忆 memoryManager + LLM 摘要压缩;截图保留最近 3 步;maxSteps=50;JPEG quality=75 |
| Linux Wayland 不支持 | v1 仅支持 X11,Wayland 为 v2 |
| sharp 与 Electron 版本冲突 (SDK 嵌入模式) | MCP Server 独立进程无此问题;嵌入模式需 electron-rebuild |
8. 依赖清单
| 依赖 | 用途 |
|---|---|
@modelcontextprotocol/sdk ^1.27.1 |
MCP Server |
@nut-tree-fork/nut-js ^4.2.6 |
桌面自动化(社区 fork,@nut-tree/nut-js 需要付费订阅) |
@mariozechner/pi-ai |
多 Provider LLM 调用 |
@mariozechner/pi-agent-core |
Agent 循环 + tool calling |
@sinclair/typebox |
AgentTool 参数 schema 定义 |
sharp ^0.33.0 |
截图 resize + JPEG 编码 |
clipboardy ^4.0.0 |
跨平台剪贴板读写 |
9. 工作空间集成
9.1 pnpm-workspace 配置
项目根目录 pnpm-workspace.yaml 已配置 crates/* 为工作空间:
packages:
- 'crates/*'
crates/agent-gui-server/ 创建后自动成为工作空间成员。
9.2 Electron 客户端依赖集成
在 crates/agent-electron-client/package.json 中添加依赖:
{
"dependencies": {
"qiming-mcp-stdio-proxy": "workspace:*",
"agent-gui-server": "workspace:*" // 新增
}
}
9.3 打包资源配置
在 crates/agent-electron-client/package.json 的 electron-builder.extraResources 中添加:
{
"extraResources": [
{
"from": "resources/qiming-mcp-stdio-proxy",
"to": "qiming-mcp-stdio-proxy"
},
{
"from": "resources/agent-gui-server", // 新增
"to": "agent-gui-server"
}
]
}
9.4 集成方式参考
与 qiming-mcp-stdio-proxy 相同:
- 工作空间依赖:
workspace:* - 打包时复制到
resources/目录 - 运行时通过子进程方式启动 MCP Server
参考文件
| 文件 | 参考内容 |
|---|---|
specs/gui-agent/gui-agent.md |
Spec 规范文档(权威需求来源) |
crates/qiming-mcp-stdio-proxy/package.json |
工程约定: ES modules、依赖版本、build/test scripts |
crates/qiming-mcp-stdio-proxy/src/index.ts |
CLI 入口模式 |
crates/qiming-mcp-stdio-proxy/src/shared/proxy-server.ts |
MCP Server 创建模式 |
crates/agent-electron-client/src/main/services/computerServer.ts |
Electron 改造目标(新增路由) |
crates/agent-electron-client/src/shared/types/computerTypes.ts |
类型扩展(新增 GuiVisionModelConfig) |