diff --git a/apps/desktop/src/hooks/use-hub.ts b/apps/desktop/src/hooks/use-hub.ts new file mode 100644 index 000000000..e5f543c19 --- /dev/null +++ b/apps/desktop/src/hooks/use-hub.ts @@ -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 + /** Refresh Hub info and agents list */ + refresh: () => Promise + /** Reconnect to a different Gateway URL */ + reconnect: (url: string) => Promise + /** Create a new agent */ + createAgent: (id?: string) => Promise + /** Close an agent */ + closeAgent: (id: string) => Promise + /** Send a message to an agent */ + sendMessage: (agentId: string, content: string) => Promise +} + +/** + * 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(null) + const [agents, setAgents] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(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 => { + 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 => { + 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 => { + 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 diff --git a/apps/desktop/src/hooks/use-skills.ts b/apps/desktop/src/hooks/use-skills.ts new file mode 100644 index 000000000..7b1fa6f1d --- /dev/null +++ b/apps/desktop/src/hooks/use-skills.ts @@ -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 = { + 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 + /** Enable a skill */ + enableSkill: (skillId: string) => Promise + /** Disable a skill */ + disableSkill: (skillId: string) => Promise + + /** Refresh skills list */ + refresh: () => Promise + + /** 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([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(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(() => { + const sourceOrder: SkillSource[] = ['bundled', 'global', 'profile'] + const groupMap = new Map() + + 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 diff --git a/apps/desktop/src/hooks/use-tools.ts b/apps/desktop/src/hooks/use-tools.ts new file mode 100644 index 000000000..586825bdc --- /dev/null +++ b/apps/desktop/src/hooks/use-tools.ts @@ -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 = { + 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 = { + 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 + /** Enable a tool */ + enableTool: (toolName: string) => Promise + /** Disable a tool */ + disableTool: (toolName: string) => Promise + + /** Refresh tools list from main process */ + refresh: () => Promise + + /** 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([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(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(() => { + const groupMap = new Map() + + 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