e2845508fa
Build and Push Images / build-and-push (push) Has been cancelled
진짜 멀티 에이전트 오케스트레이션 — 라우터 + 워커 + 합성 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>
767 lines
39 KiB
TypeScript
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>
|
|
);
|
|
}
|