chore: initialize qiming workspace repository

This commit is contained in:
Codex
2026-05-29 14:22:48 +08:00
commit bfd67a0f2c
10750 changed files with 1885711 additions and 0 deletions

View File

@@ -0,0 +1,882 @@
# GUI Agent MCP Server — Plan 计划文档
> **文档类型**: Plan计划文档— 描述"如何实现",定义技术方案与架构设计
>
> **基于**: `specs/gui-agent/gui-agent.md` Spec 规范文档
>
> **模块路径**: `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/` vs `tools/`**: 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 callhandler 内部 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-100maxSteps 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
1. 物理截图必须缩放到**逻辑分辨率**(吸收 scaleFactor否则坐标会偏移 scaleFactor 倍
2. 逻辑分辨率仍然过大时,按最长边分级等比缩放:
| 逻辑分辨率最长边 | 缩放策略 | 示例 |
|----------------|---------|------|
| ≤ 1920 | 不缩放 | 1440×900 → 1440×900 |
| 1921 ~ 2560 | 等比缩放到最长边 1920 | 2560×1440 → 1920×1080 |
| > 2560 | 等比缩放到最长边 1920 | 3840×2160 → 1920×1080 |
3. 转 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高可测试性
1. **坐标顺序修正**: 根据 `coordinateOrder` 处理
- `xy`(默认): 不变,`rawX = modelX, rawY = modelY`
- `yx`Gemini: swap`rawX = modelY, rawY = modelX`
2. **归一化** (0~1): 根据 coordinateMode 处理
- image-absolute: `normX = rawX / imageWidth`
- normalized-1000: `normX = rawX / 1000`
- normalized-999: `normX = rawX / 999`
- normalized-0-1: `normX = rawX`(直接用)
3. **逻辑坐标**: `localX = normX × logicalWidth`
4. **全局偏移**: `globalX = localX + display.origin.x`
边界校验:结果 clamp 到目标显示器范围内,超出记 warning。
### 3.4 desktop/screenshot.ts — 截图管线
1. 获取目标显示器信息bounds、scaleFactor
2. nut.js `screen.capture(region)` 截取目标显示器区域(物理分辨率)
3. 缩放到逻辑分辨率(吸收 scaleFactor若逻辑分辨率最长边 > 1920等比缩放到最长边 1920
4. sharp resize`kernel: 'lanczos3'`+ JPEG encode`quality: 75`
5. 检查字节数是否超限,超限则降 quality 重试
6. 返回 `ScreenshotResult`base64 + 完整元数据)
### 3.5 desktop/keyboard.ts + clipboard.ts — 文本输入
**typeText 智能路由**:
- 包含非 ASCII (`/[^\x00-\x7F]/`) 或长度 > 50 → clipboard.pasteText()
- 否则 → nut.js keyboard.type()
**clipboard.pasteText 流程**:
1. 读取当前剪贴板(通过 `clipboardy` 库,跨平台)
2. 写入目标文本
3. 模拟 Cmd+V (macOS) / Ctrl+V (Win/Linux)
4. 等待 100ms
5. 恢复原剪贴板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 实例创建**
```typescript
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 });
},
});
```
**运行任务**
```typescript
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` 接口):
```typescript
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 callAgent 循环自然终止stopReason: "stop")。
// system prompt 中明确指导:调用 computer_done 后不要再调其他工具。
return { content: [{ type: 'text', text: params.result }], details: { done: true } };
},
},
];
```
**Agent 循环终止机制**
pi-mono Agent 的循环在以下条件终止:
1. **LLM 返回纯文本**(无 tool call`stopReason: "stop"``computer_done` 走这条路工具返回结果后LLM 看到 "任务完成" 的 tool result输出纯文本总结循环自然结束
2. **`agent.abort()`** → `stopReason: "aborted"` — maxSteps 超限或外部 abort 走这条路
3. **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
```typescript
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` 作为 `transformContext` hook 传入 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() — 组合输出
```typescript
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-id` HTTP header 跟踪
- Session 管理:`Map<sessionId, { server, transport }>`,定期清理过期 session
- 每个 session 的 Server 注册相同的 tool handleratomicTools / taskTools
- DELETE 请求关闭指定 session
**stdio 模式(备选)**
- `new Server()` + `StdioServerTransport`,单客户端,适合简单场景
**通用**
- 注册 `ListToolsRequestSchema``CallToolRequestSchema` handler
- 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 任务在执行
- 流程:
1. `mutex.acquire()` — 获取锁(如已有任务在执行则等待)
2. `taskRunner.run(taskText, extra.signal)` — 启动 Agent 循环
3. 循环中通过 `extra.sendNotification()` 推送 progress
4. 监听 `extra.signal` 处理取消AbortSignal → `agent.abort()`
5. `mutex.release()` — 释放锁finally 块中确保释放)
6. 返回 `{ 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 HTTP13 个原子操作可调用。
1. 创建 `crates/agent-gui-server/`,配置 package.json / tsconfig / vitest参考 qiming-mcp-stdio-proxy 约定)
2. config.ts — 环境变量解析
3. utils/ — logger, platform, errors
4. coordinates/ — modelProfiles + resolver
5. desktop/ — display, screenshot, mouse, keyboard, clipboard, imageSearch
6. safety/ — hotkeys, auditLog
7. mcp/ — atomicTools, resources, serverStreamable HTTP 主模式 + stdio 备选,参考 qiming-mcp-stdio-proxy 的 HTTP server + session 管理模式)
8. index.ts — CLI 入口(`--port``--transport stdio`
**验收**: `tools/list` 返回 13 个工具;`gui_screenshot` 返回正确截图和元数据;多个 MCP 客户端可同时连接。
### Phase 2: 坐标转换验证 + 多屏
**目标**: 坐标转换准确,多屏正确。
1. CoordinateResolver 单元测试:覆盖所有 7 种模型 + Retina/HiDPI + 多屏偏移
2. 截图管线单元测试:不同分辨率缩放后 metadata 正确
3. 端到端MCP 调 `gui_screenshot` + `gui_click`,验证点击位置
### Phase 3: Agent 循环 (gui_execute_task)
**目标**: gui_execute_task 能执行自然语言 GUI 任务。
1. 安装 pi-mono 依赖(`@mariozechner/pi-ai`, `@mariozechner/pi-agent-core`, `@sinclair/typebox`
2. agent/systemPrompt.ts — 专用 system prompt
3. agent/taskRunner.ts — 创建 pi-mono `Agent` 实例,注册 `AgentTool[]`,接入 hooks
- `transformContext` → memoryManager 截图裁剪 + 记忆注入
- `beforeToolCall` → hotkeys 安全拦截
- `afterToolCall` → auditLog 审计记录
- `convertToLlm` → 消息格式过滤
- `toolExecution: 'sequential'`GUI 操作串行)
4. agent/memoryManager.ts — 三层记忆 + LLM 摘要压缩(独立 `complete()` 调用)
5. agent/stuckDetector.ts — 卡死检测
6. mcp/taskTools.ts — 注册 gui_execute_task同步阻塞 + Mutex + AbortSignal + progress notification
7. 进度通知:`agent.subscribe()` 事件 → `extra.sendNotification({ method: 'notifications/progress', params: { progressToken, progress, total, message } })`
**验收**: `gui_execute_task("打开 Finder")` 自动完成,返回步骤日志。
### Phase 4: SDK 导出
**目标**: 提供 SDK 入口。
1. lib.ts — SDK 工厂函数导出
### Phase 5: Electron 客户端改造
**目标**: 显示器选择 + 视觉模型配置接口。
按第 4 节执行。
### Phase 6: 集成测试 + 文档
1. 与 claude-code / qimingcode 的 MCP 集成验证
2. 跨平台基本验证
3. 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 发预设事件序列、mock `getModel`、mock `complete`(记忆摘要调用)
- 框架: 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=50JPEG quality=75 |
| Linux Wayland 不支持 | v1 仅支持 X11Wayland 为 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/*` 为工作空间:
```yaml
packages:
- 'crates/*'
```
`crates/agent-gui-server/` 创建后自动成为工作空间成员。
### 9.2 Electron 客户端依赖集成
`crates/agent-electron-client/package.json` 中添加依赖:
```json
{
"dependencies": {
"qiming-mcp-stdio-proxy": "workspace:*",
"agent-gui-server": "workspace:*" // 新增
}
}
```
### 9.3 打包资源配置
`crates/agent-electron-client/package.json``electron-builder.extraResources` 中添加:
```json
{
"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 |