diff --git a/.gitignore b/.gitignore index 154b8bc..84d37d3 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,8 @@ dist-electron *.sln *.sw? -.env \ No newline at end of file +.env + +.codebuddy +docs/ +.vscode \ No newline at end of file diff --git a/bun.lock b/bun.lock index b0739b6..ca4abdb 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "electron-vite-template", diff --git a/package.json b/package.json index 016466f..cfa18b5 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "dist:mac-arm64": "bun run transpile:electron && bun run build && electron-builder --mac --arm64", "dist:mac-x64": "bun run transpile:electron && bun run build && electron-builder --mac --x64", "dist:win": "bun run transpile:electron && bun run build && electron-builder --win --x64", - "dist:linux": "bun run transpile:electron && bun run build && electron-builder --linux --x64" + "dist:linux": "bun run transpile:electron && bun run build && electron-builder --linux --x64", + "postinstall": "electron-rebuild -f -w better-sqlite3" }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.6", @@ -58,4 +59,4 @@ "patchedDependencies": { "@anthropic-ai/claude-agent-sdk@0.2.6": "patches/@anthropic-ai%2Fclaude-agent-sdk@0.2.6.patch" } -} +} \ No newline at end of file diff --git a/src/electron/libs/agents/agent-config.ts b/src/electron/libs/agents/agent-config.ts new file mode 100644 index 0000000..11f538b --- /dev/null +++ b/src/electron/libs/agents/agent-config.ts @@ -0,0 +1,40 @@ +/** + * Sub Agent 配置管理核心模块 - 类型定义 + * 用于定义 AI 助手(Sub Agent)配置的 TypeScript 接口 + */ + +/** 单个 Sub Agent 配置 */ +export interface SubAgentConfig { + /** 唯一标识符 */ + id: string; + /** 显示名称(同时作为 SDK agent key) */ + name: string; + /** 简短描述(面向用户的通俗说明) */ + description: string; + /** 提示词(定义助手的行为和能力) */ + prompt: string; + /** 是否已启用 */ + enabled: boolean; + /** 模型选择('inherit' 表示继承主模型) */ + model: string; + /** 是否为内置助手 */ + isBuiltin: boolean; + /** 创建时间 */ + createdAt: string; + /** 更新时间 */ + updatedAt: string; +} + +/** Sub Agent 配置整体状态 */ +export interface AgentConfigState { + /** 配置版本号(用于迁移) */ + version: number; + /** 所有 Sub Agent 配置列表 */ + agents: SubAgentConfig[]; +} + +/** 默认配置状态 */ +export const DEFAULT_AGENT_CONFIG_STATE: AgentConfigState = { + version: 1, + agents: [], +}; diff --git a/src/electron/libs/agents/agent-ipc-handlers.ts b/src/electron/libs/agents/agent-ipc-handlers.ts new file mode 100644 index 0000000..3d1aef2 --- /dev/null +++ b/src/electron/libs/agents/agent-ipc-handlers.ts @@ -0,0 +1,108 @@ +/** + * Sub Agent IPC 处理器 + * 处理渲染进程与主进程之间的 AI 助手配置相关通信 + */ + +import { BrowserWindow, ipcMain } from "electron"; +import { getAgentManager, AgentManager } from "./agent-manager.js"; +import { isBuiltinAgent } from "./builtin-agents.js"; +import type { SubAgentConfig } from "./agent-config.js"; + +let mainWindow: BrowserWindow | null = null; +let manager: AgentManager | null = null; + +/** + * 设置 Agent IPC 处理器 + */ +export function setupAgentHandlers(win: BrowserWindow): void { + mainWindow = win; + manager = getAgentManager(); + + // 监听配置变化并广播到渲染进程 + manager.on("config-changed", () => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send("agents-config-changed"); + } + }); + + // 获取所有 AI 助手列表 + ipcMain.handle("agents-get-list", async () => { + if (!manager) return []; + return manager.getAgents(); + }); + + // 添加自定义助手 + ipcMain.handle("agents-add", async (_, agentData: { + name: string; + description: string; + prompt: string; + model?: string; + }) => { + if (!manager) throw new Error("Agent Manager not initialized"); + + // 检查名称唯一性 + const existing = manager.getAgents(); + if (existing.some((a) => a.name === agentData.name)) { + throw new Error(`助手名称"${agentData.name}"已存在,请使用其他名称`); + } + + const newAgent = manager.addAgent({ + name: agentData.name, + description: agentData.description, + prompt: agentData.prompt, + enabled: true, + model: agentData.model || "inherit", + }); + + return { success: true, agentId: newAgent.id }; + }); + + // 更新助手配置 + ipcMain.handle("agents-update", async (_, agentId: string, updates: { + name?: string; + description?: string; + prompt?: string; + model?: string; + }) => { + if (!manager) throw new Error("Agent Manager not initialized"); + + // 如果修改了名称,检查唯一性 + if (updates.name) { + const existing = manager.getAgents(); + if (existing.some((a) => a.name === updates.name && a.id !== agentId)) { + throw new Error(`助手名称"${updates.name}"已存在,请使用其他名称`); + } + } + + manager.updateAgentById(agentId, updates as Partial); + return { success: true }; + }); + + // 删除自定义助手 + ipcMain.handle("agents-delete", async (_, agentId: string) => { + if (!manager) throw new Error("Agent Manager not initialized"); + + if (isBuiltinAgent(agentId)) { + throw new Error("内置助手不可删除"); + } + + manager.removeAgent(agentId); + return { success: true }; + }); + + // 切换助手启用/禁用状态 + ipcMain.handle("agents-toggle", async (_, agentId: string, enabled: boolean) => { + if (!manager) throw new Error("Agent Manager not initialized"); + + manager.toggleAgent(agentId, enabled); + return { success: true }; + }); +} + +/** + * 清理 Agent 资源 + */ +export function cleanupAgents(): void { + mainWindow = null; + manager = null; +} diff --git a/src/electron/libs/agents/agent-manager.ts b/src/electron/libs/agents/agent-manager.ts new file mode 100644 index 0000000..fd87474 --- /dev/null +++ b/src/electron/libs/agents/agent-manager.ts @@ -0,0 +1,106 @@ +/** + * Sub Agent 管理器 + * 单例模式管理 AI 助手配置,提供 SDK 格式转换能力 + */ + +import { EventEmitter } from "events"; +import type { AgentDefinition } from "@anthropic-ai/claude-agent-sdk"; +import { AgentConfigState, SubAgentConfig } from "./agent-config.js"; +import { + loadAgentConfig, + saveAgentConfig, + addAgent, + updateAgent, + removeAgent, + toggleAgent, + getEnabledAgents, +} from "./agent-store.js"; + +export class AgentManager extends EventEmitter { + private config: AgentConfigState; + + constructor() { + super(); + this.config = loadAgentConfig(); + } + + /** 获取当前配置 */ + getConfig(): AgentConfigState { + return this.config; + } + + /** 获取所有 agent 列表 */ + getAgents(): SubAgentConfig[] { + return this.config.agents; + } + + /** 获取所有已启用的 agent */ + getEnabledAgents(): SubAgentConfig[] { + return getEnabledAgents(this.config); + } + + /** 添加自定义 agent */ + addAgent(agent: Omit): SubAgentConfig { + this.config = addAgent(this.config, agent); + this.save(); + const newAgent = this.config.agents[this.config.agents.length - 1]; + this.emit("config-changed"); + return newAgent; + } + + /** 更新 agent */ + updateAgentById(agentId: string, updates: Partial): void { + this.config = updateAgent(this.config, agentId, updates); + this.save(); + this.emit("config-changed"); + } + + /** 删除 agent */ + removeAgent(agentId: string): void { + this.config = removeAgent(this.config, agentId); + this.save(); + this.emit("config-changed"); + } + + /** 切换启用/禁用 */ + toggleAgent(agentId: string, enabled: boolean): void { + this.config = toggleAgent(this.config, agentId, enabled); + this.save(); + this.emit("config-changed"); + } + + /** + * 构建 SDK 所需的 agents 配置 + * 将已启用的 SubAgentConfig[] 转换为 Record + */ + buildSDKAgentsConfig(): Record | undefined { + const enabled = this.getEnabledAgents(); + if (enabled.length === 0) return undefined; + + const result: Record = {}; + for (const agent of enabled) { + result[agent.name] = { + description: agent.description, + prompt: agent.prompt, + model: agent.model === "inherit" ? undefined : (agent.model as AgentDefinition["model"]), + }; + } + return result; + } + + /** 持久化配置 */ + private save(): void { + saveAgentConfig(this.config); + } +} + +/** 单例实例 */ +let agentManagerInstance: AgentManager | null = null; + +/** 获取 AgentManager 单例 */ +export function getAgentManager(): AgentManager { + if (!agentManagerInstance) { + agentManagerInstance = new AgentManager(); + } + return agentManagerInstance; +} diff --git a/src/electron/libs/agents/agent-store.ts b/src/electron/libs/agents/agent-store.ts new file mode 100644 index 0000000..e9be6d2 --- /dev/null +++ b/src/electron/libs/agents/agent-store.ts @@ -0,0 +1,207 @@ +/** + * Sub Agent 配置存储模块 + * 负责 AI 助手配置的读取、保存、校验逻辑 + */ + +import { app } from "electron"; +import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs"; +import { join } from "path"; +import { + SubAgentConfig, + AgentConfigState, + DEFAULT_AGENT_CONFIG_STATE, +} from "./agent-config.js"; +import { getBuiltinAgentTemplates, isBuiltinAgent } from "./builtin-agents.js"; + +const CONFIG_FILE_NAME = "agents-config.json"; + +/** 获取配置文件路径 */ +function getConfigPath(): string { + const userDataPath = app.getPath("userData"); + return join(userDataPath, CONFIG_FILE_NAME); +} + +/** 验证单个 Sub Agent 配置格式 */ +function validateAgentConfig(agent: Partial): agent is SubAgentConfig { + return !!( + agent.id && + agent.name && + agent.description && + agent.prompt && + typeof agent.enabled === "boolean" && + typeof agent.isBuiltin === "boolean" + ); +} + +/** 验证整体配置格式 */ +function validateConfigState(config: unknown): config is AgentConfigState { + if (!config || typeof config !== "object") return false; + const c = config as AgentConfigState; + + if (typeof c.version !== "number") return false; + if (!Array.isArray(c.agents)) return false; + + for (const agent of c.agents) { + if (!validateAgentConfig(agent)) return false; + } + + return true; +} + +/** + * 补齐内置助手 + * 确保所有内置助手都存在于配置中,保留用户的启用/禁用状态,更新 prompt + */ +function ensureBuiltinAgents(config: AgentConfigState): AgentConfigState { + const builtinTemplates = getBuiltinAgentTemplates(); + const existingIds = new Set(config.agents.map((a) => a.id)); + + // 更新已有的内置助手的 prompt(保留用户的 enabled 状态) + const updatedAgents = config.agents.map((agent) => { + if (!agent.isBuiltin) return agent; + const template = builtinTemplates.find((t) => t.id === agent.id); + if (!template) return agent; + return { + ...agent, + name: template.name, + description: template.description, + prompt: template.prompt, + updatedAt: new Date().toISOString(), + }; + }); + + // 补充缺失的内置助手 + for (const template of builtinTemplates) { + if (!existingIds.has(template.id)) { + updatedAgents.push(template); + } + } + + return { + ...config, + agents: updatedAgents, + }; +} + +/** + * 加载 Sub Agent 配置 + * @returns 配置对象,如果配置不存在或损坏则返回默认配置(含内置助手) + */ +export function loadAgentConfig(): AgentConfigState { + try { + const configPath = getConfigPath(); + + if (!existsSync(configPath)) { + console.info("[agent-store] Config file not found, using default config with builtin agents"); + const defaultConfig = { ...DEFAULT_AGENT_CONFIG_STATE }; + return ensureBuiltinAgents(defaultConfig); + } + + const raw = readFileSync(configPath, "utf8"); + const config = JSON.parse(raw); + + if (!validateConfigState(config)) { + console.warn("[agent-store] Invalid config format, using default config"); + const defaultConfig = { ...DEFAULT_AGENT_CONFIG_STATE }; + return ensureBuiltinAgents(defaultConfig); + } + + // 补齐内置助手(处理版本升级时新增/更新的内置助手) + const fixedConfig = ensureBuiltinAgents(config); + + console.info(`[agent-store] Loaded agent config with ${fixedConfig.agents.length} agents`); + return fixedConfig; + } catch (error) { + console.error("[agent-store] Failed to load agent config:", error); + const defaultConfig = { ...DEFAULT_AGENT_CONFIG_STATE }; + return ensureBuiltinAgents(defaultConfig); + } +} + +/** + * 保存 Sub Agent 配置 + */ +export function saveAgentConfig(config: AgentConfigState): void { + try { + const configPath = getConfigPath(); + const userDataPath = app.getPath("userData"); + + if (!existsSync(userDataPath)) { + mkdirSync(userDataPath, { recursive: true }); + } + + writeFileSync(configPath, JSON.stringify(config, null, 2), "utf8"); + console.info("[agent-store] Agent config saved successfully"); + } catch (error) { + console.error("[agent-store] Failed to save agent config:", error); + throw error; + } +} + +/** + * 添加新的 Sub Agent 配置 + */ +export function addAgent(config: AgentConfigState, agent: Omit): AgentConfigState { + const now = new Date().toISOString(); + const newAgent: SubAgentConfig = { + ...agent, + id: generateAgentId(), + isBuiltin: false, + createdAt: now, + updatedAt: now, + }; + + return { + ...config, + agents: [...config.agents, newAgent], + }; +} + +/** + * 更新 Sub Agent 配置 + */ +export function updateAgent(config: AgentConfigState, agentId: string, updates: Partial): AgentConfigState { + return { + ...config, + agents: config.agents.map((a) => + a.id === agentId + ? { ...a, ...updates, updatedAt: new Date().toISOString() } + : a + ), + }; +} + +/** + * 删除 Sub Agent 配置(仅允许删除自定义助手) + */ +export function removeAgent(config: AgentConfigState, agentId: string): AgentConfigState { + if (isBuiltinAgent(agentId)) { + throw new Error("内置助手不可删除"); + } + + return { + ...config, + agents: config.agents.filter((a) => a.id !== agentId), + }; +} + +/** + * 启用/禁用 Sub Agent + */ +export function toggleAgent(config: AgentConfigState, agentId: string, enabled: boolean): AgentConfigState { + return updateAgent(config, agentId, { enabled }); +} + +/** + * 获取所有已启用的 Sub Agent + */ +export function getEnabledAgents(config: AgentConfigState): SubAgentConfig[] { + return config.agents.filter((a) => a.enabled); +} + +/** + * 生成唯一的 Agent ID + */ +export function generateAgentId(): string { + return `agent-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; +} diff --git a/src/electron/libs/agents/builtin-agents.ts b/src/electron/libs/agents/builtin-agents.ts new file mode 100644 index 0000000..929c8a9 --- /dev/null +++ b/src/electron/libs/agents/builtin-agents.ts @@ -0,0 +1,96 @@ +/** + * 内置预设 AI 助手定义 + * 面向日常办公场景的预设助手模板 + */ + +import { SubAgentConfig } from "./agent-config.js"; + +/** 内置助手 ID 前缀 */ +const BUILTIN_PREFIX = "builtin-"; + +/** 判断是否为内置助手 */ +export function isBuiltinAgent(id: string): boolean { + return id.startsWith(BUILTIN_PREFIX); +} + +/** 获取所有内置助手模板 */ +export function getBuiltinAgentTemplates(): SubAgentConfig[] { + const now = new Date().toISOString(); + + return [ + { + id: `${BUILTIN_PREFIX}search`, + name: "信息检索助手", + description: "擅长在文件、网页中搜索和整理信息,帮助您快速找到所需内容", + prompt: `你是一位专业的信息检索助手。你的职责是帮助用户快速、准确地查找和整理信息。 + +核心能力: +- 在本地文件和目录中高效搜索关键内容 +- 从大量信息中提取关键要点并分类整理 +- 对搜索结果进行摘要和结构化呈现 + +工作原则: +1. 优先使用精确搜索,找到最相关的内容后再扩展范围 +2. 搜索结果应按相关性排序,并附带来源说明 +3. 对复杂查询进行拆分,分步检索后综合呈现 +4. 使用清晰的格式(列表、表格等)展示结果,便于阅读 +5. 如果搜索结果不理想,主动尝试不同的关键词和搜索策略`, + enabled: true, + model: "inherit", + isBuiltin: true, + createdAt: now, + updatedAt: now, + }, + { + id: `${BUILTIN_PREFIX}writer`, + name: "写作助手", + description: "擅长撰写和润色各类文档,如邮件、报告、方案、通知等", + prompt: `你是一位专业的写作助手。你的职责是帮助用户撰写、修改和润色各类文档。 + +核心能力: +- 撰写商务邮件、工作报告、项目方案、会议纪要、通知公告等 +- 根据用户需求调整语气(正式/半正式/轻松)和格式 +- 对已有文本进行润色、精简或扩展 +- 检查并纠正语法、拼写和标点错误 + +工作原则: +1. 始终确认文档类型、目标读者和语气要求后再开始写作 +2. 结构清晰,使用适当的标题、段落和列表 +3. 语言精炼准确,避免冗余表述 +4. 对于商务文档,保持专业和得体的语气 +5. 提供修改建议时说明理由,帮助用户理解改进之处`, + enabled: true, + model: "inherit", + isBuiltin: true, + createdAt: now, + updatedAt: now, + }, + { + id: `${BUILTIN_PREFIX}data-analyst`, + name: "数据分析助手", + description: "擅长处理表格数据,进行统计分析并提供数据洞察", + prompt: `你是一位专业的数据分析助手。你的职责是帮助用户理解、分析和处理各类数据。 + +核心能力: +- 读取和解析 CSV、Excel 等表格数据文件 +- 写入和导出 Excel 文件(.xlsx 格式),支持中文字符 +- 进行基础统计分析(求和、平均值、中位数、分布等) +- 数据清洗和格式转换 +- 生成数据洞察报告和趋势分析 +- 用通俗语言解释数据含义 + +工作原则: +1. 处理数据前先了解数据的业务背景和分析目标 +2. 对数据质量进行初步检查(缺失值、异常值等) +3. 写入 Excel 文件时,确保使用 UTF-8 编码,保证中文字符正确显示,避免乱码 +4. 用简洁明了的语言描述分析结果,避免过度使用专业术语 +5. 提供可操作的建议和结论,而非仅展示数字 +6. 大数据集优先展示摘要和关键指标,再按需深入`, + enabled: true, + model: "inherit", + isBuiltin: true, + createdAt: now, + updatedAt: now, + } + ]; +} diff --git a/src/electron/libs/mcp/browser-process-manager.ts b/src/electron/libs/mcp/browser-process-manager.ts new file mode 100644 index 0000000..627d790 --- /dev/null +++ b/src/electron/libs/mcp/browser-process-manager.ts @@ -0,0 +1,382 @@ +/** + * 浏览器进程管理器 + * 管理持久化运行的 Chrome 浏览器进程,供 Playwright MCP 通过 CDP 连接 + * 替代原有的 SSE 模式,实现真正的浏览器持久化 + */ + +import { spawn, execSync, ChildProcess } from "child_process"; +import { existsSync } from "fs"; +import { EventEmitter } from "events"; +import { app } from "electron"; +import * as path from "path"; +import type { MCPBrowserMode } from "./mcp-config.js"; + +/** 浏览器进程状态 */ +export type BrowserProcessStatus = "stopped" | "starting" | "running" | "error"; + +/** 浏览器进程管理器配置 */ +export interface BrowserProcessConfig { + /** CDP 调试端口 */ + debugPort: number; + /** 浏览器运行模式 */ + browserMode: MCPBrowserMode; + /** 用户数据目录(用于持久化 cookies/登录态) */ + userDataDir?: string; +} + +/** 默认配置 */ +const DEFAULT_CONFIG: BrowserProcessConfig = { + debugPort: 9222, + browserMode: "visible", +}; + +/** 常量 */ +const HEALTH_CHECK_TIMEOUT_MS = 15000; +const HEALTH_CHECK_INTERVAL_MS = 500; +const SHUTDOWN_TIMEOUT_MS = 5000; +const MAX_LOGS = 100; + +/** + * 浏览器进程管理器 + * 单例模式,确保只有一个持久化的 Chrome 浏览器实例 + */ +export class BrowserProcessManager extends EventEmitter { + private static instance: BrowserProcessManager | null = null; + + private browserProcess: ChildProcess | null = null; + private config: BrowserProcessConfig = DEFAULT_CONFIG; + private status: BrowserProcessStatus = "stopped"; + private errorMessage?: string; + private logs: string[] = []; + + private constructor() { + super(); + } + + /** 获取单例实例 */ + public static getInstance(): BrowserProcessManager { + if (!BrowserProcessManager.instance) { + BrowserProcessManager.instance = new BrowserProcessManager(); + } + return BrowserProcessManager.instance; + } + + /** 获取当前状态 */ + public getStatus(): BrowserProcessStatus { + return this.status; + } + + /** 获取 CDP 端点地址 */ + public getCDPEndpoint(): string | undefined { + if (this.status === "running") { + return `http://localhost:${this.config.debugPort}`; + } + return undefined; + } + + /** 获取错误信息 */ + public getErrorMessage(): string | undefined { + return this.errorMessage; + } + + /** 获取日志 */ + public getLogs(): string[] { + return [...this.logs]; + } + + /** 是否正在运行 */ + public isRunning(): boolean { + return this.status === "running"; + } + + /** 更新状态 */ + private setStatus(status: BrowserProcessStatus, error?: string): void { + this.status = status; + this.errorMessage = error; + this.emit("status-change", status, error); + } + + /** 添加日志 */ + private addLog(message: string): void { + const timestamp = new Date().toISOString(); + const logEntry = `[${timestamp}] ${message}`; + this.logs.push(logEntry); + if (this.logs.length > MAX_LOGS) { + this.logs.shift(); + } + this.emit("log", logEntry); + console.log(`[BrowserProcess] ${message}`); + } + + /** + * 检查指定端口是否已有 Chrome CDP 服务 + */ + private async checkCDPAvailable(): Promise { + try { + const response = await fetch( + `http://localhost:${this.config.debugPort}/json/version`, + { signal: AbortSignal.timeout(2000) } + ); + return response.ok; + } catch { + return false; + } + } + + /** + * 查找系统中的 Chrome/Chromium 可执行文件路径 + */ + private findChromePath(): string | null { + const platform = process.platform; + + const candidates: string[] = []; + + if (platform === "darwin") { + candidates.push( + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + ); + } else if (platform === "win32") { + const programFiles = process.env["ProgramFiles"] || "C:\\Program Files"; + const programFilesX86 = process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)"; + const localAppData = process.env["LOCALAPPDATA"] || ""; + candidates.push( + path.join(programFiles, "Google\\Chrome\\Application\\chrome.exe"), + path.join(programFilesX86, "Google\\Chrome\\Application\\chrome.exe"), + path.join(localAppData, "Google\\Chrome\\Application\\chrome.exe"), + path.join(programFiles, "Microsoft\\Edge\\Application\\msedge.exe"), + ); + } else { + // Linux + candidates.push( + "google-chrome", + "google-chrome-stable", + "chromium-browser", + "chromium", + ); + } + + for (const candidate of candidates) { + // Linux 命令名需要通过 which 检查 + if (platform === "linux" && !candidate.includes("/")) { + try { + execSync(`which ${candidate}`, { stdio: "ignore" }); + return candidate; + } catch { + continue; + } + } + if (existsSync(candidate)) { + return candidate; + } + } + + return null; + } + + /** + * 获取默认的浏览器用户数据目录 + */ + public getDefaultUserDataDir(): string { + return path.join(app.getPath("userData"), "chrome-cdp-data"); + } + + /** + * 启动 Chrome 浏览器进程 + * @param config 配置选项 + * @returns CDP 端点地址 + */ + public async start(config?: Partial): Promise { + // 如果已经在运行,直接返回端点 + if (this.isRunning()) { + const endpoint = this.getCDPEndpoint()!; + this.addLog("Browser already running"); + return endpoint; + } + + // 合并配置 + if (config) { + this.config = { ...this.config, ...config }; + } + + // 先检查端口上是否已有 CDP 服务(用户可能手动启动了浏览器) + if (await this.checkCDPAvailable()) { + this.addLog(`CDP service already available on port ${this.config.debugPort}`); + this.setStatus("running"); + return this.getCDPEndpoint()!; + } + + this.setStatus("starting"); + this.addLog(`Starting Chrome browser on debug port ${this.config.debugPort}...`); + + const chromePath = this.findChromePath(); + if (!chromePath) { + const errorMsg = "未找到 Chrome/Chromium 浏览器,请确保已安装 Google Chrome"; + this.setStatus("error", errorMsg); + throw new Error(errorMsg); + } + + this.addLog(`Found Chrome at: ${chromePath}`); + + // 构建启动参数 + const args = this.buildChromeArgs(); + this.addLog(`Launch args: ${args.join(" ")}`); + + try { + this.browserProcess = spawn(chromePath, args, { + stdio: ["ignore", "pipe", "pipe"], + detached: false, + }); + + // 监听 stdout/stderr 日志 + this.browserProcess.stdout?.on("data", (data) => { + this.addLog(`stdout: ${data.toString().trim()}`); + }); + + this.browserProcess.stderr?.on("data", (data) => { + this.addLog(`stderr: ${data.toString().trim()}`); + }); + + // 监听进程错误 + this.browserProcess.on("error", (err) => { + this.addLog(`Process error: ${err.message}`); + this.setStatus("error", err.message); + this.browserProcess = null; + }); + + // 监听进程退出 + this.browserProcess.on("exit", (code, signal) => { + this.addLog(`Process exited with code ${code}, signal ${signal}`); + if (this.status === "running") { + this.setStatus("error", `Browser exited unexpectedly (code: ${code})`); + } + this.browserProcess = null; + }); + + // 等待 CDP 端点可用 + await this.waitForCDP(); + + this.setStatus("running"); + const endpoint = this.getCDPEndpoint()!; + this.addLog(`Browser ready, CDP endpoint: ${endpoint}`); + return endpoint; + + } catch (error: any) { + this.addLog(`Failed to start browser: ${error.message}`); + this.setStatus("error", error.message); + // 清理可能启动的进程 + if (this.browserProcess) { + this.browserProcess.kill("SIGKILL"); + this.browserProcess = null; + } + throw error; + } + } + + /** + * 构建 Chrome 启动参数 + */ + private buildChromeArgs(): string[] { + const args: string[] = [ + `--remote-debugging-port=${this.config.debugPort}`, + "--no-first-run", + "--no-default-browser-check", + ]; + + // 用户数据目录 + const userDataDir = this.config.userDataDir || this.getDefaultUserDataDir(); + args.push(`--user-data-dir=${userDataDir}`); + + // headless 模式 + if (this.config.browserMode === "headless") { + args.push("--headless=new"); + } + + return args; + } + + /** + * 等待 CDP 端点可用 + */ + private async waitForCDP(): Promise { + const startTime = Date.now(); + + while (Date.now() - startTime < HEALTH_CHECK_TIMEOUT_MS) { + if (await this.checkCDPAvailable()) { + return; + } + await new Promise((resolve) => setTimeout(resolve, HEALTH_CHECK_INTERVAL_MS)); + } + + throw new Error(`CDP endpoint not available after ${HEALTH_CHECK_TIMEOUT_MS}ms`); + } + + /** + * 停止浏览器进程 + */ + public async stop(): Promise { + if (!this.browserProcess) { + this.addLog("Browser not running (no managed process)"); + this.setStatus("stopped"); + return; + } + + this.addLog("Stopping browser..."); + + return new Promise((resolve) => { + if (!this.browserProcess) { + this.setStatus("stopped"); + resolve(); + return; + } + + const timeout = setTimeout(() => { + if (this.browserProcess) { + this.addLog("Force killing browser..."); + this.browserProcess.kill("SIGKILL"); + } + }, SHUTDOWN_TIMEOUT_MS); + + this.browserProcess.once("exit", () => { + clearTimeout(timeout); + this.browserProcess = null; + this.setStatus("stopped"); + this.addLog("Browser stopped"); + resolve(); + }); + + this.browserProcess.kill("SIGTERM"); + }); + } + + /** + * 重启浏览器 + */ + public async restart(config?: Partial): Promise { + this.addLog("Restarting browser..."); + await this.stop(); + return this.start(config); + } + + /** + * 获取当前配置 + */ + public getConfig(): BrowserProcessConfig { + return { ...this.config }; + } + + /** + * 清理资源(应用退出时调用) + */ + public async cleanup(): Promise { + await this.stop(); + BrowserProcessManager.instance = null; + } +} + +/** 获取 BrowserProcessManager 单例 */ +export function getBrowserProcessManager(): BrowserProcessManager { + return BrowserProcessManager.getInstance(); +} diff --git a/src/electron/libs/mcp/builtin-servers.ts b/src/electron/libs/mcp/builtin-servers.ts new file mode 100644 index 0000000..9eba2a8 --- /dev/null +++ b/src/electron/libs/mcp/builtin-servers.ts @@ -0,0 +1,233 @@ +/** + * 内置 MCP Server 配置模板 + * 提供预配置的 MCP Server,如 Playwright 浏览器工具 + */ + +import { app } from "electron"; +import * as path from "path"; +import { MCPServerConfig, MCPBrowserMode } from "./mcp-config.js"; + +/** 内置 Server 类型 */ +export type BuiltinServerType = "playwright"; + +/** Playwright MCP Server ID(固定) */ +export const PLAYWRIGHT_SERVER_ID = "builtin-playwright"; + +/** + * 获取默认的用户数据目录 + * 用于持久化浏览器会话(cookies、登录状态等) + */ +export function getDefaultUserDataDir(): string { + return path.join(app.getPath("userData"), "playwright-data"); +} + +/** + * 构建 Playwright MCP Server 的命令参数 + * @param browserMode 浏览器运行模式 + * @param userDataDir 用户数据目录(可选) + * @param cdpEndpoint CDP 端点地址(可选,启用后通过 CDP 连接已有浏览器) + */ +export function buildPlaywrightArgs( + browserMode: MCPBrowserMode = "visible", + userDataDir?: string, + cdpEndpoint?: string +): string[] { + // 基础参数:使用 -y 自动确认下载 + const args: string[] = ["-y", "@playwright/mcp@latest"]; + + // CDP 模式:连接到已有浏览器,不需要 headless 和 userDataDir 参数 + if (cdpEndpoint) { + args.push(`--cdp-endpoint=${cdpEndpoint}`); + return args; + } + + // headless 模式 + if (browserMode === "headless") { + args.push("--headless"); + } + + // 用户数据目录(用于持久化会话) + if (userDataDir) { + args.push("--user-data-dir", userDataDir); + } + + return args; +} + +/** + * 创建 Playwright MCP Server 配置 + * @param browserMode 浏览器运行模式 + * @param userDataDir 用户数据目录(留空则不持久化) + * @param persistBrowser 是否跨对话保持浏览器(CDP 模式) + * @param cdpEndpoint CDP 端点地址(persistBrowser 时使用) + */ +export function createPlaywrightServerConfig( + browserMode: MCPBrowserMode = "visible", + userDataDir?: string, + persistBrowser: boolean = false, + cdpEndpoint?: string +): MCPServerConfig { + const now = new Date().toISOString(); + + return { + id: PLAYWRIGHT_SERVER_ID, + name: "浏览器自动化", + description: "通过 Playwright 控制浏览器,支持网页操作、信息采集、自动填表等任务", + command: "npx", + args: buildPlaywrightArgs(browserMode, userDataDir, cdpEndpoint), + transportType: "stdio", + enabled: false, + isBuiltin: true, + builtinType: "playwright", + browserMode, + userDataDir, + persistBrowser, + createdAt: now, + updatedAt: now, + }; +} + +/** + * 获取所有内置 Server 配置模板 + */ +export function getBuiltinServerTemplates(): MCPServerConfig[] { + return [ + createPlaywrightServerConfig("visible"), + ]; +} + +/** + * 检查是否为内置 Server + */ +export function isBuiltinServer(serverId: string): boolean { + return serverId === PLAYWRIGHT_SERVER_ID; +} + +/** + * 更新 Playwright Server 的配置 + * @param config 现有配置 + * @param browserMode 新的浏览器模式 + * @param userDataDir 新的用户数据目录(undefined 表示不修改,null 表示清除) + * @param persistBrowser 是否跨对话保持浏览器(undefined 表示不修改) + * @param cdpEndpoint CDP 端点地址(persistBrowser 时传入) + */ +export function updatePlaywrightConfig( + config: MCPServerConfig, + browserMode: MCPBrowserMode, + userDataDir?: string | null, + persistBrowser?: boolean, + cdpEndpoint?: string +): MCPServerConfig { + // 如果 userDataDir 是 undefined,保持原值;如果是 null,则清除 + const newUserDataDir = userDataDir === undefined + ? config.userDataDir + : (userDataDir ?? undefined); + + // 如果 persistBrowser 是 undefined,保持原值 + const newPersistBrowser = persistBrowser === undefined + ? config.persistBrowser + : persistBrowser; + + // CDP 模式下不需要 userDataDir 和 browserMode 传给 Playwright(由浏览器本身管理) + const effectiveCdpEndpoint = newPersistBrowser ? cdpEndpoint : undefined; + + return { + ...config, + args: buildPlaywrightArgs(browserMode, newUserDataDir, effectiveCdpEndpoint), + browserMode, + userDataDir: newUserDataDir, + persistBrowser: newPersistBrowser, + updatedAt: new Date().toISOString(), + }; +} + + + +/** + * 执行命令并检查是否可用 + */ +async function checkCommandAvailable(command: string): Promise<{ stdout?: string; error?: Error }> { + const { exec } = await import("child_process"); + const { promisify } = await import("util"); + const execAsync = promisify(exec); + + try { + const result = await execAsync(command); + return { stdout: result.stdout }; + } catch (error) { + return { error: error as Error }; + } +} + +/** + * 检测 Node.js 环境是否可用 + */ +export async function checkNodeEnvironment(): Promise<{ + available: boolean; + version?: string; + error?: string; +}> { + const result = await checkCommandAvailable("node --version"); + + if (result.error) { + console.error("[builtin-servers] Node.js check failed:", result.error.message); + return { + available: false, + error: "Node.js 环境未检测到。请确保已安装 Node.js 并添加到系统 PATH 中。", + }; + } + + return { available: true, version: result.stdout?.trim() }; +} + +/** + * 检测 npx 命令是否可用 + */ +export async function checkNpxAvailable(): Promise<{ + available: boolean; + error?: string; +}> { + const result = await checkCommandAvailable("npx --version"); + + if (result.error) { + console.error("[builtin-servers] npx check failed:", result.error.message); + return { + available: false, + error: "npx 命令不可用。请确保已安装 npm 并添加到系统 PATH 中。", + }; + } + + return { available: true }; +} + +/** + * 预检查 Playwright MCP Server 的运行环境 + */ +export async function preflightPlaywrightCheck(): Promise<{ + ready: boolean; + issues: string[]; + suggestions: string[]; +}> { + const issues: string[] = []; + const suggestions: string[] = []; + + // 检查 Node.js + const nodeCheck = await checkNodeEnvironment(); + if (!nodeCheck.available) { + issues.push("Node.js 未安装"); + suggestions.push("请访问 https://nodejs.org 下载并安装 Node.js"); + } + + // 检查 npx + const npxCheck = await checkNpxAvailable(); + if (!npxCheck.available) { + issues.push("npx 命令不可用"); + suggestions.push("请确保 npm 已正确安装"); + } + + return { + ready: issues.length === 0, + issues, + suggestions, + }; +} diff --git a/src/electron/libs/mcp/index.ts b/src/electron/libs/mcp/index.ts new file mode 100644 index 0000000..91f5c7c --- /dev/null +++ b/src/electron/libs/mcp/index.ts @@ -0,0 +1,75 @@ +/** + * MCP (Model Context Protocol) 模块 + * + * 提供 MCP Server 配置管理功能 + * 注意:MCP Server 进程由 Claude SDK 自动管理 + * + * @module mcp + * + * 模块结构: + * - mcp-config: 类型定义和常量 + * - mcp-store: 配置持久化存储 + * - mcp-manager: 配置管理器 + * - mcp-ipc-handlers: IPC 处理器 + * - builtin-servers: 内置 Server 配置 + * - browser-process-manager: Chrome 浏览器进程管理(CDP 模式) + */ + +// ============ 类型定义 ============ +export type { + MCPServerStatus, + MCPBrowserMode, + MCPTransportType, + MCPServerConfig, + MCPServerRuntimeState, + MCPConfigState, + MCPGlobalSettings, + MCPServerInfo, + MCPConfigChangeEvent, + MCPToolInfo, + MCPServerTools, +} from "./mcp-config.js"; + +export { + DEFAULT_GLOBAL_SETTINGS, + DEFAULT_MCP_CONFIG_STATE, +} from "./mcp-config.js"; + +// ============ 配置存储 ============ +export { + loadMCPConfig, + saveMCPConfig, + getMCPServerById, + addMCPServer, + updateMCPServer, + removeMCPServer, + toggleMCPServer, + updateGlobalSettings, + getEnabledServers, + generateServerId, +} from "./mcp-store.js"; + +// ============ 配置管理 ============ +export { MCPManager, getMCPManager } from "./mcp-manager.js"; + +// ============ 内置 Server ============ +export type { BuiltinServerType } from "./builtin-servers.js"; +export { + PLAYWRIGHT_SERVER_ID, + createPlaywrightServerConfig, + updatePlaywrightConfig, + getDefaultUserDataDir, + buildPlaywrightArgs, + getBuiltinServerTemplates, + isBuiltinServer, + checkNodeEnvironment, + checkNpxAvailable, + preflightPlaywrightCheck, +} from "./builtin-servers.js"; + +// ============ 浏览器进程管理 ============ +export { BrowserProcessManager, getBrowserProcessManager } from "./browser-process-manager.js"; +export type { BrowserProcessStatus, BrowserProcessConfig } from "./browser-process-manager.js"; + +// ============ IPC 处理器 ============ +export { setupMCPHandlers, cleanupMCP } from "./mcp-ipc-handlers.js"; diff --git a/src/electron/libs/mcp/mcp-config.ts b/src/electron/libs/mcp/mcp-config.ts new file mode 100644 index 0000000..f528bdb --- /dev/null +++ b/src/electron/libs/mcp/mcp-config.ts @@ -0,0 +1,134 @@ +/** + * MCP 配置管理核心模块 - 类型定义 + * 用于定义 MCP Server 配置的 TypeScript 接口 + */ + +/** MCP Server 运行状态 */ +export type MCPServerStatus = "running" | "stopped" | "error" | "starting"; + +/** MCP Server 运行模式(针对浏览器工具) */ +export type MCPBrowserMode = "visible" | "headless"; + +/** MCP Server 传输类型 */ +export type MCPTransportType = "stdio" | "sse"; + +/** 单个 MCP Server 配置 */ +export interface MCPServerConfig { + /** 唯一标识符 */ + id: string; + /** 显示名称 */ + name: string; + /** 描述信息 */ + description?: string; + /** 启动命令 */ + command: string; + /** 命令参数 */ + args?: string[]; + /** 环境变量 */ + env?: Record; + /** 传输类型 */ + transportType: MCPTransportType; + /** 是否已启用 */ + enabled: boolean; + /** 是否为内置工具 */ + isBuiltin?: boolean; + /** 内置工具类型(如 playwright) */ + builtinType?: string; + /** 浏览器运行模式(仅对浏览器工具有效) */ + browserMode?: MCPBrowserMode; + /** 用户数据目录(用于持久化浏览器会话,仅对浏览器工具有效) */ + userDataDir?: string; + /** + * 是否跨对话保持浏览器(仅对浏览器工具有效) + * 启用后将通过 CDP 连接到独立运行的浏览器进程,浏览器状态和页面可跨多次对话保持 + */ + persistBrowser?: boolean; + /** 创建时间 */ + createdAt: string; + /** 更新时间 */ + updatedAt: string; +} + +/** MCP Server 运行时状态 */ +export interface MCPServerRuntimeState { + /** Server ID */ + serverId: string; + /** 运行状态 */ + status: MCPServerStatus; + /** 错误信息(如果有) */ + errorMessage?: string; + /** 错误详情/日志 */ + errorDetails?: string; + /** 进程 PID(如果正在运行) */ + pid?: number; + /** 重启次数 */ + restartCount: number; + /** 最后一次启动时间 */ + lastStartTime?: string; + /** 最后一次停止时间 */ + lastStopTime?: string; +} + +/** MCP 配置整体状态 */ +export interface MCPConfigState { + /** 配置版本号(用于迁移) */ + version: number; + /** 所有 MCP Server 配置列表 */ + servers: MCPServerConfig[]; + /** 全局设置 */ + globalSettings: MCPGlobalSettings; +} + +/** MCP 全局设置 */ +export interface MCPGlobalSettings { + /** 是否在应用启动时自动启动已启用的 Server */ + autoStartOnLaunch: boolean; + /** Server 启动超时时间(毫秒) */ + startupTimeout: number; + /** 最大自动重启次数 */ + maxRestartAttempts: number; +} + +/** 默认全局设置 */ +export const DEFAULT_GLOBAL_SETTINGS: MCPGlobalSettings = { + autoStartOnLaunch: true, + startupTimeout: 30000, // 30秒 + maxRestartAttempts: 3, +}; + +/** 默认配置状态 */ +export const DEFAULT_MCP_CONFIG_STATE: MCPConfigState = { + version: 1, + servers: [], + globalSettings: DEFAULT_GLOBAL_SETTINGS, +}; + +/** 用于 IPC 通信的 MCP Server 状态信息 */ +export interface MCPServerInfo { + config: MCPServerConfig; + runtime: MCPServerRuntimeState; +} + +/** MCP 配置变更事件类型 */ +export type MCPConfigChangeEvent = + | { type: "server-added"; server: MCPServerConfig } + | { type: "server-updated"; server: MCPServerConfig } + | { type: "server-removed"; serverId: string } + | { type: "server-status-changed"; serverId: string; status: MCPServerStatus; error?: string } + | { type: "global-settings-updated"; settings: MCPGlobalSettings }; + +/** MCP 工具信息(从 MCP Server 获取) */ +export interface MCPToolInfo { + /** 工具名称 */ + name: string; + /** 工具描述 */ + description?: string; + /** 输入参数 Schema */ + inputSchema?: object; +} + +/** MCP Server 提供的工具列表 */ +export interface MCPServerTools { + serverId: string; + tools: MCPToolInfo[]; +} diff --git a/src/electron/libs/mcp/mcp-ipc-handlers.ts b/src/electron/libs/mcp/mcp-ipc-handlers.ts new file mode 100644 index 0000000..80f15ce --- /dev/null +++ b/src/electron/libs/mcp/mcp-ipc-handlers.ts @@ -0,0 +1,299 @@ +/** + * MCP IPC 处理器 + * 处理渲染进程与主进程之间的 MCP 配置相关通信 + * 注意:MCP Server 进程由 Claude SDK 自动管理,这里只处理配置 + */ + +import { BrowserWindow, ipcMain } from "electron"; +import { getMCPManager, MCPManager } from "./mcp-manager.js"; +import { + addMCPServer, + updateMCPServer, + removeMCPServer, + toggleMCPServer, + getMCPServerById, + generateServerId, +} from "./mcp-store.js"; +import { + createPlaywrightServerConfig, + updatePlaywrightConfig, + getDefaultUserDataDir, + PLAYWRIGHT_SERVER_ID, + preflightPlaywrightCheck, +} from "./builtin-servers.js"; +import type { MCPBrowserMode } from "./mcp-config.js"; +import type { + MCPServerConfig, + MCPTransportType, +} from "./mcp-config.js"; + +let mainWindow: BrowserWindow | null = null; +let manager: MCPManager | null = null; + +/** IPC 响应用的 Server 信息(扁平化结构,便于前端使用) */ +interface IPCServerInfo { + id: string; + name: string; + description?: string; + command: string; + args?: string[]; + env?: Record; + transportType: MCPTransportType; + enabled: boolean; + isBuiltin?: boolean; + builtinType?: string; + browserMode?: "visible" | "headless"; + /** 用户数据目录(用于持久化浏览器会话) */ + userDataDir?: string; + /** 是否跨对话保持浏览器 */ + persistBrowser?: boolean; +} + +/** + * 将 Server 配置转换为 IPC 可传输的信息 + */ +function toServerInfo(config: MCPServerConfig): IPCServerInfo { + return { + id: config.id, + name: config.name, + description: config.description, + command: config.command, + args: config.args, + env: config.env, + transportType: config.transportType, + enabled: config.enabled, + isBuiltin: config.isBuiltin, + builtinType: config.builtinType, + browserMode: config.browserMode, + userDataDir: config.userDataDir, + persistBrowser: config.persistBrowser, + }; +} + +/** + * 设置 MCP IPC 处理器 + */ +export function setupMCPHandlers(win: BrowserWindow): void { + mainWindow = win; + manager = getMCPManager(); + + // 监听配置变化并广播到渲染进程 + manager.on("config-changed", () => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send("mcp-config-changed"); + } + }); + + // 获取所有 MCP Server + ipcMain.handle("mcp-get-servers", async () => { + if (!manager) return []; + + const config = manager.getConfig(); + return config.servers.map((s: MCPServerConfig) => toServerInfo(s)); + }); + + // 启用 MCP Server + ipcMain.handle("mcp-enable-server", async (_, serverId: string) => { + if (!manager) throw new Error("MCP Manager not initialized"); + + const config = manager.getConfig(); + const server = getMCPServerById(config, serverId); + + if (!server) { + throw new Error(`Server not found: ${serverId}`); + } + + // 更新配置(SDK 会在下次对话时自动启动) + const newConfig = toggleMCPServer(config, serverId, true); + manager.updateConfig(newConfig); + + return { success: true }; + }); + + // 禁用 MCP Server + ipcMain.handle("mcp-disable-server", async (_, serverId: string) => { + if (!manager) throw new Error("MCP Manager not initialized"); + + // 更新配置 + const config = manager.getConfig(); + const newConfig = toggleMCPServer(config, serverId, false); + manager.updateConfig(newConfig); + + return { success: true }; + }); + + // 一键启用浏览器自动化 + ipcMain.handle("mcp-enable-browser-automation", async () => { + if (!manager) throw new Error("MCP Manager not initialized"); + + // 预检查环境 + const preflight = await preflightPlaywrightCheck(); + if (!preflight.ready) { + throw new Error(`环境检查失败: ${preflight.issues.join(", ")}\n建议: ${preflight.suggestions.join(", ")}`); + } + + let config = manager.getConfig(); + + // 检查是否已存在 Playwright Server + let server = getMCPServerById(config, PLAYWRIGHT_SERVER_ID); + + if (!server) { + // 创建新的 Playwright Server 配置 + const playwrightConfig = createPlaywrightServerConfig("visible"); + config = addMCPServer(config, playwrightConfig); + server = playwrightConfig; + } + + // 启用并保存配置(SDK 会在下次对话时自动启动) + config = toggleMCPServer(config, PLAYWRIGHT_SERVER_ID, true); + manager.updateConfig(config); + + return { success: true }; + }); + + // 添加新的 MCP Server + ipcMain.handle("mcp-add-server", async (_, serverConfig: { + name: string; + description?: string; + command: string; + args?: string[]; + env?: Record; + transportType: "stdio" | "sse"; + }) => { + if (!manager) throw new Error("MCP Manager not initialized"); + + const serverId = generateServerId(); + + const newServer: Omit = { + id: serverId, + name: serverConfig.name, + description: serverConfig.description, + command: serverConfig.command, + args: serverConfig.args, + env: serverConfig.env, + transportType: serverConfig.transportType as MCPTransportType, + enabled: false, + isBuiltin: false, + }; + + let config = manager.getConfig(); + config = addMCPServer(config, newServer); + manager.updateConfig(config); + + return { success: true, serverId }; + }); + + // 更新 MCP Server 配置 + ipcMain.handle("mcp-update-server", async (_, serverId: string, updates: { + name?: string; + description?: string; + command?: string; + args?: string[]; + env?: Record; + transportType?: "stdio" | "sse"; + }) => { + if (!manager) throw new Error("MCP Manager not initialized"); + + let config = manager.getConfig(); + const server = getMCPServerById(config, serverId); + + if (!server) { + throw new Error(`Server not found: ${serverId}`); + } + + // 更新配置 + config = updateMCPServer(config, serverId, updates as Partial); + manager.updateConfig(config); + + return { success: true }; + }); + + // 删除 MCP Server + ipcMain.handle("mcp-delete-server", async (_, serverId: string) => { + if (!manager) throw new Error("MCP Manager not initialized"); + + let config = manager.getConfig(); + const server = getMCPServerById(config, serverId); + + if (!server) { + throw new Error(`Server not found: ${serverId}`); + } + + // 不允许删除内置 Server + if (server.isBuiltin) { + throw new Error("Cannot delete builtin server"); + } + + // 从配置中移除 + config = removeMCPServer(config, serverId); + manager.updateConfig(config); + + return { success: true }; + }); + + // 更新浏览器自动化配置(headless 模式、持久化会话、跨对话保持) + ipcMain.handle("mcp-update-browser-config", async (_, options: { + browserMode?: MCPBrowserMode; + userDataDir?: string | null; // null 表示清除 + enablePersistence?: boolean; // 便捷选项:是否启用持久化 + persistBrowser?: boolean; // 是否跨对话保持浏览器 + }) => { + if (!manager) throw new Error("MCP Manager not initialized"); + + let config = manager.getConfig(); + const server = getMCPServerById(config, PLAYWRIGHT_SERVER_ID); + + if (!server) { + throw new Error("Playwright server not found. Please enable browser automation first."); + } + + // 处理 enablePersistence 便捷选项 + let userDataDir = options.userDataDir; + if (options.enablePersistence === true && !userDataDir) { + userDataDir = getDefaultUserDataDir(); + } else if (options.enablePersistence === false) { + userDataDir = null; // 清除持久化 + } + + // 更新 Playwright 配置 + const browserMode = options.browserMode ?? server.browserMode ?? "visible"; + const persistBrowser = options.persistBrowser; + const updatedServer = updatePlaywrightConfig(server, browserMode, userDataDir, persistBrowser); + + // 更新配置 + config = updateMCPServer(config, PLAYWRIGHT_SERVER_ID, updatedServer); + manager.updateConfig(config); + + // 如果启用了跨对话保持,尝试启动持久化浏览器(CDP 模式) + if (updatedServer.persistBrowser && updatedServer.enabled) { + try { + await manager.ensureBrowserRunning(); + } catch (error) { + console.error("Failed to start browser:", error); + // 不抛出错误,配置已保存,下次对话时会重试 + } + } else if (!updatedServer.persistBrowser) { + // 如果禁用了跨对话保持,停止持久化浏览器 + await manager.stopBrowser(); + } + + return { + success: true, + browserMode: updatedServer.browserMode, + userDataDir: updatedServer.userDataDir, + persistBrowser: updatedServer.persistBrowser, + }; + }); + + // 获取默认的用户数据目录 + ipcMain.handle("mcp-get-default-user-data-dir", async () => { + return getDefaultUserDataDir(); + }); +} + +/** + * 清理 MCP 资源 + */ +export function cleanupMCP(): void { + mainWindow = null; +} diff --git a/src/electron/libs/mcp/mcp-manager.ts b/src/electron/libs/mcp/mcp-manager.ts new file mode 100644 index 0000000..d2c66bd --- /dev/null +++ b/src/electron/libs/mcp/mcp-manager.ts @@ -0,0 +1,231 @@ +/** + * MCP 配置管理器 + * 负责 MCP Server 配置的管理(启动由 Claude SDK 自动处理) + */ + +import { EventEmitter } from "events"; +import { + MCPServerConfig, + MCPConfigState, + MCPConfigChangeEvent, +} from "./mcp-config.js"; +import { loadMCPConfig, saveMCPConfig, getEnabledServers } from "./mcp-store.js"; +import { getBrowserProcessManager, BrowserProcessManager } from "./browser-process-manager.js"; +import { PLAYWRIGHT_SERVER_ID, buildPlaywrightArgs } from "./builtin-servers.js"; + +/** MCP Manager 事件类型 */ +export interface MCPManagerEvents { + "config-changed": (event: MCPConfigChangeEvent) => void; +} + +/** SDK MCP Server 配置类型(统一使用 stdio) */ +export type SDKMCPServerConfig = + { type?: 'stdio'; command: string; args?: string[]; env?: Record }; + +/** + * MCP 配置管理器 + * 单例模式,管理 MCP Server 配置 + * 注意:Server 进程由 Claude SDK 自动启动和管理(stdio 模式) + * 持久化浏览器通过 CDP 连接到由 BrowserProcessManager 管理的 Chrome 进程 + */ +export class MCPManager extends EventEmitter { + private static instance: MCPManager | null = null; + + /** 当前配置 */ + private config: MCPConfigState; + + /** 浏览器进程管理器实例 */ + private browserManager: BrowserProcessManager; + + private constructor() { + super(); + this.config = loadMCPConfig(); + this.browserManager = getBrowserProcessManager(); + } + + /** 获取单例实例 */ + public static getInstance(): MCPManager { + if (!MCPManager.instance) { + MCPManager.instance = new MCPManager(); + } + return MCPManager.instance; + } + + /** 重置实例(主要用于测试) */ + public static resetInstance(): void { + MCPManager.instance = null; + } + + /** 获取当前配置 */ + public getConfig(): MCPConfigState { + return this.config; + } + + /** 重新加载配置 */ + public reloadConfig(): void { + this.config = loadMCPConfig(); + } + + /** 保存配置 */ + public saveConfig(): void { + saveMCPConfig(this.config); + } + + /** 更新配置 */ + public updateConfig(newConfig: MCPConfigState): void { + this.config = newConfig; + this.saveConfig(); + } + + /** 获取已启用的 Servers */ + public getEnabledServers(): MCPServerConfig[] { + return getEnabledServers(this.config); + } + + /** + * 获取 Playwright Server 配置 + */ + public getPlaywrightConfig(): MCPServerConfig | undefined { + return this.config.servers.find(s => s.id === PLAYWRIGHT_SERVER_ID); + } + + /** + * 检查是否需要启动持久化浏览器 + */ + public needsBrowser(): boolean { + const playwright = this.getPlaywrightConfig(); + return !!playwright?.enabled && !!playwright?.persistBrowser; + } + + /** + * 确保持久化浏览器正在运行(如果配置了 persistBrowser) + * @returns CDP 端点地址或 undefined + */ + public async ensureBrowserRunning(): Promise { + const playwright = this.getPlaywrightConfig(); + + if (!playwright?.enabled || !playwright?.persistBrowser) { + // 如果不需要持久化浏览器,停止可能运行的浏览器进程 + if (this.browserManager.isRunning()) { + console.log('[mcp-manager] Stopping browser (persistence disabled)'); + await this.browserManager.stop(); + } + return undefined; + } + + // 如果已经在运行,直接返回端点 + if (this.browserManager.isRunning()) { + return this.browserManager.getCDPEndpoint(); + } + + // 启动浏览器 + console.log('[mcp-manager] Starting browser for CDP persistent mode'); + try { + const endpoint = await this.browserManager.start({ + browserMode: playwright.browserMode || 'visible', + userDataDir: playwright.userDataDir, + }); + console.log(`[mcp-manager] Browser started, CDP endpoint: ${endpoint}`); + return endpoint; + } catch (error) { + console.error('[mcp-manager] Failed to start browser:', error); + throw error; + } + } + + /** + * 停止持久化浏览器 + */ + public async stopBrowser(): Promise { + if (this.browserManager.isRunning()) { + console.log('[mcp-manager] Stopping browser'); + await this.browserManager.stop(); + } + } + + /** + * 获取浏览器状态 + */ + public getBrowserStatus(): { + running: boolean; + endpoint?: string; + error?: string; + } { + return { + running: this.browserManager.isRunning(), + endpoint: this.browserManager.getCDPEndpoint(), + error: this.browserManager.getErrorMessage(), + }; + } + + /** + * 构建用于 Claude SDK 的 MCP Servers 配置 + * 返回格式符合 SDK 的 mcpServers 选项 + * 如果 Playwright 配置了持久化浏览器,将使用 CDP 模式(stdio + --cdp-endpoint 参数) + */ + public buildSDKConfig(): Record { + const mcpServers: Record = {}; + + for (const server of this.config.servers) { + if (!server.enabled) continue; + + // 检查是否是 Playwright 且配置了持久化(CDP 模式) + if (server.id === PLAYWRIGHT_SERVER_ID && server.persistBrowser) { + const cdpEndpoint = this.browserManager.getCDPEndpoint(); + if (cdpEndpoint) { + // 使用 stdio 模式 + --cdp-endpoint 参数连接到持久化浏览器 + mcpServers[server.id] = { + type: 'stdio', + command: server.command, + args: buildPlaywrightArgs(server.browserMode, undefined, cdpEndpoint), + }; + console.log(`[mcp-manager] Configured server: ${server.id} (CDP mode at ${cdpEndpoint})`); + } else { + console.log(`[mcp-manager] Skipping server ${server.id}: browser not running`); + } + continue; + } + + // 标准 stdio 模式 + if (server.transportType !== 'stdio') { + console.log(`[mcp-manager] Skipping server ${server.id}: unsupported transport type ${server.transportType}`); + continue; + } + + mcpServers[server.id] = { + type: 'stdio', + command: server.command, + args: server.args, + env: server.env, + }; + + console.log(`[mcp-manager] Configured server: ${server.id} (${server.name}) - stdio mode`); + } + + return mcpServers; + } + + /** + * 构建用于 Claude SDK 的 MCP Servers 配置(异步版本) + * 会自动启动持久化浏览器(如果需要) + */ + public async buildSDKConfigAsync(): Promise> { + // 先确保浏览器运行(如果需要) + await this.ensureBrowserRunning(); + + // 然后构建配置 + return this.buildSDKConfig(); + } + + /** + * 清理所有资源(应用退出时调用) + */ + public async cleanup(): Promise { + await this.browserManager.cleanup(); + } +} + +/** 导出单例获取函数 */ +export function getMCPManager(): MCPManager { + return MCPManager.getInstance(); +} diff --git a/src/electron/libs/mcp/mcp-store.ts b/src/electron/libs/mcp/mcp-store.ts new file mode 100644 index 0000000..b4f8be1 --- /dev/null +++ b/src/electron/libs/mcp/mcp-store.ts @@ -0,0 +1,249 @@ +/** + * MCP 配置存储模块 + * 负责 MCP 配置的读取、保存、校验逻辑 + */ + +import { app } from "electron"; +import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs"; +import { join } from "path"; +import { + MCPConfigState, + MCPServerConfig, + MCPGlobalSettings, + MCPBrowserMode, + DEFAULT_MCP_CONFIG_STATE, + DEFAULT_GLOBAL_SETTINGS, +} from "./mcp-config.js"; +import { + PLAYWRIGHT_SERVER_ID, + createPlaywrightServerConfig, +} from "./builtin-servers.js"; + +const CONFIG_FILE_NAME = "mcp-config.json"; + +/** 获取配置文件路径 */ +function getConfigPath(): string { + const userDataPath = app.getPath("userData"); + return join(userDataPath, CONFIG_FILE_NAME); +} + +/** 验证 MCP Server 配置格式 */ +function validateServerConfig(server: Partial): server is MCPServerConfig { + return !!( + server.id && + server.name && + server.command && + server.transportType && + typeof server.enabled === "boolean" + ); +} + +/** 验证整体配置格式 */ +function validateConfigState(config: unknown): config is MCPConfigState { + if (!config || typeof config !== "object") return false; + const c = config as MCPConfigState; + + if (typeof c.version !== "number") return false; + if (!Array.isArray(c.servers)) return false; + if (!c.globalSettings || typeof c.globalSettings !== "object") return false; + + // 验证每个 server 配置 + for (const server of c.servers) { + if (!validateServerConfig(server)) return false; + } + + return true; +} + +/** 迁移旧版本配置(未来扩展用) */ +function migrateConfig(config: MCPConfigState): MCPConfigState { + // 目前版本为 1,无需迁移 + // 未来如果配置格式变化,在这里处理迁移逻辑 + return config; +} + +/** + * 修复内置 Server 配置 + * 确保内置 Server 始终使用最新的命令和参数格式 + */ +function fixBuiltinServerConfigs(config: MCPConfigState): MCPConfigState { + const fixedServers = config.servers.map((server: MCPServerConfig) => { + // 修复 Playwright 内置 Server + if (server.id === PLAYWRIGHT_SERVER_ID || server.builtinType === "playwright") { + const browserMode: MCPBrowserMode = server.browserMode || "visible"; + const latestConfig = createPlaywrightServerConfig(browserMode); + + // 保留用户的启用状态、浏览器持久化设置和时间戳,但更新命令和参数 + return { + ...latestConfig, + enabled: server.enabled, + browserMode: server.browserMode ?? latestConfig.browserMode, + userDataDir: server.userDataDir ?? latestConfig.userDataDir, + persistBrowser: server.persistBrowser ?? latestConfig.persistBrowser, + createdAt: server.createdAt || latestConfig.createdAt, + updatedAt: latestConfig.updatedAt, + }; + } + + return server; + }); + + return { + ...config, + servers: fixedServers, + }; +} + +/** + * 加载 MCP 配置 + * @returns 配置对象,如果配置不存在或损坏则返回默认配置 + */ +export function loadMCPConfig(): MCPConfigState { + try { + const configPath = getConfigPath(); + + if (!existsSync(configPath)) { + console.info("[mcp-store] Config file not found, using default config"); + return { ...DEFAULT_MCP_CONFIG_STATE }; + } + + const raw = readFileSync(configPath, "utf8"); + const config = JSON.parse(raw); + + if (!validateConfigState(config)) { + console.warn("[mcp-store] Invalid config format, using default config"); + return { ...DEFAULT_MCP_CONFIG_STATE }; + } + + // 迁移配置(如果需要) + const migratedConfig = migrateConfig(config); + + // 修复内置 Server 配置(确保使用最新的命令格式) + const fixedConfig = fixBuiltinServerConfigs(migratedConfig); + + // 确保全局设置包含所有字段(兼容旧配置) + fixedConfig.globalSettings = { + ...DEFAULT_GLOBAL_SETTINGS, + ...fixedConfig.globalSettings, + }; + + console.info(`[mcp-store] Loaded MCP config with ${fixedConfig.servers.length} servers`); + return fixedConfig; + } catch (error) { + console.error("[mcp-store] Failed to load MCP config:", error); + return { ...DEFAULT_MCP_CONFIG_STATE }; + } +} + +/** + * 保存 MCP 配置 + * @param config 配置对象 + */ +export function saveMCPConfig(config: MCPConfigState): void { + try { + const configPath = getConfigPath(); + const userDataPath = app.getPath("userData"); + + // 确保目录存在 + if (!existsSync(userDataPath)) { + mkdirSync(userDataPath, { recursive: true }); + } + + // 更新服务器配置的更新时间 + const updatedConfig = { + ...config, + servers: config.servers.map((server: MCPServerConfig) => ({ + ...server, + updatedAt: new Date().toISOString(), + })), + }; + + writeFileSync(configPath, JSON.stringify(updatedConfig, null, 2), "utf8"); + console.info("[mcp-store] MCP config saved successfully"); + } catch (error) { + console.error("[mcp-store] Failed to save MCP config:", error); + throw error; + } +} + +/** + * 获取指定 ID 的 MCP Server 配置 + */ +export function getMCPServerById(config: MCPConfigState, serverId: string): MCPServerConfig | undefined { + return config.servers.find((s: MCPServerConfig) => s.id === serverId); +} + +/** + * 添加新的 MCP Server 配置 + */ +export function addMCPServer(config: MCPConfigState, server: Omit): MCPConfigState { + const now = new Date().toISOString(); + const newServer: MCPServerConfig = { + ...server, + createdAt: now, + updatedAt: now, + }; + + return { + ...config, + servers: [...config.servers, newServer], + }; +} + +/** + * 更新 MCP Server 配置 + */ +export function updateMCPServer(config: MCPConfigState, serverId: string, updates: Partial): MCPConfigState { + return { + ...config, + servers: config.servers.map((s: MCPServerConfig) => + s.id === serverId + ? { ...s, ...updates, updatedAt: new Date().toISOString() } + : s + ), + }; +} + +/** + * 删除 MCP Server 配置 + */ +export function removeMCPServer(config: MCPConfigState, serverId: string): MCPConfigState { + return { + ...config, + servers: config.servers.filter((s: MCPServerConfig) => s.id !== serverId), + }; +} + +/** + * 启用/禁用 MCP Server + */ +export function toggleMCPServer(config: MCPConfigState, serverId: string, enabled: boolean): MCPConfigState { + return updateMCPServer(config, serverId, { enabled }); +} + +/** + * 更新全局设置 + */ +export function updateGlobalSettings(config: MCPConfigState, settings: Partial): MCPConfigState { + return { + ...config, + globalSettings: { + ...config.globalSettings, + ...settings, + }, + }; +} + +/** + * 获取所有已启用的 MCP Server + */ +export function getEnabledServers(config: MCPConfigState): MCPServerConfig[] { + return config.servers.filter((s: MCPServerConfig) => s.enabled); +} + +/** + * 生成唯一的 Server ID + */ +export function generateServerId(): string { + return `mcp-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; +} diff --git a/src/electron/libs/runner.ts b/src/electron/libs/runner.ts index 63c5049..bb941b1 100644 --- a/src/electron/libs/runner.ts +++ b/src/electron/libs/runner.ts @@ -2,8 +2,10 @@ import { query, type SDKMessage, type PermissionResult } from "@anthropic-ai/cla import type { ServerEvent } from "../types.js"; import type { Session } from "./session-store.js"; -import { getCurrentApiConfig, buildEnvForConfig, getClaudeCodePath} from "./claude-settings.js"; +import { getCurrentApiConfig, buildEnvForConfig, getClaudeCodePath } from "./claude-settings.js"; import { getEnhancedEnv } from "./util.js"; +import { getMCPManager } from "./mcp/mcp-manager.js"; +import { getAgentManager } from "./agents/agent-manager.js"; export type RunnerOptions = { @@ -44,7 +46,7 @@ export async function runClaude(options: RunnerOptions): Promise { try { // 获取当前配置 const config = getCurrentApiConfig(); - + if (!config) { onEvent({ type: "session.status", @@ -52,14 +54,41 @@ export async function runClaude(options: RunnerOptions): Promise { }); return; } - + // 使用 Anthropic SDK const env = buildEnvForConfig(config); const mergedEnv = { ...getEnhancedEnv(), ...env }; - + + // 构建 MCP Servers 配置(使用 MCP Manager) + // 如果配置了 persistBrowser,会自动启动 SSE Server + const manager = getMCPManager(); + const mcpServers = await manager.buildSDKConfigAsync(); + const mcpServerCount = Object.keys(mcpServers).length; + + if (mcpServerCount > 0) { + console.log(`[MCP] Configured ${mcpServerCount} MCP server(s) for Claude SDK:`, Object.keys(mcpServers)); + + // 检查是否有 SSE 模式的 Server + for (const [id, config] of Object.entries(mcpServers)) { + if ('url' in config) { + console.log(`[MCP] Server ${id} using SSE mode at ${config.url}`); + } + } + } else { + console.log('[MCP] No MCP servers configured'); + } + + // 构建已启用的 Sub Agents 配置 + const agentManager = getAgentManager(); + const agents = agentManager.buildSDKAgentsConfig(); + + if (agents) { + console.log(`[Agents] Configured ${Object.keys(agents).length} sub agent(s):`, Object.keys(agents)); + } + const q = query({ prompt, options: { @@ -71,6 +100,10 @@ export async function runClaude(options: RunnerOptions): Promise { permissionMode: "bypassPermissions", includePartialMessages: true, allowDangerouslySkipPermissions: true, + // 注入 MCP Servers 配置 + mcpServers: mcpServerCount > 0 ? mcpServers : undefined, + // 注入已启用的 Sub Agents + agents, canUseTool: async (toolName, input, { signal }) => { // For AskUserQuestion, we need to wait for user response if (toolName === "AskUserQuestion") { @@ -99,7 +132,7 @@ export async function runClaude(options: RunnerOptions): Promise { }); } - // Auto-approve other tools + // Auto-approve all other tools (including MCP tools) return { behavior: "allow", updatedInput: input }; } } diff --git a/src/electron/main.ts b/src/electron/main.ts index a95a611..0d6e344 100644 --- a/src/electron/main.ts +++ b/src/electron/main.ts @@ -9,6 +9,8 @@ import { saveApiConfig } from "./libs/config-store.js"; import { getCurrentApiConfig } from "./libs/claude-settings.js"; import type { ClientEvent } from "./types.js"; import "./libs/claude-settings.js"; +import { setupMCPHandlers, cleanupMCP } from "./libs/mcp/mcp-ipc-handlers.js"; +import { setupAgentHandlers, cleanupAgents } from "./libs/agents/agent-ipc-handlers.js"; let cleanupComplete = false; let mainWindow: BrowserWindow | null = null; @@ -33,6 +35,8 @@ function cleanup(): void { globalShortcut.unregisterAll(); stopPolling(); cleanupAllSessions(); + cleanupMCP(); + cleanupAgents(); killViteDevServer(); } @@ -129,10 +133,16 @@ app.on("ready", () => { saveApiConfig(config); return { success: true }; } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : String(error) + return { + success: false, + error: error instanceof Error ? error.message : String(error) }; } }); + + // Setup MCP handlers + setupMCPHandlers(mainWindow!); + + // Setup Agent handlers + setupAgentHandlers(mainWindow!); }) diff --git a/src/electron/preload.cts b/src/electron/preload.cts index af70454..3fd4f41 100644 --- a/src/electron/preload.cts +++ b/src/electron/preload.cts @@ -6,7 +6,7 @@ electron.contextBridge.exposeInMainWorld("electron", { callback(stats); }), getStaticData: () => ipcInvoke("getStaticData"), - + // Claude Agent IPC APIs sendClientEvent: (event: any) => { electron.ipcRenderer.send("client-event", event); @@ -23,18 +23,67 @@ electron.contextBridge.exposeInMainWorld("electron", { electron.ipcRenderer.on("server-event", cb); return () => electron.ipcRenderer.off("server-event", cb); }, - generateSessionTitle: (userInput: string | null) => + generateSessionTitle: (userInput: string | null) => ipcInvoke("generate-session-title", userInput), - getRecentCwds: (limit?: number) => + getRecentCwds: (limit?: number) => ipcInvoke("get-recent-cwds", limit), - selectDirectory: () => + selectDirectory: () => ipcInvoke("select-directory"), - getApiConfig: () => + getApiConfig: () => ipcInvoke("get-api-config"), - saveApiConfig: (config: any) => + saveApiConfig: (config: any) => ipcInvoke("save-api-config", config), checkApiConfig: () => - ipcInvoke("check-api-config") + ipcInvoke("check-api-config"), + + // MCP APIs + getMCPServers: () => + ipcInvoke("mcp-get-servers"), + enableMCPServer: (serverId: string) => + ipcInvoke("mcp-enable-server", serverId), + disableMCPServer: (serverId: string) => + ipcInvoke("mcp-disable-server", serverId), + enableBrowserAutomation: () => + ipcInvoke("mcp-enable-browser-automation"), + addMCPServer: (config: any) => + ipcInvoke("mcp-add-server", config), + updateMCPServer: (serverId: string, config: any) => + ipcInvoke("mcp-update-server", serverId, config), + deleteMCPServer: (serverId: string) => + ipcInvoke("mcp-delete-server", serverId), + onMCPStatusChange: (callback: (serverId: string, status: MCPServerStatus, error?: string) => void) => { + const cb = (_: Electron.IpcRendererEvent, serverId: string, status: MCPServerStatus, error?: string) => { + callback(serverId, status, error); + }; + electron.ipcRenderer.on("mcp-status-change", cb); + return () => electron.ipcRenderer.off("mcp-status-change", cb); + }, + // 浏览器 MCP 配置 API + updateBrowserConfig: (options: { + browserMode?: 'visible' | 'headless'; + userDataDir?: string | null; + enablePersistence?: boolean; + persistBrowser?: boolean; + }) => ipcInvoke("mcp-update-browser-config", options), + getDefaultUserDataDir: () => + ipcInvoke("mcp-get-default-user-data-dir"), + + // Agent APIs + getAgents: () => + ipcInvoke("agents-get-list"), + addAgent: (agent: { name: string; description: string; prompt: string; model?: string }) => + ipcInvoke("agents-add", agent), + updateAgent: (agentId: string, updates: { name?: string; description?: string; prompt?: string; model?: string }) => + ipcInvoke("agents-update", agentId, updates), + deleteAgent: (agentId: string) => + ipcInvoke("agents-delete", agentId), + toggleAgent: (agentId: string, enabled: boolean) => + ipcInvoke("agents-toggle", agentId, enabled), + onAgentsConfigChange: (callback: () => void) => { + const cb = () => callback(); + electron.ipcRenderer.on("agents-config-changed", cb); + return () => electron.ipcRenderer.off("agents-config-changed", cb); + }, } satisfies Window['electron']) function ipcInvoke(key: Key, ...args: any[]): Promise { diff --git a/src/ui/components/APIConfigPanel.tsx b/src/ui/components/APIConfigPanel.tsx new file mode 100644 index 0000000..5243da2 --- /dev/null +++ b/src/ui/components/APIConfigPanel.tsx @@ -0,0 +1,171 @@ +/** + * API 配置面板组件 + * 从 SettingsModal 中抽取的 API 配置功能 + */ + +import { useEffect, useState } from "react"; + +interface APIConfigPanelProps { + onSuccess?: () => void; +} + +export function APIConfigPanel({ onSuccess }: APIConfigPanelProps) { + const [apiKey, setApiKey] = useState(""); + const [baseURL, setBaseURL] = useState(""); + const [model, setModel] = useState(""); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + useEffect(() => { + // 加载当前配置 + setLoading(true); + window.electron.getApiConfig() + .then((config) => { + if (config) { + setApiKey(config.apiKey); + setBaseURL(config.baseURL); + setModel(config.model); + } + }) + .catch((err) => { + console.error("Failed to load API config:", err); + setError("加载配置失败"); + }) + .finally(() => { + setLoading(false); + }); + }, []); + + const handleSave = async () => { + // 验证输入 + if (!apiKey.trim()) { + setError("请输入 API Key"); + return; + } + if (!baseURL.trim()) { + setError("请输入 Base URL"); + return; + } + if (!model.trim()) { + setError("请输入模型名称"); + return; + } + + // 验证 URL 格式 + try { + new URL(baseURL); + } catch { + setError("Base URL 格式不正确"); + return; + } + + setError(null); + setSaving(true); + + try { + const result = await window.electron.saveApiConfig({ + apiKey: apiKey.trim(), + baseURL: baseURL.trim(), + model: model.trim(), + apiType: "anthropic" + }); + + if (result.success) { + setSuccess(true); + setTimeout(() => { + setSuccess(false); + onSuccess?.(); + }, 1500); + } else { + setError(result.error || "保存配置失败"); + } + } catch (err) { + console.error("Failed to save API config:", err); + setError("保存配置失败"); + } finally { + setSaving(false); + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+

+ 支持 Anthropic 官方 API 以及兼容 Anthropic 格式的第三方 API。 +

+ + + + + + + + {error && ( +
+ {error} +
+ )} + + {success && ( +
+ 配置保存成功! +
+ )} + + +
+ ); +} diff --git a/src/ui/components/AgentsPanel.tsx b/src/ui/components/AgentsPanel.tsx new file mode 100644 index 0000000..272fbe7 --- /dev/null +++ b/src/ui/components/AgentsPanel.tsx @@ -0,0 +1,467 @@ +/** + * AI 助手管理面板组件 + * 展示和管理 Sub Agent(AI 助手)列表 + */ + +import { useEffect, useState, useCallback } from "react"; + +/** AI 助手信息 */ +interface AgentInfo { + id: string; + name: string; + description: string; + prompt: string; + enabled: boolean; + model: string; + isBuiltin: boolean; + createdAt: string; + updatedAt: string; +} + +/** 助手表单数据 */ +interface AgentFormData { + name: string; + description: string; + prompt: string; +} + +/** 助手表单组件 Props */ +interface AgentFormProps { + initialData?: AgentFormData & { id?: string }; + onClose: () => void; + onSave: (data: AgentFormData) => Promise; + onDelete?: () => Promise; +} + +/** 助手创建/编辑表单 */ +function AgentForm({ initialData, onClose, onSave, onDelete }: AgentFormProps) { + const [name, setName] = useState(initialData?.name || ""); + const [description, setDescription] = useState(initialData?.description || ""); + const [prompt, setPrompt] = useState(initialData?.prompt || ""); + const [saving, setSaving] = useState(false); + const [deleting, setDeleting] = useState(false); + const [error, setError] = useState(null); + + const isEdit = !!initialData?.id; + + const handleSave = async () => { + if (!name.trim() || !description.trim() || !prompt.trim()) { + setError("请填写所有必填项"); + return; + } + setSaving(true); + setError(null); + try { + await onSave({ name: name.trim(), description: description.trim(), prompt: prompt.trim() }); + onClose(); + } catch (err) { + setError(`保存失败: ${err}`); + } finally { + setSaving(false); + } + }; + + const handleDelete = async () => { + if (!onDelete) return; + if (!window.confirm("确定要删除这个助手吗?删除后不可恢复。")) return; + setDeleting(true); + try { + await onDelete(); + onClose(); + } catch (err) { + setError(`删除失败: ${err}`); + } finally { + setDeleting(false); + } + }; + + return ( +
+
+ {/* 头部 */} +
+ + {isEdit ? "编辑助手" : "添加自定义助手"} + + +
+ + {/* 表单内容 */} +
+ {/* 名称 */} +
+ +

给你的助手起一个简短的名称

+ setName(e.target.value)} + /> +
+ + {/* 描述 */} +
+ +

简要描述这个助手能帮你做什么

+ setDescription(e.target.value)} + /> +
+ + {/* 提示词 */} +
+ +

+ 告诉 AI 这个助手应该怎么工作、有哪些注意事项 +

+