622 lines
22 KiB
TypeScript
622 lines
22 KiB
TypeScript
'use client'
|
||
|
||
import { useState, useEffect, useCallback } from 'react'
|
||
import { createClientLogger } from '@/lib/client-logger'
|
||
|
||
const log = createClientLogger('AgentSquadPanel')
|
||
|
||
interface Agent {
|
||
id: number
|
||
name: string
|
||
role: string
|
||
session_key?: string
|
||
soul_content?: string
|
||
status: 'offline' | 'idle' | 'busy' | 'error'
|
||
last_seen?: number
|
||
last_activity?: string
|
||
created_at: number
|
||
updated_at: number
|
||
config?: any
|
||
taskStats?: {
|
||
total: number
|
||
assigned: number
|
||
in_progress: number
|
||
completed: number
|
||
}
|
||
}
|
||
|
||
const statusColors: Record<string, string> = {
|
||
offline: 'bg-gray-500',
|
||
idle: 'bg-green-500',
|
||
busy: 'bg-yellow-500',
|
||
error: 'bg-red-500',
|
||
}
|
||
|
||
const statusIcons: Record<string, string> = {
|
||
offline: '⚫',
|
||
idle: '🟢',
|
||
busy: '🟡',
|
||
error: '🔴',
|
||
}
|
||
|
||
export function AgentSquadPanel() {
|
||
const [agents, setAgents] = useState<Agent[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null)
|
||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||
const [autoRefresh, setAutoRefresh] = useState(true)
|
||
|
||
// Fetch agents
|
||
const fetchAgents = useCallback(async () => {
|
||
try {
|
||
setError(null)
|
||
if (agents.length === 0) setLoading(true)
|
||
|
||
const response = await fetch('/api/agents')
|
||
if (!response.ok) throw new Error('Failed to fetch agents')
|
||
|
||
const data = await response.json()
|
||
setAgents(data.agents || [])
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : 'An error occurred')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}, [agents.length])
|
||
|
||
// Initial load
|
||
useEffect(() => {
|
||
fetchAgents()
|
||
}, [fetchAgents])
|
||
|
||
// Auto-refresh
|
||
useEffect(() => {
|
||
if (!autoRefresh) return
|
||
|
||
const interval = setInterval(fetchAgents, 10000) // Every 10 seconds
|
||
return () => clearInterval(interval)
|
||
}, [autoRefresh, fetchAgents])
|
||
|
||
// Update agent status
|
||
const updateAgentStatus = async (agentName: string, status: Agent['status'], activity?: string) => {
|
||
try {
|
||
const response = await fetch('/api/agents', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
name: agentName,
|
||
status,
|
||
last_activity: activity || `Status changed to ${status}`
|
||
})
|
||
})
|
||
|
||
if (!response.ok) throw new Error('Failed to update agent status')
|
||
|
||
// Update local state
|
||
setAgents(prev => prev.map(agent =>
|
||
agent.name === agentName
|
||
? {
|
||
...agent,
|
||
status,
|
||
last_activity: activity || `Status changed to ${status}`,
|
||
last_seen: Math.floor(Date.now() / 1000),
|
||
updated_at: Math.floor(Date.now() / 1000)
|
||
}
|
||
: agent
|
||
))
|
||
} catch (error) {
|
||
log.error('Failed to update agent status:', error)
|
||
setError('Failed to update agent status')
|
||
}
|
||
}
|
||
|
||
// Format last seen time
|
||
const formatLastSeen = (timestamp?: number) => {
|
||
if (!timestamp) return 'Never'
|
||
|
||
const now = Date.now()
|
||
const diffMs = now - (timestamp * 1000)
|
||
const diffMinutes = Math.floor(diffMs / (1000 * 60))
|
||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
||
|
||
if (diffMinutes < 1) return 'Just now'
|
||
if (diffMinutes < 60) return `${diffMinutes}m ago`
|
||
if (diffHours < 24) return `${diffHours}h ago`
|
||
if (diffDays < 7) return `${diffDays}d ago`
|
||
|
||
return new Date(timestamp * 1000).toLocaleDateString()
|
||
}
|
||
|
||
// Get status distribution for summary
|
||
const statusCounts = agents.reduce((acc, agent) => {
|
||
acc[agent.status] = (acc[agent.status] || 0) + 1
|
||
return acc
|
||
}, {} as Record<string, number>)
|
||
|
||
if (loading && agents.length === 0) {
|
||
return (
|
||
<div className="flex items-center justify-center h-64">
|
||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
||
<span className="ml-2 text-gray-400">Loading agents...</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="h-full flex flex-col bg-gray-900">
|
||
{/* Header */}
|
||
<div className="flex justify-between items-center p-4 border-b border-gray-700">
|
||
<div className="flex items-center gap-4">
|
||
<h2 className="text-xl font-bold text-white">Agent Squad</h2>
|
||
|
||
{/* Status Summary */}
|
||
<div className="flex gap-2 text-sm">
|
||
{Object.entries(statusCounts).map(([status, count]) => (
|
||
<div key={status} className="flex items-center gap-1">
|
||
<div className={`w-2 h-2 rounded-full ${statusColors[status]}`}></div>
|
||
<span className="text-gray-400">{count}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={() => setAutoRefresh(!autoRefresh)}
|
||
className={`px-3 py-1 text-sm rounded transition-colors ${
|
||
autoRefresh
|
||
? 'bg-green-600 text-white hover:bg-green-700'
|
||
: 'bg-gray-600 text-white hover:bg-gray-700'
|
||
}`}
|
||
>
|
||
{autoRefresh ? 'Live' : 'Manual'}
|
||
</button>
|
||
<button
|
||
onClick={() => setShowCreateModal(true)}
|
||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
|
||
>
|
||
+ Add Agent
|
||
</button>
|
||
<button
|
||
onClick={fetchAgents}
|
||
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors"
|
||
>
|
||
Refresh
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Error Display */}
|
||
{error && (
|
||
<div className="bg-red-900/20 border border-red-500 text-red-400 p-3 m-4 rounded">
|
||
{error}
|
||
<button
|
||
onClick={() => setError(null)}
|
||
className="float-right text-red-300 hover:text-red-100"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Agent Grid */}
|
||
<div className="flex-1 p-4 overflow-y-auto">
|
||
{agents.length === 0 ? (
|
||
<div className="text-center text-gray-500 py-8">
|
||
<div className="text-4xl mb-2">🤖</div>
|
||
<p>No agents found</p>
|
||
<p className="text-sm">Add your first agent to get started</p>
|
||
</div>
|
||
) : (
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||
{agents.map(agent => (
|
||
<div
|
||
key={agent.id}
|
||
className="bg-gray-800 rounded-lg p-4 border-l-4 border-gray-600 hover:bg-gray-750 transition-colors cursor-pointer"
|
||
onClick={() => setSelectedAgent(agent)}
|
||
>
|
||
{/* Agent Header */}
|
||
<div className="flex items-start justify-between mb-3">
|
||
<div>
|
||
<h3 className="font-semibold text-white text-lg">{agent.name}</h3>
|
||
<p className="text-gray-400 text-sm">{agent.role}</p>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2">
|
||
<div className={`w-3 h-3 rounded-full ${statusColors[agent.status]} animate-pulse`}></div>
|
||
<span className="text-xs text-gray-400">{agent.status}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Session Info */}
|
||
{agent.session_key && (
|
||
<div className="text-xs text-gray-400 mb-2">
|
||
<span className="font-medium">Session:</span> {agent.session_key}
|
||
</div>
|
||
)}
|
||
|
||
{/* Task Stats */}
|
||
{agent.taskStats && (
|
||
<div className="grid grid-cols-2 gap-2 mb-3">
|
||
<div className="bg-gray-700/50 rounded p-2 text-center">
|
||
<div className="text-lg font-semibold text-white">{agent.taskStats.total}</div>
|
||
<div className="text-xs text-gray-400">Total Tasks</div>
|
||
</div>
|
||
<div className="bg-gray-700/50 rounded p-2 text-center">
|
||
<div className="text-lg font-semibold text-yellow-400">{agent.taskStats.in_progress}</div>
|
||
<div className="text-xs text-gray-400">In Progress</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Last Activity */}
|
||
<div className="text-xs text-gray-400 mb-3">
|
||
<div>
|
||
<span className="font-medium">Last seen:</span> {formatLastSeen(agent.last_seen)}
|
||
</div>
|
||
{agent.last_activity && (
|
||
<div className="mt-1 truncate" title={agent.last_activity}>
|
||
<span className="font-medium">Activity:</span> {agent.last_activity}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Quick Actions */}
|
||
<div className="flex gap-1">
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
updateAgentStatus(agent.name, 'idle', 'Manually activated')
|
||
}}
|
||
disabled={agent.status === 'idle'}
|
||
className="flex-1 px-2 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||
>
|
||
Wake
|
||
</button>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
updateAgentStatus(agent.name, 'busy', 'Manually set to busy')
|
||
}}
|
||
disabled={agent.status === 'busy'}
|
||
className="flex-1 px-2 py-1 text-xs bg-yellow-600 text-white rounded hover:bg-yellow-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||
>
|
||
Busy
|
||
</button>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
updateAgentStatus(agent.name, 'offline', 'Manually set offline')
|
||
}}
|
||
disabled={agent.status === 'offline'}
|
||
className="flex-1 px-2 py-1 text-xs bg-gray-600 text-white rounded hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||
>
|
||
Sleep
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Agent Detail Modal */}
|
||
{selectedAgent && (
|
||
<AgentDetailModal
|
||
agent={selectedAgent}
|
||
onClose={() => setSelectedAgent(null)}
|
||
onUpdate={fetchAgents}
|
||
onStatusUpdate={updateAgentStatus}
|
||
/>
|
||
)}
|
||
|
||
{/* Create Agent Modal */}
|
||
{showCreateModal && (
|
||
<CreateAgentModal
|
||
onClose={() => setShowCreateModal(false)}
|
||
onCreated={fetchAgents}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Agent Detail Modal
|
||
function AgentDetailModal({
|
||
agent,
|
||
onClose,
|
||
onUpdate,
|
||
onStatusUpdate
|
||
}: {
|
||
agent: Agent
|
||
onClose: () => void
|
||
onUpdate: () => void
|
||
onStatusUpdate: (name: string, status: Agent['status'], activity?: string) => Promise<void>
|
||
}) {
|
||
const [editing, setEditing] = useState(false)
|
||
const [formData, setFormData] = useState({
|
||
role: agent.role,
|
||
session_key: agent.session_key || '',
|
||
soul_content: agent.soul_content || '',
|
||
})
|
||
|
||
const handleSave = async () => {
|
||
try {
|
||
const response = await fetch('/api/agents', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
name: agent.name,
|
||
...formData
|
||
})
|
||
})
|
||
|
||
if (!response.ok) throw new Error('Failed to update agent')
|
||
|
||
setEditing(false)
|
||
onUpdate()
|
||
} catch (error) {
|
||
log.error('Failed to update agent:', error)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-gray-800 rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||
<div className="p-6">
|
||
<div className="flex justify-between items-start mb-4">
|
||
<div>
|
||
<h3 className="text-xl font-bold text-white">{agent.name}</h3>
|
||
<p className="text-gray-400">{agent.role}</p>
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<div className={`w-4 h-4 rounded-full ${statusColors[agent.status]}`}></div>
|
||
<span className="text-white">{agent.status}</span>
|
||
<button onClick={onClose} className="text-gray-400 hover:text-white text-2xl">×</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Status Controls */}
|
||
<div className="mb-6 p-4 bg-gray-700/50 rounded-lg">
|
||
<h4 className="text-sm font-medium text-white mb-2">Status Control</h4>
|
||
<div className="flex gap-2">
|
||
{(['idle', 'busy', 'offline'] as const).map(status => (
|
||
<button
|
||
key={status}
|
||
onClick={() => onStatusUpdate(agent.name, status)}
|
||
className={`px-3 py-1 text-sm rounded transition-colors ${
|
||
agent.status === status
|
||
? 'bg-blue-600 text-white'
|
||
: 'bg-gray-600 text-white hover:bg-gray-500'
|
||
}`}
|
||
>
|
||
{statusIcons[status]} {status}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Agent Details */}
|
||
<div className="space-y-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-400 mb-1">Role</label>
|
||
{editing ? (
|
||
<input
|
||
type="text"
|
||
value={formData.role}
|
||
onChange={(e) => setFormData(prev => ({ ...prev, role: e.target.value }))}
|
||
className="w-full bg-gray-700 text-white rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
/>
|
||
) : (
|
||
<p className="text-white">{agent.role}</p>
|
||
)}
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-400 mb-1">Session Key</label>
|
||
{editing ? (
|
||
<input
|
||
type="text"
|
||
value={formData.session_key}
|
||
onChange={(e) => setFormData(prev => ({ ...prev, session_key: e.target.value }))}
|
||
className="w-full bg-gray-700 text-white rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
/>
|
||
) : (
|
||
<p className="text-white font-mono">{agent.session_key || 'Not set'}</p>
|
||
)}
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-400 mb-1">SOUL Content</label>
|
||
{editing ? (
|
||
<textarea
|
||
value={formData.soul_content}
|
||
onChange={(e) => setFormData(prev => ({ ...prev, soul_content: e.target.value }))}
|
||
rows={4}
|
||
className="w-full bg-gray-700 text-white rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
placeholder="Agent personality and instructions..."
|
||
/>
|
||
) : (
|
||
<p className="text-white whitespace-pre-wrap">{agent.soul_content || 'Not set'}</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Task Statistics */}
|
||
{agent.taskStats && (
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-400 mb-1">Task Statistics</label>
|
||
<div className="grid grid-cols-4 gap-2">
|
||
<div className="bg-gray-700/50 rounded p-3 text-center">
|
||
<div className="text-lg font-semibold text-white">{agent.taskStats.total}</div>
|
||
<div className="text-xs text-gray-400">Total</div>
|
||
</div>
|
||
<div className="bg-gray-700/50 rounded p-3 text-center">
|
||
<div className="text-lg font-semibold text-blue-400">{agent.taskStats.assigned}</div>
|
||
<div className="text-xs text-gray-400">Assigned</div>
|
||
</div>
|
||
<div className="bg-gray-700/50 rounded p-3 text-center">
|
||
<div className="text-lg font-semibold text-yellow-400">{agent.taskStats.in_progress}</div>
|
||
<div className="text-xs text-gray-400">In Progress</div>
|
||
</div>
|
||
<div className="bg-gray-700/50 rounded p-3 text-center">
|
||
<div className="text-lg font-semibold text-green-400">{agent.taskStats.completed}</div>
|
||
<div className="text-xs text-gray-400">Done</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Timestamps */}
|
||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||
<div>
|
||
<span className="text-gray-400">Created:</span>
|
||
<span className="text-white ml-2">{new Date(agent.created_at * 1000).toLocaleDateString()}</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-gray-400">Last Updated:</span>
|
||
<span className="text-white ml-2">{new Date(agent.updated_at * 1000).toLocaleDateString()}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Actions */}
|
||
<div className="flex gap-3 mt-6">
|
||
{editing ? (
|
||
<>
|
||
<button
|
||
onClick={handleSave}
|
||
className="flex-1 bg-blue-600 text-white py-2 rounded hover:bg-blue-700 transition-colors"
|
||
>
|
||
Save Changes
|
||
</button>
|
||
<button
|
||
onClick={() => setEditing(false)}
|
||
className="flex-1 bg-gray-600 text-white py-2 rounded hover:bg-gray-700 transition-colors"
|
||
>
|
||
Cancel
|
||
</button>
|
||
</>
|
||
) : (
|
||
<button
|
||
onClick={() => setEditing(true)}
|
||
className="flex-1 bg-blue-600 text-white py-2 rounded hover:bg-blue-700 transition-colors"
|
||
>
|
||
Edit Agent
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Create Agent Modal
|
||
function CreateAgentModal({
|
||
onClose,
|
||
onCreated
|
||
}: {
|
||
onClose: () => void
|
||
onCreated: () => void
|
||
}) {
|
||
const [formData, setFormData] = useState({
|
||
name: '',
|
||
role: '',
|
||
session_key: '',
|
||
soul_content: '',
|
||
})
|
||
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault()
|
||
|
||
try {
|
||
const response = await fetch('/api/agents', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(formData)
|
||
})
|
||
|
||
if (!response.ok) throw new Error('Failed to create agent')
|
||
|
||
onCreated()
|
||
onClose()
|
||
} catch (error) {
|
||
log.error('Error creating agent:', error)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-gray-800 rounded-lg max-w-md w-full">
|
||
<form onSubmit={handleSubmit} className="p-6">
|
||
<h3 className="text-xl font-bold text-white mb-4">Create New Agent</h3>
|
||
|
||
<div className="space-y-4">
|
||
<div>
|
||
<label className="block text-sm text-gray-400 mb-1">Name</label>
|
||
<input
|
||
type="text"
|
||
value={formData.name}
|
||
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
||
className="w-full bg-gray-700 text-white rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm text-gray-400 mb-1">Role</label>
|
||
<input
|
||
type="text"
|
||
value={formData.role}
|
||
onChange={(e) => setFormData(prev => ({ ...prev, role: e.target.value }))}
|
||
className="w-full bg-gray-700 text-white rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
placeholder="e.g., researcher, developer, analyst"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm text-gray-400 mb-1">Session Key (Optional)</label>
|
||
<input
|
||
type="text"
|
||
value={formData.session_key}
|
||
onChange={(e) => setFormData(prev => ({ ...prev, session_key: e.target.value }))}
|
||
className="w-full bg-gray-700 text-white rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
placeholder="ClawdBot session identifier"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm text-gray-400 mb-1">SOUL Content (Optional)</label>
|
||
<textarea
|
||
value={formData.soul_content}
|
||
onChange={(e) => setFormData(prev => ({ ...prev, soul_content: e.target.value }))}
|
||
className="w-full bg-gray-700 text-white rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
rows={3}
|
||
placeholder="Agent personality and instructions..."
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex gap-3 mt-6">
|
||
<button
|
||
type="submit"
|
||
className="flex-1 bg-blue-600 text-white py-2 rounded hover:bg-blue-700 transition-colors"
|
||
>
|
||
Create Agent
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="flex-1 bg-gray-600 text-white py-2 rounded hover:bg-gray-700 transition-colors"
|
||
>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)
|
||
} |