diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 4654ea1b..01cbfa36 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -466,255 +466,26 @@ pnpm --filter @multica/desktop add -D @types/qrcode.react --- -## 八、实现步骤计划 +## 八、待办事项 -### Phase 1: 统一布局与路由重构 +### 功能开发 -**目标**: 统一页面结构,移除 /admin 子路由 +#### 设备授权与白名单 -#### Step 1.1: 路由重构 +- [ ] Client 连接授权弹窗 +- [ ] 已授权设备白名单管理 +- [ ] 显示当前连接的 Client 数量 -- [x] 重构 `App.tsx` 路由 - - 移除 `/admin` 子路由 - - 统一页面结构: / (Home) / /chat / /tools / /skills -- [x] 创建 `pages/layout.tsx` - 全局布局 - - Header: Logo + 标题 + Settings 按钮 - - Tabs: Home / Chat / Tools / Skills - - Content Area: 子路由出口 -- [x] 移动页面文件到根级别 +#### Settings 页面 -#### Step 1.2: Home 页面 (三入口) +- [ ] Gateway URL 配置 +- [ ] Theme 切换 (Light / Dark / System) +- [ ] 打开 credentials.json5 按钮 -- [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 +- [ ] 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 | 联调测试 | ⏳ 待开始 | +- [ ] 断开重连、Token 过期处理 diff --git a/apps/desktop/electron/electron-env.d.ts b/apps/desktop/electron/electron-env.d.ts index 5f644318..4426985c 100644 --- a/apps/desktop/electron/electron-env.d.ts +++ b/apps/desktop/electron/electron-env.d.ts @@ -78,6 +78,12 @@ interface SkillAddResult { skills?: string[] } +interface ProfileData { + profileId: string | undefined + name: string | undefined + userContent: string | undefined +} + interface ElectronAPI { hub: { init: () => Promise @@ -116,6 +122,11 @@ interface ElectronAPI { agent: { status: () => Promise } + profile: { + get: () => Promise + updateName: (name: string) => Promise + updateUser: (content: string) => Promise + } } // Used in Renderer process, expose in `preload.ts` diff --git a/apps/desktop/electron/ipc/agent.ts b/apps/desktop/electron/ipc/agent.ts index 73ca65b5..a358b88a 100644 --- a/apps/desktop/electron/ipc/agent.ts +++ b/apps/desktop/electron/ipc/agent.ts @@ -125,8 +125,6 @@ export function registerAgentIpcHandlers(): void { * 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' } @@ -145,7 +143,6 @@ export function registerAgentIpcHandlers(): void { // Get updated status const newActiveTools = agent.getActiveTools() const isNowEnabled = newActiveTools.includes(toolName) - console.log(`[IPC] Tool ${toolName} toggled: ${isCurrentlyEnabled} -> ${isNowEnabled}`) return { name: toolName, diff --git a/apps/desktop/electron/ipc/hub.ts b/apps/desktop/electron/ipc/hub.ts index 910e8a50..24208586 100644 --- a/apps/desktop/electron/ipc/hub.ts +++ b/apps/desktop/electron/ipc/hub.ts @@ -13,31 +13,43 @@ import type { AsyncAgent } from '../../../../src/agent/async-agent.js' let hub: Hub | null = null let defaultAgentId: string | null = null +/** + * Safe log function that catches EPIPE errors. + * Electron main process stdout can be closed unexpectedly. + */ +function safeLog(...args: unknown[]): void { + try { + console.log(...args) + } catch { + // Ignore EPIPE errors when stdout is closed + } +} + /** * Initialize Hub on app startup. * Creates Hub and a default Agent automatically. */ export async function initializeHub(): Promise { if (hub) { - console.log('[Desktop] Hub already initialized') + safeLog('[Desktop] Hub already initialized') return } const gatewayUrl = process.env['GATEWAY_URL'] ?? 'http://localhost:3000' - console.log(`[Desktop] Initializing Hub, connecting to Gateway: ${gatewayUrl}`) + safeLog(`[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...') + safeLog('[Desktop] Creating default agent...') const agent = hub.createAgent() defaultAgentId = agent.sessionId - console.log(`[Desktop] Default agent created: ${defaultAgentId}`) + safeLog(`[Desktop] Default agent created: ${defaultAgentId}`) } else { defaultAgentId = agents[0] - console.log(`[Desktop] Using existing agent: ${defaultAgentId}`) + safeLog(`[Desktop] Using existing agent: ${defaultAgentId}`) } } @@ -47,7 +59,7 @@ export async function initializeHub(): Promise { function getHub(): Hub { if (!hub) { const gatewayUrl = process.env['GATEWAY_URL'] ?? 'http://localhost:3000' - console.log(`[Desktop] Creating Hub, connecting to Gateway: ${gatewayUrl}`) + safeLog(`[Desktop] Creating Hub, connecting to Gateway: ${gatewayUrl}`) hub = new Hub(gatewayUrl) } return hub @@ -289,7 +301,7 @@ export function setupDeviceConfirmation(mainWindow: Electron.BrowserWindow): voi */ export function cleanupHub(): void { if (hub) { - console.log('[Desktop] Shutting down Hub') + safeLog('[Desktop] Shutting down Hub') hub.shutdown() hub = null } diff --git a/apps/desktop/electron/ipc/index.ts b/apps/desktop/electron/ipc/index.ts index 13ad6fe4..e1ac16d2 100644 --- a/apps/desktop/electron/ipc/index.ts +++ b/apps/desktop/electron/ipc/index.ts @@ -4,10 +4,12 @@ export { registerAgentIpcHandlers, cleanupAgent } from './agent.js' export { registerSkillsIpcHandlers } from './skills.js' export { registerHubIpcHandlers, cleanupHub, initializeHub, setupDeviceConfirmation } from './hub.js' +export { registerProfileIpcHandlers } from './profile.js' import { registerAgentIpcHandlers, cleanupAgent } from './agent.js' import { registerSkillsIpcHandlers } from './skills.js' import { registerHubIpcHandlers, cleanupHub, initializeHub, setupDeviceConfirmation } from './hub.js' +import { registerProfileIpcHandlers } from './profile.js' /** * Register all IPC handlers. @@ -17,6 +19,7 @@ export function registerAllIpcHandlers(): void { registerHubIpcHandlers() registerAgentIpcHandlers() registerSkillsIpcHandlers() + registerProfileIpcHandlers() } /** diff --git a/apps/desktop/electron/ipc/profile.ts b/apps/desktop/electron/ipc/profile.ts new file mode 100644 index 00000000..ed30130f --- /dev/null +++ b/apps/desktop/electron/ipc/profile.ts @@ -0,0 +1,91 @@ +/** + * Profile IPC handlers for Electron main process. + * + * Manages agent profile settings like name and user.md content. + */ +import { ipcMain } from 'electron' +import { getCurrentHub } from './hub.js' + +/** + * 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 +} + +/** + * Profile data returned to renderer. + */ +export interface ProfileData { + profileId: string | undefined + name: string | undefined + userContent: string | undefined +} + +/** + * Register all Profile-related IPC handlers. + */ +export function registerProfileIpcHandlers(): void { + /** + * Get profile data (name + user content). + */ + ipcMain.handle('profile:get', async (): Promise => { + const agent = getDefaultAgent() + if (!agent) { + return { + profileId: undefined, + name: undefined, + userContent: undefined, + } + } + + return { + profileId: agent.getProfileId(), + name: agent.getAgentName(), + userContent: agent.getUserContent(), + } + }) + + /** + * Update agent display name. + */ + ipcMain.handle('profile:updateName', async (_event, name: string) => { + const agent = getDefaultAgent() + if (!agent) { + return { error: 'No agent available' } + } + + agent.setAgentName(name) + return { ok: true, name } + }) + + /** + * Update user.md content. + */ + ipcMain.handle('profile:updateUser', async (_event, content: string) => { + const agent = getDefaultAgent() + if (!agent) { + console.error('[Profile IPC] No agent available for updateUser') + return { error: 'No agent available' } + } + + console.log('[Profile IPC] Updating user content:', content.substring(0, 50) + '...') + agent.setUserContent(content) + + // Reload system prompt to apply changes immediately + console.log('[Profile IPC] Reloading system prompt...') + agent.reloadSystemPrompt() + + // Verify the change + const newUserContent = agent.getUserContent() + console.log('[Profile IPC] New user content:', newUserContent?.substring(0, 50) + '...') + + return { ok: true } + }) +} diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 193ca84d..bdf7168c 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -1,3 +1,49 @@ +// Patch console methods to handle EPIPE errors in Electron main process +// This MUST be done before any other imports that might use console +// EPIPE happens when stdout/stderr pipes are closed unexpectedly +const originalConsoleLog = console.log.bind(console) +const originalConsoleError = console.error.bind(console) +const originalConsoleWarn = console.warn.bind(console) + +const safeLog = (...args: unknown[]) => { + try { + originalConsoleLog(...args) + } catch { + // Ignore EPIPE errors silently + } +} + +const safeError = (...args: unknown[]) => { + try { + originalConsoleError(...args) + } catch { + // Ignore EPIPE errors silently + } +} + +const safeWarn = (...args: unknown[]) => { + try { + originalConsoleWarn(...args) + } catch { + // Ignore EPIPE errors silently + } +} + +// Override global console +console.log = safeLog +console.error = safeError +console.warn = safeWarn + +// Also handle process stdout/stderr EPIPE errors +process.stdout?.on?.('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EPIPE') return // Ignore + throw err +}) +process.stderr?.on?.('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EPIPE') return // Ignore + throw err +}) + import { app, BrowserWindow } from 'electron' import { fileURLToPath } from 'node:url' import path from 'node:path' diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 6eb74ebc..c9070f01 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -37,6 +37,12 @@ export interface SkillInfo { triggers: string[] } +export interface ProfileData { + profileId: string | undefined + name: string | undefined + userContent: string | undefined +} + // ============================================================================ // Expose typed API to Renderer process // ============================================================================ @@ -97,6 +103,13 @@ const electronAPI = { agent: { status: () => ipcRenderer.invoke('agent:status'), }, + + // Profile management + profile: { + get: (): Promise => ipcRenderer.invoke('profile:get'), + updateName: (name: string) => ipcRenderer.invoke('profile:updateName', name), + updateUser: (content: string) => ipcRenderer.invoke('profile:updateUser', content), + }, } // Expose to renderer diff --git a/apps/desktop/src/components/agent-settings-dialog.tsx b/apps/desktop/src/components/agent-settings-dialog.tsx new file mode 100644 index 00000000..3f44d548 --- /dev/null +++ b/apps/desktop/src/components/agent-settings-dialog.tsx @@ -0,0 +1,130 @@ +import { useState, useEffect } from 'react' +import { + Dialog, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} from '@multica/ui/components/ui/dialog' +import { Button } from '@multica/ui/components/ui/button' +import { Input } from '@multica/ui/components/ui/input' +import { Textarea } from '@multica/ui/components/ui/textarea' +import { Label } from '@multica/ui/components/ui/label' +import { HugeiconsIcon } from '@hugeicons/react' +import { Loading03Icon } from '@hugeicons/core-free-icons' + +interface AgentSettingsDialogProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +export function AgentSettingsDialog({ open, onOpenChange }: AgentSettingsDialogProps) { + const [loading, setLoading] = useState(false) + const [saving, setSaving] = useState(false) + const [name, setName] = useState('') + const [userContent, setUserContent] = useState('') + const [profileId, setProfileId] = useState() + + // Load profile data when dialog opens + useEffect(() => { + if (open) { + loadProfile() + } + }, [open]) + + const loadProfile = async () => { + setLoading(true) + try { + const data = await window.electronAPI.profile.get() + setProfileId(data.profileId) + setName(data.name ?? '') + setUserContent(data.userContent ?? '') + } catch (err) { + console.error('Failed to load profile:', err) + } finally { + setLoading(false) + } + } + + const handleSave = async () => { + setSaving(true) + try { + // Update name if changed + await window.electronAPI.profile.updateName(name) + // Update user content + await window.electronAPI.profile.updateUser(userContent) + onOpenChange(false) + } catch (err) { + console.error('Failed to save profile:', err) + } finally { + setSaving(false) + } + } + + return ( + + + + Edit Agent + + Customize your agent's name and personal settings. + + + + {loading ? ( +
+ +
+ ) : ( +
+ {/* Profile ID (read-only) */} + {profileId && ( +
+ Profile: {profileId} +
+ )} + + {/* Name */} +
+ + setName(e.target.value)} + placeholder="My Assistant" + /> +
+ + {/* User Content */} +
+ +

+ Help the agent understand you better. Share your preferences, role, or any context. +

+