Merge branch 'main' into feat/connection-code

This commit is contained in:
Naiyuan Qing 2026-02-03 19:42:41 +08:00
commit bb223d8a8c
29 changed files with 3853 additions and 75 deletions

View file

@ -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 后续消息发到 hubIdpayload 带 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 <id> # 单个 skill 详情
multica skills add owner/repo # 从 GitHub 添加
multica skills remove <name> # 删除 skill
multica skills install <id> # 安装依赖
```
**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<agentId, AsyncAgent> │ │ │
│ │ │ - 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<HubStatus>;
getAgentInfo: () => Promise<AgentInfo | null>;
};
tools: {
list: () => Promise<ToolStatus[]>;
setStatus: (toolName: string, enabled: boolean) => Promise<void>;
getGroups: () => Promise<Record<string, string[]>>;
getProfiles: () => Promise<string[]>;
};
skills: {
list: () => Promise<SkillInfo[]>;
add: (source: string) => Promise<void>;
remove: (name: string) => Promise<void>;
setEnabled: (name: string, enabled: boolean) => Promise<void>;
};
}
// 类型定义
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 | 联调测试 | ⏳ 待开始 |

View file

@ -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<unknown>
getStatus: () => Promise<HubStatus>
getAgentInfo: () => Promise<AgentInfo | null>
info: () => Promise<unknown>
reconnect: (url: string) => Promise<unknown>
listAgents: () => Promise<unknown>
createAgent: (id?: string) => Promise<unknown>
getAgent: (id: string) => Promise<unknown>
closeAgent: (id: string) => Promise<unknown>
sendMessage: (agentId: string, content: string) => Promise<unknown>
}
tools: {
list: () => Promise<ToolInfo[]>
toggle: (name: string) => Promise<unknown>
setStatus: (name: string, enabled: boolean) => Promise<unknown>
active: () => Promise<unknown>
reload: () => Promise<unknown>
}
skills: {
list: () => Promise<SkillInfo[]>
get: (id: string) => Promise<unknown>
toggle: (id: string) => Promise<unknown>
setStatus: (id: string, enabled: boolean) => Promise<unknown>
reload: () => Promise<unknown>
add: (source: string, options?: { name?: string; force?: boolean }) => Promise<SkillAddResult>
remove: (name: string) => Promise<SkillAddResult>
}
agent: {
status: () => Promise<unknown>
}
}
// Used in Renderer process, expose in `preload.ts`
interface Window {
ipcRenderer: import('electron').IpcRenderer
electronAPI: ElectronAPI
}

View file

@ -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<string, string[]> = {
'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
}

View file

@ -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<void> {
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<HubInfo> => {
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<AgentInfo[]> => {
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
}

View file

@ -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<void> {
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()
}

View file

@ -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 /<skill-id>
}))
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
})
}

View file

@ -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()
})

View file

@ -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<HubStatus> => ipcRenderer.invoke('hub:getStatus'),
getAgentInfo: (): Promise<AgentInfo | null> => 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<ToolInfo[]> => 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<SkillInfo[]> => 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<typeof ipcRenderer.on>) {
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

View file

@ -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",

View file

@ -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: <HomePage /> },
{ path: '/chat', element: <ChatPage /> },
{
path: '/',
element: <Layout />,
children: [
{ index: true, element: <HomePage /> },
{ path: 'chat', element: <ChatPage /> },
{ path: 'tools', element: <ToolsPage /> },
{ path: 'skills', element: <SkillsPage /> },
],
},
])
export default function App() {

View file

@ -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 (
<div className="flex flex-col items-center">
{/* QR Code with decorative corners */}
<div className="relative">
{/* Corner accents */}
<div className="absolute -top-3 -left-3 w-6 h-6 border-t-2 border-l-2 border-primary/60 rounded-tl-lg" />
<div className="absolute -top-3 -right-3 w-6 h-6 border-t-2 border-r-2 border-primary/60 rounded-tr-lg" />
<div className="absolute -bottom-3 -left-3 w-6 h-6 border-b-2 border-l-2 border-primary/60 rounded-bl-lg" />
<div className="absolute -bottom-3 -right-3 w-6 h-6 border-b-2 border-r-2 border-primary/60 rounded-br-lg" />
{/* QR Code */}
<div className="bg-white p-4 rounded-xl shadow-lg">
<QRCodeSVG
value={JSON.stringify(qrData)}
size={size}
level="M"
marginSize={0}
bgColor="#ffffff"
fgColor="#0a0a0a"
/>
</div>
{/* Expired overlay */}
{isExpired && (
<div className="absolute inset-0 bg-background/80 backdrop-blur-sm rounded-xl flex items-center justify-center">
<Button variant="outline" size="sm" onClick={handleRefresh}>
<HugeiconsIcon icon={RefreshIcon} className="size-4 mr-2" />
Refresh
</Button>
</div>
)}
</div>
{/* Info section */}
<div className="mt-6 text-center space-y-3">
<p className="text-sm text-muted-foreground">
Scan with your phone to connect
</p>
{/* Expiry timer */}
<div className="flex items-center gap-3 justify-center">
<span
className={`text-xs font-mono ${
isExpiringSoon
? 'text-orange-500 dark:text-orange-400'
: isExpired
? 'text-red-500 dark:text-red-400'
: 'text-muted-foreground'
}`}
>
{isExpired ? 'Expired' : `Expires in ${formatTime(remainingSeconds)}`}
</span>
{!isExpired && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs gap-1"
onClick={handleRefresh}
>
<HugeiconsIcon icon={RefreshIcon} className="size-3" />
Refresh
</Button>
)}
</div>
{/* Copy link button */}
<Button
variant="outline"
size="sm"
className="text-xs gap-1.5"
onClick={handleCopyLink}
>
<HugeiconsIcon
icon={copied ? CheckmarkCircle02Icon : Copy01Icon}
className="size-3.5"
/>
{copied ? 'Copied!' : 'Copy Link'}
</Button>
</div>
</div>
)
}
export default ConnectionQRCode

View file

@ -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<SkillSource, string> = {
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<SkillSource, string> = {
bundled: 'Built-in Skills',
global: 'Global Skills',
profile: 'Profile Skills',
}
interface SkillListProps {
skills: SkillInfo[]
loading: boolean
error: string | null
onToggleSkill: (skillId: string) => Promise<void>
onRefresh: () => Promise<void>
}
export function SkillList({
skills,
loading,
error,
onToggleSkill,
onRefresh,
}: SkillListProps) {
// Track toggling state for individual skills
const [togglingSkills, setTogglingSkills] = useState<Set<string>>(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<SkillSource, SkillInfo[]> = {
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 (
<div className="flex items-center justify-center py-12">
<HugeiconsIcon icon={Loading03Icon} className="size-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground">Loading skills...</span>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
{skills.filter((s) => s.enabled).length} of {skills.length} skills enabled
</div>
<Button
variant="ghost"
size="sm"
onClick={onRefresh}
disabled={loading}
className="gap-1.5"
>
<HugeiconsIcon
icon={loading ? Loading03Icon : RotateClockwiseIcon}
className={`size-4 ${loading ? 'animate-spin' : ''}`}
/>
Refresh
</Button>
</div>
{/* Error message */}
{error && (
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
{error}
</div>
)}
{/* Skills grouped by source */}
{sourceOrder.map((source) => {
const sourceSkills = skillsBySource[source]
if (sourceSkills.length === 0) return null
return (
<div key={source} className="space-y-2">
<h3 className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<span
className={`inline-block w-2 h-2 rounded-full ${
source === 'bundled'
? 'bg-blue-500'
: source === 'global'
? 'bg-green-500'
: 'bg-purple-500'
}`}
/>
{SOURCE_TITLES[source]}
<span className="text-xs">({sourceSkills.length})</span>
</h3>
<div className="space-y-1">
{sourceSkills.map((skill) => {
const isToggling = togglingSkills.has(skill.id)
return (
<div
key={skill.id}
className="flex items-center justify-between px-4 py-3 rounded-lg border hover:bg-muted/30 transition-colors"
>
{/* Left: Name + Description */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium">{skill.name}</span>
<code className="text-xs text-muted-foreground font-mono">
/{skill.id}
</code>
<Badge variant="secondary" className={`text-xs ${SOURCE_COLORS[skill.source]}`}>
{skill.source}
</Badge>
</div>
<p className="text-sm text-muted-foreground truncate mt-0.5">
{skill.description}
</p>
{skill.triggers.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{skill.triggers.slice(0, 3).map((trigger) => (
<code
key={trigger}
className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground"
>
{trigger}
</code>
))}
{skill.triggers.length > 3 && (
<span className="text-xs text-muted-foreground">
+{skill.triggers.length - 3} more
</span>
)}
</div>
)}
</div>
{/* Center: Status */}
<div className="flex items-center gap-2 px-4">
<div
className={`flex items-center gap-1 ${
skill.enabled
? 'text-green-600 dark:text-green-400'
: 'text-muted-foreground'
}`}
>
<HugeiconsIcon
icon={skill.enabled ? CheckmarkCircle02Icon : Cancel01Icon}
className="size-4"
/>
<span className="text-xs font-medium">
{skill.enabled ? 'Enabled' : 'Disabled'}
</span>
</div>
</div>
{/* Right: Toggle */}
<div className="flex items-center gap-2">
{isToggling && (
<HugeiconsIcon
icon={Loading03Icon}
className="size-4 animate-spin text-muted-foreground"
/>
)}
<Switch
checked={skill.enabled}
onCheckedChange={() => handleToggleSkill(skill.id)}
disabled={isToggling}
/>
</div>
</div>
)
})}
</div>
</div>
)
})}
{/* Empty state */}
{skills.length === 0 && !loading && (
<div className="text-center py-12 text-muted-foreground">
<p>No skills found.</p>
</div>
)}
{/* Note about persistence */}
<p className="text-xs text-muted-foreground text-center">
Changes are saved automatically. Restart Agent session to apply skill changes.
</p>
</div>
)
}
export default SkillList

View file

@ -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<string, typeof FolderOpenIcon> = {
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<void>
onRefresh: () => Promise<void>
}
export function ToolList({
tools,
groups,
loading,
error,
onToggleTool,
onRefresh,
}: ToolListProps) {
// Track which groups are expanded
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(
() => new Set(groups.map((g) => g.id))
)
// Track toggling state for individual tools
const [togglingTools, setTogglingTools] = useState<Set<string>>(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 (
<div className="flex items-center justify-center py-12">
<HugeiconsIcon icon={Loading03Icon} className="size-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground">Loading tools...</span>
</div>
)
}
return (
<div className="space-y-6">
{/* Header: Refresh button */}
<div className="flex items-center justify-between gap-4">
<div className="text-sm text-muted-foreground">
{tools.filter((t) => t.enabled).length} of {tools.length} tools enabled
</div>
<Button
variant="ghost"
size="sm"
onClick={onRefresh}
className="gap-1.5"
disabled={loading}
>
<HugeiconsIcon
icon={loading ? Loading03Icon : RotateClockwiseIcon}
className={`size-4 ${loading ? 'animate-spin' : ''}`}
/>
Refresh
</Button>
</div>
{/* Error message */}
{error && (
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
{error}
</div>
)}
{/* Tool groups */}
<div className="space-y-2">
{toolsByGroup.map((group) => {
const isExpanded = expandedGroups.has(group.id)
const GroupIcon = GROUP_ICONS[group.id] || CodeIcon
return (
<div
key={group.id}
className="border rounded-lg overflow-hidden"
>
{/* Group header */}
<button
onClick={() => toggleGroup(group.id)}
className="w-full flex items-center justify-between px-4 py-3 bg-muted/30 hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-3">
<HugeiconsIcon icon={GroupIcon} className="size-5 text-muted-foreground" />
<span className="font-medium">{group.name}</span>
<span className="text-xs text-muted-foreground">
{group.enabledCount}/{group.totalCount} enabled
</span>
</div>
<HugeiconsIcon
icon={isExpanded ? ArrowUp01Icon : ArrowDown01Icon}
className="size-4 text-muted-foreground"
/>
</button>
{/* Group tools */}
{isExpanded && (
<div className="divide-y">
{group.tools.map((tool) => {
const isToggling = togglingTools.has(tool.name)
return (
<div
key={tool.name}
className="flex items-center justify-between px-4 py-3 hover:bg-muted/20 transition-colors"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<code className="text-sm font-mono font-medium">
{tool.name}
</code>
{!tool.enabled && (
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
disabled
</span>
)}
</div>
{tool.description && (
<p className="text-xs text-muted-foreground mt-0.5 truncate">
{tool.description}
</p>
)}
</div>
<div className="flex items-center gap-2">
{isToggling && (
<HugeiconsIcon icon={Loading03Icon} className="size-4 animate-spin text-muted-foreground" />
)}
<Switch
checked={tool.enabled}
onCheckedChange={() => handleToggleTool(tool.name)}
disabled={isToggling}
/>
</div>
</div>
)
})}
</div>
)}
</div>
)
})}
</div>
{/* Note about persistence */}
<p className="text-xs text-muted-foreground text-center">
Changes are saved automatically and apply to the running Agent immediately.
</p>
</div>
)
}
export default ToolList

View file

@ -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<void>
/** Refresh Hub info and agents list */
refresh: () => Promise<void>
/** Reconnect to a different Gateway URL */
reconnect: (url: string) => Promise<void>
/** Create a new agent */
createAgent: (id?: string) => Promise<AgentInfo | null>
/** Close an agent */
closeAgent: (id: string) => Promise<boolean>
/** Send a message to an agent */
sendMessage: (agentId: string, content: string) => Promise<boolean>
}
/**
* 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<HubInfo | null>(null)
const [agents, setAgents] = useState<AgentInfo[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(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<AgentInfo | null> => {
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<boolean> => {
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<boolean> => {
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

View file

@ -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<string, string> = {
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<void>
/** Enable a skill */
enableSkill: (skillId: string) => Promise<void>
/** Disable a skill */
disableSkill: (skillId: string) => Promise<void>
/** Refresh skills list */
refresh: () => Promise<void>
/** 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<SkillInfo[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(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<SkillGroup[]>(() => {
const sourceOrder: SkillSource[] = ['bundled', 'global', 'profile']
const groupMap = new Map<SkillSource, SkillInfo[]>()
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

View file

@ -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<string, string> = {
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<string, string> = {
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<void>
/** Enable a tool */
enableTool: (toolName: string) => Promise<void>
/** Disable a tool */
disableTool: (toolName: string) => Promise<void>
/** Refresh tools list from main process */
refresh: () => Promise<void>
/** 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<ToolInfo[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(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<ToolGroup[]>(() => {
const groupMap = new Map<string, string[]>()
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

View file

@ -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 (
<div className="h-full flex items-center justify-center">
<div className="flex items-center gap-3 text-muted-foreground">
<HugeiconsIcon icon={Loading03Icon} className="size-5 animate-spin" />
<span>Connecting to Hub...</span>
</div>
</div>
)
}
// Error state
if (error) {
return (
<div className="h-full flex items-center justify-center">
<div className="flex flex-col items-center gap-3 text-destructive">
<HugeiconsIcon icon={AlertCircleIcon} className="size-8" />
<span className="font-medium">Connection Error</span>
<span className="text-sm text-muted-foreground">{error}</span>
</div>
</div>
)
}
return (
<div className="flex h-screen items-center justify-center">
<Button onClick={() => navigate('/chat')}>Open Chat</Button>
<div className="h-full flex flex-col">
{/* Main content - QR + Status */}
<div className="flex-1 flex gap-8 p-2">
{/* Left: QR Code */}
<div className="flex-1 flex flex-col items-center justify-center">
<ConnectionQRCode
gateway={hubInfo?.url ?? 'http://localhost:3000'}
hubId={hubInfo?.hubId ?? 'unknown'}
agentId={primaryAgent?.id}
expirySeconds={300}
size={180}
/>
</div>
{/* Right: Hub Status */}
<div className="flex-1 flex flex-col justify-center">
<div className="space-y-6">
{/* Hub Header */}
<div>
<div className="flex items-center gap-2 mb-1">
<span className="relative flex size-2.5">
{isConnected ? (
<>
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex rounded-full size-2.5 bg-green-500" />
</>
) : connectionState === 'connecting' || connectionState === 'reconnecting' ? (
<>
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-yellow-400 opacity-75" />
<span className="relative inline-flex rounded-full size-2.5 bg-yellow-500" />
</>
) : (
<span className="relative inline-flex rounded-full size-2.5 bg-red-500" />
)}
</span>
<span className={`text-sm font-medium ${
isConnected
? 'text-green-600 dark:text-green-400'
: connectionState === 'connecting' || connectionState === 'reconnecting'
? 'text-yellow-600 dark:text-yellow-400'
: 'text-red-600 dark:text-red-400'
}`}>
{isConnected
? 'Hub Connected'
: connectionState === 'connecting'
? 'Connecting...'
: connectionState === 'reconnecting'
? 'Reconnecting...'
: 'Disconnected'}
</span>
</div>
<h2 className="text-2xl font-semibold tracking-tight">
Local Hub
</h2>
<p className="text-sm text-muted-foreground font-mono">
{hubInfo?.hubId ?? 'Initializing...'}
</p>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-2 gap-4">
<div className="p-4 rounded-lg bg-muted/50 border border-border/50">
<p className="text-xs text-muted-foreground uppercase tracking-wider mb-1">
Gateway
</p>
<p className="font-medium text-sm truncate" title={hubInfo?.url}>
{hubInfo?.url ?? '-'}
</p>
</div>
<div className="p-4 rounded-lg bg-muted/50 border border-border/50">
<p className="text-xs text-muted-foreground uppercase tracking-wider mb-1">
Connection
</p>
<p className="font-medium capitalize">{connectionState}</p>
</div>
<div className="p-4 rounded-lg bg-muted/50 border border-border/50">
<p className="text-xs text-muted-foreground uppercase tracking-wider mb-1">
Active Agents
</p>
<p className="font-medium">{hubInfo?.agentCount ?? 0}</p>
</div>
<div className="p-4 rounded-lg bg-muted/50 border border-border/50">
<p className="text-xs text-muted-foreground uppercase tracking-wider mb-1">
Primary Agent
</p>
<p className="font-medium text-sm font-mono truncate" title={primaryAgent?.id}>
{primaryAgent?.id ?? 'None'}
</p>
</div>
</div>
</div>
</div>
</div>
{/* Bottom: Actions */}
<div className="border-t p-4">
<div className="flex items-center justify-between">
{/* Primary Action: Chat */}
<Button
size="lg"
className="gap-2 px-6"
onClick={() => navigate('/chat')}
disabled={!isConnected}
>
<HugeiconsIcon icon={Comment01Icon} className="size-5" />
Open Chat
</Button>
{/* Secondary: Connect to Remote */}
<Button
variant="ghost"
size="sm"
className="text-muted-foreground gap-1.5"
disabled
>
<HugeiconsIcon icon={LinkSquare01Icon} className="size-4" />
Connect to Remote Agent
<span className="text-xs opacity-60">(Coming soon)</span>
</Button>
</div>
</div>
</div>
)
}

View file

@ -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 (
<div className="h-dvh flex flex-col bg-background">
{/* Header */}
<header className="flex items-center justify-between px-4 py-3 border-b">
<div className="flex items-center gap-2">
<span className="text-lg font-semibold">Multica</span>
</div>
<Button variant="ghost" size="icon">
<HugeiconsIcon icon={Settings02Icon} className="size-5" />
</Button>
</header>
{/* Tabs */}
<nav className="flex gap-1 px-4 py-2 border-b">
{tabs.map((tab) => {
const isActive = tab.exact
? location.pathname === tab.path
: location.pathname.startsWith(tab.path)
return (
<NavLink key={tab.path} to={tab.path}>
<Button
variant={isActive ? 'secondary' : 'ghost'}
size="sm"
className={cn('gap-2', isActive && 'bg-secondary')}
>
<HugeiconsIcon icon={tab.icon} className="size-4" />
{tab.label}
</Button>
</NavLink>
)
})}
</nav>
{/* Content */}
<main className="flex-1 overflow-auto p-4">
<Outlet />
</main>
</div>
)
}

View file

@ -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 (
<div className="max-w-4xl mx-auto">
<Card>
<CardHeader>
<CardTitle>Skills</CardTitle>
<CardDescription>
Manage agent skills. Skills provide specialized capabilities like Git integration,
code review, and file manipulation. Toggle skills on/off to control agent behavior.
</CardDescription>
</CardHeader>
<CardContent>
<SkillList
skills={skills}
loading={loading}
error={error}
onToggleSkill={toggleSkill}
onRefresh={refresh}
/>
</CardContent>
</Card>
</div>
)
}

View file

@ -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 (
<div className="max-w-4xl mx-auto">
<Card>
<CardHeader>
<CardTitle>Tools</CardTitle>
<CardDescription>
Configure which tools are available to the Agent. Toggle individual tools on/off.
Changes apply immediately to the running Agent.
</CardDescription>
</CardHeader>
<CardContent>
<ToolList
tools={tools}
groups={groups}
loading={loading}
error={error}
onToggleTool={toggleTool}
onRefresh={refresh}
/>
</CardContent>
</Card>
</div>
)
}

View file

@ -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"],

View file

@ -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'),

View file

@ -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 <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 bg-background ring-foreground/10 rounded-xl p-6 ring-1 duration-100 fixed top-1/2 left-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 shadow-lg outline-none",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-4 right-4"
size="icon-sm"
/>
}
>
<HugeiconsIcon icon={Cancel01Icon} strokeWidth={2} />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-1.5", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg font-semibold leading-none", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
DialogOverlay,
DialogPortal,
}

94
pnpm-lock.yaml generated
View file

@ -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)

View file

@ -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();
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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<ToolErrorPayload> {
};
}
function wrapTool<TParams, TResult>(
function wrapTool<TParams extends TSchema, TResult>(
tool: AgentTool<TParams, TResult>,
): AgentTool<TParams, TResult> {
const execute = tool.execute;

View file

@ -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)