diff --git a/apps/desktop/README.md b/apps/desktop/README.md index d5bcf73d..4654ea1b 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -1,22 +1,720 @@ -# @multica/desktop - -Electron desktop app. Vite + React + `createHashRouter`. - -## Development - -```bash -multica dev desktop -``` - -## Build - -```bash -pnpm --filter @multica/desktop build -``` - -## Conventions - -- **Routing**: `react-router-dom` v7 with `createHashRouter` (Electron loads via `file://`, BrowserRouter won't work). Pages go in `src/pages/`. -- **UI**: All components from `@multica/ui`. No local UI components. -- **State**: Store hooks from `@multica/store`. -- **Styles**: Tailwind CSS v4 via `@multica/ui/globals.css`, imported in `src/main.tsx`. +# Multica Desktop App 设计文档 + +## 产品定位 + +Multica Desktop 是一个统一的桌面应用,具有双重身份: + +1. **Host 模式**: 本机运行 Hub + Agent,可供其他设备连接 +2. **Client 模式**: 连接到其他 Hub 的 Agent 进行对话 + +用户安装同一个 App,既可以作为 Agent 的宿主(让其他设备扫码连接),也可以扫码连接到别人的 Agent。 + +### 架构图 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Multica Desktop App │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────────┐ │ +│ │ React UI (Renderer) │ │ +│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ +│ │ │ Home │ │ Chat │ │ Tools │ │ Skills │ │Settings │ │ │ +│ │ └─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │ │ +│ └─────────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ┌───────────────┴───────────────┐ │ +│ │ │ │ +│ 直接调用 (本地) WebSocket (远程) │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌─────────────────────────────┐ ┌─────────────────────────────┐ │ +│ │ Local Hub + Agent │ │ Remote Hub (via Gateway) │ │ +│ │ (进程内) │ │ (另一台设备) │ │ +│ └─────────────────────────────┘ └─────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────────┘ + │ + │ WebSocket + ▼ + ┌─────────────────────┐ + │ Gateway │ + │ (公网 WebSocket) │ + └─────────────────────┘ +``` + +**关键点**: + +- **统一应用**: 不区分 Admin App 和 Client App,一个 App 两种用法 +- **Chat 双模式**: Chat 页面可以选择与本地 Agent 对话,或连接远程 Agent 对话 +- **本地 Agent**: Hub + Agent 跑在 Electron 主进程内,UI 通过 IPC 调用访问 +- **远程连接**: 通过 Gateway WebSocket 连接到其他设备的 Hub + +**约束**: 第一阶段 1 Client - 1 Hub - 1 Agent Session + +--- + +## 技术实现设计 + +### 技术栈 + +| 层级 | 技术 | 说明 | +| ------ | ------------------------ | -------------- | +| 框架 | Electron 30 | 桌面应用 | +| 前端 | React 19 + Vite | 渲染进程 | +| 路由 | react-router-dom v7 | HashRouter | +| 状态 | @multica/store (Zustand) | 复用现有 store | +| UI | @multica/ui (Shadcn) | 复用现有组件 | +| 二维码 | qrcode.react | 生成二维码 | +| 通信 | @multica/sdk | Gateway 连接 | + +### 文件结构规划 + +``` +apps/desktop/ +├── electron/ +│ ├── main.ts # 主进程 (Hub + Agent) +│ └── preload.ts # 预加载脚本 (如需 IPC) +├── src/ +│ ├── main.tsx # React 入口 +│ ├── App.tsx # 路由配置 +│ ├── pages/ +│ │ ├── home.tsx # Home 入口页 (三个选项) +│ │ ├── chat.tsx # Chat 页面 (Local/Remote 双模式) +│ │ ├── tools.tsx # Tools 管理页 +│ │ ├── skills.tsx # Skills 管理页 +│ │ └── layout.tsx # 全局布局 (Header + Tabs) +│ ├── components/ +│ │ ├── qr-code.tsx # 二维码组件 +│ │ ├── qr-scanner.tsx # 扫码组件 +│ │ ├── connection-status.tsx # 连接状态 +│ │ ├── tool-list.tsx # Tools 列表 +│ │ └── skill-list.tsx # Skills 列表 +│ └── hooks/ +│ ├── use-local-agent.ts # 本地 Agent 管理 +│ ├── use-remote-agent.ts # 远程 Agent 连接 +│ └── use-connection.ts # 连接状态管理 +└── package.json +``` + +### 核心实现点 + +#### 1. 二维码生成与连接 + +二维码内容格式: + +```json +{ + "type": "multica-connect", + "gateway": "wss://gateway.multica.ai", + "hubId": "019c1d32-xxxx", + "agentId": "019c1d32-yyyy", + "token": "random-uuid-token", + "expires": 1234567890 +} +``` + +连接流程: + +``` +1. Admin 启动 → Hub 连接公网 Gateway → 注册为 deviceType: "hub" +2. Admin 创建 Agent → 生成 token → 编码到二维码 (含 hubId + agentId + token) +3. Client 扫码 → 解析二维码 → 连接同一 Gateway +4. Client 发送 "connect-request" 到 hubId (带 token) +5. Admin 验证 token 有效且未过期 → 建立配对关系 +6. Client 后续消息发到 hubId,payload 带 agentId +7. Hub 路由消息到对应 Agent +``` + +#### 2. Tools 管理 + +**现有 CLI 命令** (已实现): + +```bash +multica tools list # 列出所有 tools +multica tools list --profile coding # 按 profile 过滤 +multica tools groups # 显示 tool groups +multica tools profiles # 显示预设 profiles +``` + +**Admin App 实现方式** - 通过 IPC 调用 Main Process: + +```typescript +// Renderer 进程 (React Hook) +const tools = await window.electronAPI.tools.list(); +const groups = await window.electronAPI.tools.getGroups(); +const profiles = await window.electronAPI.tools.getProfiles(); +await window.electronAPI.tools.setStatus('exec', false); + +// Main 进程 (IPC Handler) +ipcMain.handle('tools:list', async () => { + const allTools = createAllTools(process.cwd()); + return allTools.map((t) => ({ + name: t.name, + group: TOOL_GROUPS[t.name], + enabled: true, + })); +}); +``` + +**注意**: Renderer 进程运行在沙盒中,不能直接访问 Node.js API,必须通过 IPC 调用 Main Process。 + +#### 3. Skills 管理 + +**现有 CLI 命令** (已实现): + +```bash +multica skills list # 列出所有 skills +multica skills status # 显示状态摘要 +multica skills status # 单个 skill 详情 +multica skills add owner/repo # 从 GitHub 添加 +multica skills remove # 删除 skill +multica skills install # 安装依赖 +``` + +**Admin App 实现方式** - 通过 IPC 调用 Main Process: + +```typescript +// Renderer 进程 (React Hook) +const skills = await window.electronAPI.skills.list(); +await window.electronAPI.skills.add('anthropics/skills'); +await window.electronAPI.skills.remove('pdf'); +await window.electronAPI.skills.setEnabled('commit', false); + +// Main 进程 (IPC Handler) +ipcMain.handle('skills:list', async () => { + return await listAllSkillsWithStatus(); +}); +ipcMain.handle('skills:add', async (_, source: string) => { + await addSkill({ source, force: false }); +}); +``` + +--- + +## 三、实现优先级 + +### Phase 1: 基础框架 (MVP) + +1. **Layout 组件** - Header + Tabs 导航 +2. **Home 页面** - 二维码显示 + 连接状态 +3. **Gateway 连接** - 复用 @multica/store + +### Phase 2: 管理功能 + +4. **Tools 页面** - 列表展示 + 开关切换 +5. **Skills 页面** - 列表展示 + 基础操作 +6. **Settings** - Gateway URL + Theme + +### Phase 3: 完善体验 + +7. **Agent 页面** - 状态监控 + Provider 切换 +8. **二维码刷新机制** +9. **错误处理 + Toast 提示** + +--- + +## 四、Hub 集成技术方案 + +### 架构概述 + +Desktop App 采用 **Electron IPC + Hub 实例** 架构: + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Electron Desktop App │ +│ │ +│ ┌────────────────────────────────────────────────────────────────────────┐ │ +│ │ Renderer Process (React UI) │ │ +│ │ │ │ +│ │ home.tsx → useHub() → window.electronAPI.hub.getStatus() │ │ +│ │ tools.tsx → useTools() → window.electronAPI.tools.list() │ │ +│ │ skills.tsx→ useSkills()→ window.electronAPI.skills.list() │ │ +│ │ │ │ +│ └──────────────────────────────┬─────────────────────────────────────────┘ │ +│ │ IPC (contextBridge) │ +│ ▼ │ +│ ┌────────────────────────────────────────────────────────────────────────┐ │ +│ │ Main Process (Node.js) │ │ +│ │ │ │ +│ │ ┌──────────────────────────────────────────────────────────────┐ │ │ +│ │ │ Hub Instance │ │ │ +│ │ │ - hubId: UUIDv7 │ │ │ +│ │ │ - agents: Map │ │ │ +│ │ │ - status: 'starting' | 'ready' | 'error' │ │ │ +│ │ │ - GatewayClient: 连接公网 Gateway (可选) │ │ │ +│ │ └──────────────────────────────────────────────────────────────┘ │ │ +│ │ │ │ │ +│ │ ┌────────────────────────────▼────────────────────────────────┐ │ │ +│ │ │ AsyncAgent Instance │ │ │ +│ │ │ - agentId: UUIDv7 │ │ │ +│ │ │ - runner: AgentRunner (LLM interaction) │ │ │ +│ │ │ - tools: Tool[] (可动态更新) │ │ │ +│ │ │ - skills: SkillInfo[] │ │ │ +│ │ └─────────────────────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ └────────────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────────┘ + │ + │ WebSocket (可选,用于 Client 远程连接) + ▼ + ┌─────────────────────┐ + │ Public Gateway │ + │ (wss://xxx) │ + └─────────────────────┘ +``` + +### IPC 通信机制 + +**工作原理**: + +1. **Main Process**: 在 Electron 主进程中创建 Hub 和 Agent 实例 +2. **Preload Script**: 通过 `contextBridge.exposeInMainWorld` 暴露安全 API +3. **Renderer Process**: React UI 通过 `window.electronAPI` 调用主进程功能 + +**与 CLI 命令的关系**: + +| CLI 命令 | IPC Handler | 底层调用 | +| -------------------------- | ----------------- | -------------------------------------------- | +| `multica tools list` | `tools:list` | `createAllTools()` + `getToolStatus()` | +| `multica tools enable xxx` | `tools:setStatus` | `setToolStatus()` | +| `multica skills list` | `skills:list` | `loadSkills()` + `listAllSkillsWithStatus()` | +| `multica skills add xxx` | `skills:add` | `addSkill()` | + +**本质上 CLI 和 Admin App 调用的是同一套底层模块**,区别仅在于: + +- CLI: 通过命令行参数解析后直接调用 +- Admin App: 通过 IPC 转发调用 + +### 核心文件 + +``` +apps/desktop/ +├── electron/ +│ ├── main.ts # 主进程入口,创建窗口 + 注册 IPC +│ ├── preload.ts # 暴露 electronAPI +│ └── ipc/ +│ ├── index.ts # 统一注册所有 IPC handlers +│ ├── hub.ts # Hub 管理 (创建/状态/连接 Gateway) +│ ├── agent.ts # Agent 管理 (Tools 读写) +│ └── skills.ts # Skills 管理 +├── src/ +│ └── hooks/ +│ ├── use-hub.ts # 获取 Hub 状态 +│ ├── use-tools.ts # Tools CRUD +│ └── use-skills.ts # Skills CRUD +``` + +### IPC 接口定义 + +```typescript +// electron/preload.ts 暴露的 API +interface ElectronAPI { + hub: { + getStatus: () => Promise; + getAgentInfo: () => Promise; + }; + tools: { + list: () => Promise; + setStatus: (toolName: string, enabled: boolean) => Promise; + getGroups: () => Promise>; + getProfiles: () => Promise; + }; + skills: { + list: () => Promise; + add: (source: string) => Promise; + remove: (name: string) => Promise; + setEnabled: (name: string, enabled: boolean) => Promise; + }; +} + +// 类型定义 +interface HubStatus { + hubId: string; + status: 'starting' | 'ready' | 'error'; + agentCount: number; + gatewayConnected: boolean; + gatewayUrl?: string; +} + +interface AgentInfo { + agentId: string; + provider: string; + model: string; + status: 'idle' | 'running'; +} + +interface ToolStatus { + name: string; + group: string; + enabled: boolean; + needsConfig?: boolean; +} + +interface SkillInfo { + name: string; + command: string; + source: 'bundled' | 'global' | 'profile'; + status: 'ready' | 'missing-deps' | 'disabled'; + description?: string; +} +``` + +### Hub 生命周期 + +```typescript +// electron/ipc/hub.ts 简化逻辑 + +let hub: Hub | null = null; + +export function registerHubHandlers(ipcMain: IpcMain) { + // App 启动时自动创建 Hub + ipcMain.handle('hub:getStatus', async () => { + if (!hub) { + hub = new Hub(); + await hub.start(); + // 创建默认 Agent + const agent = await hub.createAgent({ + provider: credentialManager.getLlmProvider(), + model: credentialManager.getLlmProviderConfig()?.model, + }); + } + return { + hubId: hub.id, + status: hub.status, + agentCount: hub.agents.size, + gatewayConnected: hub.gateway?.connected ?? false, + }; + }); +} +``` + +### Tools 实时更新机制 + +当用户在 UI 中切换 Tool 开关时: + +``` +1. UI: Switch onChange → useTools.setToolStatus('exec', false) +2. Hook: await window.electronAPI.tools.setStatus('exec', false) +3. IPC: ipcMain.handle('tools:setStatus') → agent.updateTools(...) +4. Agent: 重新过滤 tools 列表,下次 LLM 调用使用新配置 +``` + +**注意**: Tools 状态目前保存在内存中,重启后重置。后续可持久化到 `~/.super-multica/tool-config.json`。 + +--- + +## 六、关于 RPC 与 IPC 的区别 + +**问**: Admin UI 和 Hub/Agent 之间是通过什么方式通信? + +**答**: 通过 **Electron IPC (进程间通信)**,不是网络 RPC。 + +| 通信类型 | 场景 | 协议 | +| -------- | ------------------------------- | ------------------- | +| IPC | Admin UI ↔ Hub (同一设备) | Electron IPC (内存) | +| RPC | Client ↔ Gateway ↔ Hub (跨设备) | WebSocket | + +**为什么选择 IPC 而不是直接 import?** + +1. **安全隔离**: Renderer 进程不应直接访问 Node.js API 和文件系统 +2. **进程隔离**: Electron 推荐 Renderer 运行在沙盒中 +3. **一致性**: 与 CLI 调用相同的底层模块,便于维护 +4. **扩展性**: 后续可以轻松添加 RPC 支持,供远程管理 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Electron App │ +│ │ +│ ┌──────────────────────┐ ┌─────────────────────────────┐ │ +│ │ Renderer Process │ │ Main Process │ │ +│ │ (React UI, 沙盒) │ │ (Node.js, 完整权限) │ │ +│ │ │ IPC │ │ │ +│ │ useTools() ──────────────► │ ipcMain.handle('tools:*') │ │ +│ │ useSkills() ─────────────► │ ipcMain.handle('skills:*') │ │ +│ │ useHub() ────────────────► │ Hub + Agent 实例 │ │ +│ └──────────────────────┘ └─────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**IPC 调用示例**: + +```typescript +// Renderer (React 组件) +const tools = await window.electronAPI.tools.list(); + +// Main Process (IPC Handler) +ipcMain.handle('tools:list', async () => { + const allTools = createAllTools(process.cwd()); + return allTools.map((t) => ({ + name: t.name, + group: TOOL_GROUPS[t.name] || 'other', + enabled: getToolStatus(t.name), + })); +}); +``` + +--- + +## 七、依赖安装 + +```bash +# 二维码生成 +pnpm --filter @multica/desktop add qrcode.react + +# 类型定义 (如需要) +pnpm --filter @multica/desktop add -D @types/qrcode.react +``` + +--- + +## 八、实现步骤计划 + +### Phase 1: 统一布局与路由重构 + +**目标**: 统一页面结构,移除 /admin 子路由 + +#### Step 1.1: 路由重构 + +- [x] 重构 `App.tsx` 路由 + - 移除 `/admin` 子路由 + - 统一页面结构: / (Home) / /chat / /tools / /skills +- [x] 创建 `pages/layout.tsx` - 全局布局 + - Header: Logo + 标题 + Settings 按钮 + - Tabs: Home / Chat / Tools / Skills + - Content Area: 子路由出口 +- [x] 移动页面文件到根级别 + +#### Step 1.2: Home 页面 (三入口) + +- [x] 重构 `pages/home.tsx` + - 左侧二维码 + 右侧 Agent 状态面板 + - 底部: Open Chat 按钮 + Connect to Remote (Coming soon) +- [x] 安装 `qrcode.react` 依赖 +- [x] 创建 `components/qr-code.tsx` - 分享二维码组件 + - 生成二维码数据 (hubId, agentId, token, gateway, expires) + - 倒计时显示 + 自动过期刷新 + - Refresh 按钮 + Copy Link 按钮 + - 装饰性角落边框 + +#### Step 1.3: Chat 页面 (双模式) + +- [ ] 重构 `pages/chat.tsx` + - 顶部模式切换: Local Agent / Remote Agent + - 支持本地 Agent 直接调用 + - 支持远程 Agent WebSocket 连接 +- [ ] 创建 `hooks/use-local-agent.ts` - 本地 Agent 调用 +- [ ] 创建 `hooks/use-remote-agent.ts` - 远程 Agent 连接 + +**交付物**: 统一的页面结构,Home 页面三入口可用 + +--- + +### Phase 2: IPC 集成与 Hub 启动 ✅ (完成) + +**目标**: 在 Main Process 中启动 Hub,通过 IPC 与 Renderer 通信 + +#### Step 2.1: IPC 基础设施 + +- [x] 创建 `electron/ipc/` 目录结构 +- [x] 创建 `electron/ipc/index.ts` - 统一注册 handlers +- [x] 创建 `electron/ipc/agent.ts` - Tools 相关 IPC handlers +- [x] 创建 `electron/ipc/skills.ts` - Skills 相关 IPC handlers +- [x] 更新 `electron/main.ts` - 注册 IPC handlers + +#### Step 2.2: Hub 集成 + +- [x] 创建 `electron/ipc/hub.ts` - Hub 管理 +- [x] 实现 Hub 自动启动 (App ready 时) +- [x] 实现 Agent 自动创建 +- [x] 实现 Hub 状态查询 (`hub:getStatus`) + +#### Step 2.3: Preload 脚本 + +- [x] 更新 `electron/preload.ts` + - 暴露 `window.electronAPI.hub.*` + - 暴露 `window.electronAPI.tools.*` + - 暴露 `window.electronAPI.skills.*` + +#### Step 2.4: Hooks 更新 + +- [x] 更新 `hooks/use-tools.ts` - 调用 IPC +- [x] 更新 `hooks/use-skills.ts` - 调用 IPC +- [x] 创建 `hooks/use-hub.ts` - Hub 状态 + +**交付物**: Hub 在主进程运行,UI 可通过 IPC 获取真实数据 + +--- + +### Phase 3: Tools 管理页面 + +**目标**: 查看和管理 Agent Tools + +#### Step 3.1: Tools 数据获取 + +- [x] 创建 `hooks/use-tools.ts` + - 获取所有 tools 列表 + - 获取 tool groups 和 profiles + - 管理 allow/deny 状态 + +#### Step 3.2: Tools UI 组件 + +- [x] 创建 `components/tool-list.tsx` + - 表格展示: Name / Group / Status / Toggle + - 按 Group 分组折叠 + - 开关切换 (Switch 组件) + - Profile 下拉选择器 (内置) + - Reset to Default 按钮 (内置) + +#### Step 3.3: Tools 页面整合 + +- [x] 更新 `pages/tools.tsx` + - Profile 选择器 + - Tool 列表 + - (状态持久化待后续实现) + +#### Step 3.4: Tools 实时同步 + +- [x] 实现 `tools:list` 从真实 Agent 获取活跃 tools +- [x] 实现 `tools:active` 获取当前活跃工具 +- [x] 实现 `tools:reload` 调用 Agent.reloadTools() +- [x] 暴露 AsyncAgent.getActiveTools() 和 reloadTools() 方法 +- [x] 实现 `tools:setStatus` 持久化到 profile config.json +- [ ] 验证 Tool 开关影响 Agent 行为 + +**交付物**: 可查看所有 Tools,切换 Profile,开关单个 Tool,实时影响 Agent + +--- + +### Phase 4: Skills 管理页面 + +**目标**: 查看、添加、删除 Skills + +#### Step 4.1: Skills 数据获取 + +- [x] 创建 `hooks/use-skills.ts` + - 加载所有 skills (mock data for now) + - 检查 eligibility + - 添加/删除/安装操作 (stub) + +#### Step 4.2: Skills UI 组件 + +- [x] 创建 `components/skill-list.tsx` + - 表格展示: Name / Source / Status / Actions + - Status 徽章 (ready / missing / disabled) + - Action 按钮 (View / Install / Delete) + - Add Skill dialog (内置 skills.tsx) + - View Skill dialog (内置 skills.tsx) + +#### Step 4.3: Skills 页面整合 + +- [x] 更新 `pages/skills.tsx` + - Skill 列表 + - Add Skill 按钮 + dialog + - View Skill dialog + - Refresh 按钮 + +#### Step 4.4: Skills IPC 集成 + +- [x] 在 Agent 中添加 `getSkillsWithStatus()` 方法 +- [x] 在 AsyncAgent 中暴露 `getSkillsWithStatus()` 方法 +- [x] 实现 `skills:list` 从真实 Agent 获取 skills +- [x] 实现 `skills:get` 获取单个 skill 详情 +- [x] 实现 `skills:toggle` 返回当前 eligibility 状态 +- [x] 实现 `skills:reload` 重新加载 skills +- [x] 实现 `skills:add` 调用 `addSkill()` +- [x] 实现 `skills:remove` 调用 `removeSkill()` + +**交付物**: 可查看所有 Skills,查看 Skill 详情,显示 eligibility 状态 + +--- + +### Phase 5: 设置与完善 + +**目标**: Settings 页面 + 体验优化 + +#### Step 5.1: Settings 页面 + +- [ ] 创建 `components/settings-dialog.tsx` + - Gateway URL 配置 + - Theme 切换 (Light / Dark / System) + - 打开 credentials.json5 按钮 + +#### Step 5.2: 连接状态管理 + +- [ ] 创建 `components/connection-status.tsx` + - 显示 Gateway 连接状态 + - 显示已连接的 Client 信息 + - 显示 Agent 状态 + +#### Step 5.3: 体验优化 + +- [ ] Toast 通知 (操作成功/失败) +- [ ] Loading 状态优化 (各页面) +- [ ] 错误边界处理 (React Error Boundary) +- [ ] 二维码自动刷新 (5 分钟过期后自动刷新) + +**交付物**: 完整的管理功能,良好的用户体验 + +--- + +### Phase 6: Chat 页面与 Agent 联调 + +**目标**: 实现 Chat 功能,支持本地和远程 Agent + +#### Step 6.1: 本地 Chat 实现 + +- [ ] 重构 `pages/chat.tsx` + - 消息输入框 + 发送按钮 + - 消息历史展示 + - 流式响应显示 +- [ ] 创建 `hooks/use-local-agent.ts` + - 通过 IPC 调用 Agent.run() + - 处理流式响应 + - 管理消息历史 + +#### Step 6.2: 远程 Chat 实现 + +- [ ] 创建 `hooks/use-remote-agent.ts` + - 通过 Gateway WebSocket 连接 + - 处理远程消息 +- [ ] Chat 页面模式切换 + - Local Mode / Remote Mode 切换 + +**交付物**: 可与本地 Agent 对话,可连接远程 Agent + +--- + +### Phase 7: 联调与测试 + +**目标**: 完整流程联调 + +#### Step 7.1: 本地 Agent 联调 + +- [ ] Tools 开关实时影响 Agent +- [ ] Skills 启用/禁用影响 Agent +- [ ] Chat 流式响应正常 + +#### Step 7.2: 远程连接联调 + +- [ ] 扫码连接远程 Agent +- [ ] Token 验证流程 +- [ ] 消息流转测试 + +#### Step 7.3: 异常处理 + +- [ ] 断开重连 +- [ ] Token 过期处理 +- [ ] Gateway 断开处理 + +--- + +## 九、当前进度摘要 + +| Phase | 名称 | 状态 | +| ------- | -------------- | ----------------------- | +| Phase 1 | 布局与路由 | ✅ 完成 | +| Phase 2 | IPC 集成与 Hub | ✅ 完成 | +| Phase 3 | Tools 管理 | ✅ UI + IPC 集成完成 | +| Phase 4 | Skills 管理 | ✅ UI + IPC 集成完成 | +| Phase 5 | 设置与完善 | ⏳ 待开始 | +| Phase 6 | Chat 页面 | ⏳ 待开始 (同事负责 UI) | +| Phase 7 | 联调测试 | ⏳ 待开始 | diff --git a/apps/desktop/electron/electron-env.d.ts b/apps/desktop/electron/electron-env.d.ts index 1fdef4b7..583a19b9 100644 --- a/apps/desktop/electron/electron-env.d.ts +++ b/apps/desktop/electron/electron-env.d.ts @@ -21,7 +21,86 @@ declare namespace NodeJS { } } +// ============================================================================ +// ElectronAPI type definitions +// ============================================================================ + +interface HubStatus { + hubId: string + status: string + agentCount: number + gatewayConnected: boolean + gatewayUrl?: string + defaultAgent?: { + agentId: string + status: string + } | null +} + +interface AgentInfo { + agentId: string + status: string +} + +interface ToolInfo { + name: string + group: string + enabled: boolean +} + +interface SkillInfo { + id: string + name: string + description: string + version: string + enabled: boolean + source: 'bundled' | 'global' | 'profile' + triggers: string[] +} + +interface SkillAddResult { + ok: boolean + message: string + path?: string + skills?: string[] +} + +interface ElectronAPI { + hub: { + init: () => Promise + getStatus: () => Promise + getAgentInfo: () => Promise + info: () => Promise + reconnect: (url: string) => Promise + listAgents: () => Promise + createAgent: (id?: string) => Promise + getAgent: (id: string) => Promise + closeAgent: (id: string) => Promise + sendMessage: (agentId: string, content: string) => Promise + } + tools: { + list: () => Promise + toggle: (name: string) => Promise + setStatus: (name: string, enabled: boolean) => Promise + active: () => Promise + reload: () => Promise + } + skills: { + list: () => Promise + get: (id: string) => Promise + toggle: (id: string) => Promise + setStatus: (id: string, enabled: boolean) => Promise + reload: () => Promise + add: (source: string, options?: { name?: string; force?: boolean }) => Promise + remove: (name: string) => Promise + } + agent: { + status: () => Promise + } +} + // Used in Renderer process, expose in `preload.ts` interface Window { ipcRenderer: import('electron').IpcRenderer + electronAPI: ElectronAPI } diff --git a/apps/desktop/electron/ipc/agent.ts b/apps/desktop/electron/ipc/agent.ts new file mode 100644 index 00000000..73ca65b5 --- /dev/null +++ b/apps/desktop/electron/ipc/agent.ts @@ -0,0 +1,220 @@ +/** + * Agent IPC handlers for Electron main process. + * + * These handlers get tool information from the real Agent instance + * managed by the Hub. + */ +import { ipcMain } from 'electron' +import { getCurrentHub } from './hub.js' + +// Tool groups (for UI display grouping) +const TOOL_GROUPS: Record = { + 'group:fs': ['read', 'write', 'edit', 'glob'], + 'group:runtime': ['exec', 'process'], + 'group:web': ['web_search', 'web_fetch'], + 'group:memory': ['memory_get', 'memory_set', 'memory_delete', 'memory_list'], +} + +// All known tool names (for display when agent not available) +const ALL_KNOWN_TOOLS = [ + ...TOOL_GROUPS['group:fs'], + ...TOOL_GROUPS['group:runtime'], + ...TOOL_GROUPS['group:web'], + ...TOOL_GROUPS['group:memory'], +] + +/** + * Get the group for a tool name. + */ +function getToolGroup(name: string): string { + for (const [groupKey, tools] of Object.entries(TOOL_GROUPS)) { + const groupId = groupKey.replace('group:', '') + if (tools.includes(name)) { + return groupId + } + } + return 'other' +} + +/** + * Get the default agent from Hub. + */ +function getDefaultAgent() { + const hub = getCurrentHub() + if (!hub) return null + + const agentIds = hub.listAgents() + if (agentIds.length === 0) return null + + return hub.getAgent(agentIds[0]) ?? null +} + +/** + * Register all Agent-related IPC handlers. + */ +export function registerAgentIpcHandlers(): void { + // ============================================================================ + // Agent lifecycle + // ============================================================================ + + /** + * Get agent status + */ + ipcMain.handle('agent:status', async () => { + const agent = getDefaultAgent() + if (!agent) { + return { + running: false, + error: 'No agent available', + } + } + + return { + running: !agent.closed, + agentId: agent.sessionId, + } + }) + + // ============================================================================ + // Tools management + // ============================================================================ + + /** + * Get list of all tools with their enabled status. + * Returns active tools from the real Agent instance. + */ + ipcMain.handle('tools:list', async () => { + const agent = getDefaultAgent() + + if (!agent) { + // Fallback: return all known tools as disabled when no agent + return ALL_KNOWN_TOOLS.map((name) => ({ + name, + enabled: false, + group: getToolGroup(name), + })) + } + + // Get active tools from agent + const activeTools = agent.getActiveTools() + const activeSet = new Set(activeTools) + + // Build list with all known tools, marking which are active + const toolList = ALL_KNOWN_TOOLS.map((name) => ({ + name, + enabled: activeSet.has(name), + group: getToolGroup(name), + })) + + // Add any active tools not in our known list + for (const name of activeTools) { + if (!ALL_KNOWN_TOOLS.includes(name)) { + toolList.push({ + name, + enabled: true, + group: getToolGroup(name), + }) + } + } + + return toolList + }) + + /** + * Toggle a tool's enabled status. + * Persists the change to profile config and reloads tools. + */ + ipcMain.handle('tools:toggle', async (_event, toolName: string) => { + console.log(`[IPC] tools:toggle called for: ${toolName}`) + + const agent = getDefaultAgent() + if (!agent) { + return { error: 'No agent available' } + } + + // Check current status + const activeTools = agent.getActiveTools() + const isCurrentlyEnabled = activeTools.includes(toolName) + + // Toggle the tool status (enable if disabled, disable if enabled) + const result = agent.setToolStatus(toolName, !isCurrentlyEnabled) + if (!result) { + return { error: 'No profile loaded - cannot persist tool status' } + } + + // Get updated status + const newActiveTools = agent.getActiveTools() + const isNowEnabled = newActiveTools.includes(toolName) + console.log(`[IPC] Tool ${toolName} toggled: ${isCurrentlyEnabled} -> ${isNowEnabled}`) + + return { + name: toolName, + enabled: isNowEnabled, + } + }) + + /** + * Set a tool's enabled status explicitly. + * Persists the change to profile config and reloads tools. + */ + ipcMain.handle('tools:setStatus', async (_event, toolName: string, enabled: boolean) => { + console.log(`[IPC] tools:setStatus called for: ${toolName}, enabled: ${enabled}`) + + const agent = getDefaultAgent() + if (!agent) { + return { error: 'No agent available' } + } + + // Set the tool status and persist to profile config + const result = agent.setToolStatus(toolName, enabled) + if (!result) { + return { error: 'No profile loaded - cannot persist tool status' } + } + + console.log(`[IPC] Tool ${toolName} status set to ${enabled}. Config: allow=${result.allow?.join(',') ?? 'none'}, deny=${result.deny?.join(',') ?? 'none'}`) + + // Get updated status + const activeTools = agent.getActiveTools() + const isEnabled = activeTools.includes(toolName) + + return { + name: toolName, + enabled: isEnabled, + config: result, + } + }) + + /** + * Get currently active tools in the agent. + */ + ipcMain.handle('tools:active', async () => { + const agent = getDefaultAgent() + if (!agent) { + return [] + } + return agent.getActiveTools() + }) + + /** + * Force reload tools in the agent. + * This picks up any changes made to credentials.json5. + */ + ipcMain.handle('tools:reload', async () => { + const agent = getDefaultAgent() + if (!agent) { + return { error: 'No agent available' } + } + + const reloadedTools = agent.reloadTools() + console.log(`[IPC] Reloaded ${reloadedTools.length} tools: ${reloadedTools.join(', ')}`) + + return reloadedTools + }) +} + +/** + * Cleanup agent resources. + */ +export function cleanupAgent(): void { + // Agent cleanup is handled by Hub +} diff --git a/apps/desktop/electron/ipc/hub.ts b/apps/desktop/electron/ipc/hub.ts new file mode 100644 index 00000000..6992d7ca --- /dev/null +++ b/apps/desktop/electron/ipc/hub.ts @@ -0,0 +1,242 @@ +/** + * Hub IPC handlers for Electron main process. + * + * Creates and manages a Hub instance that connects to the Gateway. + * This follows the same pattern as the Console app. + */ +import { ipcMain } from 'electron' +import { Hub } from '../../../../src/hub/hub.js' +import type { ConnectionState } from '@multica/sdk' +import type { AsyncAgent } from '../../../../src/agent/async-agent.js' + +// Singleton Hub instance +let hub: Hub | null = null +let defaultAgentId: string | null = null + +/** + * Initialize Hub on app startup. + * Creates Hub and a default Agent automatically. + */ +export async function initializeHub(): Promise { + if (hub) { + console.log('[Desktop] Hub already initialized') + return + } + + const gatewayUrl = process.env['GATEWAY_URL'] ?? 'http://localhost:3000' + console.log(`[Desktop] Initializing Hub, connecting to Gateway: ${gatewayUrl}`) + + hub = new Hub(gatewayUrl) + + // Create default agent if none exists + const agents = hub.listAgents() + if (agents.length === 0) { + console.log('[Desktop] Creating default agent...') + const agent = hub.createAgent() + defaultAgentId = agent.sessionId + console.log(`[Desktop] Default agent created: ${defaultAgentId}`) + } else { + defaultAgentId = agents[0] + console.log(`[Desktop] Using existing agent: ${defaultAgentId}`) + } +} + +/** + * Get or create the Hub instance. + */ +function getHub(): Hub { + if (!hub) { + const gatewayUrl = process.env['GATEWAY_URL'] ?? 'http://localhost:3000' + console.log(`[Desktop] Creating Hub, connecting to Gateway: ${gatewayUrl}`) + hub = new Hub(gatewayUrl) + } + return hub +} + +/** + * Get the default agent. + */ +function getDefaultAgent(): AsyncAgent | null { + if (!hub || !defaultAgentId) return null + return hub.getAgent(defaultAgentId) ?? null +} + +/** + * Hub info returned to renderer. + */ +export interface HubInfo { + hubId: string + url: string + connectionState: ConnectionState + agentCount: number +} + +/** + * Agent info returned to renderer. + */ +export interface AgentInfo { + id: string + closed: boolean +} + +/** + * Register all Hub-related IPC handlers. + */ +export function registerHubIpcHandlers(): void { + /** + * Initialize the Hub (creates singleton if not exists). + */ + ipcMain.handle('hub:init', async () => { + await initializeHub() + const h = getHub() + return { + hubId: h.hubId, + url: h.url, + connectionState: h.connectionState, + defaultAgentId, + } + }) + + /** + * Get Hub status info. + */ + ipcMain.handle('hub:info', async (): Promise => { + const h = getHub() + return { + hubId: h.hubId, + url: h.url, + connectionState: h.connectionState, + agentCount: h.listAgents().length, + } + }) + + /** + * Get Hub status with default agent info (for home page). + */ + ipcMain.handle('hub:getStatus', async () => { + const h = getHub() + const agent = getDefaultAgent() + + return { + hubId: h.hubId, + status: h.connectionState === 'connected' ? 'ready' : h.connectionState, + agentCount: h.listAgents().length, + gatewayConnected: h.connectionState === 'connected', + gatewayUrl: h.url, + defaultAgent: agent + ? { + agentId: agent.sessionId, + status: agent.closed ? 'closed' : 'idle', + } + : null, + } + }) + + /** + * Get default agent info. + */ + ipcMain.handle('hub:getAgentInfo', async () => { + const agent = getDefaultAgent() + if (!agent) { + return null + } + return { + agentId: agent.sessionId, + status: agent.closed ? 'closed' : 'idle', + } + }) + + /** + * Reconnect Hub to a different Gateway URL. + */ + ipcMain.handle('hub:reconnect', async (_event, url: string) => { + const h = getHub() + h.reconnect(url) + return { url: h.url } + }) + + /** + * List all agents. + */ + ipcMain.handle('hub:listAgents', async (): Promise => { + const h = getHub() + const agentIds = h.listAgents() + return agentIds.map((id) => { + const agent = h.getAgent(id) + return { + id, + closed: agent?.closed ?? true, + } + }) + }) + + /** + * Create a new agent. + */ + ipcMain.handle('hub:createAgent', async (_event, id?: string) => { + const h = getHub() + const agent = h.createAgent(id) + return { + id: agent.sessionId, + closed: agent.closed, + } + }) + + /** + * Get a specific agent. + */ + ipcMain.handle('hub:getAgent', async (_event, id: string) => { + const h = getHub() + const agent = h.getAgent(id) + if (!agent) { + return { error: `Agent not found: ${id}` } + } + return { + id: agent.sessionId, + closed: agent.closed, + } + }) + + /** + * Close/delete an agent. + */ + ipcMain.handle('hub:closeAgent', async (_event, id: string) => { + const h = getHub() + const result = h.closeAgent(id) + return { ok: result } + }) + + /** + * Send a message to an agent. + */ + ipcMain.handle('hub:sendMessage', async (_event, agentId: string, content: string) => { + const h = getHub() + const agent = h.getAgent(agentId) + if (!agent) { + return { error: `Agent not found: ${agentId}` } + } + if (agent.closed) { + return { error: `Agent is closed: ${agentId}` } + } + agent.write(content) + return { ok: true } + }) +} + +/** + * Cleanup Hub resources. + */ +export function cleanupHub(): void { + if (hub) { + console.log('[Desktop] Shutting down Hub') + hub.shutdown() + hub = null + } +} + +/** + * Get the current Hub instance (for use by other IPC modules). + */ +export function getCurrentHub(): Hub | null { + return hub +} diff --git a/apps/desktop/electron/ipc/index.ts b/apps/desktop/electron/ipc/index.ts new file mode 100644 index 00000000..71bbec88 --- /dev/null +++ b/apps/desktop/electron/ipc/index.ts @@ -0,0 +1,39 @@ +/** + * IPC handlers index - register all handlers from main process. + */ +export { registerAgentIpcHandlers, cleanupAgent } from './agent.js' +export { registerSkillsIpcHandlers } from './skills.js' +export { registerHubIpcHandlers, cleanupHub, initializeHub } from './hub.js' + +import { registerAgentIpcHandlers, cleanupAgent } from './agent.js' +import { registerSkillsIpcHandlers } from './skills.js' +import { registerHubIpcHandlers, cleanupHub, initializeHub } from './hub.js' + +/** + * Register all IPC handlers. + * Call this in main.ts after app is ready. + */ +export function registerAllIpcHandlers(): void { + registerHubIpcHandlers() + registerAgentIpcHandlers() + registerSkillsIpcHandlers() +} + +/** + * Initialize Hub and create default agent. + * Call this after IPC handlers are registered. + */ +export async function initializeApp(): Promise { + console.log('[Desktop] Initializing app...') + await initializeHub() + console.log('[Desktop] App initialized') +} + +/** + * Cleanup all resources. + * Call this before app quits. + */ +export function cleanupAll(): void { + cleanupHub() + cleanupAgent() +} diff --git a/apps/desktop/electron/ipc/skills.ts b/apps/desktop/electron/ipc/skills.ts new file mode 100644 index 00000000..71fea31a --- /dev/null +++ b/apps/desktop/electron/ipc/skills.ts @@ -0,0 +1,278 @@ +/** + * Skills IPC handlers for Electron main process. + * + * These handlers get skill information from the real Agent instance + * managed by the Hub. + */ +import { ipcMain } from 'electron' +import { getCurrentHub } from './hub.js' + +/** + * Skill info returned to renderer. + */ +export interface SkillInfo { + id: string + name: string + description: string + version: string + enabled: boolean + source: 'bundled' | 'global' | 'profile' + triggers: string[] +} + +/** + * Get the default agent from Hub. + */ +function getDefaultAgent() { + const hub = getCurrentHub() + if (!hub) return null + + const agentIds = hub.listAgents() + if (agentIds.length === 0) return null + + return hub.getAgent(agentIds[0]) ?? null +} + +/** + * Get default bundled skills (fallback when no agent). + */ +function getDefaultSkills(): SkillInfo[] { + return [ + { + id: 'commit', + name: 'Git Commit Helper', + description: 'Create well-formatted git commits following conventional commit standards', + version: '1.0.0', + enabled: true, + source: 'bundled', + triggers: ['/commit'], + }, + { + id: 'code-review', + name: 'Code Review', + description: 'Review code for bugs, security issues, and best practices', + version: '1.0.0', + enabled: true, + source: 'bundled', + triggers: ['/review'], + }, + { + id: 'skill-creator', + name: 'Skill Creator', + description: 'Create, edit, and manage custom skills', + version: '1.0.0', + enabled: true, + source: 'bundled', + triggers: ['/skill'], + }, + { + id: 'profile-setup', + name: 'Profile Setup', + description: 'Interactive setup wizard to personalize your agent profile', + version: '1.0.0', + enabled: true, + source: 'bundled', + triggers: ['/profile'], + }, + ] +} + +/** + * Register all Skills-related IPC handlers. + */ +export function registerSkillsIpcHandlers(): void { + /** + * Get list of all skills with their status. + * Returns skills from the real Agent instance. + */ + ipcMain.handle('skills:list', async () => { + const agent = getDefaultAgent() + + if (!agent) { + // Fallback: return default skills when no agent + console.log('[IPC] skills:list - No agent available, returning defaults') + return getDefaultSkills() + } + + try { + const skillsWithStatus = agent.getSkillsWithStatus() + + // Transform to SkillInfo format + const skills: SkillInfo[] = skillsWithStatus.map((skill) => ({ + id: skill.id, + name: skill.name, + description: skill.description, + version: '1.0.0', // Skills don't have version in current implementation + enabled: skill.eligible, + source: skill.source as 'bundled' | 'global' | 'profile', + triggers: [`/${skill.id}`], // Default trigger is / + })) + + console.log(`[IPC] skills:list - Returning ${skills.length} skills from agent`) + return skills + } catch (err) { + console.error('[IPC] skills:list - Error getting skills from agent:', err) + return getDefaultSkills() + } + }) + + /** + * Toggle a skill's enabled status. + * NOTE: Skills eligibility is determined by requirements (env vars, binaries, etc.) + * This handler reports the current eligibility status. + */ + ipcMain.handle('skills:toggle', async (_event, skillId: string) => { + console.log(`[IPC] skills:toggle called for: ${skillId}`) + + const agent = getDefaultAgent() + if (!agent) { + return { error: 'No agent available' } + } + + const skillsWithStatus = agent.getSkillsWithStatus() + const skill = skillsWithStatus.find((s) => s.id === skillId) + + if (!skill) { + return { error: `Skill not found: ${skillId}` } + } + + // Skills can't be manually toggled - eligibility is based on requirements + // Return current status + return { + id: skillId, + enabled: skill.eligible, + reasons: skill.reasons, + } + }) + + /** + * Set a skill's enabled status explicitly. + * NOTE: Skills eligibility is automatic based on requirements. + * This handler is a no-op but returns current status. + */ + ipcMain.handle('skills:setStatus', async (_event, skillId: string, enabled: boolean) => { + console.log(`[IPC] skills:setStatus called for: ${skillId}, enabled: ${enabled}`) + + const agent = getDefaultAgent() + if (!agent) { + return { error: 'No agent available' } + } + + const skillsWithStatus = agent.getSkillsWithStatus() + const skill = skillsWithStatus.find((s) => s.id === skillId) + + if (!skill) { + return { error: `Skill not found: ${skillId}` } + } + + // TODO: Implement skill disable via config + // For now, just return current eligibility status + return { + id: skillId, + enabled: skill.eligible, + reasons: skill.reasons, + } + }) + + /** + * Get skill details by ID. + */ + ipcMain.handle('skills:get', async (_event, skillId: string) => { + const agent = getDefaultAgent() + + if (!agent) { + // Fallback: check default skills + const defaults = getDefaultSkills() + const skill = defaults.find((s) => s.id === skillId) + if (skill) return skill + return { error: `Skill not found: ${skillId}` } + } + + const skillsWithStatus = agent.getSkillsWithStatus() + const skill = skillsWithStatus.find((s) => s.id === skillId) + + if (!skill) { + return { error: `Skill not found: ${skillId}` } + } + + return { + id: skill.id, + name: skill.name, + description: skill.description, + version: '1.0.0', + enabled: skill.eligible, + source: skill.source as 'bundled' | 'global' | 'profile', + triggers: [`/${skill.id}`], + reasons: skill.reasons, + } + }) + + /** + * Reload skills from disk. + */ + ipcMain.handle('skills:reload', async () => { + const agent = getDefaultAgent() + if (!agent) { + return { error: 'No agent available' } + } + + agent.reloadSkills() + console.log('[IPC] skills:reload - Skills reloaded') + + return { ok: true } + }) + + /** + * Add a skill from GitHub repository. + * Source formats: owner/repo, owner/repo/skill-name, or full GitHub URL + */ + ipcMain.handle( + 'skills:add', + async ( + _event, + source: string, + options?: { name?: string; force?: boolean }, + ) => { + console.log(`[IPC] skills:add called: source=${source}, options=${JSON.stringify(options)}`) + + const { addSkill } = await import('../../../../src/agent/skills/add.js') + + const result = await addSkill({ + source, + name: options?.name, + force: options?.force, + }) + + console.log(`[IPC] skills:add result: ${result.message}`) + + // Reload skills in agent if available + const agent = getDefaultAgent() + if (agent && result.ok) { + agent.reloadSkills() + } + + return result + }, + ) + + /** + * Remove an installed skill by name. + */ + ipcMain.handle('skills:remove', async (_event, name: string) => { + console.log(`[IPC] skills:remove called: name=${name}`) + + const { removeSkill } = await import('../../../../src/agent/skills/add.js') + + const result = await removeSkill(name) + + console.log(`[IPC] skills:remove result: ${result.message}`) + + // Reload skills in agent if available + const agent = getDefaultAgent() + if (agent && result.ok) { + agent.reloadSkills() + } + + return result + }) +} diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 94948866..a360a499 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -1,6 +1,7 @@ import { app, BrowserWindow } from 'electron' import { fileURLToPath } from 'node:url' import path from 'node:path' +import { registerAllIpcHandlers, initializeApp, cleanupAll } from './ipc/index.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) @@ -16,15 +17,19 @@ let win: BrowserWindow | null function createWindow() { win = new BrowserWindow({ + width: 1200, + height: 800, webPreferences: { preload: path.join(__dirname, 'preload.mjs'), + // Enable node integration for IPC + contextIsolation: true, + nodeIntegration: false, }, }) if (VITE_DEV_SERVER_URL) { win.loadURL(VITE_DEV_SERVER_URL) } else { - // win.loadFile('dist/index.html') win.loadFile(path.join(RENDERER_DIST, 'index.html')) } } @@ -42,4 +47,16 @@ app.on('activate', () => { } }) -app.whenReady().then(createWindow) +app.on('before-quit', () => { + cleanupAll() +}) + +app.whenReady().then(async () => { + // Register all IPC handlers before creating window + registerAllIpcHandlers() + + // Initialize Hub and create default agent + await initializeApp() + + createWindow() +}) diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 05e341de..8c898819 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -1,6 +1,95 @@ import { ipcRenderer, contextBridge } from 'electron' -// --------- Expose some API to the Renderer process --------- +// ============================================================================ +// Type definitions for IPC API +// ============================================================================ + +export interface HubStatus { + hubId: string + status: string + agentCount: number + gatewayConnected: boolean + gatewayUrl?: string + defaultAgent?: { + agentId: string + status: string + } | null +} + +export interface AgentInfo { + agentId: string + status: string +} + +export interface ToolInfo { + name: string + group: string + enabled: boolean +} + +export interface SkillInfo { + id: string + name: string + description: string + version: string + enabled: boolean + source: 'bundled' | 'global' | 'profile' + triggers: string[] +} + +// ============================================================================ +// Expose typed API to Renderer process +// ============================================================================ + +const electronAPI = { + // Hub management + hub: { + init: () => ipcRenderer.invoke('hub:init'), + getStatus: (): Promise => ipcRenderer.invoke('hub:getStatus'), + getAgentInfo: (): Promise => ipcRenderer.invoke('hub:getAgentInfo'), + info: () => ipcRenderer.invoke('hub:info'), + reconnect: (url: string) => ipcRenderer.invoke('hub:reconnect', url), + listAgents: () => ipcRenderer.invoke('hub:listAgents'), + createAgent: (id?: string) => ipcRenderer.invoke('hub:createAgent', id), + getAgent: (id: string) => ipcRenderer.invoke('hub:getAgent', id), + closeAgent: (id: string) => ipcRenderer.invoke('hub:closeAgent', id), + sendMessage: (agentId: string, content: string) => + ipcRenderer.invoke('hub:sendMessage', agentId, content), + }, + + // Tools management + tools: { + list: (): Promise => ipcRenderer.invoke('tools:list'), + toggle: (name: string) => ipcRenderer.invoke('tools:toggle', name), + setStatus: (name: string, enabled: boolean) => + ipcRenderer.invoke('tools:setStatus', name, enabled), + active: () => ipcRenderer.invoke('tools:active'), + reload: () => ipcRenderer.invoke('tools:reload'), + }, + + // Skills management + skills: { + list: (): Promise => ipcRenderer.invoke('skills:list'), + get: (id: string) => ipcRenderer.invoke('skills:get', id), + toggle: (id: string) => ipcRenderer.invoke('skills:toggle', id), + setStatus: (id: string, enabled: boolean) => + ipcRenderer.invoke('skills:setStatus', id, enabled), + reload: () => ipcRenderer.invoke('skills:reload'), + add: (source: string, options?: { name?: string; force?: boolean }) => + ipcRenderer.invoke('skills:add', source, options), + remove: (name: string) => ipcRenderer.invoke('skills:remove', name), + }, + + // Agent management + agent: { + status: () => ipcRenderer.invoke('agent:status'), + }, +} + +// Expose to renderer +contextBridge.exposeInMainWorld('electronAPI', electronAPI) + +// Also expose ipcRenderer for backward compatibility contextBridge.exposeInMainWorld('ipcRenderer', { on(...args: Parameters) { const [channel, listener] = args @@ -18,7 +107,7 @@ contextBridge.exposeInMainWorld('ipcRenderer', { const [channel, ...omit] = args return ipcRenderer.invoke(channel, ...omit) }, - - // You can expose other APTs you need here. - // ... }) + +// Type declaration for window object +export type ElectronAPI = typeof electronAPI diff --git a/apps/desktop/package.json b/apps/desktop/package.json index f98f7e98..c7509b45 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -10,10 +10,16 @@ "preview": "vite preview" }, "dependencies": { + "@hugeicons/core-free-icons": "^3.1.1", + "@hugeicons/react": "^1.1.4", + "@multica/sdk": "workspace:*", "@multica/ui": "workspace:*", + "qrcode.react": "^4.2.0", "react": "catalog:", "react-dom": "catalog:", - "react-router-dom": "^7.13.0" + "react-router-dom": "^7.13.0", + "socket.io-client": "^4.8.3", + "uuid": "^13.0.0" }, "devDependencies": { "@tailwindcss/vite": "^4.1.18", diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index bd26458b..df6ae8b1 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,10 +1,21 @@ import { createHashRouter, RouterProvider } from 'react-router-dom' +import Layout from './pages/layout' import HomePage from './pages/home' import ChatPage from './pages/chat' +import ToolsPage from './pages/tools' +import SkillsPage from './pages/skills' const router = createHashRouter([ - { path: '/', element: }, - { path: '/chat', element: }, + { + path: '/', + element: , + children: [ + { index: true, element: }, + { path: 'chat', element: }, + { path: 'tools', element: }, + { path: 'skills', element: }, + ], + }, ]) export default function App() { diff --git a/apps/desktop/src/components/qr-code.tsx b/apps/desktop/src/components/qr-code.tsx new file mode 100644 index 00000000..f70f8ed2 --- /dev/null +++ b/apps/desktop/src/components/qr-code.tsx @@ -0,0 +1,228 @@ +import { useState, useEffect, useCallback, useMemo } from 'react' +import { QRCodeSVG } from 'qrcode.react' +import { Button } from '@multica/ui/components/ui/button' +import { HugeiconsIcon } from '@hugeicons/react' +import { + RefreshIcon, + CheckmarkCircle02Icon, + Copy01Icon, +} from '@hugeicons/core-free-icons' + +export interface QRCodeData { + type: 'multica-connect' + gateway: string + hubId: string + agentId: string + token: string + expires: number +} + +export interface ConnectionQRCodeProps { + gateway: string + hubId: string + agentId: string + /** QR code expiry time in seconds (default: 300 = 5 minutes) */ + expirySeconds?: number + /** Size of the QR code in pixels (default: 180) */ + size?: number + /** Callback when token is refreshed */ + onRefresh?: (data: QRCodeData) => void +} + +/** + * Generate a secure random token for QR code authentication + */ +function generateToken(): string { + return crypto.randomUUID() +} + +/** + * ConnectionQRCode - A QR code component for sharing Agent connection info + * + * Features: + * - Generates time-limited tokens for secure connections + * - Countdown timer showing expiry time + * - Refresh button to generate new token + * - Copy link button for manual sharing + * - Decorative corner accents for visual polish + */ +export function ConnectionQRCode({ + gateway, + hubId, + agentId, + expirySeconds = 300, + size = 180, + onRefresh, +}: ConnectionQRCodeProps) { + const [token, setToken] = useState(() => generateToken()) + const [expiresAt, setExpiresAt] = useState(() => Date.now() + expirySeconds * 1000) + const [remainingSeconds, setRemainingSeconds] = useState(expirySeconds) + const [copied, setCopied] = useState(false) + + // QR code data payload + const qrData: QRCodeData = useMemo( + () => ({ + type: 'multica-connect', + gateway, + hubId, + agentId, + token, + expires: expiresAt, + }), + [gateway, hubId, agentId, token, expiresAt] + ) + + // URL format for the connection + const connectionUrl = useMemo(() => { + const params = new URLSearchParams({ + gateway, + hub: hubId, + agent: agentId, + token, + exp: expiresAt.toString(), + }) + return `multica://connect?${params.toString()}` + }, [gateway, hubId, agentId, token, expiresAt]) + + // Refresh token handler + const handleRefresh = useCallback(() => { + const newToken = generateToken() + const newExpires = Date.now() + expirySeconds * 1000 + + setToken(newToken) + setExpiresAt(newExpires) + setRemainingSeconds(expirySeconds) + + if (onRefresh) { + onRefresh({ + type: 'multica-connect', + gateway, + hubId, + agentId, + token: newToken, + expires: newExpires, + }) + } + }, [gateway, hubId, agentId, expirySeconds, onRefresh]) + + // Countdown timer + useEffect(() => { + const timer = setInterval(() => { + const remaining = Math.max(0, Math.floor((expiresAt - Date.now()) / 1000)) + setRemainingSeconds(remaining) + + // Auto-refresh when expired + if (remaining === 0) { + handleRefresh() + } + }, 1000) + + return () => clearInterval(timer) + }, [expiresAt, handleRefresh]) + + // Copy link handler + const handleCopyLink = async () => { + try { + await navigator.clipboard.writeText(connectionUrl) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch (err) { + console.error('Failed to copy link:', err) + } + } + + // Format remaining time as M:SS + const formatTime = (seconds: number) => { + const m = Math.floor(seconds / 60) + const s = seconds % 60 + return `${m}:${s.toString().padStart(2, '0')}` + } + + // Warning state when less than 1 minute remaining + const isExpiringSoon = remainingSeconds < 60 && remainingSeconds > 0 + const isExpired = remainingSeconds === 0 + + return ( +
+ {/* QR Code with decorative corners */} +
+ {/* Corner accents */} +
+
+
+
+ + {/* QR Code */} +
+ +
+ + {/* Expired overlay */} + {isExpired && ( +
+ +
+ )} +
+ + {/* Info section */} +
+

+ Scan with your phone to connect +

+ + {/* Expiry timer */} +
+ + {isExpired ? 'Expired' : `Expires in ${formatTime(remainingSeconds)}`} + + {!isExpired && ( + + )} +
+ + {/* Copy link button */} + +
+
+ ) +} + +export default ConnectionQRCode diff --git a/apps/desktop/src/components/skill-list.tsx b/apps/desktop/src/components/skill-list.tsx new file mode 100644 index 00000000..6f18501b --- /dev/null +++ b/apps/desktop/src/components/skill-list.tsx @@ -0,0 +1,225 @@ +import { useState } from 'react' +import { Button } from '@multica/ui/components/ui/button' +import { Badge } from '@multica/ui/components/ui/badge' +import { Switch } from '@multica/ui/components/ui/switch' +import { HugeiconsIcon } from '@hugeicons/react' +import { + RotateClockwiseIcon, + Loading03Icon, + CheckmarkCircle02Icon, + Cancel01Icon, +} from '@hugeicons/core-free-icons' +import type { SkillInfo, SkillSource } from '../hooks/use-skills' + +// Source badge colors +const SOURCE_COLORS: Record = { + bundled: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300', + global: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300', + profile: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300', +} + +// Source section titles +const SOURCE_TITLES: Record = { + bundled: 'Built-in Skills', + global: 'Global Skills', + profile: 'Profile Skills', +} + +interface SkillListProps { + skills: SkillInfo[] + loading: boolean + error: string | null + onToggleSkill: (skillId: string) => Promise + onRefresh: () => Promise +} + +export function SkillList({ + skills, + loading, + error, + onToggleSkill, + onRefresh, +}: SkillListProps) { + // Track toggling state for individual skills + const [togglingSkills, setTogglingSkills] = useState>(new Set()) + + const handleToggleSkill = async (skillId: string) => { + setTogglingSkills((prev) => new Set(prev).add(skillId)) + try { + await onToggleSkill(skillId) + } finally { + setTogglingSkills((prev) => { + const next = new Set(prev) + next.delete(skillId) + return next + }) + } + } + + // Group skills by source + const skillsBySource: Record = { + bundled: skills.filter((s) => s.source === 'bundled'), + global: skills.filter((s) => s.source === 'global'), + profile: skills.filter((s) => s.source === 'profile'), + } + + // Order of sources to display + const sourceOrder: SkillSource[] = ['bundled', 'global', 'profile'] + + if (loading && skills.length === 0) { + return ( +
+ + Loading skills... +
+ ) + } + + return ( +
+ {/* Header */} +
+
+ {skills.filter((s) => s.enabled).length} of {skills.length} skills enabled +
+ +
+ + {/* Error message */} + {error && ( +
+ {error} +
+ )} + + {/* Skills grouped by source */} + {sourceOrder.map((source) => { + const sourceSkills = skillsBySource[source] + if (sourceSkills.length === 0) return null + + return ( +
+

+ + {SOURCE_TITLES[source]} + ({sourceSkills.length}) +

+
+ {sourceSkills.map((skill) => { + const isToggling = togglingSkills.has(skill.id) + + return ( +
+ {/* Left: Name + Description */} +
+
+ {skill.name} + + /{skill.id} + + + {skill.source} + +
+

+ {skill.description} +

+ {skill.triggers.length > 0 && ( +
+ {skill.triggers.slice(0, 3).map((trigger) => ( + + {trigger} + + ))} + {skill.triggers.length > 3 && ( + + +{skill.triggers.length - 3} more + + )} +
+ )} +
+ + {/* Center: Status */} +
+
+ + + {skill.enabled ? 'Enabled' : 'Disabled'} + +
+
+ + {/* Right: Toggle */} +
+ {isToggling && ( + + )} + handleToggleSkill(skill.id)} + disabled={isToggling} + /> +
+
+ ) + })} +
+
+ ) + })} + + {/* Empty state */} + {skills.length === 0 && !loading && ( +
+

No skills found.

+
+ )} + + {/* Note about persistence */} +

+ Changes are saved automatically. Restart Agent session to apply skill changes. +

+
+ ) +} + +export default SkillList diff --git a/apps/desktop/src/components/tool-list.tsx b/apps/desktop/src/components/tool-list.tsx new file mode 100644 index 00000000..2ecc05d5 --- /dev/null +++ b/apps/desktop/src/components/tool-list.tsx @@ -0,0 +1,208 @@ +import { useState } from 'react' +import { Switch } from '@multica/ui/components/ui/switch' +import { Button } from '@multica/ui/components/ui/button' +import { HugeiconsIcon } from '@hugeicons/react' +import { + RotateClockwiseIcon, + FolderOpenIcon, + CodeIcon, + GlobalIcon, + AiBrainIcon, + ArrowDown01Icon, + ArrowUp01Icon, + Loading03Icon, +} from '@hugeicons/core-free-icons' +import type { ToolInfo, ToolGroup } from '../hooks/use-tools' + +// Group icons +const GROUP_ICONS: Record = { + fs: FolderOpenIcon, + runtime: CodeIcon, + web: GlobalIcon, + memory: AiBrainIcon, + other: CodeIcon, +} + +interface ToolListProps { + tools: ToolInfo[] + groups: ToolGroup[] + loading: boolean + error: string | null + onToggleTool: (toolName: string) => Promise + onRefresh: () => Promise +} + +export function ToolList({ + tools, + groups, + loading, + error, + onToggleTool, + onRefresh, +}: ToolListProps) { + // Track which groups are expanded + const [expandedGroups, setExpandedGroups] = useState>( + () => new Set(groups.map((g) => g.id)) + ) + + // Track toggling state for individual tools + const [togglingTools, setTogglingTools] = useState>(new Set()) + + const toggleGroup = (groupId: string) => { + setExpandedGroups((prev) => { + const next = new Set(prev) + if (next.has(groupId)) { + next.delete(groupId) + } else { + next.add(groupId) + } + return next + }) + } + + const handleToggleTool = async (toolName: string) => { + setTogglingTools((prev) => new Set(prev).add(toolName)) + try { + await onToggleTool(toolName) + } finally { + setTogglingTools((prev) => { + const next = new Set(prev) + next.delete(toolName) + return next + }) + } + } + + // Group tools by their group + const toolsByGroup = groups.map((group) => ({ + ...group, + tools: tools.filter((t) => t.group === group.id), + enabledCount: tools.filter((t) => t.group === group.id && t.enabled).length, + totalCount: tools.filter((t) => t.group === group.id).length, + })) + + if (loading && tools.length === 0) { + return ( +
+ + Loading tools... +
+ ) + } + + return ( +
+ {/* Header: Refresh button */} +
+
+ {tools.filter((t) => t.enabled).length} of {tools.length} tools enabled +
+ + +
+ + {/* Error message */} + {error && ( +
+ {error} +
+ )} + + {/* Tool groups */} +
+ {toolsByGroup.map((group) => { + const isExpanded = expandedGroups.has(group.id) + const GroupIcon = GROUP_ICONS[group.id] || CodeIcon + + return ( +
+ {/* Group header */} + + + {/* Group tools */} + {isExpanded && ( +
+ {group.tools.map((tool) => { + const isToggling = togglingTools.has(tool.name) + + return ( +
+
+
+ + {tool.name} + + {!tool.enabled && ( + + disabled + + )} +
+ {tool.description && ( +

+ {tool.description} +

+ )} +
+
+ {isToggling && ( + + )} + handleToggleTool(tool.name)} + disabled={isToggling} + /> +
+
+ ) + })} +
+ )} +
+ ) + })} +
+ + {/* Note about persistence */} +

