Files
pipeline/frontend/app/(main)/admin/aiAssistant/workspace/page.tsx
T
chpark e2845508fa
Build and Push Images / build-and-push (push) Has been cancelled
feat(ai-orch): Coordinator (Orchestrator-Worker) 패턴 구현
진짜 멀티 에이전트 오케스트레이션 — 라우터 + 워커 + 합성 3단계

Engine (multiAgentExecutionEngine.ts)
- execution_mode 'coordinator' 신규 추가
- executeCoordinator() 3 라운드 구현:
  Round 1: coordinator 가 사용자 요청 분해 → JSON delegations 출력
           system prompt 에 sub-agent 카탈로그(role/desc/connectors) 자동 inject
  Round 2: delegated sub-agent 들 각자 짧은 task 만 받아 병렬 실행
  Round 3: coordinator 가 모든 sub-agent 응답을 받아 사용자 친화적 최종 답변 합성
- coordinator 식별: member.config.is_coordinator === true 우선,
                    없으면 execution_order 가 가장 작은 멤버
- JSON 파싱 실패 시 모든 worker 에 원문 fallback

Service (aiAgentGroupService.ts)
- updateMember 에 config / is_coordinator 옵션 추가
  is_coordinator 단축키는 기존 config jsonb 와 || 머지

UI (workspace/page.tsx)
- EXEC_MODES 에 'coordinator' (Compass 아이콘) 추가
- MemberCardContent 에 코디네이터 토글 버튼 (왕관 아이콘) — 클릭 즉시
  updateGroupMember(is_coordinator) 호출
- 활성 시 분홍 배지 "코디네이터" 표시

검증 결과 (PLM 그룹 / 환율전문가=coordinator)
- 사용자: "환율과 화성시 날씨 알려줘"
  Round 1: 기상전문가 ← "화성시의 날씨 정보를 알려줘"
  Round 2: 기상전문가 → API 호출 → HTML 응답 → 정직하게 "데이터 못받음"
  Round 3: coordinator 가 환율/날씨 모두 정리한 단일 답변 합성

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 20:19:01 +09:00

767 lines
39 KiB
TypeScript

