feat(agents): Add Create/Delete Agent functionality

Backend:
- Added POST /city/agents endpoint for creating agents
- Added DELETE /city/agents/{id} endpoint for soft-deleting agents
- Added CreateAgentRequest, CreateAgentResponse, DeleteAgentResponse models

Frontend:
- Added '+ Новий агент' button on /agents page
- Created /agents/new page with full agent creation form
- Added 'Видалити агента' button in agent Identity tab (Danger Zone)

Features:
- Auto-generate slug from display_name
- Support for all agent fields: kind, role, model, node, district, microdao
- Color picker for agent color
- Visibility toggles (is_public, is_orchestrator)
- Soft delete with confirmation dialog
This commit is contained in:
Apple
2025-12-01 09:29:42 -08:00
parent 649d07ee29
commit 6cd8148872
5 changed files with 584 additions and 13 deletions

View File

@@ -471,6 +471,41 @@ export default function AgentConsolePage() {
canEdit={true}
onUpdated={refresh}
/>
{/* Danger Zone */}
<div className="bg-red-500/10 rounded-xl p-6 border border-red-500/30">
<h2 className="text-xl font-semibold text-red-400 mb-4 flex items-center gap-2">
<Settings className="w-5 h-5" />
Небезпечна зона
</h2>
<p className="text-white/50 mb-4">
Видалення агента приховає його з усіх списків. Цю дію можна скасувати через адміністратора.
</p>
<Button
variant="destructive"
onClick={async () => {
if (!confirm(`Ви впевнені, що хочете видалити агента "${dashboard.profile.display_name}"?`)) {
return;
}
try {
const res = await fetch(`/api/city/agents/${dashboard.profile.agent_id}`, {
method: 'DELETE',
});
if (res.ok) {
window.location.href = '/agents';
} else {
const data = await res.json();
alert(data.detail || 'Failed to delete agent');
}
} catch (e) {
alert('Error deleting agent');
}
}}
className="bg-red-600 hover:bg-red-700"
>
Видалити агента
</Button>
</div>
</div>
)}

View File

@@ -0,0 +1,366 @@
'use client';
import { useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { Bot, ChevronLeft, Save, Loader2 } from 'lucide-react';
// Districts list
const DISTRICTS = [
{ key: 'leadership', name: 'Leadership Hall' },
{ key: 'system', name: 'System Control' },
{ key: 'engineering', name: 'Engineering Lab' },
{ key: 'marketing', name: 'Marketing Hub' },
{ key: 'finance', name: 'Finance Office' },
{ key: 'web3', name: 'Web3 District' },
{ key: 'security', name: 'Security Bunker' },
{ key: 'vision', name: 'Vision Studio' },
{ key: 'rnd', name: 'R&D Laboratory' },
{ key: 'memory', name: 'Memory Vault' },
];
// Agent kinds
const AGENT_KINDS = [
{ key: 'assistant', name: 'Assistant', emoji: '🤖' },
{ key: 'orchestrator', name: 'Orchestrator', emoji: '🎭' },
{ key: 'specialist', name: 'Specialist', emoji: '🔧' },
{ key: 'civic', name: 'Civic', emoji: '🏛️' },
{ key: 'security', name: 'Security', emoji: '🛡️' },
{ key: 'finance', name: 'Finance', emoji: '💰' },
{ key: 'research', name: 'Research', emoji: '🔬' },
{ key: 'marketing', name: 'Marketing', emoji: '📢' },
{ key: 'builder', name: 'Builder', emoji: '🏗️' },
];
// Nodes
const NODES = [
{ id: 'node-1-hetzner-gex44', name: 'NODE1 (Hetzner Production)' },
{ id: 'node-2-macbook-m4max', name: 'NODE2 (MacBook Development)' },
];
export default function NewAgentPage() {
const router = useRouter();
const searchParams = useSearchParams();
const prefilledMicrodao = searchParams.get('microdao');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Form state
const [formData, setFormData] = useState({
display_name: '',
slug: '',
kind: 'assistant',
role: '',
model: '',
node_id: 'node-2-macbook-m4max',
home_microdao_id: prefilledMicrodao || '',
district: '',
avatar_url: '',
color_hint: '#6366F1',
is_public: false,
is_orchestrator: false,
priority: 'medium',
});
// Auto-generate slug from display_name
const handleNameChange = (name: string) => {
const slug = name
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim();
setFormData(prev => ({ ...prev, display_name: name, slug }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setIsSubmitting(true);
try {
const response = await fetch('/api/city/agents', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.detail || 'Failed to create agent');
}
const data = await response.json();
router.push(`/agents/${data.id}`);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setIsSubmitting(false);
}
};
return (
<div className="min-h-screen bg-gradient-to-b from-slate-900 via-slate-900 to-slate-950">
<div className="max-w-3xl mx-auto px-4 py-12">
{/* Header */}
<div className="mb-8">
<Link
href="/agents"
className="inline-flex items-center gap-2 text-white/60 hover:text-white mb-4 transition-colors"
>
<ChevronLeft className="w-4 h-4" />
Назад до агентів
</Link>
<div className="flex items-center gap-3">
<Bot className="w-8 h-8 text-violet-400" />
<h1 className="text-3xl font-bold text-white">Створити агента</h1>
</div>
<p className="text-white/60 mt-2">
Створіть нового AI-агента для мережі DAARION
</p>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Error message */}
{error && (
<div className="bg-red-500/10 border border-red-500/30 rounded-xl p-4">
<p className="text-red-400">{error}</p>
</div>
)}
{/* Basic Info */}
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6 space-y-4">
<h2 className="text-lg font-semibold text-white mb-4">Основна інформація</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm text-white/60 mb-2">
Ім&apos;я агента *
</label>
<input
type="text"
value={formData.display_name}
onChange={(e) => handleNameChange(e.target.value)}
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/30 focus:outline-none focus:border-violet-500/50"
placeholder="Наприклад: Helix"
required
/>
</div>
<div>
<label className="block text-sm text-white/60 mb-2">
Slug (ID)
</label>
<input
type="text"
value={formData.slug}
onChange={(e) => setFormData(prev => ({ ...prev, slug: e.target.value }))}
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/30 focus:outline-none focus:border-violet-500/50"
placeholder="helix"
required
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm text-white/60 mb-2">
Тип агента
</label>
<select
value={formData.kind}
onChange={(e) => setFormData(prev => ({ ...prev, kind: e.target.value }))}
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white focus:outline-none focus:border-violet-500/50"
>
{AGENT_KINDS.map(kind => (
<option key={kind.key} value={kind.key} className="bg-slate-800">
{kind.emoji} {kind.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm text-white/60 mb-2">
Роль / Посада
</label>
<input
type="text"
value={formData.role}
onChange={(e) => setFormData(prev => ({ ...prev, role: e.target.value }))}
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/30 focus:outline-none focus:border-violet-500/50"
placeholder="CTO, Security Expert, etc."
/>
</div>
</div>
</div>
{/* Location */}
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6 space-y-4">
<h2 className="text-lg font-semibold text-white mb-4">Розташування</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm text-white/60 mb-2">
Нода
</label>
<select
value={formData.node_id}
onChange={(e) => setFormData(prev => ({ ...prev, node_id: e.target.value }))}
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white focus:outline-none focus:border-violet-500/50"
>
{NODES.map(node => (
<option key={node.id} value={node.id} className="bg-slate-800">
{node.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm text-white/60 mb-2">
Район міста
</label>
<select
value={formData.district}
onChange={(e) => setFormData(prev => ({ ...prev, district: e.target.value }))}
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white focus:outline-none focus:border-violet-500/50"
>
<option value="" className="bg-slate-800">Не вибрано</option>
{DISTRICTS.map(d => (
<option key={d.key} value={d.key} className="bg-slate-800">
{d.name}
</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-sm text-white/60 mb-2">
MicroDAO (опціонально)
</label>
<input
type="text"
value={formData.home_microdao_id}
onChange={(e) => setFormData(prev => ({ ...prev, home_microdao_id: e.target.value }))}
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/30 focus:outline-none focus:border-violet-500/50"
placeholder="daarion, energy-union, etc."
/>
</div>
</div>
{/* Model & Appearance */}
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6 space-y-4">
<h2 className="text-lg font-semibold text-white mb-4">Модель та вигляд</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm text-white/60 mb-2">
LLM модель
</label>
<input
type="text"
value={formData.model}
onChange={(e) => setFormData(prev => ({ ...prev, model: e.target.value }))}
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/30 focus:outline-none focus:border-violet-500/50"
placeholder="gpt-oss:latest, qwen2.5-coder:32b"
/>
</div>
<div>
<label className="block text-sm text-white/60 mb-2">
Колір
</label>
<div className="flex gap-2">
<input
type="color"
value={formData.color_hint}
onChange={(e) => setFormData(prev => ({ ...prev, color_hint: e.target.value }))}
className="w-12 h-12 rounded-xl cursor-pointer bg-transparent"
/>
<input
type="text"
value={formData.color_hint}
onChange={(e) => setFormData(prev => ({ ...prev, color_hint: e.target.value }))}
className="flex-1 px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/30 focus:outline-none focus:border-violet-500/50"
/>
</div>
</div>
</div>
<div>
<label className="block text-sm text-white/60 mb-2">
URL аватара
</label>
<input
type="text"
value={formData.avatar_url}
onChange={(e) => setFormData(prev => ({ ...prev, avatar_url: e.target.value }))}
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/30 focus:outline-none focus:border-violet-500/50"
placeholder="https://..."
/>
</div>
</div>
{/* Visibility */}
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6 space-y-4">
<h2 className="text-lg font-semibold text-white mb-4">Видимість</h2>
<div className="space-y-3">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={formData.is_public}
onChange={(e) => setFormData(prev => ({ ...prev, is_public: e.target.checked }))}
className="w-5 h-5 rounded bg-white/5 border-white/20 text-violet-500 focus:ring-violet-500"
/>
<span className="text-white">Публічний громадянин міста</span>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={formData.is_orchestrator}
onChange={(e) => setFormData(prev => ({ ...prev, is_orchestrator: e.target.checked }))}
className="w-5 h-5 rounded bg-white/5 border-white/20 text-violet-500 focus:ring-violet-500"
/>
<span className="text-white">Оркестратор (керує іншими агентами)</span>
</label>
</div>
</div>
{/* Submit */}
<div className="flex justify-end gap-4">
<Link
href="/agents"
className="px-6 py-3 bg-white/5 border border-white/10 rounded-xl text-white hover:bg-white/10 transition-colors"
>
Скасувати
</Link>
<button
type="submit"
disabled={isSubmitting || !formData.display_name || !formData.slug}
className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-violet-500 to-purple-600 rounded-xl text-white font-semibold hover:from-violet-400 hover:to-purple-500 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Створення...
</>
) : (
<>
<Save className="w-5 h-5" />
Створити агента
</>
)}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -1,7 +1,7 @@
'use client';
import Link from 'next/link';
import { Bot, Users, Building2, Server, ExternalLink, Shield } from 'lucide-react';
import { Bot, Users, Building2, Server, ExternalLink, Shield, Plus } from 'lucide-react';
import { useAgentList } from '@/hooks/useAgents';
import { AgentSummary, getGovLevelBadge } from '@/lib/types/agents';
import { AgentPresenceBadge } from '@/components/ui/AgentPresenceBadge';
@@ -131,18 +131,29 @@ export default function AgentsPage() {
<div className="max-w-7xl mx-auto px-4 py-12">
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-3 mb-2">
<Bot className="w-8 h-8 text-violet-400" />
<h1 className="text-4xl font-bold bg-gradient-to-r from-violet-400 to-purple-400 bg-clip-text text-transparent">
Agent Console
</h1>
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-3 mb-2">
<Bot className="w-8 h-8 text-violet-400" />
<h1 className="text-4xl font-bold bg-gradient-to-r from-violet-400 to-purple-400 bg-clip-text text-transparent">
Agent Console
</h1>
</div>
<p className="text-white/60 text-lg">
Всі AI-агенти мережі DAARION
</p>
<p className="text-cyan-400 mt-2">
Знайдено агентів: {total}
</p>
</div>
<Link
href="/agents/new"
className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-violet-500 to-purple-600 rounded-xl text-white font-semibold hover:from-violet-400 hover:to-purple-500 transition-all shadow-lg shadow-violet-500/25 hover:shadow-violet-500/40"
>
<Plus className="w-5 h-5" />
Новий агент
</Link>
</div>
<p className="text-white/60 text-lg">
Всі AI-агенти мережі DAARION
</p>
<p className="text-cyan-400 mt-2">
Знайдено агентів: {total}
</p>
</div>
{/* Filters */}

View File

@@ -579,6 +579,48 @@ class MicrodaoOption(BaseModel):
is_active: bool = True
# =============================================================================
# Agent Management (Create/Delete)
# =============================================================================
class CreateAgentRequest(BaseModel):
"""Request to create a new agent"""
slug: str
display_name: str
kind: str = "assistant" # assistant, orchestrator, specialist, civic
role: Optional[str] = None
model: Optional[str] = None
node_id: Optional[str] = None
home_node_id: Optional[str] = None
home_microdao_id: Optional[str] = None
district: Optional[str] = None
primary_room_slug: Optional[str] = None
avatar_url: Optional[str] = None
color_hint: Optional[str] = None
is_public: bool = False
is_orchestrator: bool = False
priority: str = "medium"
class CreateAgentResponse(BaseModel):
"""Response after creating an agent"""
id: str
slug: str
display_name: str
kind: str
node_id: Optional[str] = None
home_microdao_id: Optional[str] = None
district: Optional[str] = None
created_at: datetime
class DeleteAgentResponse(BaseModel):
"""Response after deleting an agent"""
ok: bool
message: str
agent_id: str
# =============================================================================
# Visibility Updates (Task 029)
# =============================================================================

View File

@@ -47,7 +47,10 @@ from models_city import (
MicrodaoRoomUpdate,
AttachExistingRoomRequest,
SwapperModel,
NodeSwapperDetail
NodeSwapperDetail,
CreateAgentRequest,
CreateAgentResponse,
DeleteAgentResponse
)
import repo_city
from common.redis_client import PresenceRedis, get_redis
@@ -2535,6 +2538,120 @@ async def get_agents():
raise HTTPException(status_code=500, detail="Failed to get agents")
@router.post("/agents", response_model=CreateAgentResponse)
async def create_agent(body: CreateAgentRequest):
"""
Створити нового агента
"""
try:
pool = await repo_city.get_pool()
# Check if slug already exists
existing = await pool.fetchrow(
"SELECT id FROM agents WHERE id = $1 OR slug = $1",
body.slug
)
if existing:
raise HTTPException(status_code=400, detail=f"Agent with slug '{body.slug}' already exists")
# Generate ID from slug
agent_id = body.slug
# Insert agent
row = await pool.fetchrow("""
INSERT INTO agents (
id, slug, display_name, kind, role, model,
node_id, home_node_id, home_microdao_id, district,
primary_room_slug, avatar_url, color_hint,
is_public, is_orchestrator, priority,
created_at, updated_at
) VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, $9, $10,
$11, $12, $13,
$14, $15, $16,
NOW(), NOW()
)
RETURNING id, slug, display_name, kind, node_id, home_microdao_id, district, created_at
""",
agent_id,
body.slug,
body.display_name,
body.kind,
body.role,
body.model,
body.node_id,
body.home_node_id or body.node_id,
body.home_microdao_id,
body.district,
body.primary_room_slug,
body.avatar_url,
body.color_hint,
body.is_public,
body.is_orchestrator,
body.priority
)
logger.info(f"Created agent: {agent_id}")
return CreateAgentResponse(
id=row["id"],
slug=row["slug"],
display_name=row["display_name"],
kind=row["kind"],
node_id=row["node_id"],
home_microdao_id=row["home_microdao_id"],
district=row["district"],
created_at=row["created_at"]
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to create agent: {e}")
raise HTTPException(status_code=500, detail=f"Failed to create agent: {str(e)}")
@router.delete("/agents/{agent_id}", response_model=DeleteAgentResponse)
async def delete_agent(agent_id: str):
"""
Видалити агента (soft delete - встановлює is_archived=true, deleted_at=now())
"""
try:
pool = await repo_city.get_pool()
# Check if agent exists
existing = await pool.fetchrow(
"SELECT id, display_name FROM agents WHERE id = $1 AND deleted_at IS NULL",
agent_id
)
if not existing:
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
# Soft delete
await pool.execute("""
UPDATE agents
SET is_archived = true,
deleted_at = NOW(),
updated_at = NOW()
WHERE id = $1
""", agent_id)
logger.info(f"Deleted (archived) agent: {agent_id}")
return DeleteAgentResponse(
ok=True,
message=f"Agent '{existing['display_name']}' has been archived",
agent_id=agent_id
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to delete agent: {e}")
raise HTTPException(status_code=500, detail=f"Failed to delete agent: {str(e)}")
@router.get("/agents/online", response_model=List[AgentPresence])
async def get_online_agents():
"""