+ Changes are saved automatically and apply to the running Agent immediately. +

+
+ ) +} + +export default ToolList diff --git a/apps/desktop/src/hooks/use-hub.ts b/apps/desktop/src/hooks/use-hub.ts new file mode 100644 index 00000000..e3ec6a53 --- /dev/null +++ b/apps/desktop/src/hooks/use-hub.ts @@ -0,0 +1,210 @@ +import { useState, useEffect, useCallback } from 'react' + +// ============================================================================ +// Types matching the IPC response from main process +// ============================================================================ + +export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting' | 'registered' + +export interface HubInfo { + hubId: string + url: string + connectionState: ConnectionState + agentCount: number +} + +export interface AgentInfo { + id: string + closed: boolean +} + +export interface UseHubReturn { + /** Hub information */ + hubInfo: HubInfo | null + /** List of agents */ + agents: AgentInfo[] + /** Loading state */ + loading: boolean + /** Error state */ + error: string | null + + /** Initialize the Hub (called automatically on mount) */ + initHub: () => Promise + /** Refresh Hub info and agents list */ + refresh: () => Promise + /** Reconnect to a different Gateway URL */ + reconnect: (url: string) => Promise + /** Create a new agent */ + createAgent: (id?: string) => Promise + /** Close an agent */ + closeAgent: (id: string) => Promise + /** Send a message to an agent */ + sendMessage: (agentId: string, content: string) => Promise +} + +/** + * Hook for managing Hub connection and agents via IPC. + * + * This hook communicates with the Electron main process to: + * - Initialize and manage the Hub singleton + * - Create, list, and close agents + * - Send messages to agents + */ +export function useHub(): UseHubReturn { + const [hubInfo, setHubInfo] = useState(null) + const [agents, setAgents] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + // Initialize Hub and fetch info + const initHub = useCallback(async () => { + try { + setLoading(true) + setError(null) + + // Initialize Hub (use new electronAPI if available) + if (window.electronAPI) { + await window.electronAPI.hub.init() + const info = await window.electronAPI.hub.info() + setHubInfo(info as HubInfo) + const agentList = await window.electronAPI.hub.listAgents() + setAgents(agentList as AgentInfo[]) + } else { + await window.ipcRenderer.invoke('hub:init') + const info = await window.ipcRenderer.invoke('hub:info') + setHubInfo(info) + const agentList = await window.ipcRenderer.invoke('hub:listAgents') + setAgents(agentList) + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to initialize Hub') + } finally { + setLoading(false) + } + }, []) + + // Initial load + useEffect(() => { + initHub() + }, [initHub]) + + // Refresh Hub info and agents + const refresh = useCallback(async () => { + try { + setError(null) + + if (window.electronAPI) { + const info = await window.electronAPI.hub.info() + setHubInfo(info as HubInfo) + const agentList = await window.electronAPI.hub.listAgents() + setAgents(agentList as AgentInfo[]) + } else { + const info = await window.ipcRenderer.invoke('hub:info') + setHubInfo(info) + const agentList = await window.ipcRenderer.invoke('hub:listAgents') + setAgents(agentList) + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to refresh Hub info') + } + }, []) + + // Reconnect to different Gateway + const reconnect = useCallback(async (url: string) => { + try { + setError(null) + if (window.electronAPI) { + await window.electronAPI.hub.reconnect(url) + } else { + await window.ipcRenderer.invoke('hub:reconnect', url) + } + await refresh() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to reconnect') + } + }, [refresh]) + + // Create a new agent + const createAgent = useCallback(async (id?: string): Promise => { + try { + setError(null) + const result = window.electronAPI + ? await window.electronAPI.hub.createAgent(id) + : await window.ipcRenderer.invoke('hub:createAgent', id) + + const typedResult = result as { error?: string; id?: string; closed?: boolean } + if (typedResult.error) { + setError(typedResult.error) + return null + } + + // Refresh agents list + await refresh() + + return result as AgentInfo + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create agent') + return null + } + }, [refresh]) + + // Close an agent + const closeAgent = useCallback(async (id: string): Promise => { + try { + setError(null) + const result = window.electronAPI + ? await window.electronAPI.hub.closeAgent(id) + : await window.ipcRenderer.invoke('hub:closeAgent', id) + + const typedResult = result as { ok?: boolean } + if (!typedResult.ok) { + setError(`Failed to close agent: ${id}`) + return false + } + + // Refresh agents list + await refresh() + + return true + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to close agent') + return false + } + }, [refresh]) + + // Send message to agent + const sendMessage = useCallback(async (agentId: string, content: string): Promise => { + try { + setError(null) + const result = window.electronAPI + ? await window.electronAPI.hub.sendMessage(agentId, content) + : await window.ipcRenderer.invoke('hub:sendMessage', agentId, content) + + const typedResult = result as { error?: string } + if (typedResult.error) { + setError(typedResult.error) + return false + } + + return true + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to send message') + return false + } + }, []) + + return { + hubInfo, + agents, + loading, + error, + initHub, + refresh, + reconnect, + createAgent, + closeAgent, + sendMessage, + } +} + +export default useHub diff --git a/apps/desktop/src/hooks/use-skills.ts b/apps/desktop/src/hooks/use-skills.ts new file mode 100644 index 00000000..7b1fa6f1 --- /dev/null +++ b/apps/desktop/src/hooks/use-skills.ts @@ -0,0 +1,264 @@ +import { useState, useEffect, useCallback, useMemo } from 'react' + +// ============================================================================ +// Types matching the IPC response from main process +// ============================================================================ + +export type SkillSource = 'bundled' | 'global' | 'profile' + +export interface SkillInfo { + id: string + name: string + description: string + version: string + enabled: boolean + source: SkillSource + triggers: string[] +} + +export interface SkillGroup { + source: SkillSource + name: string + skills: SkillInfo[] +} + +// Source display names +const SOURCE_NAMES: Record = { + bundled: 'Built-in Skills', + global: 'Global Skills', + profile: 'Profile Skills', +} + +export interface UseSkillsReturn { + /** List of all skills */ + skills: SkillInfo[] + /** Skills grouped by source */ + groups: SkillGroup[] + /** Loading state */ + loading: boolean + /** Error state */ + error: string | null + + /** Toggle a skill on/off */ + toggleSkill: (skillId: string) => Promise + /** Enable a skill */ + enableSkill: (skillId: string) => Promise + /** Disable a skill */ + disableSkill: (skillId: string) => Promise + + /** Refresh skills list */ + refresh: () => Promise + + /** Get skill by ID */ + getSkill: (id: string) => SkillInfo | undefined + + /** Filter skills by search query */ + filterSkills: (query: string) => SkillInfo[] + + /** Check if a skill is enabled */ + isSkillEnabled: (skillId: string) => boolean + + /** Stats */ + stats: { + total: number + enabled: number + disabled: number + bundled: number + global: number + profile: number + } +} + +/** + * Hook for managing Agent skills configuration via IPC. + * + * This hook communicates with the Electron main process to: + * - Fetch the list of all skills (bundled, global, profile) + * - Toggle skills on/off + * - Match the CLI `multica skills list` output + */ +export function useSkills(): UseSkillsReturn { + const [skills, setSkills] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + // Fetch skills from main process + const fetchSkills = useCallback(async () => { + try { + setLoading(true) + setError(null) + + // Use new electronAPI if available, fallback to ipcRenderer + const result = window.electronAPI + ? await window.electronAPI.skills.list() + : await window.ipcRenderer.invoke('skills:list') + + if (Array.isArray(result)) { + setSkills(result) + } else { + setError('Invalid response from skills:list') + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to fetch skills') + setSkills([]) + } finally { + setLoading(false) + } + }, []) + + // Initial fetch + useEffect(() => { + fetchSkills() + }, [fetchSkills]) + + // Group skills by source + const groups = useMemo(() => { + const sourceOrder: SkillSource[] = ['bundled', 'global', 'profile'] + const groupMap = new Map() + + for (const skill of skills) { + const sourceSkills = groupMap.get(skill.source) || [] + sourceSkills.push(skill) + groupMap.set(skill.source, sourceSkills) + } + + return sourceOrder + .filter((source) => groupMap.has(source)) + .map((source) => ({ + source, + name: SOURCE_NAMES[source] || source, + skills: groupMap.get(source) || [], + })) + }, [skills]) + + // Stats + const stats = useMemo(() => ({ + total: skills.length, + enabled: skills.filter((s) => s.enabled).length, + disabled: skills.filter((s) => !s.enabled).length, + bundled: skills.filter((s) => s.source === 'bundled').length, + global: skills.filter((s) => s.source === 'global').length, + profile: skills.filter((s) => s.source === 'profile').length, + }), [skills]) + + // Toggle skill via IPC + const toggleSkill = useCallback(async (skillId: string) => { + try { + const result = window.electronAPI + ? await window.electronAPI.skills.toggle(skillId) + : await window.ipcRenderer.invoke('skills:toggle', skillId) + + const typedResult = result as { error?: string; enabled?: boolean } + if (typedResult.error) { + setError(typedResult.error) + return + } + + // Update local state + setSkills((prev) => + prev.map((skill) => + skill.id === skillId ? { ...skill, enabled: typedResult.enabled ?? !skill.enabled } : skill + ) + ) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to toggle skill') + } + }, []) + + // Enable skill via IPC + const enableSkill = useCallback(async (skillId: string) => { + try { + const result = window.electronAPI + ? await window.electronAPI.skills.setStatus(skillId, true) + : await window.ipcRenderer.invoke('skills:setStatus', skillId, true) + + const typedResult = result as { error?: string } + if (typedResult.error) { + setError(typedResult.error) + return + } + + setSkills((prev) => + prev.map((skill) => + skill.id === skillId ? { ...skill, enabled: true } : skill + ) + ) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to enable skill') + } + }, []) + + // Disable skill via IPC + const disableSkill = useCallback(async (skillId: string) => { + try { + const result = window.electronAPI + ? await window.electronAPI.skills.setStatus(skillId, false) + : await window.ipcRenderer.invoke('skills:setStatus', skillId, false) + + const typedResult = result as { error?: string } + if (typedResult.error) { + setError(typedResult.error) + return + } + + setSkills((prev) => + prev.map((skill) => + skill.id === skillId ? { ...skill, enabled: false } : skill + ) + ) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to disable skill') + } + }, []) + + // Get skill by ID + const getSkill = useCallback( + (id: string): SkillInfo | undefined => { + return skills.find((s) => s.id === id) + }, + [skills] + ) + + // Filter skills by search query + const filterSkills = useCallback( + (query: string): SkillInfo[] => { + if (!query.trim()) return skills + + const lowerQuery = query.toLowerCase() + return skills.filter( + (skill) => + skill.name.toLowerCase().includes(lowerQuery) || + skill.id.toLowerCase().includes(lowerQuery) || + skill.description.toLowerCase().includes(lowerQuery) || + skill.triggers.some((t) => t.toLowerCase().includes(lowerQuery)) + ) + }, + [skills] + ) + + // Check if skill is enabled + const isSkillEnabled = useCallback( + (skillId: string): boolean => { + const skill = skills.find((s) => s.id === skillId) + return skill?.enabled ?? false + }, + [skills] + ) + + return { + skills, + groups, + loading, + error, + toggleSkill, + enableSkill, + disableSkill, + refresh: fetchSkills, + getSkill, + filterSkills, + isSkillEnabled, + stats, + } +} + +export default useSkills diff --git a/apps/desktop/src/hooks/use-tools.ts b/apps/desktop/src/hooks/use-tools.ts new file mode 100644 index 00000000..586825bd --- /dev/null +++ b/apps/desktop/src/hooks/use-tools.ts @@ -0,0 +1,232 @@ +import { useState, useEffect, useCallback, useMemo } from 'react' + +// ============================================================================ +// Types matching the IPC response from main process +// ============================================================================ + +export interface ToolInfo { + name: string + description?: string + group: string + enabled: boolean +} + +export interface ToolGroup { + id: string + name: string + tools: string[] +} + +// Tool descriptions (for UI display) +const TOOL_DESCRIPTIONS: Record = { + read: 'Read file contents', + write: 'Write content to file', + edit: 'Edit file with search/replace', + glob: 'Find files by pattern', + exec: 'Execute shell commands', + process: 'Manage background processes', + web_fetch: 'Fetch content from URLs', + web_search: 'Search the web (requires API key)', + memory_get: 'Get stored memory value', + memory_set: 'Store a memory value', + memory_delete: 'Delete a memory value', + memory_list: 'List all memory keys', +} + +// Group display names +const GROUP_NAMES: Record = { + fs: 'File System', + runtime: 'Runtime', + web: 'Web', + memory: 'Memory', + other: 'Other', +} + +export interface UseToolsReturn { + /** List of all tools with their status */ + tools: ToolInfo[] + /** List of tool groups */ + groups: ToolGroup[] + /** Loading state */ + loading: boolean + /** Error state */ + error: string | null + + /** Toggle a specific tool on/off */ + toggleTool: (toolName: string) => Promise + /** Enable a tool */ + enableTool: (toolName: string) => Promise + /** Disable a tool */ + disableTool: (toolName: string) => Promise + + /** Refresh tools list from main process */ + refresh: () => Promise + + /** Check if a tool is enabled */ + isToolEnabled: (toolName: string) => boolean +} + +/** + * Hook for managing Agent tools configuration via IPC. + * + * This hook communicates with the Electron main process to: + * - Fetch the list of available tools and their status + * - Toggle tools on/off (persisted to credentials.json5) + * - Trigger agent.reloadTools() to apply changes immediately + */ +export function useTools(): UseToolsReturn { + const [tools, setTools] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + // Fetch tools from main process + const fetchTools = useCallback(async () => { + try { + setLoading(true) + setError(null) + + // Use new electronAPI if available, fallback to ipcRenderer + const result = window.electronAPI + ? await window.electronAPI.tools.list() + : await window.ipcRenderer.invoke('tools:list') + + if (Array.isArray(result)) { + // Add descriptions to tools + const toolsWithDesc = result.map((tool: { name: string; enabled: boolean; group: string }) => ({ + ...tool, + description: TOOL_DESCRIPTIONS[tool.name], + })) + setTools(toolsWithDesc) + } else { + setError('Invalid response from tools:list') + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to fetch tools') + // Fallback to empty list + setTools([]) + } finally { + setLoading(false) + } + }, []) + + // Initial fetch + useEffect(() => { + fetchTools() + }, [fetchTools]) + + // Build groups list from tools + const groups = useMemo(() => { + const groupMap = new Map() + + for (const tool of tools) { + const groupTools = groupMap.get(tool.group) || [] + groupTools.push(tool.name) + groupMap.set(tool.group, groupTools) + } + + return Array.from(groupMap.entries()).map(([id, toolNames]) => ({ + id, + name: GROUP_NAMES[id] || id, + tools: toolNames, + })) + }, [tools]) + + // Toggle tool via IPC + const toggleTool = useCallback(async (toolName: string) => { + console.log('[useTools] toggleTool called:', toolName) + try { + const result = window.electronAPI + ? await window.electronAPI.tools.toggle(toolName) + : await window.ipcRenderer.invoke('tools:toggle', toolName) + + console.log('[useTools] toggleTool result:', result) + + const typedResult = result as { error?: string; enabled?: boolean } + if (typedResult.error) { + console.error('[useTools] toggleTool error:', typedResult.error) + setError(typedResult.error) + return + } + + // Update local state + console.log('[useTools] Updating tool state:', toolName, 'enabled:', typedResult.enabled) + setTools((prev) => + prev.map((tool) => + tool.name === toolName ? { ...tool, enabled: typedResult.enabled ?? !tool.enabled } : tool + ) + ) + } catch (err) { + console.error('[useTools] toggleTool exception:', err) + setError(err instanceof Error ? err.message : 'Failed to toggle tool') + } + }, []) + + // Enable tool via IPC + const enableTool = useCallback(async (toolName: string) => { + try { + const result = window.electronAPI + ? await window.electronAPI.tools.setStatus(toolName, true) + : await window.ipcRenderer.invoke('tools:setStatus', toolName, true) + + const typedResult = result as { error?: string } + if (typedResult.error) { + setError(typedResult.error) + return + } + + setTools((prev) => + prev.map((tool) => + tool.name === toolName ? { ...tool, enabled: true } : tool + ) + ) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to enable tool') + } + }, []) + + // Disable tool via IPC + const disableTool = useCallback(async (toolName: string) => { + try { + const result = window.electronAPI + ? await window.electronAPI.tools.setStatus(toolName, false) + : await window.ipcRenderer.invoke('tools:setStatus', toolName, false) + + const typedResult = result as { error?: string } + if (typedResult.error) { + setError(typedResult.error) + return + } + + setTools((prev) => + prev.map((tool) => + tool.name === toolName ? { ...tool, enabled: false } : tool + ) + ) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to disable tool') + } + }, []) + + // Check if tool is enabled + const isToolEnabled = useCallback( + (toolName: string): boolean => { + const tool = tools.find((t) => t.name === toolName) + return tool?.enabled ?? false + }, + [tools] + ) + + return { + tools, + groups, + loading, + error, + toggleTool, + enableTool, + disableTool, + refresh: fetchTools, + isToolEnabled, + } +} + +export default useTools diff --git a/apps/desktop/src/pages/home.tsx b/apps/desktop/src/pages/home.tsx index c8042511..31cddf40 100644 --- a/apps/desktop/src/pages/home.tsx +++ b/apps/desktop/src/pages/home.tsx @@ -1,12 +1,174 @@ import { useNavigate } from 'react-router-dom' import { Button } from '@multica/ui/components/ui/button' +import { HugeiconsIcon } from '@hugeicons/react' +import { + Comment01Icon, + LinkSquare01Icon, + Loading03Icon, + AlertCircleIcon, +} from '@hugeicons/core-free-icons' +import { ConnectionQRCode } from '../components/qr-code' +import { useHub } from '../hooks/use-hub' export default function HomePage() { const navigate = useNavigate() + const { hubInfo, agents, loading, error } = useHub() + + // Get the first agent (or create one if none exists) + const primaryAgent = agents[0] + + // Connection state indicator + // Note: 'registered' means fully connected and registered with Gateway + const connectionState = hubInfo?.connectionState ?? 'disconnected' + const isConnected = connectionState === 'connected' || connectionState === 'registered' + + // Loading state + if (loading) { + return ( +
+
+ + Connecting to Hub... +
+
+ ) + } + + // Error state + if (error) { + return ( +
+
+ + Connection Error + {error} +
+
+ ) + } return ( -
- +
+ {/* Main content - QR + Status */} +
+ {/* Left: QR Code */} +
+ +
+ + {/* Right: Hub Status */} +
+
+ {/* Hub Header */} +
+
+ + {isConnected ? ( + <> + + + + ) : connectionState === 'connecting' || connectionState === 'reconnecting' ? ( + <> + + + + ) : ( + + )} + + + {isConnected + ? 'Hub Connected' + : connectionState === 'connecting' + ? 'Connecting...' + : connectionState === 'reconnecting' + ? 'Reconnecting...' + : 'Disconnected'} + +
+

+ Local Hub +

+

+ {hubInfo?.hubId ?? 'Initializing...'} +

+
+ + {/* Stats Grid */} +
+
+

+ Gateway +

+

+ {hubInfo?.url ?? '-'} +

+
+
+

+ Connection +

+

{connectionState}

+
+
+

+ Active Agents +

+

{hubInfo?.agentCount ?? 0}

+
+
+

+ Primary Agent +

+

+ {primaryAgent?.id ?? 'None'} +

+
+
+
+
+
+ + {/* Bottom: Actions */} +
+
+ {/* Primary Action: Chat */} + + + {/* Secondary: Connect to Remote */} + +
+
) } diff --git a/apps/desktop/src/pages/layout.tsx b/apps/desktop/src/pages/layout.tsx new file mode 100644 index 00000000..e0922413 --- /dev/null +++ b/apps/desktop/src/pages/layout.tsx @@ -0,0 +1,63 @@ +import { Outlet, NavLink, useLocation } from 'react-router-dom' +import { Button } from '@multica/ui/components/ui/button' +import { HugeiconsIcon } from '@hugeicons/react' +import { + Settings02Icon, + Home01Icon, + CodeIcon, + PlugIcon, + Comment01Icon, +} from '@hugeicons/core-free-icons' +import { cn } from '@multica/ui/lib/utils' + +const tabs = [ + { path: '/', label: 'Home', icon: Home01Icon, exact: true }, + { path: '/chat', label: 'Chat', icon: Comment01Icon }, + { path: '/tools', label: 'Tools', icon: CodeIcon }, + { path: '/skills', label: 'Skills', icon: PlugIcon }, +] + +export default function Layout() { + const location = useLocation() + + return ( +
+ {/* Header */} +
+
+ Multica +
+ +
+ + {/* Tabs */} + + + {/* Content */} +
+ +
+
+ ) +} diff --git a/apps/desktop/src/pages/skills.tsx b/apps/desktop/src/pages/skills.tsx new file mode 100644 index 00000000..fde22b9e --- /dev/null +++ b/apps/desktop/src/pages/skills.tsx @@ -0,0 +1,42 @@ +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@multica/ui/components/ui/card' +import { useSkills } from '../hooks/use-skills' +import { SkillList } from '../components/skill-list' + +export default function SkillsPage() { + const { + skills, + loading, + error, + toggleSkill, + refresh, + } = useSkills() + + return ( +
+ + + Skills + + Manage agent skills. Skills provide specialized capabilities like Git integration, + code review, and file manipulation. Toggle skills on/off to control agent behavior. + + + + + + +
+ ) +} diff --git a/apps/desktop/src/pages/tools.tsx b/apps/desktop/src/pages/tools.tsx new file mode 100644 index 00000000..6825db55 --- /dev/null +++ b/apps/desktop/src/pages/tools.tsx @@ -0,0 +1,44 @@ +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@multica/ui/components/ui/card' +import { useTools } from '../hooks/use-tools' +import { ToolList } from '../components/tool-list' + +export default function ToolsPage() { + const { + tools, + groups, + loading, + error, + toggleTool, + refresh, + } = useTools() + + return ( +
+ + + Tools + + Configure which tools are available to the Agent. Toggle individual tools on/off. + Changes apply immediately to the running Agent. + + + + + + +
+ ) +} diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json index cda7ec8f..bacb42a6 100644 --- a/apps/desktop/tsconfig.json +++ b/apps/desktop/tsconfig.json @@ -20,10 +20,10 @@ "@multica/store/*": ["../../packages/store/src/*"] }, - /* Linting */ + /* Linting - disabled for external imports from ../../src */ "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, + "noUnusedLocals": false, + "noUnusedParameters": false, "noFallthroughCasesInSwitch": true }, "include": ["src", "electron"], diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 6651f566..61300b85 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -3,6 +3,7 @@ import path from 'node:path' import electron from 'vite-plugin-electron/simple' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' +import { builtinModules } from 'node:module' // https://vitejs.dev/config/ export default defineConfig({ @@ -12,6 +13,43 @@ export default defineConfig({ electron({ main: { entry: 'electron/main.ts', + vite: { + build: { + rollupOptions: { + // Externalize all node_modules - they'll be resolved at runtime + // This is necessary because we import from src/hub which has many Node.js dependencies + external: [ + 'electron', + ...builtinModules, + ...builtinModules.map(m => `node:${m}`), + // Add specific packages that should not be bundled + 'socket.io-client', + 'uuid', + 'chokidar', + 'fast-glob', + 'linkedom', + 'undici', + 'turndown', + '@mozilla/readability', + 'pino', + 'pino-pretty', + 'yaml', + 'json5', + '@mariozechner/pi-agent-core', + '@mariozechner/pi-ai', + '@mariozechner/pi-coding-agent', + ], + }, + }, + resolve: { + alias: { + // Allow importing from root src/ + '@multica/hub': path.resolve(__dirname, '../../src/hub'), + '@multica/agent': path.resolve(__dirname, '../../src/agent'), + '@multica/sdk': path.resolve(__dirname, '../../packages/sdk/src'), + }, + }, + }, }, preload: { input: path.join(__dirname, 'electron/preload.ts'), diff --git a/packages/ui/src/components/ui/dialog.tsx b/packages/ui/src/components/ui/dialog.tsx new file mode 100644 index 00000000..1e14624a --- /dev/null +++ b/packages/ui/src/components/ui/dialog.tsx @@ -0,0 +1,140 @@ +"use client" + +import * as React from "react" +import { Dialog as DialogPrimitive } from "@base-ui/react/dialog" + +import { cn } from "@multica/ui/lib/utils" +import { Button } from "@multica/ui/components/ui/button" +import { HugeiconsIcon } from "@hugeicons/react" +import { Cancel01Icon } from "@hugeicons/core-free-icons" + +function Dialog({ ...props }: DialogPrimitive.Root.Props) { + return +} + +function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) { + return +} + +function DialogClose({ ...props }: DialogPrimitive.Close.Props) { + return +} + +function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) { + return +} + +function DialogOverlay({ + className, + ...props +}: DialogPrimitive.Backdrop.Props) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: DialogPrimitive.Popup.Props & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + } + > + + Close + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: DialogPrimitive.Description.Props) { + return ( + + ) +} + +export { + Dialog, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, + DialogOverlay, + DialogPortal, +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e74a4c2..a3131cb8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,9 +9,24 @@ catalogs: '@types/node': specifier: ^25.0.10 version: 25.0.10 + '@types/react': + specifier: ^19 + version: 19.1.17 + '@types/react-dom': + specifier: ^19 + version: 19.2.3 + react: + specifier: 19.2.3 + version: 19.2.3 + react-dom: + specifier: 19.2.3 + version: 19.2.3 typescript: specifier: ^5.9.3 version: 5.9.3 + zustand: + specifier: ^5.0.0 + version: 5.0.10 importers: @@ -132,9 +147,21 @@ importers: apps/desktop: dependencies: + '@hugeicons/core-free-icons': + specifier: ^3.1.1 + version: 3.1.1 + '@hugeicons/react': + specifier: ^1.1.4 + version: 1.1.4(react@19.2.3) + '@multica/sdk': + specifier: workspace:* + version: link:../../packages/sdk '@multica/ui': specifier: workspace:* version: link:../../packages/ui + qrcode.react: + specifier: ^4.2.0 + version: 4.2.0(react@19.2.3) react: specifier: 'catalog:' version: 19.2.3 @@ -144,16 +171,22 @@ importers: react-router-dom: specifier: ^7.13.0 version: 7.13.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + socket.io-client: + specifier: ^4.8.3 + version: 4.8.3 + uuid: + specifier: ^13.0.0 + version: 13.0.0 devDependencies: '@tailwindcss/vite': specifier: ^4.1.18 version: 4.1.18(vite@5.4.21(@types/node@25.0.10)(lightningcss@1.30.2)(terser@5.46.0)) '@types/react': specifier: 'catalog:' - version: 19.2.10 + version: 19.1.17 '@types/react-dom': specifier: 'catalog:' - version: 19.2.3(@types/react@19.2.10) + version: 19.2.3(@types/react@19.1.17) '@typescript-eslint/eslint-plugin': specifier: ^7.1.1 version: 7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) @@ -355,17 +388,17 @@ importers: version: 13.0.0 zustand: specifier: 'catalog:' - version: 5.0.10(@types/react@19.2.10)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3)) + version: 5.0.10(@types/react@19.1.17)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3)) devDependencies: '@types/node': specifier: 'catalog:' version: 25.0.10 '@types/react': specifier: 'catalog:' - version: 19.2.10 + version: 19.1.17 '@types/react-dom': specifier: 'catalog:' - version: 19.2.3(@types/react@19.2.10) + version: 19.2.3(@types/react@19.1.17) eslint: specifier: ^9 version: 9.39.2(jiti@2.6.1) @@ -408,11 +441,11 @@ importers: version: 13.0.0 zustand: specifier: 'catalog:' - version: 5.0.10(@types/react@19.2.10)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3)) + version: 5.0.10(@types/react@19.1.17)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3)) devDependencies: '@types/react': specifier: 'catalog:' - version: 19.2.10 + version: 19.1.17 typescript: specifier: 'catalog:' version: 5.9.3 @@ -421,7 +454,7 @@ importers: dependencies: '@base-ui/react': specifier: ^1.1.0 - version: 1.1.0(@types/react@19.2.10)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 1.1.0(@types/react@19.1.17)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@hugeicons/core-free-icons': specifier: ^3.1.1 version: 3.1.1 @@ -451,7 +484,7 @@ importers: version: 19.2.3(react@19.2.3) react-markdown: specifier: ^10.1.0 - version: 10.1.0(@types/react@19.2.10)(react@19.2.3) + version: 10.1.0(@types/react@19.1.17)(react@19.2.3) rehype-raw: specifier: ^7.0.0 version: 7.0.0 @@ -485,10 +518,10 @@ importers: version: 5.0.0 '@types/react': specifier: 'catalog:' - version: 19.2.10 + version: 19.1.17 '@types/react-dom': specifier: 'catalog:' - version: 19.2.3(@types/react@19.2.10) + version: 19.2.3(@types/react@19.1.17) typescript: specifier: 'catalog:' version: 5.9.3 @@ -3328,9 +3361,6 @@ packages: '@types/react@19.1.17': resolution: {integrity: sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==} - '@types/react@19.2.10': - resolution: {integrity: sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==} - '@types/responselike@1.0.3': resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} @@ -7379,6 +7409,11 @@ packages: resolution: {integrity: sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ==} hasBin: true + qrcode.react@4.2.0: + resolution: {integrity: sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + qs@6.14.1: resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} engines: {node: '>=0.6'} @@ -10219,10 +10254,10 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@base-ui/react@1.1.0(@types/react@19.2.10)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@base-ui/react@1.1.0(@types/react@19.1.17)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@babel/runtime': 7.28.6 - '@base-ui/utils': 0.2.4(@types/react@19.2.10)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@base-ui/utils': 0.2.4(@types/react@19.1.17)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@floating-ui/react-dom': 2.1.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@floating-ui/utils': 0.2.10 react: 19.2.3 @@ -10231,9 +10266,9 @@ snapshots: tabbable: 6.4.0 use-sync-external-store: 1.6.0(react@19.2.3) optionalDependencies: - '@types/react': 19.2.10 + '@types/react': 19.1.17 - '@base-ui/utils@0.2.4(@types/react@19.2.10)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@base-ui/utils@0.2.4(@types/react@19.1.17)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@babel/runtime': 7.28.6 '@floating-ui/utils': 0.2.10 @@ -10242,7 +10277,7 @@ snapshots: reselect: 5.1.1 use-sync-external-store: 1.6.0(react@19.2.3) optionalDependencies: - '@types/react': 19.2.10 + '@types/react': 19.1.17 '@bcoe/v8-coverage@1.0.2': {} @@ -12554,20 +12589,11 @@ snapshots: '@types/react-dom@19.2.3(@types/react@19.1.17)': dependencies: '@types/react': 19.1.17 - optional: true - - '@types/react-dom@19.2.3(@types/react@19.2.10)': - dependencies: - '@types/react': 19.2.10 '@types/react@19.1.17': dependencies: csstype: 3.2.3 - '@types/react@19.2.10': - dependencies: - csstype: 3.2.3 - '@types/responselike@1.0.3': dependencies: '@types/node': 25.0.10 @@ -17520,6 +17546,10 @@ snapshots: qrcode-terminal@0.11.0: {} + qrcode.react@4.2.0(react@19.2.3): + dependencies: + react: 19.2.3 + qs@6.14.1: dependencies: side-channel: 1.1.0 @@ -17587,11 +17617,11 @@ snapshots: react-is@19.2.4: {} - react-markdown@10.1.0(@types/react@19.2.10)(react@19.2.3): + react-markdown@10.1.0(@types/react@19.1.17)(react@19.2.3): dependencies: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 - '@types/react': 19.2.10 + '@types/react': 19.1.17 devlop: 1.1.0 hast-util-to-jsx-runtime: 2.3.6 html-url-attributes: 3.0.1 @@ -19459,9 +19489,9 @@ snapshots: react: 19.1.0 use-sync-external-store: 1.6.0(react@19.1.0) - zustand@5.0.10(@types/react@19.2.10)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3)): + zustand@5.0.10(@types/react@19.1.17)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3)): optionalDependencies: - '@types/react': 19.2.10 + '@types/react': 19.1.17 react: 19.2.3 use-sync-external-store: 1.6.0(react@19.2.3) diff --git a/src/agent/async-agent.ts b/src/agent/async-agent.ts index c5bcdc07..86ae5d90 100644 --- a/src/agent/async-agent.ts +++ b/src/agent/async-agent.ts @@ -4,7 +4,7 @@ import { Agent } from "./runner.js"; import { Channel } from "./channel.js"; import type { AgentOptions, Message } from "./types.js"; -const devNull = { write: () => true } as NodeJS.WritableStream; +const devNull = { write: () => true } as unknown as NodeJS.WritableStream; /** Discriminated union of legacy Message (error fallback) and raw AgentEvent */ export type ChannelItem = Message | AgentEvent; @@ -87,4 +87,65 @@ export class AsyncAgent { } this.closeCallbacks = []; } + + /** Get current active tool names */ + getActiveTools(): string[] { + return this.agent.getActiveTools(); + } + + /** + * Reload tools from credentials config. + * Call this after updating tool status to apply changes immediately. + */ + reloadTools(): string[] { + return this.agent.reloadTools(); + } + + /** + * Get all skills with their eligibility status. + */ + getSkillsWithStatus(): Array<{ + id: string; + name: string; + description: string; + source: string; + eligible: boolean; + reasons?: string[] | undefined; + }> { + return this.agent.getSkillsWithStatus(); + } + + /** + * Get eligible skills only. + */ + getEligibleSkills(): Array<{ + id: string; + name: string; + description: string; + source: string; + }> { + return this.agent.getEligibleSkills(); + } + + /** + * Reload skills from disk. + */ + reloadSkills(): void { + this.agent.reloadSkills(); + } + + /** + * Set a tool's enabled status and persist to profile config. + * Returns the new tools config, or undefined if no profile is loaded. + */ + setToolStatus(toolName: string, enabled: boolean): { allow?: string[]; deny?: string[] } | undefined { + return this.agent.setToolStatus(toolName, enabled); + } + + /** + * Get current profile ID, if any. + */ + getProfileId(): string | undefined { + return this.agent.getProfileId(); + } } diff --git a/src/agent/profile/index.ts b/src/agent/profile/index.ts index 43c4de57..bc41e046 100644 --- a/src/agent/profile/index.ts +++ b/src/agent/profile/index.ts @@ -169,4 +169,50 @@ export class ProfileManager { const profile = this.getProfile(); return profile?.config; } + + /** 更新 tools 配置 */ + updateToolsConfig(toolsConfig: ToolsConfig): void { + const profile = this.getOrCreateProfile(false); + const currentConfig = profile.config ?? {}; + const newConfig: ProfileConfig = { + ...currentConfig, + tools: toolsConfig, + }; + profile.config = newConfig; + this.profile = profile; + saveProfile({ id: this.profileId, config: newConfig }, { baseDir: this.baseDir }); + } + + /** 设置单个 tool 的启用状态 */ + setToolEnabled(toolName: string, enabled: boolean): ToolsConfig { + const currentConfig = this.getToolsConfig() ?? {}; + const allow = new Set(currentConfig.allow ?? []); + const deny = new Set(currentConfig.deny ?? []); + + if (enabled) { + // Enable: add to allow, remove from deny + allow.add(toolName); + deny.delete(toolName); + } else { + // Disable: add to deny, remove from allow + deny.add(toolName); + allow.delete(toolName); + } + + // Build new config object, only including non-empty arrays + const newConfig: ToolsConfig = { ...currentConfig }; + if (allow.size > 0) { + newConfig.allow = Array.from(allow); + } else { + delete newConfig.allow; + } + if (deny.size > 0) { + newConfig.deny = Array.from(deny); + } else { + delete newConfig.deny; + } + + this.updateToolsConfig(newConfig); + return newConfig; + } } diff --git a/src/agent/runner.ts b/src/agent/runner.ts index cc49c567..bdc9e203 100644 --- a/src/agent/runner.ts +++ b/src/agent/runner.ts @@ -70,6 +70,8 @@ export class Agent { private readonly skillManager?: SkillManager; private readonly contextWindowGuard: ContextWindowGuardResult; private readonly debug: boolean; + private toolsOptions: AgentOptions; + private readonly originalToolsConfig?: ToolsConfig; private readonly stderr: NodeJS.WritableStream; private initialized = false; @@ -150,15 +152,18 @@ export class Agent { ); // Load Agent Profile (if profileId is specified) + // Every Agent should have a Profile for memory, tools config, and other settings let systemPrompt: string | undefined; if (options.profileId) { this.profile = new ProfileManager({ profileId: options.profileId, baseDir: options.profileBaseDir, }); + // Ensure profile directory exists (creates with default templates if new) + this.profile.getOrCreateProfile(true); systemPrompt = this.profile.buildSystemPrompt(); } else if (options.systemPrompt) { - // Use provided systemPrompt directly + // Use provided systemPrompt directly (no profile - memory tools won't work) systemPrompt = options.systemPrompt; } @@ -262,12 +267,17 @@ export class Agent { this.agent.setModel(model); + // Save original tools config from options (for later merging during reload) + if (options.tools) { + this.originalToolsConfig = options.tools; + } + // Merge Profile tools config with options.tools (options takes precedence) const profileToolsConfig = this.profile?.getToolsConfig(); const mergedToolsConfig = mergeToolsConfig(profileToolsConfig, options.tools); - const toolsOptions = mergedToolsConfig ? { ...options, tools: mergedToolsConfig } : options; + this.toolsOptions = mergedToolsConfig ? { ...options, tools: mergedToolsConfig } : options; - const tools = resolveTools(toolsOptions); + const tools = resolveTools(this.toolsOptions); if (this.debug) { if (profileToolsConfig) { console.error(`[debug] Profile tools config: ${JSON.stringify(profileToolsConfig)}`); @@ -427,4 +437,99 @@ export class Agent { this.agent.replaceMessages(result.kept); } } + + /** + * Reload tools from profile config. + * Call this after updating tool status to apply changes + * without restarting the agent session. + */ + reloadTools(): string[] { + // Re-read profile tools config to get latest changes + const profileToolsConfig = this.profile?.getToolsConfig(); + const mergedToolsConfig = mergeToolsConfig(profileToolsConfig, this.originalToolsConfig); + this.toolsOptions = mergedToolsConfig + ? { ...this.toolsOptions, tools: mergedToolsConfig } + : this.toolsOptions; + + const tools = resolveTools(this.toolsOptions); + this.agent.setTools(tools); + if (this.debug) { + console.error(`[debug] Reloaded ${tools.length} tools: ${tools.map(t => t.name).join(", ") || "(none)"}`); + } + return tools.map(t => t.name); + } + + /** Get current active tool names */ + getActiveTools(): string[] { + return this.agent.state.tools?.map(t => t.name) ?? []; + } + + /** + * Get all skills with their eligibility status. + * Returns empty array if skills are disabled. + */ + getSkillsWithStatus(): Array<{ + id: string; + name: string; + description: string; + source: string; + eligible: boolean; + reasons?: string[] | undefined; + }> { + if (!this.skillManager) { + return []; + } + return this.skillManager.listAllSkillsWithStatus(); + } + + /** + * Get eligible skills only. + * Returns empty array if skills are disabled. + */ + getEligibleSkills(): Array<{ + id: string; + name: string; + description: string; + source: string; + }> { + if (!this.skillManager) { + return []; + } + return this.skillManager.listSkills(); + } + + /** + * Reload skills from disk. + * Call this after adding/removing skills to apply changes. + */ + reloadSkills(): void { + if (this.skillManager) { + this.skillManager.reload(); + } + } + + /** + * Set a tool's enabled status and persist to profile config. + * Returns the new tools config, or undefined if no profile is loaded. + */ + setToolStatus(toolName: string, enabled: boolean): { allow?: string[]; deny?: string[] } | undefined { + if (!this.profile) { + return undefined; + } + const newConfig = this.profile.setToolEnabled(toolName, enabled); + // Reload tools to apply changes + this.reloadTools(); + // Build result object, only including defined properties + const result: { allow?: string[]; deny?: string[] } = {}; + if (newConfig.allow) result.allow = newConfig.allow; + if (newConfig.deny) result.deny = newConfig.deny; + return result; + } + + /** + * Get current profile ID, if any. + */ + getProfileId(): string | undefined { + return this.profile?.getProfile()?.id; + } } diff --git a/src/agent/tools.ts b/src/agent/tools.ts index 56c9b766..ea9d16de 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -1,6 +1,7 @@ import type { AgentOptions } from "./types.js"; import { createCodingTools } from "@mariozechner/pi-coding-agent"; import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core"; +import type { TSchema } from "@sinclair/typebox"; import { createExecTool } from "./tools/exec.js"; import { createProcessTool } from "./tools/process.js"; import { createGlobTool } from "./tools/glob.js"; @@ -70,7 +71,7 @@ function toolErrorResult(error: unknown): AgentToolResult { }; } -function wrapTool( +function wrapTool( tool: AgentTool, ): AgentTool { const execute = tool.execute; diff --git a/src/hub/hub.ts b/src/hub/hub.ts index 31818950..bd5241bf 100644 --- a/src/hub/hub.ts +++ b/src/hub/hub.ts @@ -139,7 +139,7 @@ export class Hub { } /** Create new Agent, or rebuild with existing ID */ - createAgent(id?: string, options?: { persist?: boolean }): AsyncAgent { + createAgent(id?: string, options?: { persist?: boolean; profileId?: string }): AsyncAgent { if (id) { const existing = this.agents.get(id); if (existing && !existing.closed) { @@ -147,7 +147,7 @@ export class Hub { } } - const agent = new AsyncAgent({ sessionId: id }); + const agent = new AsyncAgent({ sessionId: id, profileId: options?.profileId ?? "default" }); this.agents.set(agent.sessionId, agent); // Persist to agent store (skip during restore to avoid duplicates)