merge: resolve conflicts with origin/main
Merge remote main branch, keeping both device verification (ws-auth-handshake) and agent settings/profile features from main. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
commit
c3ded8da41
15 changed files with 560 additions and 256 deletions
|
|
@ -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 过期处理
|
||||
|
|
|
|||
11
apps/desktop/electron/electron-env.d.ts
vendored
11
apps/desktop/electron/electron-env.d.ts
vendored
|
|
@ -78,6 +78,12 @@ interface SkillAddResult {
|
|||
skills?: string[]
|
||||
}
|
||||
|
||||
interface ProfileData {
|
||||
profileId: string | undefined
|
||||
name: string | undefined
|
||||
userContent: string | undefined
|
||||
}
|
||||
|
||||
interface ElectronAPI {
|
||||
hub: {
|
||||
init: () => Promise<unknown>
|
||||
|
|
@ -116,6 +122,11 @@ interface ElectronAPI {
|
|||
agent: {
|
||||
status: () => Promise<unknown>
|
||||
}
|
||||
profile: {
|
||||
get: () => Promise<ProfileData>
|
||||
updateName: (name: string) => Promise<unknown>
|
||||
updateUser: (content: string) => Promise<unknown>
|
||||
}
|
||||
}
|
||||
|
||||
// Used in Renderer process, expose in `preload.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,
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<void> {
|
|||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
91
apps/desktop/electron/ipc/profile.ts
Normal file
91
apps/desktop/electron/ipc/profile.ts
Normal file
|
|
@ -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<ProfileData> => {
|
||||
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 }
|
||||
})
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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<ProfileData> => ipcRenderer.invoke('profile:get'),
|
||||
updateName: (name: string) => ipcRenderer.invoke('profile:updateName', name),
|
||||
updateUser: (content: string) => ipcRenderer.invoke('profile:updateUser', content),
|
||||
},
|
||||
}
|
||||
|
||||
// Expose to renderer
|
||||
|
|
|
|||
130
apps/desktop/src/components/agent-settings-dialog.tsx
Normal file
130
apps/desktop/src/components/agent-settings-dialog.tsx
Normal file
|
|
@ -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<string | undefined>()
|
||||
|
||||
// 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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Agent</DialogTitle>
|
||||
<DialogDescription>
|
||||
Customize your agent's name and personal settings.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<HugeiconsIcon icon={Loading03Icon} className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Profile ID (read-only) */}
|
||||
{profileId && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Profile: {profileId}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-name">Name</Label>
|
||||
<Input
|
||||
id="agent-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="My Assistant"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* User Content */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="user-content">About You</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Help the agent understand you better. Share your preferences, role, or any context.
|
||||
</p>
|
||||
<Textarea
|
||||
id="user-content"
|
||||
value={userContent}
|
||||
onChange={(e) => setUserContent(e.target.value)}
|
||||
placeholder="- I'm a frontend developer - I prefer TypeScript - Please respond in Chinese"
|
||||
className="min-h-[160px] font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={loading || saving}>
|
||||
{saving && <HugeiconsIcon icon={Loading03Icon} className="size-4 animate-spin mr-2" />}
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default AgentSettingsDialog
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Button } from '@multica/ui/components/ui/button'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
|
|
@ -6,14 +7,39 @@ import {
|
|||
LinkSquare01Icon,
|
||||
Loading03Icon,
|
||||
AlertCircleIcon,
|
||||
Edit02Icon,
|
||||
} from '@hugeicons/core-free-icons'
|
||||
import { ConnectionQRCode } from '../components/qr-code'
|
||||
import { DeviceList } from '../components/device-list'
|
||||
import { AgentSettingsDialog } from '../components/agent-settings-dialog'
|
||||
import { useHub } from '../hooks/use-hub'
|
||||
|
||||
export default function HomePage() {
|
||||
const navigate = useNavigate()
|
||||
const { hubInfo, agents, loading, error } = useHub()
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [agentName, setAgentName] = useState<string | undefined>()
|
||||
|
||||
// Load agent profile info
|
||||
useEffect(() => {
|
||||
loadAgentInfo()
|
||||
}, [])
|
||||
|
||||
// Reload agent info when settings dialog closes
|
||||
useEffect(() => {
|
||||
if (!settingsOpen) {
|
||||
loadAgentInfo()
|
||||
}
|
||||
}, [settingsOpen])
|
||||
|
||||
const loadAgentInfo = async () => {
|
||||
try {
|
||||
const data = await window.electronAPI.profile.get()
|
||||
setAgentName(data.name)
|
||||
} catch (err) {
|
||||
console.error('Failed to load agent info:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Get the first agent (or create one if none exists)
|
||||
const primaryAgent = agents[0]
|
||||
|
|
@ -108,6 +134,23 @@ export default function HomePage() {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{/* Agent Settings */}
|
||||
<div className="p-4 rounded-lg bg-muted/50 border border-border/50">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wider">
|
||||
Agent Settings
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
>
|
||||
<HugeiconsIcon icon={Edit02Icon} className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="font-medium">{agentName || 'Unnamed Agent'}</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">
|
||||
|
|
@ -148,6 +191,9 @@ export default function HomePage() {
|
|||
<DeviceList />
|
||||
</div>
|
||||
|
||||
{/* Agent Settings Dialog */}
|
||||
<AgentSettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
|
||||
|
||||
{/* Bottom: Actions */}
|
||||
<div className="border-t p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
|
|||
|
|
@ -148,4 +148,40 @@ export class AsyncAgent {
|
|||
getProfileId(): string | undefined {
|
||||
return this.agent.getProfileId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agent display name from profile config.
|
||||
*/
|
||||
getAgentName(): string | undefined {
|
||||
return this.agent.getAgentName();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update agent display name in profile config.
|
||||
*/
|
||||
setAgentName(name: string): void {
|
||||
this.agent.setAgentName(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user.md content from profile.
|
||||
*/
|
||||
getUserContent(): string | undefined {
|
||||
return this.agent.getUserContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user.md content in profile.
|
||||
*/
|
||||
setUserContent(content: string): void {
|
||||
this.agent.setUserContent(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload profile from disk and rebuild system prompt.
|
||||
* Call this after updating profile files to apply changes immediately.
|
||||
*/
|
||||
reloadSystemPrompt(): void {
|
||||
this.agent.reloadSystemPrompt();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,10 @@ import {
|
|||
loadProfile,
|
||||
profileExists,
|
||||
saveProfile,
|
||||
writeProfileConfig,
|
||||
writeProfileFile,
|
||||
} from "./storage.js";
|
||||
import { PROFILE_FILES } from "./types.js";
|
||||
|
||||
export { type AgentProfile, type CreateProfileOptions, type ProfileConfig, type ProfileManagerOptions } from "./types.js";
|
||||
export { DEFAULT_TEMPLATES } from "./templates.js";
|
||||
|
|
@ -110,6 +113,12 @@ export class ProfileManager {
|
|||
return this.profile;
|
||||
}
|
||||
|
||||
/** 重新从磁盘加载 profile(清除缓存) */
|
||||
reloadProfile(): AgentProfile | undefined {
|
||||
this.profile = loadAgentProfile(this.profileId, { baseDir: this.baseDir });
|
||||
return this.profile;
|
||||
}
|
||||
|
||||
/** 获取或创建 profile */
|
||||
getOrCreateProfile(useTemplates = true): AgentProfile {
|
||||
if (!this.profile) {
|
||||
|
|
@ -129,6 +138,7 @@ export class ProfileManager {
|
|||
/** 构建 system prompt */
|
||||
buildSystemPrompt(): string {
|
||||
const profile = this.getProfile();
|
||||
console.log('[ProfileManager] buildSystemPrompt called, profile exists:', !!profile);
|
||||
if (!profile) {
|
||||
return "";
|
||||
}
|
||||
|
|
@ -136,11 +146,15 @@ export class ProfileManager {
|
|||
const parts: string[] = [];
|
||||
|
||||
if (profile.soul) {
|
||||
console.log('[ProfileManager] Adding soul, length:', profile.soul.length);
|
||||
parts.push(profile.soul);
|
||||
}
|
||||
|
||||
if (profile.user) {
|
||||
console.log('[ProfileManager] Adding user, content:', profile.user.substring(0, 100));
|
||||
parts.push(profile.user);
|
||||
} else {
|
||||
console.log('[ProfileManager] No user content in profile');
|
||||
}
|
||||
|
||||
if (profile.workspace) {
|
||||
|
|
@ -215,4 +229,72 @@ export class ProfileManager {
|
|||
this.updateToolsConfig(newConfig);
|
||||
return newConfig;
|
||||
}
|
||||
|
||||
/** 获取 Agent 名称 */
|
||||
getName(): string | undefined {
|
||||
const profile = this.getProfile();
|
||||
return profile?.config?.name;
|
||||
}
|
||||
|
||||
/** 更新 Agent 名称 */
|
||||
updateName(name: string): void {
|
||||
const profile = this.getOrCreateProfile(false);
|
||||
const currentConfig = profile.config ?? {};
|
||||
const newConfig: ProfileConfig = {
|
||||
...currentConfig,
|
||||
name,
|
||||
};
|
||||
profile.config = newConfig;
|
||||
this.profile = profile;
|
||||
writeProfileConfig(this.profileId, newConfig, { baseDir: this.baseDir });
|
||||
|
||||
// Also update soul.md to include the agent name
|
||||
this.updateSoulWithName(name);
|
||||
}
|
||||
|
||||
/** 更新 soul.md,确保包含 Agent 名称 */
|
||||
private updateSoulWithName(name: string): void {
|
||||
const profile = this.getOrCreateProfile(true); // 确保有默认模板
|
||||
let soulContent = profile.soul ?? DEFAULT_TEMPLATES.soul;
|
||||
|
||||
// 替换 soul.md 中的 Name 字段
|
||||
// 匹配 "- **Name:** xxx" 格式
|
||||
const namePattern = /- \*\*Name:\*\* .*/;
|
||||
const newNameLine = `- **Name:** ${name}`;
|
||||
|
||||
if (namePattern.test(soulContent)) {
|
||||
soulContent = soulContent.replace(namePattern, newNameLine);
|
||||
} else {
|
||||
// 如果没有找到 Name 字段,在 Identity 部分后添加
|
||||
const identityPattern = /## Identity\n/;
|
||||
if (identityPattern.test(soulContent)) {
|
||||
soulContent = soulContent.replace(identityPattern, `## Identity\n\n${newNameLine}\n`);
|
||||
} else {
|
||||
// 如果没有 Identity 部分,在开头添加
|
||||
soulContent = `# Soul\n\n## Identity\n\n${newNameLine}\n\n${soulContent}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 保存更新后的 soul.md
|
||||
writeProfileFile(this.profileId, PROFILE_FILES.soul, soulContent, { baseDir: this.baseDir });
|
||||
// 更新缓存
|
||||
if (this.profile) {
|
||||
this.profile.soul = soulContent;
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取 user.md 内容 */
|
||||
getUserContent(): string | undefined {
|
||||
const profile = this.getProfile();
|
||||
return profile?.user;
|
||||
}
|
||||
|
||||
/** 更新 user.md 内容 */
|
||||
updateUserContent(content: string): void {
|
||||
writeProfileFile(this.profileId, PROFILE_FILES.user, content, { baseDir: this.baseDir });
|
||||
// Update cached profile
|
||||
if (this.profile) {
|
||||
this.profile.user = content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
*/
|
||||
|
||||
export const DEFAULT_TEMPLATES = {
|
||||
soul: `# Soul
|
||||
soul: `# Soul
|
||||
|
||||
_You're not a chatbot. You're becoming someone._
|
||||
|
||||
|
|
@ -40,7 +40,7 @@ If you change this file, tell the user — it's your soul, and they should know.
|
|||
_This file is yours to evolve. As you learn who you are, update it._
|
||||
`,
|
||||
|
||||
user: `# User
|
||||
user: `# User
|
||||
|
||||
_Learn about the person you're helping. Update this as you go._
|
||||
|
||||
|
|
@ -58,7 +58,7 @@ _(What do they care about? What projects are they working on? What annoys them?
|
|||
The more you know, the better you can help. But remember — you're learning about a person, not building a dossier. Respect the difference.
|
||||
`,
|
||||
|
||||
workspace: `# Workspace
|
||||
workspace: `# Workspace
|
||||
|
||||
This folder is home. Treat it that way.
|
||||
|
||||
|
|
@ -127,7 +127,7 @@ Capture what matters. Decisions, context, things to remember.
|
|||
This is a starting point. Add your own conventions, style, and rules as you figure out what works.
|
||||
`,
|
||||
|
||||
memory: `# Memory
|
||||
memory: `# Memory
|
||||
|
||||
_(Persistent knowledge will be stored here. Update this as you learn.)_
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ export const PROFILE_FILES = {
|
|||
|
||||
/** Profile config.json structure */
|
||||
export interface ProfileConfig {
|
||||
/** Agent display name */
|
||||
name?: string;
|
||||
/** Tools policy configuration */
|
||||
tools?: ToolsConfig;
|
||||
/** Default LLM provider */
|
||||
|
|
|
|||
|
|
@ -446,12 +446,15 @@ export class Agent {
|
|||
reloadTools(): string[] {
|
||||
// Re-read profile tools config to get latest changes
|
||||
const profileToolsConfig = this.profile?.getToolsConfig();
|
||||
console.log(`[Agent] reloadTools: profileToolsConfig =`, JSON.stringify(profileToolsConfig));
|
||||
const mergedToolsConfig = mergeToolsConfig(profileToolsConfig, this.originalToolsConfig);
|
||||
console.log(`[Agent] reloadTools: mergedToolsConfig =`, JSON.stringify(mergedToolsConfig));
|
||||
this.toolsOptions = mergedToolsConfig
|
||||
? { ...this.toolsOptions, tools: mergedToolsConfig }
|
||||
: this.toolsOptions;
|
||||
|
||||
const tools = resolveTools(this.toolsOptions);
|
||||
console.log(`[Agent] reloadTools: resolved ${tools.length} tools: ${tools.map(t => t.name).join(", ") || "(none)"}`);
|
||||
this.agent.setTools(tools);
|
||||
if (this.debug) {
|
||||
console.error(`[debug] Reloaded ${tools.length} tools: ${tools.map(t => t.name).join(", ") || "(none)"}`);
|
||||
|
|
@ -532,4 +535,65 @@ export class Agent {
|
|||
getProfileId(): string | undefined {
|
||||
return this.profile?.getProfile()?.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agent display name from profile config.
|
||||
*/
|
||||
getAgentName(): string | undefined {
|
||||
return this.profile?.getName();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update agent display name in profile config.
|
||||
*/
|
||||
setAgentName(name: string): void {
|
||||
this.profile?.updateName(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user.md content from profile.
|
||||
*/
|
||||
getUserContent(): string | undefined {
|
||||
return this.profile?.getUserContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user.md content in profile.
|
||||
*/
|
||||
setUserContent(content: string): void {
|
||||
this.profile?.updateUserContent(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload profile from disk and rebuild system prompt.
|
||||
* Call this after updating profile files to apply changes immediately.
|
||||
*/
|
||||
reloadSystemPrompt(): void {
|
||||
if (!this.profile) {
|
||||
console.log('[Agent] reloadSystemPrompt: no profile');
|
||||
return;
|
||||
}
|
||||
|
||||
// Reload profile from disk
|
||||
console.log('[Agent] Reloading profile from disk...');
|
||||
this.profile.reloadProfile();
|
||||
|
||||
// Rebuild system prompt
|
||||
let systemPrompt = this.profile.buildSystemPrompt();
|
||||
console.log('[Agent] Built system prompt, length:', systemPrompt?.length);
|
||||
|
||||
// Re-add skills prompt if skills are enabled
|
||||
if (this.skillManager) {
|
||||
const skillsPrompt = this.skillManager.buildModelSkillsPrompt();
|
||||
if (skillsPrompt) {
|
||||
systemPrompt = systemPrompt ? `${systemPrompt}\n\n${skillsPrompt}` : skillsPrompt;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply new system prompt
|
||||
if (systemPrompt) {
|
||||
console.log('[Agent] Applying system prompt, first 200 chars:', systemPrompt.substring(0, 200));
|
||||
this.agent.setSystemPrompt(systemPrompt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue