refactor(desktop): migrate to zustand stores and remove hook layer
- Add new stores: skills, tools, cron-jobs, hub - Remove wrapper hooks: use-channels, use-cron-jobs, use-hub, use-provider, use-skills, use-tools, use-heartbeat - Update all components and pages to use stores directly - Add 800ms minimum loading time for refresh operations - Add toast notifications for store actions (success/error feedback) - Remove unused chat-mode store and remote-chat component Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
3d25aa96f4
commit
6ea6c9fff5
29 changed files with 929 additions and 1389 deletions
|
|
@ -11,7 +11,7 @@ import {
|
|||
CancelCircleIcon,
|
||||
AlertCircleIcon,
|
||||
} from '@hugeicons/core-free-icons'
|
||||
import type { CronJobInfo } from '../hooks/use-cron-jobs'
|
||||
import type { CronJobInfo } from '../stores/cron-jobs'
|
||||
|
||||
interface CronJobListProps {
|
||||
jobs: CronJobInfo[]
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useState, useCallback } from 'react'
|
|||
import { Loading } from '@multica/ui/components/ui/loading'
|
||||
import { ChatView } from '@multica/ui/components/chat-view'
|
||||
import { useLocalChat } from '../hooks/use-local-chat'
|
||||
import { useProvider } from '../hooks/use-provider'
|
||||
import { useProviderStore } from '../stores/provider'
|
||||
import { ApiKeyDialog } from './api-key-dialog'
|
||||
import { OAuthDialog } from './oauth-dialog'
|
||||
|
||||
|
|
@ -24,7 +24,7 @@ export function LocalChat() {
|
|||
clearError,
|
||||
} = useLocalChat()
|
||||
|
||||
const { providers, current, setProvider: switchProvider, refresh: refreshProviders } = useProvider()
|
||||
const { providers, current, setProvider: switchProvider, refresh: refreshProviders } = useProviderStore()
|
||||
|
||||
// Provider config dialog state
|
||||
const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false)
|
||||
|
|
|
|||
|
|
@ -185,13 +185,9 @@ export function ConnectionQRCode({
|
|||
</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>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
{/* Expiry timer */}
|
||||
<div className="flex items-center gap-3 justify-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`text-xs font-mono ${
|
||||
isExpiringSoon
|
||||
|
|
@ -203,32 +199,18 @@ export function ConnectionQRCode({
|
|||
>
|
||||
{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>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={isExpired ? handleRefresh : handleCopyLink}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={isExpired ? RefreshIcon : (copied ? CheckmarkCircle02Icon : Copy01Icon)}
|
||||
className="size-3.5 mr-1"
|
||||
/>
|
||||
{isExpired ? 'Refresh' : (copied ? 'Copied!' : 'Copy Link')}
|
||||
</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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,51 +0,0 @@
|
|||
import { Loading } from '@multica/ui/components/ui/loading'
|
||||
import { ChatView } from '@multica/ui/components/chat-view'
|
||||
import { DevicePairing } from '@multica/ui/components/device-pairing'
|
||||
import { useGatewayChat } from '@multica/hooks/use-gateway-chat'
|
||||
import type { UseGatewayConnectionReturn } from '@multica/hooks/use-gateway-connection'
|
||||
|
||||
export function RemoteChat({ gateway }: { gateway: UseGatewayConnectionReturn }) {
|
||||
const { pageState, connectionState, error, client, identity, pairingKey, connect, disconnect } = gateway
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden w-full">
|
||||
{pageState === 'loading' && (
|
||||
<div className="flex-1 flex items-center justify-center gap-2 text-muted-foreground text-sm">
|
||||
<Loading />
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(pageState === 'not-connected' || pageState === 'connecting') && (
|
||||
<DevicePairing
|
||||
key={pairingKey}
|
||||
connectionState={connectionState}
|
||||
lastError={error}
|
||||
onConnect={connect}
|
||||
onCancel={disconnect}
|
||||
/>
|
||||
)}
|
||||
|
||||
{pageState === 'connected' && client && identity && (
|
||||
<ConnectedChat
|
||||
client={client}
|
||||
hubId={identity.hubId}
|
||||
agentId={identity.agentId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ConnectedChat({
|
||||
client,
|
||||
hubId,
|
||||
agentId,
|
||||
}: {
|
||||
client: NonNullable<UseGatewayConnectionReturn['client']>
|
||||
hubId: string
|
||||
agentId: string
|
||||
}) {
|
||||
const chat = useGatewayChat({ client, hubId, agentId })
|
||||
return <ChatView {...chat} />
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@ import {
|
|||
CheckmarkCircle02Icon,
|
||||
Cancel01Icon,
|
||||
} from '@hugeicons/core-free-icons'
|
||||
import type { SkillInfo, SkillSource } from '../hooks/use-skills'
|
||||
import type { SkillInfo, SkillSource } from '../stores/skills'
|
||||
|
||||
// Source badge colors
|
||||
const SOURCE_COLORS: Record<SkillSource, string> = {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from 'react'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { Switch } from '@multica/ui/components/ui/switch'
|
||||
import { Button } from '@multica/ui/components/ui/button'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
|
|
@ -14,7 +14,18 @@ import {
|
|||
Time04Icon,
|
||||
UserMultipleIcon,
|
||||
} from '@hugeicons/core-free-icons'
|
||||
import type { ToolInfo, ToolGroup } from '../hooks/use-tools'
|
||||
import type { ToolInfo } from '../stores/tools'
|
||||
|
||||
// Group display names
|
||||
const GROUP_NAMES: Record<string, string> = {
|
||||
fs: 'File System',
|
||||
runtime: 'Runtime',
|
||||
web: 'Web',
|
||||
memory: 'Memory',
|
||||
subagent: 'Subagent',
|
||||
cron: 'Cron',
|
||||
other: 'Other',
|
||||
}
|
||||
|
||||
// Group icons
|
||||
const GROUP_ICONS: Record<string, typeof FolderOpenIcon> = {
|
||||
|
|
@ -29,7 +40,6 @@ const GROUP_ICONS: Record<string, typeof FolderOpenIcon> = {
|
|||
|
||||
interface ToolListProps {
|
||||
tools: ToolInfo[]
|
||||
groups: ToolGroup[]
|
||||
loading: boolean
|
||||
error: string | null
|
||||
onToggleTool: (toolName: string) => Promise<void>
|
||||
|
|
@ -38,12 +48,23 @@ interface ToolListProps {
|
|||
|
||||
export function ToolList({
|
||||
tools,
|
||||
groups,
|
||||
loading,
|
||||
error,
|
||||
onToggleTool,
|
||||
onRefresh,
|
||||
}: ToolListProps) {
|
||||
// Compute groups from tools
|
||||
const groups = useMemo(() => {
|
||||
const groupIds = [...new Set(tools.map(t => t.group))]
|
||||
return groupIds.map(id => ({
|
||||
id,
|
||||
name: GROUP_NAMES[id] || id,
|
||||
tools: tools.filter(t => t.group === id),
|
||||
enabledCount: tools.filter(t => t.group === id && t.enabled).length,
|
||||
totalCount: tools.filter(t => t.group === id).length,
|
||||
}))
|
||||
}, [tools])
|
||||
|
||||
// Track which groups are expanded
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(
|
||||
() => new Set(groups.map((g) => g.id))
|
||||
|
|
@ -77,14 +98,6 @@ export function ToolList({
|
|||
}
|
||||
}
|
||||
|
||||
// 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">
|
||||
|
|
@ -126,7 +139,7 @@ export function ToolList({
|
|||
|
||||
{/* Tool groups */}
|
||||
<div className="space-y-2">
|
||||
{toolsByGroup.map((group) => {
|
||||
{groups.map((group) => {
|
||||
const isExpanded = expandedGroups.has(group.id)
|
||||
const GroupIcon = GROUP_ICONS[group.id] || CodeIcon
|
||||
|
||||
|
|
|
|||
|
|
@ -1,54 +0,0 @@
|
|||
/**
|
||||
* Hook for managing channel accounts (Telegram, Discord, etc.) in the Desktop App.
|
||||
*
|
||||
* Uses the global ChannelsStore for state management.
|
||||
* Data is fetched once at app startup and shared across all components.
|
||||
*/
|
||||
import { useChannelsStore } from '../stores/channels'
|
||||
|
||||
export interface UseChannelsReturn {
|
||||
/** Runtime states of all channel accounts */
|
||||
states: ChannelAccountStateInfo[]
|
||||
/** Raw channel config from credentials.json5 */
|
||||
config: Record<string, Record<string, Record<string, unknown>> | undefined>
|
||||
/** Loading state */
|
||||
loading: boolean
|
||||
/** Error message if any */
|
||||
error: string | null
|
||||
/** Refresh states and config */
|
||||
refresh: () => Promise<void>
|
||||
/** Save a bot token — persists to file and starts the bot immediately */
|
||||
saveToken: (channelId: string, accountId: string, token: string) => Promise<{ ok: boolean; error?: string }>
|
||||
/** Remove a bot token — stops the bot and removes from file */
|
||||
removeToken: (channelId: string, accountId: string) => Promise<{ ok: boolean; error?: string }>
|
||||
/** Stop a channel account without removing config */
|
||||
stopChannel: (channelId: string, accountId: string) => Promise<{ ok: boolean; error?: string }>
|
||||
/** Start a channel account from saved config */
|
||||
startChannel: (channelId: string, accountId: string) => Promise<{ ok: boolean; error?: string }>
|
||||
}
|
||||
|
||||
export function useChannels(): UseChannelsReturn {
|
||||
const {
|
||||
states,
|
||||
config,
|
||||
loading,
|
||||
error,
|
||||
refresh,
|
||||
saveToken,
|
||||
removeToken,
|
||||
stopChannel,
|
||||
startChannel,
|
||||
} = useChannelsStore()
|
||||
|
||||
return {
|
||||
states,
|
||||
config,
|
||||
loading,
|
||||
error,
|
||||
refresh,
|
||||
saveToken,
|
||||
removeToken,
|
||||
stopChannel,
|
||||
startChannel,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
import { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
export interface CronJobInfo {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
enabled: boolean
|
||||
schedule: string
|
||||
sessionTarget: string
|
||||
nextRunAt: string | null
|
||||
lastStatus: 'ok' | 'error' | 'skipped' | null
|
||||
lastRunAt: string | null
|
||||
lastDurationMs: number | null
|
||||
lastError: string | null
|
||||
}
|
||||
|
||||
export interface UseCronJobsReturn {
|
||||
jobs: CronJobInfo[]
|
||||
loading: boolean
|
||||
error: string | null
|
||||
toggleJob: (jobId: string) => Promise<void>
|
||||
removeJob: (jobId: string) => Promise<void>
|
||||
refresh: () => Promise<void>
|
||||
}
|
||||
|
||||
export function useCronJobs(): UseCronJobsReturn {
|
||||
const [jobs, setJobs] = useState<CronJobInfo[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchJobs = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const result = window.electronAPI
|
||||
? await window.electronAPI.cron.list()
|
||||
: await window.ipcRenderer.invoke('cron:list')
|
||||
|
||||
if (Array.isArray(result)) {
|
||||
setJobs(result)
|
||||
} else {
|
||||
setError('Invalid response from cron:list')
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch cron jobs')
|
||||
setJobs([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchJobs()
|
||||
}, [fetchJobs])
|
||||
|
||||
const toggleJob = useCallback(async (jobId: string) => {
|
||||
try {
|
||||
const result = window.electronAPI
|
||||
? await window.electronAPI.cron.toggle(jobId)
|
||||
: await window.ipcRenderer.invoke('cron:toggle', jobId)
|
||||
|
||||
const typed = result as { error?: string; id?: string; enabled?: boolean }
|
||||
if (typed.error) {
|
||||
setError(typed.error)
|
||||
return
|
||||
}
|
||||
|
||||
setJobs((prev) =>
|
||||
prev.map((job) =>
|
||||
job.id === jobId ? { ...job, enabled: typed.enabled ?? !job.enabled } : job
|
||||
)
|
||||
)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to toggle job')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const removeJob = useCallback(async (jobId: string) => {
|
||||
try {
|
||||
const result = window.electronAPI
|
||||
? await window.electronAPI.cron.remove(jobId)
|
||||
: await window.ipcRenderer.invoke('cron:remove', jobId)
|
||||
|
||||
const typed = result as { error?: string; ok?: boolean }
|
||||
if (typed.error) {
|
||||
setError(typed.error)
|
||||
return
|
||||
}
|
||||
|
||||
setJobs((prev) => prev.filter((job) => job.id !== jobId))
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to remove job')
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
jobs,
|
||||
loading,
|
||||
error,
|
||||
toggleJob,
|
||||
removeJob,
|
||||
refresh: fetchJobs,
|
||||
}
|
||||
}
|
||||
|
||||
export default useCronJobs
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
export type HeartbeatEvent = {
|
||||
ts: number;
|
||||
status: "sent" | "ok-empty" | "ok-token" | "skipped" | "failed";
|
||||
preview?: string;
|
||||
durationMs?: number;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export function useHeartbeat() {
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [lastEvent, setLastEvent] = useState<HeartbeatEvent | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const event = (await window.electronAPI.heartbeat.last()) as HeartbeatEvent | null;
|
||||
setLastEvent(event);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
const timer = setInterval(() => {
|
||||
void refresh();
|
||||
}, 15000);
|
||||
return () => clearInterval(timer);
|
||||
}, [refresh]);
|
||||
|
||||
const toggleEnabled = useCallback(async () => {
|
||||
const next = !enabled;
|
||||
const result = await window.electronAPI.heartbeat.setEnabled(next);
|
||||
if (result.ok) {
|
||||
setEnabled(next);
|
||||
} else {
|
||||
setError(result.error ?? "Failed to update heartbeat setting");
|
||||
}
|
||||
}, [enabled]);
|
||||
|
||||
const wakeNow = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await window.electronAPI.heartbeat.wake("manual");
|
||||
if (!result.ok) {
|
||||
setError(result.error ?? "Failed to run heartbeat");
|
||||
}
|
||||
await refresh();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [refresh]);
|
||||
|
||||
return {
|
||||
enabled,
|
||||
lastEvent,
|
||||
loading,
|
||||
error,
|
||||
refresh,
|
||||
toggleEnabled,
|
||||
wakeNow,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,221 +0,0 @@
|
|||
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])
|
||||
|
||||
// Subscribe to connection state changes pushed from main process
|
||||
useEffect(() => {
|
||||
const handler = (state: string) => {
|
||||
setHubInfo((prev) => prev ? { ...prev, connectionState: state as HubInfo['connectionState'] } : prev)
|
||||
}
|
||||
window.electronAPI?.hub.onConnectionStateChanged(handler)
|
||||
return () => {
|
||||
window.electronAPI?.hub.offConnectionStateChanged()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 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
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
/**
|
||||
* Hook for managing LLM providers in the Desktop App.
|
||||
*
|
||||
* Uses the global ProviderStore for state management.
|
||||
* Data is fetched once at app startup and shared across all components.
|
||||
*/
|
||||
import { useCallback } from 'react'
|
||||
import { useProviderStore } from '../stores/provider'
|
||||
|
||||
interface UseProviderReturn {
|
||||
/** All providers with their status */
|
||||
providers: ProviderStatus[]
|
||||
/** Only available (configured) providers */
|
||||
availableProviders: ProviderStatus[]
|
||||
/** Current provider and model info */
|
||||
current: CurrentProviderInfo | null
|
||||
/** Loading state */
|
||||
loading: boolean
|
||||
/** Error message if any */
|
||||
error: string | null
|
||||
/** Refresh provider list and current status */
|
||||
refresh: () => Promise<void>
|
||||
/** Switch to a different provider (and optionally model) */
|
||||
setProvider: (providerId: string, modelId?: string) => Promise<{ ok: boolean; error?: string }>
|
||||
/** Get metadata for a specific provider */
|
||||
getProviderMeta: (providerId: string) => ProviderStatus | undefined
|
||||
}
|
||||
|
||||
export function useProvider(): UseProviderReturn {
|
||||
const {
|
||||
providers,
|
||||
current,
|
||||
loading,
|
||||
error,
|
||||
refresh,
|
||||
setProvider,
|
||||
} = useProviderStore()
|
||||
|
||||
const availableProviders = providers.filter((p) => p.available)
|
||||
|
||||
const getProviderMeta = useCallback(
|
||||
(providerId: string) => {
|
||||
return providers.find((p) => p.id === providerId)
|
||||
},
|
||||
[providers]
|
||||
)
|
||||
|
||||
return {
|
||||
providers,
|
||||
availableProviders,
|
||||
current,
|
||||
loading,
|
||||
error,
|
||||
refresh,
|
||||
setProvider,
|
||||
getProviderMeta,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,264 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,236 +0,0 @@
|
|||
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 via Devv Search',
|
||||
memory_get: 'Get stored memory value',
|
||||
memory_set: 'Store a memory value',
|
||||
memory_delete: 'Delete a memory value',
|
||||
memory_list: 'List all memory keys',
|
||||
memory_search: 'Search memory files for keywords',
|
||||
cron: 'Create and manage scheduled tasks',
|
||||
}
|
||||
|
||||
// Group display names
|
||||
const GROUP_NAMES: Record<string, string> = {
|
||||
fs: 'File System',
|
||||
runtime: 'Runtime',
|
||||
web: 'Web',
|
||||
memory: 'Memory',
|
||||
subagent: 'Subagent',
|
||||
cron: 'Cron',
|
||||
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
|
||||
|
|
@ -17,13 +17,13 @@ import { DeviceList } from '../components/device-list'
|
|||
import { AgentSettingsDialog } from '../components/agent-settings-dialog'
|
||||
import { ApiKeyDialog } from '../components/api-key-dialog'
|
||||
import { OAuthDialog } from '../components/oauth-dialog'
|
||||
import { useHub } from '../hooks/use-hub'
|
||||
import { useProvider } from '../hooks/use-provider'
|
||||
import { useHubStore } from '../stores/hub'
|
||||
import { useProviderStore } from '../stores/provider'
|
||||
|
||||
export default function HomePage() {
|
||||
const navigate = useNavigate()
|
||||
const { hubInfo, agents, loading, error } = useHub()
|
||||
const { providers, current, setProvider, refresh, loading: providerLoading } = useProvider()
|
||||
const { hubInfo, agents, loading, error } = useHubStore()
|
||||
const { providers, current, setProvider, refresh, loading: providerLoading } = useProviderStore()
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [agentName, setAgentName] = useState<string | undefined>()
|
||||
const [providerDropdownOpen, setProviderDropdownOpen] = useState(false)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
import { Button } from '@multica/ui/components/ui/button'
|
||||
import { Input } from '@multica/ui/components/ui/input'
|
||||
import { Badge } from '@multica/ui/components/ui/badge'
|
||||
import { useChannels, type UseChannelsReturn } from '../hooks/use-channels'
|
||||
import { useChannelsStore } from '../stores/channels'
|
||||
|
||||
/** Status badge color mapping */
|
||||
function statusVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||
|
|
@ -21,8 +21,8 @@ function statusVariant(status: string): 'default' | 'secondary' | 'destructive'
|
|||
}
|
||||
}
|
||||
|
||||
function TelegramCard({ channels }: { channels: UseChannelsReturn }) {
|
||||
const { states, config, saveToken, removeToken, startChannel, stopChannel } = channels
|
||||
function TelegramCard() {
|
||||
const { states, config, saveToken, removeToken, startChannel, stopChannel } = useChannelsStore()
|
||||
const [token, setToken] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [localError, setLocalError] = useState<string | null>(null)
|
||||
|
|
@ -153,25 +153,28 @@ function TelegramCard({ channels }: { channels: UseChannelsReturn }) {
|
|||
}
|
||||
|
||||
export default function ChannelsPage() {
|
||||
const channels = useChannels()
|
||||
const { loading, error } = channels
|
||||
const { loading, error } = useChannelsStore()
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Channels</h2>
|
||||
<div className="h-full flex flex-col p-6 overflow-auto">
|
||||
{/* Page Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-lg font-medium">Channels</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Connect messaging platforms to your Agent.
|
||||
Channels let you talk to your agent from other platforms like Telegram or Slack. Connect one to chat with your agent anywhere.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
) : error ? (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
) : (
|
||||
<TelegramCard channels={channels} />
|
||||
)}
|
||||
{/* Configuration Area */}
|
||||
<div className="flex-1 min-h-0">
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
) : error ? (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
) : (
|
||||
<TelegramCard />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,128 +1,9 @@
|
|||
import { Button } from '@multica/ui/components/ui/button'
|
||||
import { RemoteChat } from '../components/remote-chat'
|
||||
import { LocalChat } from '../components/local-chat'
|
||||
import { useChatModeStore } from '../stores/chat-mode'
|
||||
import { useGatewayConnection, type UseGatewayConnectionReturn } from '@multica/hooks/use-gateway-connection'
|
||||
|
||||
function ModeNav({ gateway }: { gateway: UseGatewayConnectionReturn }) {
|
||||
const { mode, setMode } = useChatModeStore()
|
||||
|
||||
if (mode === 'select') return null
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 px-6 py-1 shrink-0">
|
||||
<NavButton active={mode === 'local'} onClick={() => setMode('local')}>
|
||||
Local
|
||||
</NavButton>
|
||||
<NavButton active={mode === 'remote'} onClick={() => setMode('remote')}>
|
||||
Remote
|
||||
</NavButton>
|
||||
|
||||
{mode === 'remote' && gateway.pageState === 'connected' && (
|
||||
<>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={gateway.disconnect}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NavButton({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`px-3 py-1 text-xs rounded-md transition-colors ${
|
||||
active
|
||||
? 'bg-accent text-accent-foreground font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function ModeSelect() {
|
||||
const setMode = useChatModeStore((s) => s.setMode)
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center gap-6 p-4">
|
||||
<div className="text-center space-y-2">
|
||||
<h2 className="text-lg font-semibold">Start a Conversation</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose how you want to connect
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 w-full max-w-xs">
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={() => setMode('local')}
|
||||
className="w-full"
|
||||
>
|
||||
Local Agent
|
||||
<span className="text-xs ml-2 opacity-70">(Direct IPC)</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
onClick={() => setMode('remote')}
|
||||
className="w-full"
|
||||
>
|
||||
Remote Agent
|
||||
<span className="text-xs ml-2 opacity-70">(Via Gateway)</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ChatPage() {
|
||||
const mode = useChatModeStore((s) => s.mode)
|
||||
const gateway = useGatewayConnection()
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
<ModeNav gateway={gateway} />
|
||||
|
||||
{mode === 'select' && <ModeSelect />}
|
||||
|
||||
{mode === 'local' && <LocalChat />}
|
||||
|
||||
<ChatPanel visible={mode === 'remote'}>
|
||||
<RemoteChat gateway={gateway} />
|
||||
</ChatPanel>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ChatPanel({
|
||||
visible,
|
||||
children,
|
||||
}: {
|
||||
visible: boolean
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`flex-1 min-h-0 ${visible ? 'flex flex-col' : 'hidden'}`}
|
||||
>
|
||||
{children}
|
||||
<LocalChat />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,43 +1,30 @@
|
|||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@multica/ui/components/ui/card'
|
||||
import { useCronJobs } from '../hooks/use-cron-jobs'
|
||||
import { useCronJobsStore } from '../stores/cron-jobs'
|
||||
import { CronJobList } from '../components/cron-job-list'
|
||||
|
||||
export default function CronsPage() {
|
||||
const {
|
||||
jobs,
|
||||
loading,
|
||||
error,
|
||||
toggleJob,
|
||||
removeJob,
|
||||
refresh,
|
||||
} = useCronJobs()
|
||||
const { jobs, loading, error, toggleJob, removeJob, refresh } = useCronJobsStore()
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Cron Jobs</CardTitle>
|
||||
<CardDescription>
|
||||
View and manage scheduled tasks. Create new jobs by asking the Agent in Chat.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CronJobList
|
||||
jobs={jobs}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onToggleJob={toggleJob}
|
||||
onRemoveJob={removeJob}
|
||||
onRefresh={refresh}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="h-full flex flex-col p-6 overflow-auto">
|
||||
{/* Page Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-lg font-medium">Scheduled Tasks</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Scheduled tasks run automatically at set times. Ask your agent to create one, like "remind me every morning" or "check my inbox daily."
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Configuration Area */}
|
||||
<div className="flex-1 min-h-0">
|
||||
<CronJobList
|
||||
jobs={jobs}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onToggleJob={toggleJob}
|
||||
onRemoveJob={removeJob}
|
||||
onRefresh={refresh}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,22 @@
|
|||
import { Outlet, NavLink, useLocation } from 'react-router-dom'
|
||||
import { Outlet, NavLink, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Toaster } from '@multica/ui/components/ui/sonner'
|
||||
import { Button } from '@multica/ui/components/ui/button'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import {
|
||||
Home01Icon,
|
||||
Comment01Icon,
|
||||
CodeIcon,
|
||||
PlugIcon,
|
||||
Share08Icon,
|
||||
Time04Icon,
|
||||
PuzzleIcon,
|
||||
Wrench01Icon,
|
||||
Message01Icon,
|
||||
RepeatIcon,
|
||||
ArrowLeft02Icon,
|
||||
ArrowRight02Icon,
|
||||
} from '@hugeicons/core-free-icons'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
|
|
@ -26,19 +30,67 @@ import { cn } from '@multica/ui/lib/utils'
|
|||
import { ModeToggle } from '../components/mode-toggle'
|
||||
import { DeviceConfirmDialog } from '../components/device-confirm-dialog'
|
||||
|
||||
const navItems = [
|
||||
const mainNavItems = [
|
||||
{ path: '/', label: 'Home', icon: Home01Icon },
|
||||
{ path: '/chat', label: 'Chat', icon: Comment01Icon },
|
||||
{ path: '/tools', label: 'Tools', icon: CodeIcon },
|
||||
{ path: '/skills', label: 'Skills', icon: PlugIcon },
|
||||
{ path: '/channels', label: 'Channels', icon: Share08Icon },
|
||||
{ path: '/crons', label: 'Crons', icon: Time04Icon },
|
||||
]
|
||||
|
||||
const configNavItems = [
|
||||
{ path: '/skills', label: 'Skills', icon: PuzzleIcon },
|
||||
{ path: '/tools', label: 'Tools', icon: Wrench01Icon },
|
||||
{ path: '/channels', label: 'Channels', icon: Message01Icon },
|
||||
{ path: '/crons', label: 'Crons', icon: RepeatIcon },
|
||||
]
|
||||
|
||||
// All nav items for header lookup
|
||||
const allNavItems = [...mainNavItems, ...configNavItems]
|
||||
|
||||
function NavigationButtons() {
|
||||
const navigate = useNavigate()
|
||||
// useLocation() triggers re-render on route change so we can re-evaluate history state
|
||||
useLocation()
|
||||
|
||||
const historyIdx = window.history.state?.idx ?? 0
|
||||
const canGoBack = historyIdx > 0
|
||||
const canGoForward = historyIdx < window.history.length - 1
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-0.5 ml-auto mr-2"
|
||||
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => navigate(-1)}
|
||||
disabled={!canGoBack}
|
||||
>
|
||||
<HugeiconsIcon icon={ArrowLeft02Icon} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => navigate(1)}
|
||||
disabled={!canGoForward}
|
||||
>
|
||||
<HugeiconsIcon icon={ArrowRight02Icon} />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MainHeader() {
|
||||
const { state, isMobile } = useSidebar()
|
||||
const location = useLocation()
|
||||
const needsTrafficLightSpace = state === 'collapsed' || isMobile
|
||||
|
||||
// Find current page info
|
||||
const currentPage = allNavItems.find((item) =>
|
||||
item.path === '/'
|
||||
? location.pathname === '/'
|
||||
: location.pathname.startsWith(item.path)
|
||||
)
|
||||
|
||||
return (
|
||||
<header className="h-12 shrink-0 flex items-center px-4">
|
||||
{/* Drag placeholder for traffic lights when sidebar is collapsed */}
|
||||
|
|
@ -52,8 +104,15 @@ function MainHeader() {
|
|||
|
||||
<SidebarTrigger />
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
{/* Center: Current page */}
|
||||
<div className="flex-1 flex justify-center">
|
||||
{currentPage && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<HugeiconsIcon icon={currentPage.icon} className="size-4" />
|
||||
<span>{currentPage.label}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Theme toggle */}
|
||||
<ModeToggle />
|
||||
|
|
@ -68,16 +127,19 @@ export default function Layout() {
|
|||
<div className="flex h-screen flex-col bg-background text-foreground">
|
||||
<SidebarProvider className="flex-1 overflow-hidden">
|
||||
<Sidebar>
|
||||
{/* Traffic light area */}
|
||||
{/* Traffic light area with navigation */}
|
||||
<SidebarHeader
|
||||
className="h-12 shrink-0"
|
||||
className="h-12 shrink-0 flex items-center"
|
||||
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
|
||||
/>
|
||||
>
|
||||
<NavigationButtons />
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
{/* Main navigation */}
|
||||
<SidebarGroup>
|
||||
<SidebarMenu>
|
||||
{navItems.map((item) => {
|
||||
<SidebarMenu className="space-y-0.5">
|
||||
{mainNavItems.map((item) => {
|
||||
const isActive = item.path === '/'
|
||||
? location.pathname === '/'
|
||||
: location.pathname.startsWith(item.path)
|
||||
|
|
@ -85,7 +147,39 @@ export default function Layout() {
|
|||
<SidebarMenuItem key={item.path}>
|
||||
<NavLink to={item.path}>
|
||||
<SidebarMenuButton isActive={isActive}>
|
||||
<HugeiconsIcon icon={item.icon} className="size-4" />
|
||||
<HugeiconsIcon
|
||||
icon={item.icon}
|
||||
className={cn(
|
||||
'size-4 transition-colors',
|
||||
!isActive && 'text-muted-foreground/50 group-hover/menu-button:text-foreground'
|
||||
)}
|
||||
/>
|
||||
<span>{item.label}</span>
|
||||
</SidebarMenuButton>
|
||||
</NavLink>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
|
||||
{/* Configuration */}
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Configuration</SidebarGroupLabel>
|
||||
<SidebarMenu className="space-y-0.5">
|
||||
{configNavItems.map((item) => {
|
||||
const isActive = location.pathname.startsWith(item.path)
|
||||
return (
|
||||
<SidebarMenuItem key={item.path}>
|
||||
<NavLink to={item.path}>
|
||||
<SidebarMenuButton isActive={isActive}>
|
||||
<HugeiconsIcon
|
||||
icon={item.icon}
|
||||
className={cn(
|
||||
'size-4 transition-colors',
|
||||
!isActive && 'text-muted-foreground/50 group-hover/menu-button:text-foreground'
|
||||
)}
|
||||
/>
|
||||
<span>{item.label}</span>
|
||||
</SidebarMenuButton>
|
||||
</NavLink>
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import {
|
|||
Tick02Icon,
|
||||
InformationCircleIcon,
|
||||
} from '@hugeicons/core-free-icons'
|
||||
import { useChannels } from '../../../hooks/use-channels'
|
||||
import { useChannelsStore } from '../../../stores/channels'
|
||||
import { StepDots } from './step-dots'
|
||||
|
||||
function statusVariant(
|
||||
|
|
@ -41,7 +41,7 @@ interface ConnectStepProps {
|
|||
}
|
||||
|
||||
export default function ConnectStep({ onNext, onBack }: ConnectStepProps) {
|
||||
const { states, config, saveToken } = useChannels()
|
||||
const { states, config, saveToken } = useChannelsStore()
|
||||
|
||||
const [token, setToken] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { Link } from '@multica/ui/components/ui/link'
|
|||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import { ArrowLeft02Icon, HelpCircleIcon } from '@hugeicons/core-free-icons'
|
||||
import { cn } from '@multica/ui/lib/utils'
|
||||
import { useProvider } from '../../../hooks/use-provider'
|
||||
import { useProviderStore } from '../../../stores/provider'
|
||||
import { ApiKeyDialog } from '../../../components/api-key-dialog'
|
||||
import { OAuthDialog } from '../../../components/oauth-dialog'
|
||||
import { StepDots } from './step-dots'
|
||||
|
|
@ -25,7 +25,7 @@ interface SetupStepProps {
|
|||
|
||||
export default function SetupStep({ onNext, onBack }: SetupStepProps) {
|
||||
const { providers, current, loading, error, refresh, setProvider } =
|
||||
useProvider()
|
||||
useProviderStore()
|
||||
const { setProviderConfigured } = useOnboardingStore()
|
||||
|
||||
const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false)
|
||||
|
|
|
|||
|
|
@ -1,42 +1,29 @@
|
|||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@multica/ui/components/ui/card'
|
||||
import { useSkills } from '../hooks/use-skills'
|
||||
import { useSkillsStore } from '../stores/skills'
|
||||
import { SkillList } from '../components/skill-list'
|
||||
|
||||
export default function SkillsPage() {
|
||||
const {
|
||||
skills,
|
||||
loading,
|
||||
error,
|
||||
toggleSkill,
|
||||
refresh,
|
||||
} = useSkills()
|
||||
const { skills, loading, error, toggleSkill, refresh } = useSkillsStore()
|
||||
|
||||
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 className="h-full flex flex-col p-6 overflow-auto">
|
||||
{/* Page Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-lg font-medium">Skills</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Skills are modular capabilities that expand what your agent can do. You can also ask your agent to create new skills for you.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Configuration Area */}
|
||||
<div className="flex-1 min-h-0">
|
||||
<SkillList
|
||||
skills={skills}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onToggleSkill={toggleSkill}
|
||||
onRefresh={refresh}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,44 +1,29 @@
|
|||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@multica/ui/components/ui/card'
|
||||
import { useTools } from '../hooks/use-tools'
|
||||
import { useToolsStore } from '../stores/tools'
|
||||
import { ToolList } from '../components/tool-list'
|
||||
|
||||
export default function ToolsPage() {
|
||||
const {
|
||||
tools,
|
||||
groups,
|
||||
loading,
|
||||
error,
|
||||
toggleTool,
|
||||
refresh,
|
||||
} = useTools()
|
||||
const { tools, loading, error, toggleTool, refresh } = useToolsStore()
|
||||
|
||||
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 className="h-full flex flex-col p-6 overflow-auto">
|
||||
{/* Page Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-lg font-medium">Tools</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Tools are actions your agent can perform, like reading files, searching the web, or running code. Toggle them to control what your agent can do.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Configuration Area */}
|
||||
<div className="flex-1 min-h-0">
|
||||
<ToolList
|
||||
tools={tools}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onToggleTool={toggleTool}
|
||||
onRefresh={refresh}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import { create } from 'zustand'
|
||||
import { toast } from '@multica/ui/components/ui/sonner'
|
||||
|
||||
// Minimum loading time for user perception (ms)
|
||||
const MIN_LOADING_TIME = 800
|
||||
|
||||
interface ChannelsStore {
|
||||
// State
|
||||
|
|
@ -53,12 +57,20 @@ export const useChannelsStore = create<ChannelsStore>()((set, get) => ({
|
|||
refresh: async () => {
|
||||
set({ loading: true, error: null })
|
||||
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
const [stateList, channelConfig] = await Promise.all([
|
||||
window.electronAPI.channels.listStates(),
|
||||
window.electronAPI.channels.getConfig(),
|
||||
])
|
||||
|
||||
// Ensure minimum loading time for user perception
|
||||
const elapsed = Date.now() - startTime
|
||||
if (elapsed < MIN_LOADING_TIME) {
|
||||
await new Promise(resolve => setTimeout(resolve, MIN_LOADING_TIME - elapsed))
|
||||
}
|
||||
|
||||
set({
|
||||
states: stateList,
|
||||
config: channelConfig,
|
||||
|
|
@ -66,6 +78,7 @@ export const useChannelsStore = create<ChannelsStore>()((set, get) => ({
|
|||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
set({ error: message })
|
||||
toast.error('Failed to refresh channels', { description: message })
|
||||
console.error('[ChannelsStore] Failed to refresh:', message)
|
||||
} finally {
|
||||
set({ loading: false })
|
||||
|
|
@ -80,14 +93,17 @@ export const useChannelsStore = create<ChannelsStore>()((set, get) => ({
|
|||
|
||||
if (result.ok) {
|
||||
await get().refresh()
|
||||
toast.success('Channel connected')
|
||||
return { ok: true }
|
||||
} else {
|
||||
set({ error: result.error ?? 'Failed to save token' })
|
||||
toast.error('Failed to connect channel', { description: result.error })
|
||||
return { ok: false, error: result.error }
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
set({ error: message })
|
||||
toast.error('Failed to connect channel', { description: message })
|
||||
return { ok: false, error: message }
|
||||
}
|
||||
},
|
||||
|
|
@ -100,14 +116,17 @@ export const useChannelsStore = create<ChannelsStore>()((set, get) => ({
|
|||
|
||||
if (result.ok) {
|
||||
await get().refresh()
|
||||
toast.success('Channel removed')
|
||||
return { ok: true }
|
||||
} else {
|
||||
set({ error: result.error ?? 'Failed to remove token' })
|
||||
toast.error('Failed to remove channel', { description: result.error })
|
||||
return { ok: false, error: result.error }
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
set({ error: message })
|
||||
toast.error('Failed to remove channel', { description: message })
|
||||
return { ok: false, error: message }
|
||||
}
|
||||
},
|
||||
|
|
@ -120,14 +139,17 @@ export const useChannelsStore = create<ChannelsStore>()((set, get) => ({
|
|||
|
||||
if (result.ok) {
|
||||
await get().refresh()
|
||||
toast.success('Channel stopped')
|
||||
return { ok: true }
|
||||
} else {
|
||||
set({ error: result.error ?? 'Failed to stop channel' })
|
||||
toast.error('Failed to stop channel', { description: result.error })
|
||||
return { ok: false, error: result.error }
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
set({ error: message })
|
||||
toast.error('Failed to stop channel', { description: message })
|
||||
return { ok: false, error: message }
|
||||
}
|
||||
},
|
||||
|
|
@ -140,14 +162,17 @@ export const useChannelsStore = create<ChannelsStore>()((set, get) => ({
|
|||
|
||||
if (result.ok) {
|
||||
await get().refresh()
|
||||
toast.success('Channel started')
|
||||
return { ok: true }
|
||||
} else {
|
||||
set({ error: result.error ?? 'Failed to start channel' })
|
||||
toast.error('Failed to start channel', { description: result.error })
|
||||
return { ok: false, error: result.error }
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
set({ error: message })
|
||||
toast.error('Failed to start channel', { description: message })
|
||||
return { ok: false, error: message }
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
import { create } from "zustand"
|
||||
|
||||
export type ChatMode = "select" | "local" | "remote"
|
||||
|
||||
interface ChatModeStore {
|
||||
mode: ChatMode
|
||||
setMode: (mode: ChatMode) => void
|
||||
}
|
||||
|
||||
export const useChatModeStore = create<ChatModeStore>((set) => ({
|
||||
mode: "select",
|
||||
setMode: (mode) => set({ mode }),
|
||||
}))
|
||||
162
apps/desktop/src/renderer/src/stores/cron-jobs.ts
Normal file
162
apps/desktop/src/renderer/src/stores/cron-jobs.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import { create } from 'zustand'
|
||||
import { toast } from '@multica/ui/components/ui/sonner'
|
||||
|
||||
// Minimum loading time for user perception (ms)
|
||||
const MIN_LOADING_TIME = 800
|
||||
|
||||
// Types matching the IPC response
|
||||
export interface CronJobInfo {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
enabled: boolean
|
||||
schedule: string
|
||||
sessionTarget: string
|
||||
nextRunAt: string | null
|
||||
lastStatus: 'ok' | 'error' | 'skipped' | null
|
||||
lastRunAt: string | null
|
||||
lastDurationMs: number | null
|
||||
lastError: string | null
|
||||
}
|
||||
|
||||
interface CronJobsStore {
|
||||
// State
|
||||
jobs: CronJobInfo[]
|
||||
loading: boolean
|
||||
error: string | null
|
||||
initialized: boolean
|
||||
|
||||
// Actions
|
||||
fetch: () => Promise<void>
|
||||
refresh: () => Promise<void>
|
||||
toggleJob: (jobId: string) => Promise<void>
|
||||
removeJob: (jobId: string) => Promise<void>
|
||||
}
|
||||
|
||||
export const useCronJobsStore = create<CronJobsStore>()((set, get) => ({
|
||||
jobs: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
initialized: false,
|
||||
|
||||
fetch: async () => {
|
||||
// Skip if already initialized
|
||||
if (get().initialized) return
|
||||
|
||||
set({ loading: true, error: null })
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.cron.list()
|
||||
|
||||
if (Array.isArray(result)) {
|
||||
set({
|
||||
jobs: result as CronJobInfo[],
|
||||
initialized: true,
|
||||
})
|
||||
} else {
|
||||
set({ error: 'Invalid response from cron:list' })
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
set({ error: message })
|
||||
console.error('[CronJobsStore] Failed to load:', message)
|
||||
} finally {
|
||||
set({ loading: false })
|
||||
}
|
||||
},
|
||||
|
||||
refresh: async () => {
|
||||
set({ loading: true, error: null })
|
||||
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.cron.list()
|
||||
|
||||
// Ensure minimum loading time for user perception
|
||||
const elapsed = Date.now() - startTime
|
||||
if (elapsed < MIN_LOADING_TIME) {
|
||||
await new Promise(resolve => setTimeout(resolve, MIN_LOADING_TIME - elapsed))
|
||||
}
|
||||
|
||||
if (Array.isArray(result)) {
|
||||
set({ jobs: result as CronJobInfo[] })
|
||||
} else {
|
||||
set({ error: 'Invalid response from cron:list' })
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
set({ error: message })
|
||||
toast.error('Failed to refresh tasks', { description: message })
|
||||
console.error('[CronJobsStore] Failed to refresh:', message)
|
||||
} finally {
|
||||
set({ loading: false })
|
||||
}
|
||||
},
|
||||
|
||||
toggleJob: async (jobId: string) => {
|
||||
set({ error: null })
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.cron.toggle(jobId)
|
||||
const typedResult = result as { error?: string; id?: string; enabled?: boolean }
|
||||
|
||||
if (typedResult.error) {
|
||||
set({ error: typedResult.error })
|
||||
toast.error('Failed to toggle task', { description: typedResult.error })
|
||||
return
|
||||
}
|
||||
|
||||
// Find job name for toast
|
||||
const job = get().jobs.find(j => j.id === jobId)
|
||||
const jobName = job?.name ?? jobId
|
||||
|
||||
// Update local state
|
||||
set((state) => ({
|
||||
jobs: state.jobs.map((job) =>
|
||||
job.id === jobId
|
||||
? { ...job, enabled: typedResult.enabled ?? !job.enabled }
|
||||
: job
|
||||
),
|
||||
}))
|
||||
|
||||
toast.success(`${jobName} ${typedResult.enabled ? 'enabled' : 'disabled'}`)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
set({ error: message })
|
||||
toast.error('Failed to toggle task', { description: message })
|
||||
console.error('[CronJobsStore] Failed to toggle:', message)
|
||||
}
|
||||
},
|
||||
|
||||
removeJob: async (jobId: string) => {
|
||||
set({ error: null })
|
||||
|
||||
try {
|
||||
// Find job name before removing
|
||||
const job = get().jobs.find(j => j.id === jobId)
|
||||
const jobName = job?.name ?? jobId
|
||||
|
||||
const result = await window.electronAPI.cron.remove(jobId)
|
||||
const typedResult = result as { error?: string; ok?: boolean }
|
||||
|
||||
if (typedResult.error) {
|
||||
set({ error: typedResult.error })
|
||||
toast.error('Failed to remove task', { description: typedResult.error })
|
||||
return
|
||||
}
|
||||
|
||||
// Update local state
|
||||
set((state) => ({
|
||||
jobs: state.jobs.filter((job) => job.id !== jobId),
|
||||
}))
|
||||
|
||||
toast.success(`${jobName} removed`)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
set({ error: message })
|
||||
toast.error('Failed to remove task', { description: message })
|
||||
console.error('[CronJobsStore] Failed to remove:', message)
|
||||
}
|
||||
},
|
||||
}))
|
||||
112
apps/desktop/src/renderer/src/stores/hub.ts
Normal file
112
apps/desktop/src/renderer/src/stores/hub.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { create } from 'zustand'
|
||||
|
||||
// Connection state types
|
||||
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
|
||||
}
|
||||
|
||||
interface HubStore {
|
||||
// State
|
||||
hubInfo: HubInfo | null
|
||||
agents: AgentInfo[]
|
||||
loading: boolean
|
||||
error: string | null
|
||||
initialized: boolean
|
||||
|
||||
// Actions
|
||||
init: () => Promise<void>
|
||||
refresh: () => Promise<void>
|
||||
reconnect: (url: string) => Promise<{ ok: boolean; error?: string }>
|
||||
}
|
||||
|
||||
export const useHubStore = create<HubStore>()((set, get) => ({
|
||||
hubInfo: null,
|
||||
agents: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
initialized: false,
|
||||
|
||||
init: async () => {
|
||||
// Skip if already initialized
|
||||
if (get().initialized) return
|
||||
|
||||
set({ loading: true, error: null })
|
||||
|
||||
try {
|
||||
await window.electronAPI.hub.init()
|
||||
const info = await window.electronAPI.hub.info()
|
||||
const agentList = await window.electronAPI.hub.listAgents()
|
||||
|
||||
set({
|
||||
hubInfo: info as HubInfo,
|
||||
agents: agentList as AgentInfo[],
|
||||
initialized: true,
|
||||
})
|
||||
|
||||
// Subscribe to connection state changes
|
||||
window.electronAPI.hub.onConnectionStateChanged((state: string) => {
|
||||
set((prev) => ({
|
||||
hubInfo: prev.hubInfo
|
||||
? { ...prev.hubInfo, connectionState: state as ConnectionState }
|
||||
: prev.hubInfo,
|
||||
}))
|
||||
})
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
set({ error: message })
|
||||
console.error('[HubStore] Failed to initialize:', message)
|
||||
} finally {
|
||||
set({ loading: false })
|
||||
}
|
||||
},
|
||||
|
||||
refresh: async () => {
|
||||
set({ error: null })
|
||||
|
||||
try {
|
||||
const info = await window.electronAPI.hub.info()
|
||||
const agentList = await window.electronAPI.hub.listAgents()
|
||||
|
||||
set({
|
||||
hubInfo: info as HubInfo,
|
||||
agents: agentList as AgentInfo[],
|
||||
})
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
set({ error: message })
|
||||
console.error('[HubStore] Failed to refresh:', message)
|
||||
}
|
||||
},
|
||||
|
||||
reconnect: async (url: string) => {
|
||||
set({ error: null })
|
||||
|
||||
try {
|
||||
await window.electronAPI.hub.reconnect(url)
|
||||
await get().refresh()
|
||||
return { ok: true }
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
set({ error: message })
|
||||
return { ok: false, error: message }
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
// Selector helpers
|
||||
export const selectPrimaryAgent = (agents: AgentInfo[]) => agents[0] ?? null
|
||||
|
||||
export const selectIsConnected = (hubInfo: HubInfo | null) => {
|
||||
if (!hubInfo) return false
|
||||
return hubInfo.connectionState === 'connected' || hubInfo.connectionState === 'registered'
|
||||
}
|
||||
|
|
@ -1,4 +1,8 @@
|
|||
import { create } from 'zustand'
|
||||
import { toast } from '@multica/ui/components/ui/sonner'
|
||||
|
||||
// Minimum loading time for user perception (ms)
|
||||
const MIN_LOADING_TIME = 800
|
||||
|
||||
interface ProviderStore {
|
||||
// State
|
||||
|
|
@ -50,12 +54,20 @@ export const useProviderStore = create<ProviderStore>()((set, get) => ({
|
|||
refresh: async () => {
|
||||
set({ loading: true, error: null })
|
||||
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
const [providerList, currentInfo] = await Promise.all([
|
||||
window.electronAPI.provider.list(),
|
||||
window.electronAPI.provider.current(),
|
||||
])
|
||||
|
||||
// Ensure minimum loading time for user perception
|
||||
const elapsed = Date.now() - startTime
|
||||
if (elapsed < MIN_LOADING_TIME) {
|
||||
await new Promise(resolve => setTimeout(resolve, MIN_LOADING_TIME - elapsed))
|
||||
}
|
||||
|
||||
set({
|
||||
providers: providerList,
|
||||
current: currentInfo,
|
||||
|
|
@ -63,6 +75,7 @@ export const useProviderStore = create<ProviderStore>()((set, get) => ({
|
|||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
set({ error: message })
|
||||
toast.error('Failed to refresh providers', { description: message })
|
||||
console.error('[ProviderStore] Failed to refresh providers:', message)
|
||||
} finally {
|
||||
set({ loading: false })
|
||||
|
|
@ -78,15 +91,27 @@ export const useProviderStore = create<ProviderStore>()((set, get) => ({
|
|||
if (result.ok) {
|
||||
// Refresh to update current status
|
||||
await get().refresh()
|
||||
// Find provider name for toast
|
||||
const provider = get().providers.find(p => p.id === providerId)
|
||||
toast.success(`Switched to ${provider?.name ?? providerId}`)
|
||||
return { ok: true }
|
||||
} else {
|
||||
set({ error: result.error ?? 'Unknown error' })
|
||||
toast.error('Failed to switch provider', { description: result.error })
|
||||
return { ok: false, error: result.error }
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
set({ error: message })
|
||||
toast.error('Failed to switch provider', { description: message })
|
||||
return { ok: false, error: message }
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
// Selector helpers
|
||||
export const selectAvailableProviders = (providers: ProviderStatus[]) =>
|
||||
providers.filter(p => p.available)
|
||||
|
||||
export const selectProviderById = (providers: ProviderStatus[], id: string) =>
|
||||
providers.find(p => p.id === id)
|
||||
|
|
|
|||
175
apps/desktop/src/renderer/src/stores/skills.ts
Normal file
175
apps/desktop/src/renderer/src/stores/skills.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
import { create } from 'zustand'
|
||||
import { toast } from '@multica/ui/components/ui/sonner'
|
||||
|
||||
// Minimum loading time for user perception (ms)
|
||||
const MIN_LOADING_TIME = 800
|
||||
|
||||
// Types matching the IPC response
|
||||
export type SkillSource = 'bundled' | 'global' | 'profile'
|
||||
|
||||
export interface SkillInfo {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
version: string
|
||||
enabled: boolean
|
||||
source: SkillSource
|
||||
triggers: string[]
|
||||
}
|
||||
|
||||
interface SkillsStore {
|
||||
// State
|
||||
skills: SkillInfo[]
|
||||
loading: boolean
|
||||
error: string | null
|
||||
initialized: boolean
|
||||
|
||||
// Actions
|
||||
fetch: () => Promise<void>
|
||||
refresh: () => Promise<void>
|
||||
toggleSkill: (skillId: string) => Promise<void>
|
||||
setSkillStatus: (skillId: string, enabled: boolean) => Promise<void>
|
||||
}
|
||||
|
||||
export const useSkillsStore = create<SkillsStore>()((set, get) => ({
|
||||
skills: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
initialized: false,
|
||||
|
||||
fetch: async () => {
|
||||
// Skip if already initialized
|
||||
if (get().initialized) return
|
||||
|
||||
set({ loading: true, error: null })
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.skills.list()
|
||||
|
||||
if (Array.isArray(result)) {
|
||||
set({
|
||||
skills: result,
|
||||
initialized: true,
|
||||
})
|
||||
} else {
|
||||
set({ error: 'Invalid response from skills:list' })
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
set({ error: message })
|
||||
console.error('[SkillsStore] Failed to load:', message)
|
||||
} finally {
|
||||
set({ loading: false })
|
||||
}
|
||||
},
|
||||
|
||||
refresh: async () => {
|
||||
set({ loading: true, error: null })
|
||||
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.skills.list()
|
||||
|
||||
// Ensure minimum loading time for user perception
|
||||
const elapsed = Date.now() - startTime
|
||||
if (elapsed < MIN_LOADING_TIME) {
|
||||
await new Promise(resolve => setTimeout(resolve, MIN_LOADING_TIME - elapsed))
|
||||
}
|
||||
|
||||
if (Array.isArray(result)) {
|
||||
set({ skills: result })
|
||||
} else {
|
||||
set({ error: 'Invalid response from skills:list' })
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
set({ error: message })
|
||||
toast.error('Failed to refresh skills', { description: message })
|
||||
console.error('[SkillsStore] Failed to refresh:', message)
|
||||
} finally {
|
||||
set({ loading: false })
|
||||
}
|
||||
},
|
||||
|
||||
toggleSkill: async (skillId: string) => {
|
||||
set({ error: null })
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.skills.toggle(skillId)
|
||||
const typedResult = result as { error?: string; enabled?: boolean }
|
||||
|
||||
if (typedResult.error) {
|
||||
set({ error: typedResult.error })
|
||||
toast.error('Failed to toggle skill', { description: typedResult.error })
|
||||
return
|
||||
}
|
||||
|
||||
// Find skill name for toast
|
||||
const skill = get().skills.find(s => s.id === skillId)
|
||||
const skillName = skill?.name ?? skillId
|
||||
|
||||
// Update local state
|
||||
set((state) => ({
|
||||
skills: state.skills.map((skill) =>
|
||||
skill.id === skillId
|
||||
? { ...skill, enabled: typedResult.enabled ?? !skill.enabled }
|
||||
: skill
|
||||
),
|
||||
}))
|
||||
|
||||
toast.success(`${skillName} ${typedResult.enabled ? 'enabled' : 'disabled'}`)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
set({ error: message })
|
||||
toast.error('Failed to toggle skill', { description: message })
|
||||
console.error('[SkillsStore] Failed to toggle:', message)
|
||||
}
|
||||
},
|
||||
|
||||
setSkillStatus: async (skillId: string, enabled: boolean) => {
|
||||
set({ error: null })
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.skills.setStatus(skillId, enabled)
|
||||
const typedResult = result as { error?: string }
|
||||
|
||||
if (typedResult.error) {
|
||||
set({ error: typedResult.error })
|
||||
toast.error('Failed to update skill', { description: typedResult.error })
|
||||
return
|
||||
}
|
||||
|
||||
// Find skill name for toast
|
||||
const skill = get().skills.find(s => s.id === skillId)
|
||||
const skillName = skill?.name ?? skillId
|
||||
|
||||
// Update local state
|
||||
set((state) => ({
|
||||
skills: state.skills.map((skill) =>
|
||||
skill.id === skillId ? { ...skill, enabled } : skill
|
||||
),
|
||||
}))
|
||||
|
||||
toast.success(`${skillName} ${enabled ? 'enabled' : 'disabled'}`)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
set({ error: message })
|
||||
toast.error('Failed to update skill', { description: message })
|
||||
console.error('[SkillsStore] Failed to set status:', message)
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
// Selector helpers (use with useMemo in components)
|
||||
export const selectEnabledSkills = (skills: SkillInfo[]) =>
|
||||
skills.filter(s => s.enabled)
|
||||
|
||||
export const selectSkillStats = (skills: SkillInfo[]) => ({
|
||||
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,
|
||||
})
|
||||
183
apps/desktop/src/renderer/src/stores/tools.ts
Normal file
183
apps/desktop/src/renderer/src/stores/tools.ts
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
import { create } from 'zustand'
|
||||
import { toast } from '@multica/ui/components/ui/sonner'
|
||||
|
||||
// Minimum loading time for user perception (ms)
|
||||
const MIN_LOADING_TIME = 800
|
||||
|
||||
// Types matching the IPC response
|
||||
export interface ToolInfo {
|
||||
name: string
|
||||
description?: string
|
||||
group: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
// 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 via Devv Search',
|
||||
memory_get: 'Get stored memory value',
|
||||
memory_set: 'Store a memory value',
|
||||
memory_delete: 'Delete a memory value',
|
||||
memory_list: 'List all memory keys',
|
||||
memory_search: 'Search memory files for keywords',
|
||||
cron: 'Create and manage scheduled tasks',
|
||||
}
|
||||
|
||||
interface ToolsStore {
|
||||
// State
|
||||
tools: ToolInfo[]
|
||||
loading: boolean
|
||||
error: string | null
|
||||
initialized: boolean
|
||||
|
||||
// Actions
|
||||
fetch: () => Promise<void>
|
||||
refresh: () => Promise<void>
|
||||
toggleTool: (toolName: string) => Promise<void>
|
||||
setToolStatus: (toolName: string, enabled: boolean) => Promise<void>
|
||||
}
|
||||
|
||||
export const useToolsStore = create<ToolsStore>()((set, get) => ({
|
||||
tools: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
initialized: false,
|
||||
|
||||
fetch: async () => {
|
||||
// Skip if already initialized
|
||||
if (get().initialized) return
|
||||
|
||||
set({ loading: true, error: null })
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.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],
|
||||
}))
|
||||
set({
|
||||
tools: toolsWithDesc,
|
||||
initialized: true,
|
||||
})
|
||||
} else {
|
||||
set({ error: 'Invalid response from tools:list' })
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
set({ error: message })
|
||||
console.error('[ToolsStore] Failed to load:', message)
|
||||
} finally {
|
||||
set({ loading: false })
|
||||
}
|
||||
},
|
||||
|
||||
refresh: async () => {
|
||||
set({ loading: true, error: null })
|
||||
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.tools.list()
|
||||
|
||||
// Ensure minimum loading time for user perception
|
||||
const elapsed = Date.now() - startTime
|
||||
if (elapsed < MIN_LOADING_TIME) {
|
||||
await new Promise(resolve => setTimeout(resolve, MIN_LOADING_TIME - elapsed))
|
||||
}
|
||||
|
||||
if (Array.isArray(result)) {
|
||||
const toolsWithDesc = result.map((tool: { name: string; enabled: boolean; group: string }) => ({
|
||||
...tool,
|
||||
description: TOOL_DESCRIPTIONS[tool.name],
|
||||
}))
|
||||
set({ tools: toolsWithDesc })
|
||||
} else {
|
||||
set({ error: 'Invalid response from tools:list' })
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
set({ error: message })
|
||||
toast.error('Failed to refresh tools', { description: message })
|
||||
console.error('[ToolsStore] Failed to refresh:', message)
|
||||
} finally {
|
||||
set({ loading: false })
|
||||
}
|
||||
},
|
||||
|
||||
toggleTool: async (toolName: string) => {
|
||||
set({ error: null })
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.tools.toggle(toolName)
|
||||
const typedResult = result as { error?: string; enabled?: boolean }
|
||||
|
||||
if (typedResult.error) {
|
||||
set({ error: typedResult.error })
|
||||
toast.error('Failed to toggle tool', { description: typedResult.error })
|
||||
return
|
||||
}
|
||||
|
||||
// Update local state
|
||||
set((state) => ({
|
||||
tools: state.tools.map((tool) =>
|
||||
tool.name === toolName
|
||||
? { ...tool, enabled: typedResult.enabled ?? !tool.enabled }
|
||||
: tool
|
||||
),
|
||||
}))
|
||||
|
||||
toast.success(`${toolName} ${typedResult.enabled ? 'enabled' : 'disabled'}`)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
set({ error: message })
|
||||
toast.error('Failed to toggle tool', { description: message })
|
||||
console.error('[ToolsStore] Failed to toggle:', message)
|
||||
}
|
||||
},
|
||||
|
||||
setToolStatus: async (toolName: string, enabled: boolean) => {
|
||||
set({ error: null })
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.tools.setStatus(toolName, enabled)
|
||||
const typedResult = result as { error?: string }
|
||||
|
||||
if (typedResult.error) {
|
||||
set({ error: typedResult.error })
|
||||
toast.error('Failed to update tool', { description: typedResult.error })
|
||||
return
|
||||
}
|
||||
|
||||
// Update local state
|
||||
set((state) => ({
|
||||
tools: state.tools.map((tool) =>
|
||||
tool.name === toolName ? { ...tool, enabled } : tool
|
||||
),
|
||||
}))
|
||||
|
||||
toast.success(`${toolName} ${enabled ? 'enabled' : 'disabled'}`)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
set({ error: message })
|
||||
toast.error('Failed to update tool', { description: message })
|
||||
console.error('[ToolsStore] Failed to set status:', message)
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
// Selector helpers
|
||||
export const selectEnabledTools = (tools: ToolInfo[]) =>
|
||||
tools.filter(t => t.enabled)
|
||||
|
||||
export const selectEnabledToolsCount = (tools: ToolInfo[]) =>
|
||||
tools.filter(t => t.enabled).length
|
||||
Loading…
Add table
Add a link
Reference in a new issue