"use client";
import { useEffect, useState, useCallback } from "react";
import { aiAgentApi } from "@/lib/api/aiAgent";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Network, Plus, Pencil, Trash2, Loader2, Bot, Database, Globe, FileText, Cpu, Bug, X, Zap, ListOrdered, Shuffle, GripVertical, ArrowDown, Check, Crown, Compass } from "lucide-react";
import { toast } from "sonner";
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy, useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
const CONNECTOR_ICONS: Record<string, any> = { database: Database, rest_api: Globe, file: FileText, plc: Cpu, crawler: Bug };
const CONNECTOR_COLORS: Record<string, string> = { database: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400", rest_api: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400", file: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400", plc: "bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400", crawler: "bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400" };
const EXEC_MODES = [
{ value: "parallel", label: "병렬", desc: "모두 동시 실행", icon: Zap, color: "text-amber-500" },
{ value: "sequential", label: "순차", desc: "1→2→3 순서대로", icon: ListOrdered, color: "text-blue-500" },
{ value: "mixed", label: "혼합", desc: "같은 순서=병렬", icon: Shuffle, color: "text-violet-500" },
{ value: "coordinator", label: "코디네이터", desc: "리더가 분배·합성", icon: Compass, color: "text-rose-500" },
];
export default function WorkspacePage() {
const [groups, setGroups] = useState<any[]>([]);
const [agents, setAgents] = useState<any[]>([]);
const [connectors, setConnectors] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [groupModalOpen, setGroupModalOpen] = useState(false);
const [editingGroup, setEditingGroup] = useState<any>(null);
const [groupForm, setGroupForm] = useState({ name: "", description: "", execution_mode: "parallel" });
const [memberModalOpen, setMemberModalOpen] = useState(false);
const [activeGroupId, setActiveGroupId] = useState<number | null>(null);
const [selectedAgents, setSelectedAgents] = useState<Array<{
agent_id: number; role_name: string; connectors: any[];
}>>([]);
const [detailGroup, setDetailGroup] = useState<any>(null);
const [detailOpen, setDetailOpen] = useState(false);
const [saving, setSaving] = useState(false);
const loadData = useCallback(async () => {
setLoading(true);
try {
const [groupRes, agentRes, connRes] = await Promise.all([
aiAgentApi.listGroups(),
aiAgentApi.list({ company_code: "*" } as any),
aiAgentApi.getAvailableConnectors(),
]);
setGroups(groupRes.data || []);
setAgents(agentRes.data || []);
setConnectors(connRes.data || []);
} catch { toast.error("데이터 로드 실패"); }
setLoading(false);
}, []);
useEffect(() => { loadData(); }, [loadData]);
const loadGroupDetail = async (id: number) => {
try {
const res = await aiAgentApi.getGroup(id);
setDetailGroup(res.data);
setDetailOpen(true);
} catch { toast.error("그룹 상세 로드 실패"); }
};
const handleSaveGroup = async () => {
if (!groupForm.name) { toast.error("그룹 이름을 입력하세요."); return; }
setSaving(true);
try {
if (editingGroup) {
await aiAgentApi.updateGroup(editingGroup.id, groupForm);
toast.success("그룹이 수정되었습니다.");
if (detailGroup?.id === editingGroup.id) loadGroupDetail(editingGroup.id);
} else {
await aiAgentApi.createGroup(groupForm);
toast.success("그룹이 생성되었습니다.");
}
setGroupModalOpen(false);
loadData();
} catch (e: any) { toast.error(e.response?.data?.message || "저장 실패"); }
setSaving(false);
};
const handleDeleteGroup = async (id: number) => {
if (!confirm("그룹을 삭제하시겠습니까?")) return;
try { await aiAgentApi.deleteGroup(id); toast.success("삭제 완료"); loadData(); if (detailGroup?.id === id) setDetailOpen(false); }
catch { toast.error("삭제 실패"); }
};
// 다중 에이전트 추가
const openAddMembers = (groupId: number) => {
setActiveGroupId(groupId);
setSelectedAgents([]);
setMemberModalOpen(true);
};
const toggleAgentSelection = (agentId: number, agentName: string) => {
setSelectedAgents((prev) => {
const exists = prev.find((a) => a.agent_id === agentId);
if (exists) return prev.filter((a) => a.agent_id !== agentId);
return [...prev, { agent_id: agentId, role_name: agentName, connectors: [] }];
});
};
const updateSelectedAgentRole = (agentId: number, role: string) => {
setSelectedAgents((prev) => prev.map((a) => a.agent_id === agentId ? { ...a, role_name: role } : a));
};
const addConnectorToAgent = (agentId: number, conn: any) => {
setSelectedAgents((prev) => prev.map((a) =>
a.agent_id === agentId
? { ...a, connectors: [...a.connectors, { type: conn.type, connection_id: conn.connection_id, name: conn.name }] }
: a
));
};
const removeConnectorFromAgent = (agentId: number, connIdx: number) => {
setSelectedAgents((prev) => prev.map((a) =>
a.agent_id === agentId
? { ...a, connectors: a.connectors.filter((_, i) => i !== connIdx) }
: a
));
};
const handleSaveMembers = async () => {
if (selectedAgents.length === 0) { toast.error("에이전트를 선택하세요"); return; }
if (selectedAgents.some((a) => !a.role_name)) { toast.error("모든 에이전트의 역할을 입력하세요"); return; }
setSaving(true);
try {
const baseOrder = (detailGroup?.members?.length || 0) + 1;
for (let i = 0; i < selectedAgents.length; i++) {
const agent = selectedAgents[i];
await aiAgentApi.addGroupMember(activeGroupId!, {
agent_id: agent.agent_id,
role_name: agent.role_name,
connectors: agent.connectors,
execution_order: baseOrder + i,
});
}
toast.success(`${selectedAgents.length}개 에이전트가 추가되었습니다.`);
setMemberModalOpen(false);
loadGroupDetail(activeGroupId!);
} catch (e: any) { toast.error(e.response?.data?.message || "추가 실패"); }
setSaving(false);
};
const handleRemoveMember = async (memberId: number) => {
if (!confirm("멤버를 제거하시겠습니까?")) return;
try { await aiAgentApi.removeGroupMember(memberId); toast.success("제거 완료"); loadGroupDetail(detailGroup.id); }
catch { toast.error("제거 실패"); }
};
// 실행 모드 즉시 변경
const handleChangeExecMode = async (mode: string) => {
if (!detailGroup) return;
try {
await aiAgentApi.updateGroup(detailGroup.id, { execution_mode: mode });
setDetailGroup({ ...detailGroup, execution_mode: mode });
toast.success(`실행 모드: ${EXEC_MODES.find((m) => m.value === mode)?.label}`);
} catch { toast.error("변경 실패"); }
};
// 드래그 앤 드롭 순서 변경
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id || !detailGroup?.members) return;
const members = [...detailGroup.members].sort((a: any, b: any) => a.execution_order - b.execution_order);
const oldIndex = members.findIndex((m: any) => String(m.id) === String(active.id));
const newIndex = members.findIndex((m: any) => String(m.id) === String(over.id));
if (oldIndex === -1 || newIndex === -1) return;
// 순서 재배치
const [moved] = members.splice(oldIndex, 1);
members.splice(newIndex, 0, moved);
// 즉시 UI 업데이트 (낙관적)
const updatedMembers = members.map((m: any, i: number) => ({ ...m, execution_order: i + 1 }));
setDetailGroup({ ...detailGroup, members: updatedMembers });
// 백엔드 업데이트
try {
await Promise.all(updatedMembers.map((m: any) =>
aiAgentApi.updateGroupMember(m.id, { execution_order: m.execution_order })
));
} catch {
toast.error("순서 변경 실패");
loadGroupDetail(detailGroup.id);
}
};
// 멤버 커넥터 즉시 추가/제거
const handleAddMemberConnector = async (memberId: number, conn: any) => {
const member = detailGroup?.members?.find((m: any) => m.id === memberId);
if (!member) return;
const newConnectors = [...(member.connectors || []), { type: conn.type, connection_id: conn.connection_id, name: conn.name }];
try {
await aiAgentApi.updateGroupMember(memberId, { connectors: newConnectors });
setDetailGroup((prev: any) => ({
...prev,
members: prev.members.map((m: any) => m.id === memberId ? { ...m, connectors: newConnectors } : m),
}));
toast.success(`${conn.name} 연결됨`);
} catch { toast.error("커넥터 연결 실패"); }
};
const handleRemoveMemberConnector = async (memberId: number, connIdx: number) => {
const member = detailGroup?.members?.find((m: any) => m.id === memberId);
if (!member) return;
const newConnectors = (member.connectors || []).filter((_: any, i: number) => i !== connIdx);
try {
await aiAgentApi.updateGroupMember(memberId, { connectors: newConnectors });
setDetailGroup((prev: any) => ({
...prev,
members: prev.members.map((m: any) => m.id === memberId ? { ...m, connectors: newConnectors } : m),
}));
} catch { toast.error("커넥터 제거 실패"); }
};
const activeAgents = agents.filter((a) => a.status === "active");
return (
<div className="h-full overflow-y-auto bg-background">
<div className="space-y-4 p-4 sm:p-5">
{/* 헤더 */}
<div className="flex items-center justify-between border-b pb-3">
<div>
<h1 className="text-lg font-bold tracking-tight"> </h1>
<p className="mt-0.5 text-xs text-muted-foreground"> </p>
</div>
<Button onClick={() => { setEditingGroup(null); setGroupForm({ name: "", description: "", execution_mode: "parallel" }); setGroupModalOpen(true); }} size="sm" className="h-8 gap-1 text-xs">
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<div className="flex flex-1 overflow-hidden" style={{ height: "calc(100% - 100px)" }}>
{/* 왼쪽: 그룹 목록 */}
<div className="w-64 shrink-0 overflow-auto border-r p-3 space-y-1.5">
{loading ? (
<div className="flex items-center justify-center py-12"><Loader2 className="h-5 w-5 animate-spin" /></div>
) : groups.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Network className="mb-2 h-5 w-5" />
<p className="text-xs"> </p>
</div>
) : (
groups.map((g) => (
<div
key={g.id}
className={`cursor-pointer rounded-lg border p-2.5 transition-colors hover:bg-accent/50 ${detailGroup?.id === g.id ? "border-primary bg-primary/5" : ""}`}
onClick={() => loadGroupDetail(g.id)}
>
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-semibold">{g.name}</p>
<p className="text-[10px] text-muted-foreground">{g.member_count || 0} </p>
</div>
<Badge variant={g.status === "active" ? "default" : "secondary"} className="h-4 text-[9px]">{g.status}</Badge>
</div>
</div>
))
)}
</div>
{/* 오른쪽: 그룹 상세 */}
<div className="flex-1 overflow-auto p-4 sm:p-5">
{!detailGroup ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<Network className="mb-3 h-8 w-8 opacity-20" />
<p className="text-xs"> </p>
</div>
) : (
<div className="space-y-4">
{/* 그룹 헤더 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<h2 className="text-base font-bold">{detailGroup.name}</h2>
{/* 실행 모드 즉시 변경 */}
<div className="flex rounded-md border p-0.5">
{EXEC_MODES.map((mode) => (
<button
key={mode.value}
className={`flex items-center gap-1 rounded px-2 py-0.5 text-[10px] font-medium transition-colors ${
detailGroup.execution_mode === mode.value ? "bg-primary/10 text-primary" : "text-muted-foreground hover:text-foreground"
}`}
onClick={() => handleChangeExecMode(mode.value)}
>
<mode.icon className="h-3 w-3" />
{mode.label}
</button>
))}
</div>
</div>
<div className="flex gap-1.5">
<Button size="sm" className="h-7 gap-1 text-xs" onClick={() => openAddMembers(detailGroup.id)}>
<Plus className="h-3 w-3" />
</Button>
<Button size="sm" variant="outline" className="h-7 gap-1 text-xs" onClick={() => { setEditingGroup(detailGroup); setGroupForm({ name: detailGroup.name, description: detailGroup.description || "", execution_mode: detailGroup.execution_mode || "parallel" }); setGroupModalOpen(true); }}>
<Pencil className="h-3 w-3" />
</Button>
<Button size="sm" variant="destructive" className="h-7 gap-1 text-xs" onClick={() => handleDeleteGroup(detailGroup.id)}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
{detailGroup.description && <p className="text-xs text-muted-foreground">{detailGroup.description}</p>}
{/* 멤버 파이프라인 뷰 */}
{(!detailGroup.members || detailGroup.members.length === 0) ? (
<div className="rounded-lg border-2 border-dashed p-10 text-center">
<Bot className="mx-auto mb-2 h-6 w-6 text-muted-foreground/30" />
<p className="text-xs text-muted-foreground"> </p>
</div>
) : (() => {
const mode = detailGroup.execution_mode;
const members = detailGroup.members || [];
// 병렬: 모든 에이전트를 같은 레벨에 표시
if (mode === "parallel") {
return (
<div>
<div className="mb-2 flex items-center gap-1.5">
<Zap className="h-3.5 w-3.5 text-amber-500" />
<span className="text-[11px] font-semibold text-muted-foreground"> </span>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{members.map((member: any) => (
<MemberCard key={member.id} member={member} connectors={connectors} onRemove={() => handleRemoveMember(member.id)} onAddConnector={(conn) => handleAddMemberConnector(member.id, conn)} onRemoveConnector={(ci) => handleRemoveMemberConnector(member.id, ci)} />
))}
</div>
</div>
);
}
// 순차: 드래그 앤 드롭으로 순서 변경
if (mode === "sequential") {
const sorted = [...members].sort((a: any, b: any) => a.execution_order - b.execution_order);
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={sorted.map((m: any) => String(m.id))} strategy={verticalListSortingStrategy}>
<div className="space-y-1">
{sorted.map((member: any, idx: number) => (
<div key={member.id}>
<SortableMemberCard member={member} index={idx} onRemove={() => handleRemoveMember(member.id)} />
{idx < sorted.length - 1 && (
<div className="flex justify-center py-1"><ArrowDown className="h-4 w-4 text-primary/30" /></div>
)}
</div>
))}
</div>
</SortableContext>
</DndContext>
);
}
// 혼합: execution_order로 그룹핑 + 드래그 앤 드롭
const allSorted = [...members].sort((a: any, b: any) => a.execution_order - b.execution_order);
const orderGroups = new Map<number, any[]>();
for (const m of allSorted) {
const order = m.execution_order;
if (!orderGroups.has(order)) orderGroups.set(order, []);
orderGroups.get(order)!.push(m);
}
const sortedOrders = [...orderGroups.keys()].sort((a, b) => a - b);
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={allSorted.map((m: any) => String(m.id))} strategy={verticalListSortingStrategy}>
<div className="space-y-1">
{sortedOrders.map((order, orderIdx) => {
const membersInOrder = orderGroups.get(order)!;
const isParallel = membersInOrder.length > 1;
return (
<div key={order}>
<div className="mb-1.5 flex items-center gap-1.5">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground">{orderIdx + 1}</span>
<span className="text-[10px] text-muted-foreground">
Step {orderIdx + 1} {isParallel ? "(병렬)" : "(순차)"}
</span>
{isParallel && <Zap className="h-3 w-3 text-amber-500" />}
</div>
<div className={isParallel ? "grid grid-cols-2 gap-2 lg:grid-cols-3" : ""}>
{membersInOrder.map((member: any, idx: number) => (
<SortableMemberCard key={member.id} member={member} index={idx} connectors={connectors} onRemove={() => handleRemoveMember(member.id)} onAddConnector={(conn) => handleAddMemberConnector(member.id, conn)} onRemoveConnector={(ci) => handleRemoveMemberConnector(member.id, ci)} />
))}
</div>
{orderIdx < sortedOrders.length - 1 && (
<div className="flex justify-center py-1"><ArrowDown className="h-4 w-4 text-primary/30" /></div>
)}
</div>
);
})}
</div>
</SortableContext>
</DndContext>
);
})()}
</div>
)}
</div>
</div>
{/* 그룹 생성/수정 모달 */}
<Dialog open={groupModalOpen} onOpenChange={setGroupModalOpen}>
<DialogContent className="max-w-sm">
<DialogHeader><DialogTitle className="text-sm">{editingGroup ? "그룹 수정" : "그룹 만들기"}</DialogTitle></DialogHeader>
<div className="space-y-3 pt-1">
<div>
<Label className="text-[11px]"> </Label>
<Input value={groupForm.name} onChange={(e) => setGroupForm({ ...groupForm, name: e.target.value })} placeholder="예: PLM 분석 에이전트" className="mt-1 h-8 text-xs" />
</div>
<div>
<Label className="text-[11px]"></Label>
<Input value={groupForm.description} onChange={(e) => setGroupForm({ ...groupForm, description: e.target.value })} placeholder="그룹 목적" className="mt-1 h-8 text-xs" />
</div>
<div>
<Label className="text-[11px] mb-1.5 block"> </Label>
<div className="grid grid-cols-3 gap-1.5">
{EXEC_MODES.map((mode) => (
<button
key={mode.value}
type="button"
onClick={() => setGroupForm({ ...groupForm, execution_mode: mode.value })}
className={`flex flex-col items-center gap-1 rounded-lg border-2 p-2 transition-all ${
groupForm.execution_mode === mode.value ? "border-primary bg-primary/5" : "border-border hover:border-primary/30"
}`}
>
<mode.icon className={`h-4 w-4 ${groupForm.execution_mode === mode.value ? "text-primary" : "text-muted-foreground"}`} />
<span className="text-[10px] font-semibold">{mode.label}</span>
<span className="text-[9px] text-muted-foreground">{mode.desc}</span>
</button>
))}
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => setGroupModalOpen(false)}></Button>
<Button size="sm" className="h-7 text-xs" onClick={handleSaveGroup} disabled={saving}>
{saving && <Loader2 className="mr-1 h-3 w-3 animate-spin" />}{editingGroup ? "수정" : "만들기"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* 다중 에이전트 추가 모달 */}
<Dialog open={memberModalOpen} onOpenChange={setMemberModalOpen}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogHeader><DialogTitle className="text-sm"> ({selectedAgents.length} )</DialogTitle></DialogHeader>
<div className="space-y-4 pt-1">
{/* 에이전트 선택 (체크박스) */}
<div>
<Label className="text-[11px] mb-1.5 block"> ( )</Label>
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2 max-h-48 overflow-auto rounded-lg border p-2">
{activeAgents.map((a) => {
const isSelected = selectedAgents.some((s) => s.agent_id === a.id);
return (
<button
key={a.id}
className={`flex items-center gap-2 rounded-md border p-2 text-left transition-all ${isSelected ? "border-primary bg-primary/5" : "hover:bg-accent"}`}
onClick={() => toggleAgentSelection(a.id, a.name)}
>
<div className={`flex h-4 w-4 items-center justify-center rounded border ${isSelected ? "bg-primary border-primary text-primary-foreground" : "border-muted-foreground/30"}`}>
{isSelected && <Check className="h-3 w-3" />}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium">{a.name}</p>
<p className="truncate text-[10px] text-muted-foreground font-mono">{a.model}</p>
</div>
</button>
);
})}
</div>
</div>
{/* 선택된 에이전트별 설정 */}
{selectedAgents.length > 0 && (
<div className="space-y-3">
<Label className="text-[11px]"> </Label>
{selectedAgents.map((sa, idx) => {
const agentInfo = agents.find((a) => a.id === sa.agent_id);
return (
<div key={sa.agent_id} className="rounded-lg border bg-card p-3 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground">{idx + 1}</span>
<Bot className="h-3.5 w-3.5 text-primary" />
<span className="text-xs font-semibold">{agentInfo?.name || "에이전트"}</span>
<span className="text-[9px] font-mono text-muted-foreground">{agentInfo?.model}</span>
</div>
<Button variant="ghost" size="sm" className="h-5 w-5 p-0 text-destructive" onClick={() => toggleAgentSelection(sa.agent_id, "")}>
<X className="h-3 w-3" />
</Button>
</div>
{/* 역할 */}
<Input
value={sa.role_name}
onChange={(e) => updateSelectedAgentRole(sa.agent_id, e.target.value)}
placeholder="이 그룹에서의 역할 (예: 매출 데이터 분석)"
className="h-7 text-xs"
/>
{/* 커넥터 */}
<div className="flex flex-wrap gap-1">
{sa.connectors.map((conn, ci) => {
const Icon = CONNECTOR_ICONS[conn.type] || Database;
const color = CONNECTOR_COLORS[conn.type] || "bg-gray-100 text-gray-700";
return (
<span key={ci} className={`flex items-center gap-0.5 rounded-full px-2 py-0.5 text-[9px] font-medium ${color}`}>
<Icon className="h-2.5 w-2.5" />{conn.name}
<button onClick={() => removeConnectorFromAgent(sa.agent_id, ci)} className="ml-0.5 hover:text-destructive"><X className="h-2.5 w-2.5" /></button>
</span>
);
})}
{/* 커넥터 추가 드롭다운 */}
<Select onValueChange={(v) => {
const conn = connectors.find((c) => `${c.type}-${c.connection_id}` === v);
if (conn) addConnectorToAgent(sa.agent_id, conn);
}}>
<SelectTrigger className="h-5 w-auto border-dashed px-1.5 text-[9px]">
<Plus className="h-2.5 w-2.5 mr-0.5" />
</SelectTrigger>
<SelectContent>
{connectors.map((conn) => {
const Icon = CONNECTOR_ICONS[conn.type] || Database;
return (
<SelectItem key={`${conn.type}-${conn.connection_id}`} value={`${conn.type}-${conn.connection_id}`} className="text-xs">
<div className="flex items-center gap-1.5">
<Icon className="h-3 w-3" />
{conn.name}
<span className="text-[10px] text-muted-foreground">{conn.type}</span>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
</div>
);
})}
</div>
)}
<div className="flex justify-end gap-2 pt-2 border-t">
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => setMemberModalOpen(false)}></Button>
<Button size="sm" className="h-7 text-xs" onClick={handleSaveMembers} disabled={saving || selectedAgents.length === 0}>
{saving && <Loader2 className="mr-1 h-3 w-3 animate-spin" />}
{selectedAgents.length}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}
interface MemberCardProps {
member: any;
connectors: any[];
onRemove: () => void;
onAddConnector: (conn: any) => void;
onRemoveConnector: (idx: number) => void;
}
/* 멤버 카드 (병렬 모드용 - 드래그 없음) */
function MemberCard({ member, connectors, onRemove, onAddConnector, onRemoveConnector }: MemberCardProps) {
return (
<div className="rounded-lg border bg-card p-3 transition-all hover:shadow-sm">
<MemberCardContent member={member} connectors={connectors} onRemove={onRemove} onAddConnector={onAddConnector} onRemoveConnector={onRemoveConnector} />
</div>
);
}
/* Sortable 멤버 카드 (순차/혼합 모드용 - 드래그 가능) */
function SortableMemberCard({ member, index, connectors, onRemove, onAddConnector, onRemoveConnector }: MemberCardProps & { index: number }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: String(member.id) });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
zIndex: isDragging ? 50 : "auto" as any,
};
return (
<div ref={setNodeRef} style={style} className={`rounded-lg border bg-card p-3 transition-all ${isDragging ? "shadow-lg ring-2 ring-primary/30" : "hover:shadow-sm"}`}>
<div className="flex items-start gap-2">
<button {...attributes} {...listeners} className="mt-0.5 flex h-5 w-5 shrink-0 cursor-grab items-center justify-center rounded text-muted-foreground hover:bg-accent active:cursor-grabbing">
<GripVertical className="h-3.5 w-3.5" />
</button>
<div className="min-w-0 flex-1">
<MemberCardContent member={member} connectors={connectors} onRemove={onRemove} onAddConnector={onAddConnector} onRemoveConnector={onRemoveConnector} />
</div>
</div>
</div>
);
}
const CONNECTOR_TYPE_OPTIONS = [
{ type: "database", label: "데이터베이스", icon: Database, color: "text-blue-500" },
{ type: "rest_api", label: "REST API", icon: Globe, color: "text-green-500" },
{ type: "plc", label: "장비 (PLC)", icon: Cpu, color: "text-purple-500" },
{ type: "crawler", label: "크롤링", icon: Bug, color: "text-rose-500" },
{ type: "file", label: "파일", icon: FileText, color: "text-amber-500" },
];
/* 공통 멤버 카드 내용 - 2단계 커넥터 추가 */
function MemberCardContent({ member, connectors, onRemove, onAddConnector, onRemoveConnector }: MemberCardProps) {
const [pickerOpen, setPickerOpen] = useState(false);
const [selectedType, setSelectedType] = useState<string | null>(null);
const [isCoord, setIsCoord] = useState(!!member.config?.is_coordinator);
const memberConnectors = member.connectors || [];
const filteredConnectors = selectedType
? connectors.filter((c) => c.type === selectedType)
: [];
const toggleCoordinator = async () => {
const next = !isCoord;
setIsCoord(next);
try {
await aiAgentApi.updateGroupMember(member.id, { is_coordinator: next });
toast.success(next ? "코디네이터로 지정" : "코디네이터 해제");
} catch (e: any) {
setIsCoord(!next);
toast.error(e?.response?.data?.message || "코디네이터 토글 실패");
}
};
return (
<div>
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<Bot className="h-3.5 w-3.5 text-primary" />
<span className="text-xs font-semibold">{member.role_name}</span>
<Badge variant="outline" className="h-4 text-[9px]">{member.agent_name || "에이전트"}</Badge>
{isCoord && (
<Badge className="h-4 gap-0.5 bg-rose-500/15 px-1 text-[9px] text-rose-700 hover:bg-rose-500/20">
<Crown className="h-2.5 w-2.5" />
</Badge>
)}
</div>
<p className="mt-0.5 ml-5 text-[10px] text-muted-foreground font-mono">{member.agent_model}</p>
</div>
<div className="flex items-center gap-1">
<button
onClick={toggleCoordinator}
className={`flex h-5 px-1.5 items-center justify-center rounded text-[9px] gap-0.5 transition-colors ${
isCoord
? "bg-rose-500/20 text-rose-700 hover:bg-rose-500/30"
: "border border-dashed text-muted-foreground hover:border-rose-400 hover:text-rose-500"
}`}
title="이 에이전트를 그룹 코디네이터로 지정 (요청 분배·결과 합성 담당)"
>
<Crown className="h-2.5 w-2.5" />
</button>
<button className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-destructive hover:bg-destructive/10" onClick={onRemove}>
<Trash2 className="h-3 w-3" />
</button>
</div>
</div>
{/* 연결된 커넥터 */}
<div className="mt-2 ml-5 flex flex-wrap items-center gap-1">
{memberConnectors.map((conn: any, ci: number) => {
const Icon = CONNECTOR_ICONS[conn.type] || Database;
const color = CONNECTOR_COLORS[conn.type] || "bg-gray-100 text-gray-700";
return (
<span key={ci} className={`flex items-center gap-0.5 rounded-full px-1.5 py-0.5 text-[9px] font-medium ${color}`}>
<Icon className="h-2.5 w-2.5" />{conn.name}
<button onClick={() => onRemoveConnector(ci)} className="ml-0.5 hover:text-destructive"><X className="h-2.5 w-2.5" /></button>
</span>
);
})}
{/* + 연결 버튼 */}
<button
onClick={() => { setPickerOpen(!pickerOpen); setSelectedType(null); }}
className="flex items-center gap-0.5 rounded-full border border-dashed px-1.5 py-0.5 text-[9px] text-muted-foreground transition-colors hover:border-primary hover:text-primary"
>
<Plus className="h-2.5 w-2.5" />
</button>
</div>
{/* 2단계 커넥터 선택 팝업 */}
{pickerOpen && (
<div className="mt-2 ml-5 rounded-lg border bg-background p-2 shadow-sm">
{!selectedType ? (
/* Step 1: 타입 선택 */
<div>
<p className="mb-1.5 text-[10px] font-semibold text-muted-foreground"> </p>
<div className="grid grid-cols-3 gap-1">
{CONNECTOR_TYPE_OPTIONS.map((opt) => {
const count = connectors.filter((c) => c.type === opt.type).length;
return (
<button
key={opt.type}
disabled={count === 0}
className={`flex flex-col items-center gap-1 rounded-md border p-2 text-center transition-all ${count > 0 ? "hover:border-primary/40 hover:bg-primary/5" : "opacity-30"}`}
onClick={() => setSelectedType(opt.type)}
>
<opt.icon className={`h-4 w-4 ${opt.color}`} />
<span className="text-[9px] font-medium">{opt.label}</span>
<span className="text-[8px] text-muted-foreground">{count}</span>
</button>
);
})}
</div>
</div>
) : (
/* Step 2: 항목 선택 */
<div>
<div className="mb-1.5 flex items-center justify-between">
<button onClick={() => setSelectedType(null)} className="flex items-center gap-1 text-[10px] text-primary hover:underline">
<ArrowDown className="h-3 w-3 rotate-90" />
</button>
<p className="text-[10px] font-semibold text-muted-foreground">
{CONNECTOR_TYPE_OPTIONS.find((o) => o.type === selectedType)?.label}
</p>
</div>
<div className="max-h-32 space-y-0.5 overflow-auto">
{filteredConnectors.length === 0 ? (
<p className="py-3 text-center text-[10px] text-muted-foreground"> </p>
) : (
filteredConnectors.map((conn) => {
const Icon = CONNECTOR_ICONS[conn.type] || Database;
const alreadyAdded = memberConnectors.some((mc: any) => mc.connection_id === conn.connection_id && mc.type === conn.type);
return (
<button
key={`${conn.type}-${conn.connection_id}`}
disabled={alreadyAdded}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors ${alreadyAdded ? "opacity-40" : "hover:bg-accent"}`}
onClick={() => {
onAddConnector(conn);
setPickerOpen(false);
setSelectedType(null);
}}
>
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
<span className="flex-1 font-medium">{conn.name}</span>
{conn.database_name && <span className="text-[10px] text-muted-foreground">{conn.database_name}</span>}
{conn.base_url && <span className="truncate text-[10px] text-muted-foreground max-w-[120px]">{conn.base_url}</span>}
{alreadyAdded && <span className="text-[9px] text-muted-foreground"></span>}
</button>
);
})
)}
</div>
</div>
)}
<button onClick={() => { setPickerOpen(false); setSelectedType(null); }} className="mt-1.5 w-full rounded py-1 text-[10px] text-muted-foreground hover:bg-muted">
</button>
</div>
)}
</div>
);
}