feat: Add intelligent auto-router and enhanced integrations
- Add intelligent-router.sh hook for automatic agent routing - Add AUTO-TRIGGER-SUMMARY.md documentation - Add FINAL-INTEGRATION-SUMMARY.md documentation - Complete Prometheus integration (6 commands + 4 tools) - Complete Dexto integration (12 commands + 5 tools) - Enhanced Ralph with access to all agents - Fix /clawd command (removed disable-model-invocation) - Update hooks.json to v5 with intelligent routing - 291 total skills now available - All 21 commands with automatic routing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
643
dexto/packages/webui/components/AgentSelector/AgentSelector.tsx
Normal file
643
dexto/packages/webui/components/AgentSelector/AgentSelector.tsx
Normal file
@@ -0,0 +1,643 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { queryKeys } from '@/lib/queryKeys.js';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
useAgents,
|
||||
useAgentPath,
|
||||
useSwitchAgent,
|
||||
useInstallAgent,
|
||||
useUninstallAgent,
|
||||
} from '../hooks/useAgents';
|
||||
import { useRecentAgentsStore } from '@/lib/stores/recentAgentsStore';
|
||||
import { useSessionStore } from '@/lib/stores/sessionStore';
|
||||
import { Button } from '../ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '../ui/dropdown-menu';
|
||||
import {
|
||||
ChevronDown,
|
||||
Check,
|
||||
DownloadCloud,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
BadgeCheck,
|
||||
Plus,
|
||||
} from 'lucide-react';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import CreateAgentModal from './CreateAgentModal';
|
||||
import { useAnalytics } from '@/lib/analytics/index.js';
|
||||
|
||||
type AgentItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
author?: string;
|
||||
tags?: string[];
|
||||
type: 'builtin' | 'custom';
|
||||
};
|
||||
|
||||
type AgentsResponse = {
|
||||
installed: AgentItem[];
|
||||
available: AgentItem[];
|
||||
current: { id: string | null; name: string | null };
|
||||
};
|
||||
|
||||
type AgentSelectorProps = {
|
||||
mode?: 'default' | 'badge' | 'title';
|
||||
};
|
||||
|
||||
export default function AgentSelector({ mode = 'default' }: AgentSelectorProps) {
|
||||
const navigate = useNavigate();
|
||||
const currentSessionId = useSessionStore((s) => s.currentSessionId);
|
||||
const analytics = useAnalytics();
|
||||
const analyticsRef = useRef(analytics);
|
||||
|
||||
const recentAgents = useRecentAgentsStore((state) => state.recentAgents);
|
||||
const addToRecentAgents = useRecentAgentsStore((state) => state.addRecentAgent);
|
||||
|
||||
const [switching, setSwitching] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
|
||||
// Keep analytics ref up to date to avoid stale closure issues
|
||||
useEffect(() => {
|
||||
analyticsRef.current = analytics;
|
||||
}, [analytics]);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Invalidate all agent-specific queries when switching agents
|
||||
// This replaces the DOM event pattern (dexto:agentSwitched)
|
||||
const invalidateAgentSpecificQueries = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.sessions.all });
|
||||
queryClient.invalidateQueries({ queryKey: ['sessions', 'history'] }); // All session histories
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.memories.all });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.servers.all });
|
||||
queryClient.invalidateQueries({ queryKey: ['servers', 'tools'] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.resources.all });
|
||||
queryClient.invalidateQueries({ queryKey: ['greeting'] }); // Hierarchical invalidation
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.prompts.all });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agent.config }); // Agent config (CustomizePanel)
|
||||
}, [queryClient]);
|
||||
|
||||
// Check if an agent path is from the global ~/.dexto directory
|
||||
// Global pattern: /Users/<user>/.dexto/agents or /home/<user>/.dexto/agents
|
||||
// Also handles Windows: C:\Users\<user>\.dexto\agents
|
||||
const isGlobalAgent = useCallback((path: string): boolean => {
|
||||
// Match paths where .dexto appears within first 4 segments (home directory level)
|
||||
// POSIX: /Users/username/.dexto/agents/... (index 2)
|
||||
// Windows: C:/Users/username/.dexto/agents/... (index 3, drive letter adds extra segment)
|
||||
// Project: /Users/username/Projects/my-project/.dexto/agents/... (5+ segments)
|
||||
// Normalize Windows backslashes to forward slashes for consistent parsing
|
||||
const normalized = path.replace(/\\/g, '/');
|
||||
const segments = normalized.split('/').filter(Boolean);
|
||||
const dextoIndex = segments.findIndex((s) => s === '.dexto');
|
||||
return dextoIndex >= 0 && dextoIndex <= 3;
|
||||
}, []);
|
||||
|
||||
// Fetch agents list and path using typed hooks
|
||||
const { data: agentsData, isLoading: agentsLoading, refetch: refetchAgents } = useAgents();
|
||||
const { data: currentAgentPathData } = useAgentPath();
|
||||
|
||||
const installed = useMemo(() => agentsData?.installed || [], [agentsData?.installed]);
|
||||
const available = useMemo(() => agentsData?.available || [], [agentsData?.available]);
|
||||
const currentId = agentsData?.current.id || null;
|
||||
const currentAgentPath = currentAgentPathData ?? null;
|
||||
|
||||
// Agent mutations using typed hooks
|
||||
const switchAgentMutation = useSwitchAgent();
|
||||
const installAgentMutation = useInstallAgent();
|
||||
const deleteAgentMutation = useUninstallAgent();
|
||||
|
||||
// Sync current agent path to recent agents when it loads
|
||||
useEffect(() => {
|
||||
if (currentAgentPath?.path && currentAgentPath?.name) {
|
||||
addToRecentAgents({
|
||||
id: currentAgentPath.name,
|
||||
name: currentAgentPath.name,
|
||||
path: currentAgentPath.path,
|
||||
});
|
||||
}
|
||||
}, [currentAgentPath, addToRecentAgents]);
|
||||
|
||||
const loading = agentsLoading;
|
||||
|
||||
const handleSwitch = useCallback(
|
||||
async (agentId: string) => {
|
||||
try {
|
||||
setSwitching(true);
|
||||
// Check if the agent exists in the installed list
|
||||
const agent = installed.find((agent) => agent.id === agentId);
|
||||
if (!agent) {
|
||||
console.error(`Agent not found in installed list: ${agentId}`);
|
||||
throw new Error(
|
||||
`Agent '${agentId}' not found. Please refresh the agents list.`
|
||||
);
|
||||
}
|
||||
|
||||
// Capture current agent ID before switch
|
||||
const fromAgentId = currentId;
|
||||
|
||||
await switchAgentMutation.mutateAsync({ id: agentId });
|
||||
|
||||
setOpen(false); // Close dropdown after successful switch
|
||||
|
||||
// Track agent switch using ref to avoid stale closure
|
||||
analyticsRef.current.trackAgentSwitched({
|
||||
fromAgentId,
|
||||
toAgentId: agentId,
|
||||
toAgentName: agent.name,
|
||||
sessionId: currentSessionId || undefined,
|
||||
});
|
||||
|
||||
// Invalidate all agent-specific queries
|
||||
invalidateAgentSpecificQueries();
|
||||
|
||||
// Navigate back to home after switching agents
|
||||
// The ChatApp component will automatically handle returnToWelcome when sessionId prop is undefined
|
||||
navigate({ to: '/' });
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Switch agent failed: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to switch agent';
|
||||
alert(`Failed to switch agent: ${errorMessage}`);
|
||||
} finally {
|
||||
setSwitching(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
installed,
|
||||
navigate,
|
||||
currentId,
|
||||
currentSessionId,
|
||||
switchAgentMutation,
|
||||
invalidateAgentSpecificQueries,
|
||||
]
|
||||
);
|
||||
|
||||
const handleSwitchToPath = useCallback(
|
||||
async (agent: { id: string; name: string; path: string }) => {
|
||||
try {
|
||||
setSwitching(true);
|
||||
|
||||
// Capture current agent ID before switch
|
||||
const fromAgentId = currentId;
|
||||
|
||||
await switchAgentMutation.mutateAsync({ id: agent.id, path: agent.path });
|
||||
|
||||
setOpen(false); // Close dropdown after successful switch
|
||||
|
||||
// Add to recent agents
|
||||
addToRecentAgents(agent);
|
||||
|
||||
// Track agent switch using ref to avoid stale closure
|
||||
analyticsRef.current.trackAgentSwitched({
|
||||
fromAgentId,
|
||||
toAgentId: agent.id,
|
||||
toAgentName: agent.name,
|
||||
sessionId: currentSessionId || undefined,
|
||||
});
|
||||
|
||||
// Invalidate all agent-specific queries
|
||||
invalidateAgentSpecificQueries();
|
||||
|
||||
// Navigate back to home after switching agents
|
||||
// The ChatApp component will automatically handle returnToWelcome when sessionId prop is undefined
|
||||
navigate({ to: '/' });
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Switch agent failed: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to switch agent';
|
||||
alert(`Failed to switch agent: ${errorMessage}`);
|
||||
} finally {
|
||||
setSwitching(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
addToRecentAgents,
|
||||
navigate,
|
||||
currentId,
|
||||
currentSessionId,
|
||||
switchAgentMutation,
|
||||
invalidateAgentSpecificQueries,
|
||||
]
|
||||
);
|
||||
|
||||
const handleInstall = useCallback(
|
||||
async (agentId: string) => {
|
||||
try {
|
||||
setSwitching(true);
|
||||
|
||||
// Capture current agent ID before operations
|
||||
const fromAgentId = currentId;
|
||||
|
||||
// Step 1: Install the agent
|
||||
await installAgentMutation.mutateAsync({ id: agentId });
|
||||
|
||||
// Step 2: Refetch agents list to ensure cache has fresh data
|
||||
await queryClient.refetchQueries({ queryKey: queryKeys.agents.all });
|
||||
|
||||
// Step 3: Verify agent is now in installed list
|
||||
const freshData = queryClient.getQueryData<AgentsResponse>(queryKeys.agents.all);
|
||||
const agent = freshData?.installed.find((a) => a.id === agentId);
|
||||
if (!agent) {
|
||||
throw new Error(
|
||||
`Agent '${agentId}' not found after installation. Please refresh.`
|
||||
);
|
||||
}
|
||||
|
||||
// Step 4: Switch to the newly installed agent
|
||||
await switchAgentMutation.mutateAsync({ id: agentId });
|
||||
|
||||
setOpen(false);
|
||||
|
||||
// Step 5: Track the switch analytics
|
||||
analyticsRef.current.trackAgentSwitched({
|
||||
fromAgentId,
|
||||
toAgentId: agentId,
|
||||
toAgentName: agent.name,
|
||||
sessionId: currentSessionId || undefined,
|
||||
});
|
||||
|
||||
// Step 6: Invalidate all agent-specific queries
|
||||
invalidateAgentSpecificQueries();
|
||||
|
||||
// Step 7: Navigate to home
|
||||
// The ChatApp component will automatically handle returnToWelcome when sessionId prop is undefined
|
||||
navigate({ to: '/' });
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Install/switch agent failed: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : 'Failed to install/switch agent';
|
||||
alert(`Failed to install/switch agent: ${errorMessage}`);
|
||||
} finally {
|
||||
setSwitching(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
navigate,
|
||||
currentId,
|
||||
currentSessionId,
|
||||
queryClient,
|
||||
installAgentMutation,
|
||||
switchAgentMutation,
|
||||
invalidateAgentSpecificQueries,
|
||||
]
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (agent: AgentItem, e: React.MouseEvent) => {
|
||||
e.stopPropagation(); // Prevent triggering switch when clicking delete
|
||||
if (!confirm(`Are you sure you want to delete the custom agent "${agent.name}"?`)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setSwitching(true);
|
||||
await deleteAgentMutation.mutateAsync({ id: agent.id });
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Delete agent failed: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to delete agent';
|
||||
alert(`Failed to delete agent: ${errorMessage}`);
|
||||
} finally {
|
||||
setSwitching(false);
|
||||
}
|
||||
},
|
||||
[deleteAgentMutation]
|
||||
);
|
||||
|
||||
const currentLabel = useMemo(() => {
|
||||
if (!currentId) return 'Choose Agent';
|
||||
const match =
|
||||
installed.find((agent) => agent.id === currentId) ||
|
||||
available.find((agent) => agent.id === currentId);
|
||||
return match?.name ?? currentId;
|
||||
}, [available, currentId, installed]);
|
||||
|
||||
const handleAgentCreated = useCallback(
|
||||
async (_agentName: string) => {
|
||||
await refetchAgents();
|
||||
},
|
||||
[refetchAgents]
|
||||
);
|
||||
|
||||
const getButtonClassName = (mode: string) => {
|
||||
switch (mode) {
|
||||
case 'badge':
|
||||
// Teal text, transparent bg
|
||||
return `h-9 px-4 text-lg font-medium rounded-lg bg-transparent text-teal-600 hover:bg-muted/50 hover:text-teal-700 dark:text-teal-400 dark:hover:text-teal-300 transition-colors min-w-[120px] max-w-[180px] md:min-w-[160px] md:max-w-[280px] lg:max-w-[400px] xl:max-w-[500px]`;
|
||||
case 'title':
|
||||
return `h-11 px-4 text-lg font-bold rounded-lg bg-gradient-to-r from-teal-500/30 to-teal-500/40 text-teal-600 hover:from-teal-500/50 hover:to-teal-500/60 hover:text-teal-700 focus-visible:ring-2 focus-visible:ring-teal-500/50 focus-visible:ring-offset-2 border border-teal-500/40 dark:text-teal-400 dark:hover:text-teal-300 dark:border-teal-400 transition-all duration-200 shadow-lg hover:shadow-xl`;
|
||||
default:
|
||||
return `h-10 px-3 text-sm rounded-lg bg-teal-500/40 text-teal-600 hover:bg-teal-500/50 hover:text-teal-700 focus-visible:ring-2 focus-visible:ring-teal-500/50 focus-visible:ring-offset-2 border border-teal-500/50 dark:text-teal-400 dark:hover:text-teal-300 dark:border-teal-400 transition-all duration-200 shadow-lg hover:shadow-xl`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant={mode === 'badge' ? 'ghost' : 'default'}
|
||||
size="sm"
|
||||
className={getButtonClassName(mode)}
|
||||
disabled={switching}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full min-w-0">
|
||||
{mode !== 'badge' && (
|
||||
<Sparkles className="w-4 h-4 mr-2 flex-shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'truncate min-w-0',
|
||||
mode === 'badge'
|
||||
? 'flex-1 text-left'
|
||||
: 'flex-1 text-center px-1'
|
||||
)}
|
||||
>
|
||||
{switching
|
||||
? 'Switching...'
|
||||
: mode === 'title'
|
||||
? `Agent: ${currentLabel}`
|
||||
: currentLabel}
|
||||
</span>
|
||||
<ChevronDown className="w-4 h-4 ml-1.5 flex-shrink-0 opacity-60" />
|
||||
</div>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Select agent</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="w-80 max-h-96 overflow-y-auto shadow-xl border-border/30"
|
||||
>
|
||||
{loading && (
|
||||
<DropdownMenuItem disabled className="text-center text-muted-foreground">
|
||||
Loading agents...
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{!loading && (
|
||||
<>
|
||||
{/* Create New Agent Button */}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setCreateModalOpen(true);
|
||||
setOpen(false);
|
||||
}}
|
||||
disabled={switching}
|
||||
className="cursor-pointer py-3 px-3 mx-1 my-1 bg-gradient-to-r from-primary/10 to-primary/5 hover:from-primary/15 hover:to-primary/10 border border-primary/20 hover:border-primary/30 transition-all rounded-md shadow-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<Plus className="w-4 h-4 text-primary" />
|
||||
<span className="font-semibold text-primary">New Agent</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* Current Agent (if loaded from file and not in installed list) */}
|
||||
{currentAgentPath &&
|
||||
!installed.some((a) => a.id === currentAgentPath.name) && (
|
||||
<>
|
||||
<div className="px-3 py-2 mt-1 text-xs font-bold text-teal-600 dark:text-teal-400 uppercase tracking-wider border-b border-border/20">
|
||||
Currently Active
|
||||
</div>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
handleSwitchToPath({
|
||||
id: currentAgentPath.name,
|
||||
name: currentAgentPath.name,
|
||||
path: currentAgentPath.path,
|
||||
})
|
||||
}
|
||||
disabled={
|
||||
switching || currentId === currentAgentPath.name
|
||||
}
|
||||
className="cursor-pointer py-3 px-3 hover:bg-muted/60 transition-colors rounded-md mx-1"
|
||||
>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm truncate">
|
||||
{currentAgentPath.name}
|
||||
</span>
|
||||
{currentId === currentAgentPath.name && (
|
||||
<Check className="w-4 h-4 text-green-600 flex-shrink-0 animate-in fade-in duration-200" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">
|
||||
Loaded from file
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Recent Agents */}
|
||||
{recentAgents.length > 0 && (
|
||||
<>
|
||||
<div className="px-3 py-2 mt-1 text-xs font-bold text-foreground/70 uppercase tracking-wider border-b border-border/20">
|
||||
Recent
|
||||
</div>
|
||||
{recentAgents
|
||||
.filter(
|
||||
(ra) =>
|
||||
!installed.some((a) => a.id === ra.id) &&
|
||||
ra.id !== currentAgentPath?.name &&
|
||||
!isGlobalAgent(ra.path) // Filter out global dexto directory agents
|
||||
)
|
||||
.slice(0, 3)
|
||||
.map((agent) => (
|
||||
<DropdownMenuItem
|
||||
key={agent.path}
|
||||
onClick={() => handleSwitchToPath(agent)}
|
||||
disabled={switching || agent.id === currentId}
|
||||
className="cursor-pointer py-3 px-3 hover:bg-muted/60 transition-colors rounded-md mx-1"
|
||||
>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm truncate">
|
||||
{agent.name}
|
||||
</span>
|
||||
{agent.id === currentId && (
|
||||
<Check className="w-4 h-4 text-green-600 flex-shrink-0 animate-in fade-in duration-200" />
|
||||
)}
|
||||
</div>
|
||||
<p
|
||||
className="text-xs text-muted-foreground mt-0.5 truncate"
|
||||
title={agent.path}
|
||||
>
|
||||
{agent.path}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Installed Custom Agents */}
|
||||
{installed.filter((a) => a.type === 'custom').length > 0 && (
|
||||
<>
|
||||
<div className="px-3 py-2 mt-1 text-xs font-bold text-primary uppercase tracking-wider flex items-center gap-1 border-b border-border/20">
|
||||
<BadgeCheck className="w-3 h-3" />
|
||||
Custom Agents
|
||||
</div>
|
||||
{installed
|
||||
.filter((a) => a.type === 'custom')
|
||||
.map((agent) => (
|
||||
<DropdownMenuItem
|
||||
key={agent.id}
|
||||
onClick={() => handleSwitch(agent.id)}
|
||||
disabled={switching || agent.id === currentId}
|
||||
className="cursor-pointer py-3 px-3 hover:bg-muted/60 transition-colors rounded-md mx-1"
|
||||
>
|
||||
<div className="flex items-center justify-between w-full gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm truncate">
|
||||
{agent.name}
|
||||
</span>
|
||||
{agent.id === currentId && (
|
||||
<Check className="w-4 h-4 text-green-600 flex-shrink-0 animate-in fade-in duration-200" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">
|
||||
{agent.description}
|
||||
</p>
|
||||
{agent.author && (
|
||||
<p className="text-xs text-muted-foreground/80 mt-0.5">
|
||||
by {agent.author}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => handleDelete(agent, e)}
|
||||
disabled={switching}
|
||||
className="p-1 hover:bg-red-100 dark:hover:bg-red-900/30 rounded transition-colors"
|
||||
title="Delete custom agent"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-600 dark:text-red-400" />
|
||||
</button>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{/* Installed Builtin Agents */}
|
||||
{installed.filter((a) => a.type === 'builtin').length > 0 && (
|
||||
<>
|
||||
{installed.filter((a) => a.type === 'custom').length > 0 && (
|
||||
<DropdownMenuSeparator />
|
||||
)}
|
||||
<div className="px-3 py-2 mt-1 text-xs font-bold text-foreground/70 uppercase tracking-wider border-b border-border/20">
|
||||
Installed
|
||||
</div>
|
||||
{installed
|
||||
.filter((a) => a.type === 'builtin')
|
||||
.map((agent) => (
|
||||
<DropdownMenuItem
|
||||
key={agent.id}
|
||||
onClick={() => handleSwitch(agent.id)}
|
||||
disabled={switching || agent.id === currentId}
|
||||
className="cursor-pointer py-3 px-3 hover:bg-muted/60 transition-colors rounded-md mx-1"
|
||||
>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm truncate">
|
||||
{agent.name}
|
||||
</span>
|
||||
{agent.id === currentId && (
|
||||
<Check className="w-4 h-4 text-green-600 flex-shrink-0 animate-in fade-in duration-200" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">
|
||||
{agent.description}
|
||||
</p>
|
||||
{agent.author && (
|
||||
<p className="text-xs text-muted-foreground/80 mt-0.5">
|
||||
by {agent.author}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{/* Available Builtin Agents */}
|
||||
{available.filter((a) => a.type === 'builtin').length > 0 && (
|
||||
<>
|
||||
{installed.length > 0 && <DropdownMenuSeparator />}
|
||||
<div className="px-3 py-2 mt-1 text-xs font-bold text-foreground/70 uppercase tracking-wider border-b border-border/20">
|
||||
Available
|
||||
</div>
|
||||
{available
|
||||
.filter((a) => a.type === 'builtin')
|
||||
.map((agent) => (
|
||||
<DropdownMenuItem
|
||||
key={agent.id}
|
||||
onClick={() => handleInstall(agent.id)}
|
||||
disabled={switching}
|
||||
className="cursor-pointer py-3 px-3 hover:bg-muted/60 transition-colors rounded-md mx-1"
|
||||
>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm truncate">
|
||||
{agent.name}
|
||||
</span>
|
||||
<DownloadCloud className="w-4 h-4 text-blue-600 flex-shrink-0" />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">
|
||||
{agent.description}
|
||||
</p>
|
||||
{agent.author && (
|
||||
<p className="text-xs text-muted-foreground/80 mt-0.5">
|
||||
by {agent.author}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{!loading && installed.length === 0 && available.length === 0 && (
|
||||
<DropdownMenuItem
|
||||
disabled
|
||||
className="text-center text-muted-foreground"
|
||||
>
|
||||
No agents found
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<CreateAgentModal
|
||||
open={createModalOpen}
|
||||
onOpenChange={setCreateModalOpen}
|
||||
onAgentCreated={handleAgentCreated}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useCreateAgent, type CreateAgentPayload } from '../hooks/useAgents';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '../ui/dialog';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Textarea } from '../ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
|
||||
import { AlertCircle, Loader2, Eye, EyeOff, Info } from 'lucide-react';
|
||||
import { LLM_PROVIDERS } from '@dexto/core';
|
||||
|
||||
interface CreateAgentModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onAgentCreated?: (agentName: string) => void;
|
||||
}
|
||||
|
||||
interface FormData {
|
||||
id: string;
|
||||
idManuallyEdited: boolean;
|
||||
name: string;
|
||||
description: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
apiKey: string;
|
||||
systemPrompt: string;
|
||||
}
|
||||
|
||||
const initialFormData: FormData = {
|
||||
id: '',
|
||||
idManuallyEdited: false,
|
||||
name: '',
|
||||
description: '',
|
||||
provider: 'anthropic',
|
||||
model: 'claude-sonnet-4-5-20250929',
|
||||
apiKey: '',
|
||||
systemPrompt: '',
|
||||
};
|
||||
|
||||
// Convert name to a valid ID (lowercase, hyphens, no special chars)
|
||||
function nameToId(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9\s-]/g, '') // Remove special chars
|
||||
.replace(/\s+/g, '-') // Spaces to hyphens
|
||||
.replace(/-+/g, '-') // Multiple hyphens to single
|
||||
.replace(/^-|-$/g, ''); // Trim leading/trailing hyphens
|
||||
}
|
||||
|
||||
export default function CreateAgentModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
onAgentCreated,
|
||||
}: CreateAgentModalProps) {
|
||||
const [form, setForm] = useState<FormData>(initialFormData);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const createAgentMutation = useCreateAgent();
|
||||
const isCreating = createAgentMutation.isPending;
|
||||
|
||||
const updateField = (field: keyof FormData, value: string) => {
|
||||
setForm((prev) => {
|
||||
const next = { ...prev, [field]: value };
|
||||
|
||||
// Auto-generate ID from name if ID hasn't been manually edited
|
||||
if (field === 'name' && !prev.idManuallyEdited) {
|
||||
next.id = nameToId(value);
|
||||
}
|
||||
|
||||
// Mark ID as manually edited if user types in it
|
||||
if (field === 'id') {
|
||||
next.idManuallyEdited = true;
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
if (errors[field]) {
|
||||
setErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[field];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!form.id.trim()) {
|
||||
newErrors.id = 'Required';
|
||||
} else if (!/^[a-z0-9-]+$/.test(form.id)) {
|
||||
newErrors.id = 'Lowercase letters, numbers, and hyphens only';
|
||||
}
|
||||
|
||||
if (!form.name.trim()) {
|
||||
newErrors.name = 'Required';
|
||||
}
|
||||
|
||||
if (!form.description.trim()) {
|
||||
newErrors.description = 'Required';
|
||||
}
|
||||
|
||||
if (!form.provider) {
|
||||
newErrors.provider = 'Required';
|
||||
}
|
||||
|
||||
if (!form.model.trim()) {
|
||||
newErrors.model = 'Required';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
setCreateError(null);
|
||||
|
||||
const payload: CreateAgentPayload = {
|
||||
id: form.id.trim(),
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim(),
|
||||
config: {
|
||||
llm: {
|
||||
provider: form.provider as CreateAgentPayload['config']['llm']['provider'],
|
||||
model: form.model.trim(),
|
||||
apiKey: form.apiKey.trim() || undefined,
|
||||
},
|
||||
...(form.systemPrompt.trim() && {
|
||||
systemPrompt: {
|
||||
contributors: [
|
||||
{
|
||||
id: 'primary',
|
||||
type: 'static' as const,
|
||||
priority: 0,
|
||||
enabled: true,
|
||||
content: form.systemPrompt.trim(),
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
createAgentMutation.mutate(payload, {
|
||||
onSuccess: (data) => {
|
||||
setForm(initialFormData);
|
||||
setErrors({});
|
||||
onOpenChange(false);
|
||||
if (onAgentCreated && data.id) {
|
||||
onAgentCreated(data.id);
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setCreateError(error.message);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setForm(initialFormData);
|
||||
setErrors({});
|
||||
setCreateError(null);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md max-h-[85vh] flex flex-col gap-0 p-0 overflow-hidden">
|
||||
<DialogHeader className="px-5 pt-5 pb-4 border-b border-border/40">
|
||||
<DialogTitle className="text-base">Create Agent</DialogTitle>
|
||||
<DialogDescription className="text-sm">
|
||||
Configure your new agent. Advanced options can be set after creation.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Error */}
|
||||
{createError && (
|
||||
<div className="mx-5 mt-4 p-3 rounded-lg bg-destructive/10 border border-destructive/20 flex items-start gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-destructive shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-destructive">{createError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-5">
|
||||
{/* Identity */}
|
||||
<Section title="Identity">
|
||||
<Field label="Name" required error={errors.name}>
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(e) => updateField('name', e.target.value)}
|
||||
placeholder="My Agent"
|
||||
aria-invalid={!!errors.name}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="ID"
|
||||
required
|
||||
error={errors.id}
|
||||
hint={!form.idManuallyEdited ? 'Auto-generated from name' : undefined}
|
||||
>
|
||||
<Input
|
||||
value={form.id}
|
||||
onChange={(e) => updateField('id', e.target.value)}
|
||||
placeholder="my-agent"
|
||||
aria-invalid={!!errors.id}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Description" required error={errors.description}>
|
||||
<Input
|
||||
value={form.description}
|
||||
onChange={(e) => updateField('description', e.target.value)}
|
||||
placeholder="A helpful assistant for..."
|
||||
aria-invalid={!!errors.description}
|
||||
/>
|
||||
</Field>
|
||||
</Section>
|
||||
|
||||
{/* Model */}
|
||||
<Section title="Language Model">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Provider" required error={errors.provider}>
|
||||
<Select
|
||||
value={form.provider}
|
||||
onValueChange={(value) => updateField('provider', value)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select provider..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LLM_PROVIDERS.map((p) => (
|
||||
<SelectItem key={p} value={p}>
|
||||
{p.charAt(0).toUpperCase() +
|
||||
p.slice(1).replace(/-/g, ' ')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Model" required error={errors.model}>
|
||||
<Input
|
||||
value={form.model}
|
||||
onChange={(e) => updateField('model', e.target.value)}
|
||||
placeholder="claude-sonnet-4-5-20250929"
|
||||
aria-invalid={!!errors.model}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="API Key" hint="Leave empty to use environment variable">
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
value={form.apiKey}
|
||||
onChange={(e) => updateField('apiKey', e.target.value)}
|
||||
placeholder="$ANTHROPIC_API_KEY"
|
||||
className="pr-9"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-muted/50 transition-colors"
|
||||
aria-label={showApiKey ? 'Hide API key' : 'Show API key'}
|
||||
>
|
||||
{showApiKey ? (
|
||||
<EyeOff className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
</Section>
|
||||
|
||||
{/* System Prompt */}
|
||||
<Section title="System Prompt" optional>
|
||||
<Field>
|
||||
<Textarea
|
||||
value={form.systemPrompt}
|
||||
onChange={(e) => updateField('systemPrompt', e.target.value)}
|
||||
placeholder="You are a helpful assistant..."
|
||||
rows={4}
|
||||
className="font-mono text-sm resize-y"
|
||||
/>
|
||||
</Field>
|
||||
<p className="text-[11px] text-muted-foreground/70 flex items-center gap-1">
|
||||
<Info className="h-3 w-3" />
|
||||
You can add MCP servers and other options after creation
|
||||
</p>
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<DialogFooter className="px-5 py-4 border-t border-border/40 bg-muted/20">
|
||||
<Button variant="outline" onClick={handleCancel} disabled={isCreating}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={isCreating}>
|
||||
{isCreating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SHARED COMPONENTS
|
||||
// ============================================================================
|
||||
|
||||
function Section({
|
||||
title,
|
||||
optional,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
optional?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-2.5 flex items-baseline gap-2">
|
||||
<h3 className="text-sm font-medium text-foreground">{title}</h3>
|
||||
{optional && (
|
||||
<span className="text-[10px] text-muted-foreground/60 uppercase tracking-wide">
|
||||
Optional
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-3">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
required,
|
||||
hint,
|
||||
error,
|
||||
children,
|
||||
}: {
|
||||
label?: string;
|
||||
required?: boolean;
|
||||
hint?: string;
|
||||
error?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
{label && (
|
||||
<label className="block text-xs font-medium text-muted-foreground mb-1.5">
|
||||
{label}
|
||||
{required && <span className="text-destructive ml-0.5">*</span>}
|
||||
</label>
|
||||
)}
|
||||
{children}
|
||||
{hint && !error && <p className="text-[11px] text-muted-foreground/70 mt-1">{hint}</p>}
|
||||
{error && <p className="text-[11px] text-destructive mt-1">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user