354 lines
9.7 KiB
TypeScript
354 lines
9.7 KiB
TypeScript
/**
|
||
* 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<string, string> = {
|
||
...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;
|
||
}
|