Merge branch 'main' into feat/connection-code
This commit is contained in:
commit
bb223d8a8c
29 changed files with 3853 additions and 75 deletions
|
|
@ -1,22 +1,720 @@
|
|||
# @multica/desktop
|
||||
|
||||
Electron desktop app. Vite + React + `createHashRouter`.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
multica dev desktop
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
pnpm --filter @multica/desktop build
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Routing**: `react-router-dom` v7 with `createHashRouter` (Electron loads via `file://`, BrowserRouter won't work). Pages go in `src/pages/`.
|
||||
- **UI**: All components from `@multica/ui`. No local UI components.
|
||||
- **State**: Store hooks from `@multica/store`.
|
||||
- **Styles**: Tailwind CSS v4 via `@multica/ui/globals.css`, imported in `src/main.tsx`.
|
||||
# Multica Desktop App 设计文档
|
||||
|
||||
## 产品定位
|
||||
|
||||
Multica Desktop 是一个统一的桌面应用,具有双重身份:
|
||||
|
||||
1. **Host 模式**: 本机运行 Hub + Agent,可供其他设备连接
|
||||
2. **Client 模式**: 连接到其他 Hub 的 Agent 进行对话
|
||||
|
||||
用户安装同一个 App,既可以作为 Agent 的宿主(让其他设备扫码连接),也可以扫码连接到别人的 Agent。
|
||||
|
||||
### 架构图
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Multica Desktop App │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ React UI (Renderer) │ │
|
||||
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │
|
||||
│ │ │ Home │ │ Chat │ │ Tools │ │ Skills │ │Settings │ │ │
|
||||
│ │ └─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │ │
|
||||
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────────────┴───────────────┐ │
|
||||
│ │ │ │
|
||||
│ 直接调用 (本地) WebSocket (远程) │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ ┌─────────────────────────────┐ ┌─────────────────────────────┐ │
|
||||
│ │ Local Hub + Agent │ │ Remote Hub (via Gateway) │ │
|
||||
│ │ (进程内) │ │ (另一台设备) │ │
|
||||
│ └─────────────────────────────┘ └─────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ WebSocket
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ Gateway │
|
||||
│ (公网 WebSocket) │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
**关键点**:
|
||||
|
||||
- **统一应用**: 不区分 Admin App 和 Client App,一个 App 两种用法
|
||||
- **Chat 双模式**: Chat 页面可以选择与本地 Agent 对话,或连接远程 Agent 对话
|
||||
- **本地 Agent**: Hub + Agent 跑在 Electron 主进程内,UI 通过 IPC 调用访问
|
||||
- **远程连接**: 通过 Gateway WebSocket 连接到其他设备的 Hub
|
||||
|
||||
**约束**: 第一阶段 1 Client - 1 Hub - 1 Agent Session
|
||||
|
||||
---
|
||||
|
||||
## 技术实现设计
|
||||
|
||||
### 技术栈
|
||||
|
||||
| 层级 | 技术 | 说明 |
|
||||
| ------ | ------------------------ | -------------- |
|
||||
| 框架 | Electron 30 | 桌面应用 |
|
||||
| 前端 | React 19 + Vite | 渲染进程 |
|
||||
| 路由 | react-router-dom v7 | HashRouter |
|
||||
| 状态 | @multica/store (Zustand) | 复用现有 store |
|
||||
| UI | @multica/ui (Shadcn) | 复用现有组件 |
|
||||
| 二维码 | qrcode.react | 生成二维码 |
|
||||
| 通信 | @multica/sdk | Gateway 连接 |
|
||||
|
||||
### 文件结构规划
|
||||
|
||||
```
|
||||
apps/desktop/
|
||||
├── electron/
|
||||
│ ├── main.ts # 主进程 (Hub + Agent)
|
||||
│ └── preload.ts # 预加载脚本 (如需 IPC)
|
||||
├── src/
|
||||
│ ├── main.tsx # React 入口
|
||||
│ ├── App.tsx # 路由配置
|
||||
│ ├── pages/
|
||||
│ │ ├── home.tsx # Home 入口页 (三个选项)
|
||||
│ │ ├── chat.tsx # Chat 页面 (Local/Remote 双模式)
|
||||
│ │ ├── tools.tsx # Tools 管理页
|
||||
│ │ ├── skills.tsx # Skills 管理页
|
||||
│ │ └── layout.tsx # 全局布局 (Header + Tabs)
|
||||
│ ├── components/
|
||||
│ │ ├── qr-code.tsx # 二维码组件
|
||||
│ │ ├── qr-scanner.tsx # 扫码组件
|
||||
│ │ ├── connection-status.tsx # 连接状态
|
||||
│ │ ├── tool-list.tsx # Tools 列表
|
||||
│ │ └── skill-list.tsx # Skills 列表
|
||||
│ └── hooks/
|
||||
│ ├── use-local-agent.ts # 本地 Agent 管理
|
||||
│ ├── use-remote-agent.ts # 远程 Agent 连接
|
||||
│ └── use-connection.ts # 连接状态管理
|
||||
└── package.json
|
||||
```
|
||||
|
||||
### 核心实现点
|
||||
|
||||
#### 1. 二维码生成与连接
|
||||
|
||||
二维码内容格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "multica-connect",
|
||||
"gateway": "wss://gateway.multica.ai",
|
||||
"hubId": "019c1d32-xxxx",
|
||||
"agentId": "019c1d32-yyyy",
|
||||
"token": "random-uuid-token",
|
||||
"expires": 1234567890
|
||||
}
|
||||
```
|
||||
|
||||
连接流程:
|
||||
|
||||
```
|
||||
1. Admin 启动 → Hub 连接公网 Gateway → 注册为 deviceType: "hub"
|
||||
2. Admin 创建 Agent → 生成 token → 编码到二维码 (含 hubId + agentId + token)
|
||||
3. Client 扫码 → 解析二维码 → 连接同一 Gateway
|
||||
4. Client 发送 "connect-request" 到 hubId (带 token)
|
||||
5. Admin 验证 token 有效且未过期 → 建立配对关系
|
||||
6. Client 后续消息发到 hubId,payload 带 agentId
|
||||
7. Hub 路由消息到对应 Agent
|
||||
```
|
||||
|
||||
#### 2. Tools 管理
|
||||
|
||||
**现有 CLI 命令** (已实现):
|
||||
|
||||
```bash
|
||||
multica tools list # 列出所有 tools
|
||||
multica tools list --profile coding # 按 profile 过滤
|
||||
multica tools groups # 显示 tool groups
|
||||
multica tools profiles # 显示预设 profiles
|
||||
```
|
||||
|
||||
**Admin App 实现方式** - 通过 IPC 调用 Main Process:
|
||||
|
||||
```typescript
|
||||
// Renderer 进程 (React Hook)
|
||||
const tools = await window.electronAPI.tools.list();
|
||||
const groups = await window.electronAPI.tools.getGroups();
|
||||
const profiles = await window.electronAPI.tools.getProfiles();
|
||||
await window.electronAPI.tools.setStatus('exec', false);
|
||||
|
||||
// Main 进程 (IPC Handler)
|
||||
ipcMain.handle('tools:list', async () => {
|
||||
const allTools = createAllTools(process.cwd());
|
||||
return allTools.map((t) => ({
|
||||
name: t.name,
|
||||
group: TOOL_GROUPS[t.name],
|
||||
enabled: true,
|
||||
}));
|
||||
});
|
||||
```
|
||||
|
||||
**注意**: Renderer 进程运行在沙盒中,不能直接访问 Node.js API,必须通过 IPC 调用 Main Process。
|
||||
|
||||
#### 3. Skills 管理
|
||||
|
||||
**现有 CLI 命令** (已实现):
|
||||
|
||||
```bash
|
||||
multica skills list # 列出所有 skills
|
||||
multica skills status # 显示状态摘要
|
||||
multica skills status <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 | 联调测试 | ⏳ 待开始 |
|
||||
|
|
|
|||
79
apps/desktop/electron/electron-env.d.ts
vendored
79
apps/desktop/electron/electron-env.d.ts
vendored
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
220
apps/desktop/electron/ipc/agent.ts
Normal file
220
apps/desktop/electron/ipc/agent.ts
Normal 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
|
||||
}
|
||||
242
apps/desktop/electron/ipc/hub.ts
Normal file
242
apps/desktop/electron/ipc/hub.ts
Normal 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
|
||||
}
|
||||
39
apps/desktop/electron/ipc/index.ts
Normal file
39
apps/desktop/electron/ipc/index.ts
Normal 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()
|
||||
}
|
||||
278
apps/desktop/electron/ipc/skills.ts
Normal file
278
apps/desktop/electron/ipc/skills.ts
Normal 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
|
||||
})
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
228
apps/desktop/src/components/qr-code.tsx
Normal file
228
apps/desktop/src/components/qr-code.tsx
Normal 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
|
||||
225
apps/desktop/src/components/skill-list.tsx
Normal file
225
apps/desktop/src/components/skill-list.tsx
Normal 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
|
||||
208
apps/desktop/src/components/tool-list.tsx
Normal file
208
apps/desktop/src/components/tool-list.tsx
Normal 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
|
||||
210
apps/desktop/src/hooks/use-hub.ts
Normal file
210
apps/desktop/src/hooks/use-hub.ts
Normal 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
|
||||
264
apps/desktop/src/hooks/use-skills.ts
Normal file
264
apps/desktop/src/hooks/use-skills.ts
Normal 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
|
||||
232
apps/desktop/src/hooks/use-tools.ts
Normal file
232
apps/desktop/src/hooks/use-tools.ts
Normal 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
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
63
apps/desktop/src/pages/layout.tsx
Normal file
63
apps/desktop/src/pages/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
42
apps/desktop/src/pages/skills.tsx
Normal file
42
apps/desktop/src/pages/skills.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
44
apps/desktop/src/pages/tools.tsx
Normal file
44
apps/desktop/src/pages/tools.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
140
packages/ui/src/components/ui/dialog.tsx
Normal file
140
packages/ui/src/components/ui/dialog.tsx
Normal 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
94
pnpm-lock.yaml
generated
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue