/** * GUI Agent Server 管理器(Electron 集成) * * 提供非 Windows 平台下 agent-gui-server (Node.js MCP server) 的启动、停止和状态管理。 * agent-gui-server 提供 GUI 桌面自动化能力(截图、鼠标、键盘等 MCP tools)。 * * 端口通过 settings 中的 guiMcpPort 配置(默认 60008),与 agent 会话注入的 MCP URL 对齐。 * API Key 通过 GUI_AGENT_API_KEY 环境变量传入(复用 anthropic_api_key)。 */ import * as fs from "fs"; import * as path from "path"; import log from "electron-log"; import { t } from "../i18n"; import { ManagedProcess } from "../../processManager"; import { getResourcesPath, getNodeBinPathWithFallback, getAppEnv, } from "../system/dependencies"; import { isWindows } from "../system/shellEnv"; import { getDb, readSetting } from "../../db"; import { DEFAULT_GUI_MCP_PORT } from "@shared/constants"; import { killProcessTreesListeningOnTcpPort } from "../utils/processTree"; /** GUI Agent Server 进程名称 */ const PROCESS_NAME = "gui-agent-server"; /** * 从 DB 读取 GUI MCP 端口配置 */ export function getGuiMcpPort(): number { try { const config = readSetting("step1_config") as { guiMcpPort?: number; } | null; return config?.guiMcpPort ?? DEFAULT_GUI_MCP_PORT; } catch { return DEFAULT_GUI_MCP_PORT; } } /** * 获取 agent-gui-server 的入口 JS 文件路径 * * 打包后: process.resourcesPath/agent-gui-server/dist/index.js * 开发时: {cwd}/resources/agent-gui-server/dist/index.js */ function getGuiAgentServerEntryPath(): string | null { const resourcesPath = getResourcesPath(); const entryPath = path.join( resourcesPath, "agent-gui-server", "dist", "index.js", ); if (fs.existsSync(entryPath)) { return entryPath; } log.warn(`[GuiAgentServer] Entry file not found: ${entryPath}`); return null; } /** * 从 SQLite 获取 API Key(优先 gui_agent_vision_model 中的 apiKey) */ function getApiKeyFromDb(): string | null { try { const db = getDb(); if (!db) return null; // 优先读取 gui_agent_vision_model 配置中的 apiKey const visionConfigRow = db .prepare("SELECT value FROM settings WHERE key = ?") .get("gui_agent_vision_model") as { value: string } | undefined; if (visionConfigRow) { const config = JSON.parse(visionConfigRow.value); if (config?.apiKey) return config.apiKey; } // 回退到全局 anthropic_api_key const apiKeyRow = db .prepare("SELECT value FROM settings WHERE key = ?") .get("anthropic_api_key") as { value: string } | undefined; return apiKeyRow?.value ?? null; } catch (e) { log.warn("[GuiAgentServer] Failed to read API Key:", e); return null; } } /** * 检查视觉模型是否已配置 */ function checkVisionModelConfigured(): boolean { try { const db = getDb(); if (!db) return false; const visionConfigRow = db .prepare("SELECT value FROM settings WHERE key = ?") .get("gui_agent_vision_model") as { value: string } | undefined; if (visionConfigRow) { const config = JSON.parse(visionConfigRow.value); if (config?.apiKey && config?.model) return true; } return false; } catch (e) { return false; } } /** * 获取视觉模型名称 */ function getVisionModelName(): string | null { try { const db = getDb(); if (!db) return null; const visionConfigRow = db .prepare("SELECT value FROM settings WHERE key = ?") .get("gui_agent_vision_model") as { value: string } | undefined; if (visionConfigRow) { const config = JSON.parse(visionConfigRow.value); if (config?.model) return config.model; } return null; } catch (e) { return null; } } // ========== 状态管理 ========== let processInstance: ManagedProcess | null = null; let running = false; let runningPort: number | null = null; let lastError: string | null = null; /** * 启动 GUI Agent Server * * 仅在非 Windows 平台有效。Windows 平台使用 windows-mcp(见 windowsMcp.ts)。 */ export async function startGuiAgentServer(): Promise<{ success: boolean; port?: number; error?: string; }> { // Windows 平台跳过(使用 windows-mcp) if (isWindows()) { log.info("[GuiAgentServer] Skipped: Windows platform uses windows-mcp"); return { success: true }; } // 已运行则直接返回 if (running && processInstance && runningPort !== null) { return { success: true, port: runningPort }; } // 读取配置的端口 const port = getGuiMcpPort(); // 启动前清理占用该端口的残留进程(跨平台实现) try { log.info(`[GuiAgentServer] Pre-start port sweep for ${port}...`); await killProcessTreesListeningOnTcpPort(port); await new Promise((r) => setTimeout(r, 450)); } catch (e) { log.warn("[GuiAgentServer] Pre-start port sweep:", e); } // 解析入口文件 const entryPath = getGuiAgentServerEntryPath(); if (!entryPath) { const error = t("Claw.GUIAgent.entryNotFound"); log.error("[GuiAgentServer] GUI Agent entry file not found"); return { success: false, error }; } // 检查 Node.js 可用性 const nodeBinPath = getNodeBinPathWithFallback(); if (!nodeBinPath) { const error = t("Claw.GUIAgent.nodeNotFound"); log.error("[GuiAgentServer] Node.js binary not found"); return { success: false, error }; } // 获取 API Key const apiKey = getApiKeyFromDb(); if (!apiKey) { log.warn("[GuiAgentServer] API key missing (GUI Agent may be limited)"); } // 检查视觉模型是否配置 const hasVisionModel = checkVisionModelConfigured(); const visionModelName = getVisionModelName(); // 构建环境变量 const env: Record = { ...getAppEnv(), GUI_AGENT_API_KEY: apiKey ?? "", // 明确指定传输协议和端口 GUI_AGENT_TRANSPORT: "http", GUI_AGENT_PORT: port.toString(), // 传递视觉模型名称,无配置则为空(用于在 MCP 工具列表中禁用 gui_analyze_screen) GUI_AGENT_VISION_MODEL: hasVisionModel ? (visionModelName ?? "") : "", // 禁用遥测 ANONYMIZED_TELEMETRY: "false", }; // 构建启动参数 const args = [entryPath, "--port", port.toString(), "--transport", "http"]; // 创建并启动进程 processInstance = new ManagedProcess(PROCESS_NAME); log.info(`[GuiAgentServer] Starting on port ${port}...`); log.info(`[GuiAgentServer] Entry: ${entryPath}`); try { const result = await processInstance.start({ command: nodeBinPath, args, env, startupDelayMs: 5000, // GUI Agent Server 需要较长的启动时间 onStdoutLine: (line) => { // 转发到 electron-log if (line.trim()) { log.info(`[GuiAgentServer] ${line}`); } }, }); if (!result.success) { lastError = result.error ?? "Failed to start"; running = false; runningPort = null; processInstance = null; log.error(`[GuiAgentServer] Failed to start: ${lastError}`); return { success: false, error: lastError }; } running = true; runningPort = port; lastError = null; log.info(`[GuiAgentServer] Started successfully on port ${port}`); return { success: true, port }; } catch (e) { const errorMsg = e instanceof Error ? e.message : String(e); lastError = errorMsg; running = false; runningPort = null; processInstance = null; log.error(`[GuiAgentServer] Start exception: ${errorMsg}`); return { success: false, error: errorMsg }; } } /** * 停止 GUI Agent Server */ export async function stopGuiAgentServer(): Promise<{ success: boolean; error?: string; }> { if (!running || !processInstance) { running = false; runningPort = null; return { success: true }; } log.info("[GuiAgentServer] Stopping..."); // 兜底:ManagedProcess 进程可能已退出但端口仍被占用(如子进程残留) try { await killProcessTreesListeningOnTcpPort(getGuiMcpPort()); } catch (e) { log.warn("[GuiAgentServer] TCP port sweep after stop:", e); } try { const result = await processInstance.stopAsync(); running = false; runningPort = null; processInstance = null; if (result.success) { log.info("[GuiAgentServer] Stopped successfully"); return { success: true }; } else { log.error(`[GuiAgentServer] Stop failed: ${result.message}`); return { success: false, error: result.message }; } } catch (e) { const errorMsg = e instanceof Error ? e.message : String(e); running = false; runningPort = null; processInstance = null; log.error(`[GuiAgentServer] Stop exception: ${errorMsg}`); return { success: false, error: errorMsg }; } } /** * 获取 GUI Agent Server 运行状态 */ export function getGuiAgentServerStatus(): { running: boolean; port?: number; error?: string | null; } { return { running, port: running ? (runningPort ?? undefined) : undefined, error: lastError, }; } /** * 获取 GUI Agent Server MCP URL(供 Agent 引擎注入使用) * * 返回 http://127.0.0.1:{runningPort}/mcp,使用实际运行的端口。 * 仅在非 Windows 平台且内嵌 gui 服务已启动时返回非 null。 * Windows 桌面自动化由 windows-mcp 提供,请使用 getWindowsMcpUrl()(见 acpEngine)。 */ export function getGuiAgentServerUrl(): string | null { if (isWindows()) { return null; } if (!running || runningPort === null) { return null; } return `http://127.0.0.1:${runningPort}/mcp`; } /** * 检查 GUI Agent Server 是否可用(入口文件存在且非 Windows) */ export function isGuiAgentServerAvailable(): boolean { if (isWindows()) { return false; } const entryPath = getGuiAgentServerEntryPath(); return entryPath !== null; }