feat(desktop): add React hooks for Hub, Tools, and Skills state

- Create use-hub.ts for Hub connection state and agent info
- Create use-tools.ts for tools list and toggle functionality
- Create use-skills.ts for skills list and management

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jiang Bohan
2026-02-03 18:25:24 +08:00
parent 2a4fcded03
commit 150fde80a9
3 changed files with 706 additions and 0 deletions

View File

@@ -0,0 +1,210 @@
import { useState, useEffect, useCallback } from 'react'
// ============================================================================
// Types matching the IPC response from main process
// ============================================================================
export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting'
export interface HubInfo {
hubId: string
url: string
connectionState: ConnectionState
agentCount: number
}
export interface AgentInfo {
id: string
closed: boolean
}
export interface UseHubReturn {
/** Hub information */
hubInfo: HubInfo | null
/** List of agents */
agents: AgentInfo[]
/** Loading state */
loading: boolean
/** Error state */
error: string | null
/** Initialize the Hub (called automatically on mount) */
initHub: () => Promise<void>
/** Refresh Hub info and agents list */
refresh: () => Promise<void>
/** Reconnect to a different Gateway URL */
reconnect: (url: string) => Promise<void>
/** Create a new agent */
createAgent: (id?: string) => Promise<AgentInfo | null>
/** Close an agent */
closeAgent: (id: string) => Promise<boolean>
/** Send a message to an agent */
sendMessage: (agentId: string, content: string) => Promise<boolean>
}
/**
* Hook for managing Hub connection and agents via IPC.
*
* This hook communicates with the Electron main process to:
* - Initialize and manage the Hub singleton
* - Create, list, and close agents
* - Send messages to agents
*/
export function useHub(): UseHubReturn {
const [hubInfo, setHubInfo] = useState<HubInfo | null>(null)
const [agents, setAgents] = useState<AgentInfo[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Initialize Hub and fetch info
const initHub = useCallback(async () => {
try {
setLoading(true)
setError(null)
// Initialize Hub (use new electronAPI if available)
if (window.electronAPI) {
await window.electronAPI.hub.init()
const info = await window.electronAPI.hub.info()
setHubInfo(info as HubInfo)
const agentList = await window.electronAPI.hub.listAgents()
setAgents(agentList as AgentInfo[])
} else {
await window.ipcRenderer.invoke('hub:init')
const info = await window.ipcRenderer.invoke('hub:info')
setHubInfo(info)
const agentList = await window.ipcRenderer.invoke('hub:listAgents')
setAgents(agentList)
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to initialize Hub')
} finally {
setLoading(false)
}
}, [])
// Initial load
useEffect(() => {
initHub()
}, [initHub])
// Refresh Hub info and agents
const refresh = useCallback(async () => {
try {
setError(null)
if (window.electronAPI) {
const info = await window.electronAPI.hub.info()
setHubInfo(info as HubInfo)
const agentList = await window.electronAPI.hub.listAgents()
setAgents(agentList as AgentInfo[])
} else {
const info = await window.ipcRenderer.invoke('hub:info')
setHubInfo(info)
const agentList = await window.ipcRenderer.invoke('hub:listAgents')
setAgents(agentList)
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to refresh Hub info')
}
}, [])
// Reconnect to different Gateway
const reconnect = useCallback(async (url: string) => {
try {
setError(null)
if (window.electronAPI) {
await window.electronAPI.hub.reconnect(url)
} else {
await window.ipcRenderer.invoke('hub:reconnect', url)
}
await refresh()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to reconnect')
}
}, [refresh])
// Create a new agent
const createAgent = useCallback(async (id?: string): Promise<AgentInfo | null> => {
try {
setError(null)
const result = window.electronAPI
? await window.electronAPI.hub.createAgent(id)
: await window.ipcRenderer.invoke('hub:createAgent', id)
const typedResult = result as { error?: string; id?: string; closed?: boolean }
if (typedResult.error) {
setError(typedResult.error)
return null
}
// Refresh agents list
await refresh()
return result as AgentInfo
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create agent')
return null
}
}, [refresh])
// Close an agent
const closeAgent = useCallback(async (id: string): Promise<boolean> => {
try {
setError(null)
const result = window.electronAPI
? await window.electronAPI.hub.closeAgent(id)
: await window.ipcRenderer.invoke('hub:closeAgent', id)
const typedResult = result as { ok?: boolean }
if (!typedResult.ok) {
setError(`Failed to close agent: ${id}`)
return false
}
// Refresh agents list
await refresh()
return true
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to close agent')
return false
}
}, [refresh])
// Send message to agent
const sendMessage = useCallback(async (agentId: string, content: string): Promise<boolean> => {
try {
setError(null)
const result = window.electronAPI
? await window.electronAPI.hub.sendMessage(agentId, content)
: await window.ipcRenderer.invoke('hub:sendMessage', agentId, content)
const typedResult = result as { error?: string }
if (typedResult.error) {
setError(typedResult.error)
return false
}
return true
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to send message')
return false
}
}, [])
return {
hubInfo,
agents,
loading,
error,
initHub,
refresh,
reconnect,
createAgent,
closeAgent,
sendMessage,
}
}
export default useHub

View File

@@ -0,0 +1,264 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
// ============================================================================
// Types matching the IPC response from main process
// ============================================================================
export type SkillSource = 'bundled' | 'global' | 'profile'
export interface SkillInfo {
id: string
name: string
description: string
version: string
enabled: boolean
source: SkillSource
triggers: string[]
}
export interface SkillGroup {
source: SkillSource
name: string
skills: SkillInfo[]
}
// Source display names
const SOURCE_NAMES: Record<string, string> = {
bundled: 'Built-in Skills',
global: 'Global Skills',
profile: 'Profile Skills',
}
export interface UseSkillsReturn {
/** List of all skills */
skills: SkillInfo[]
/** Skills grouped by source */
groups: SkillGroup[]
/** Loading state */
loading: boolean
/** Error state */
error: string | null
/** Toggle a skill on/off */
toggleSkill: (skillId: string) => Promise<void>
/** Enable a skill */
enableSkill: (skillId: string) => Promise<void>
/** Disable a skill */
disableSkill: (skillId: string) => Promise<void>
/** Refresh skills list */
refresh: () => Promise<void>
/** Get skill by ID */
getSkill: (id: string) => SkillInfo | undefined
/** Filter skills by search query */
filterSkills: (query: string) => SkillInfo[]
/** Check if a skill is enabled */
isSkillEnabled: (skillId: string) => boolean
/** Stats */
stats: {
total: number
enabled: number
disabled: number
bundled: number
global: number
profile: number
}
}
/**
* Hook for managing Agent skills configuration via IPC.
*
* This hook communicates with the Electron main process to:
* - Fetch the list of all skills (bundled, global, profile)
* - Toggle skills on/off
* - Match the CLI `multica skills list` output
*/
export function useSkills(): UseSkillsReturn {
const [skills, setSkills] = useState<SkillInfo[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Fetch skills from main process
const fetchSkills = useCallback(async () => {
try {
setLoading(true)
setError(null)
// Use new electronAPI if available, fallback to ipcRenderer
const result = window.electronAPI
? await window.electronAPI.skills.list()
: await window.ipcRenderer.invoke('skills:list')
if (Array.isArray(result)) {
setSkills(result)
} else {
setError('Invalid response from skills:list')
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch skills')
setSkills([])
} finally {
setLoading(false)
}
}, [])
// Initial fetch
useEffect(() => {
fetchSkills()
}, [fetchSkills])
// Group skills by source
const groups = useMemo<SkillGroup[]>(() => {
const sourceOrder: SkillSource[] = ['bundled', 'global', 'profile']
const groupMap = new Map<SkillSource, SkillInfo[]>()
for (const skill of skills) {
const sourceSkills = groupMap.get(skill.source) || []
sourceSkills.push(skill)
groupMap.set(skill.source, sourceSkills)
}
return sourceOrder
.filter((source) => groupMap.has(source))
.map((source) => ({
source,
name: SOURCE_NAMES[source] || source,
skills: groupMap.get(source) || [],
}))
}, [skills])
// Stats
const stats = useMemo(() => ({
total: skills.length,
enabled: skills.filter((s) => s.enabled).length,
disabled: skills.filter((s) => !s.enabled).length,
bundled: skills.filter((s) => s.source === 'bundled').length,
global: skills.filter((s) => s.source === 'global').length,
profile: skills.filter((s) => s.source === 'profile').length,
}), [skills])
// Toggle skill via IPC
const toggleSkill = useCallback(async (skillId: string) => {
try {
const result = window.electronAPI
? await window.electronAPI.skills.toggle(skillId)
: await window.ipcRenderer.invoke('skills:toggle', skillId)
const typedResult = result as { error?: string; enabled?: boolean }
if (typedResult.error) {
setError(typedResult.error)
return
}
// Update local state
setSkills((prev) =>
prev.map((skill) =>
skill.id === skillId ? { ...skill, enabled: typedResult.enabled ?? !skill.enabled } : skill
)
)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to toggle skill')
}
}, [])
// Enable skill via IPC
const enableSkill = useCallback(async (skillId: string) => {
try {
const result = window.electronAPI
? await window.electronAPI.skills.setStatus(skillId, true)
: await window.ipcRenderer.invoke('skills:setStatus', skillId, true)
const typedResult = result as { error?: string }
if (typedResult.error) {
setError(typedResult.error)
return
}
setSkills((prev) =>
prev.map((skill) =>
skill.id === skillId ? { ...skill, enabled: true } : skill
)
)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to enable skill')
}
}, [])
// Disable skill via IPC
const disableSkill = useCallback(async (skillId: string) => {
try {
const result = window.electronAPI
? await window.electronAPI.skills.setStatus(skillId, false)
: await window.ipcRenderer.invoke('skills:setStatus', skillId, false)
const typedResult = result as { error?: string }
if (typedResult.error) {
setError(typedResult.error)
return
}
setSkills((prev) =>
prev.map((skill) =>
skill.id === skillId ? { ...skill, enabled: false } : skill
)
)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to disable skill')
}
}, [])
// Get skill by ID
const getSkill = useCallback(
(id: string): SkillInfo | undefined => {
return skills.find((s) => s.id === id)
},
[skills]
)
// Filter skills by search query
const filterSkills = useCallback(
(query: string): SkillInfo[] => {
if (!query.trim()) return skills
const lowerQuery = query.toLowerCase()
return skills.filter(
(skill) =>
skill.name.toLowerCase().includes(lowerQuery) ||
skill.id.toLowerCase().includes(lowerQuery) ||
skill.description.toLowerCase().includes(lowerQuery) ||
skill.triggers.some((t) => t.toLowerCase().includes(lowerQuery))
)
},
[skills]
)
// Check if skill is enabled
const isSkillEnabled = useCallback(
(skillId: string): boolean => {
const skill = skills.find((s) => s.id === skillId)
return skill?.enabled ?? false
},
[skills]
)
return {
skills,
groups,
loading,
error,
toggleSkill,
enableSkill,
disableSkill,
refresh: fetchSkills,
getSkill,
filterSkills,
isSkillEnabled,
stats,
}
}
export default useSkills

View File

@@ -0,0 +1,232 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
// ============================================================================
// Types matching the IPC response from main process
// ============================================================================
export interface ToolInfo {
name: string
description?: string
group: string
enabled: boolean
}
export interface ToolGroup {
id: string
name: string
tools: string[]
}
// Tool descriptions (for UI display)
const TOOL_DESCRIPTIONS: Record<string, string> = {
read: 'Read file contents',
write: 'Write content to file',
edit: 'Edit file with search/replace',
glob: 'Find files by pattern',
exec: 'Execute shell commands',
process: 'Manage background processes',
web_fetch: 'Fetch content from URLs',
web_search: 'Search the web (requires API key)',
memory_get: 'Get stored memory value',
memory_set: 'Store a memory value',
memory_delete: 'Delete a memory value',
memory_list: 'List all memory keys',
}
// Group display names
const GROUP_NAMES: Record<string, string> = {
fs: 'File System',
runtime: 'Runtime',
web: 'Web',
memory: 'Memory',
other: 'Other',
}
export interface UseToolsReturn {
/** List of all tools with their status */
tools: ToolInfo[]
/** List of tool groups */
groups: ToolGroup[]
/** Loading state */
loading: boolean
/** Error state */
error: string | null
/** Toggle a specific tool on/off */
toggleTool: (toolName: string) => Promise<void>
/** Enable a tool */
enableTool: (toolName: string) => Promise<void>
/** Disable a tool */
disableTool: (toolName: string) => Promise<void>
/** Refresh tools list from main process */
refresh: () => Promise<void>
/** Check if a tool is enabled */
isToolEnabled: (toolName: string) => boolean
}
/**
* Hook for managing Agent tools configuration via IPC.
*
* This hook communicates with the Electron main process to:
* - Fetch the list of available tools and their status
* - Toggle tools on/off (persisted to credentials.json5)
* - Trigger agent.reloadTools() to apply changes immediately
*/
export function useTools(): UseToolsReturn {
const [tools, setTools] = useState<ToolInfo[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Fetch tools from main process
const fetchTools = useCallback(async () => {
try {
setLoading(true)
setError(null)
// Use new electronAPI if available, fallback to ipcRenderer
const result = window.electronAPI
? await window.electronAPI.tools.list()
: await window.ipcRenderer.invoke('tools:list')
if (Array.isArray(result)) {
// Add descriptions to tools
const toolsWithDesc = result.map((tool: { name: string; enabled: boolean; group: string }) => ({
...tool,
description: TOOL_DESCRIPTIONS[tool.name],
}))
setTools(toolsWithDesc)
} else {
setError('Invalid response from tools:list')
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch tools')
// Fallback to empty list
setTools([])
} finally {
setLoading(false)
}
}, [])
// Initial fetch
useEffect(() => {
fetchTools()
}, [fetchTools])
// Build groups list from tools
const groups = useMemo<ToolGroup[]>(() => {
const groupMap = new Map<string, string[]>()
for (const tool of tools) {
const groupTools = groupMap.get(tool.group) || []
groupTools.push(tool.name)
groupMap.set(tool.group, groupTools)
}
return Array.from(groupMap.entries()).map(([id, toolNames]) => ({
id,
name: GROUP_NAMES[id] || id,
tools: toolNames,
}))
}, [tools])
// Toggle tool via IPC
const toggleTool = useCallback(async (toolName: string) => {
console.log('[useTools] toggleTool called:', toolName)
try {
const result = window.electronAPI
? await window.electronAPI.tools.toggle(toolName)
: await window.ipcRenderer.invoke('tools:toggle', toolName)
console.log('[useTools] toggleTool result:', result)
const typedResult = result as { error?: string; enabled?: boolean }
if (typedResult.error) {
console.error('[useTools] toggleTool error:', typedResult.error)
setError(typedResult.error)
return
}
// Update local state
console.log('[useTools] Updating tool state:', toolName, 'enabled:', typedResult.enabled)
setTools((prev) =>
prev.map((tool) =>
tool.name === toolName ? { ...tool, enabled: typedResult.enabled ?? !tool.enabled } : tool
)
)
} catch (err) {
console.error('[useTools] toggleTool exception:', err)
setError(err instanceof Error ? err.message : 'Failed to toggle tool')
}
}, [])
// Enable tool via IPC
const enableTool = useCallback(async (toolName: string) => {
try {
const result = window.electronAPI
? await window.electronAPI.tools.setStatus(toolName, true)
: await window.ipcRenderer.invoke('tools:setStatus', toolName, true)
const typedResult = result as { error?: string }
if (typedResult.error) {
setError(typedResult.error)
return
}
setTools((prev) =>
prev.map((tool) =>
tool.name === toolName ? { ...tool, enabled: true } : tool
)
)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to enable tool')
}
}, [])
// Disable tool via IPC
const disableTool = useCallback(async (toolName: string) => {
try {
const result = window.electronAPI
? await window.electronAPI.tools.setStatus(toolName, false)
: await window.ipcRenderer.invoke('tools:setStatus', toolName, false)
const typedResult = result as { error?: string }
if (typedResult.error) {
setError(typedResult.error)
return
}
setTools((prev) =>
prev.map((tool) =>
tool.name === toolName ? { ...tool, enabled: false } : tool
)
)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to disable tool')
}
}, [])
// Check if tool is enabled
const isToolEnabled = useCallback(
(toolName: string): boolean => {
const tool = tools.find((t) => t.name === toolName)
return tool?.enabled ?? false
},
[tools]
)
return {
tools,
groups,
loading,
error,
toggleTool,
enableTool,
disableTool,
refresh: fetchTools,
isToolEnabled,
}
}
export default useTools