From a6b66ac0d105207514faa2730643584f3e403b50 Mon Sep 17 00:00:00 2001 From: johngreen Date: Mon, 4 May 2026 08:04:24 +0900 Subject: [PATCH] =?UTF-8?q?feat(ai-workspace):=20=ED=98=BC=ED=95=A9=20?= =?UTF-8?q?=EB=AA=A8=EB=93=9C=20=EB=B3=91=EB=A0=AC=20=EA=B7=B8=EB=A3=A8?= =?UTF-8?q?=ED=95=91=20UI=20+=20=EC=B9=B4=EB=93=9C=EB=B3=84=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 스텝 사이 화살표에 "병렬로 합치기" 버튼 추가 → 두 스텝을 같은 execution_order 로 묶음 - 병렬 스텝 카드별 분리 버튼 (trash 왼쪽) → 해당 카드만 다음 스텝으로 빼냄 - 병렬 스텝 헤더 amber 배경 + ⚡ "병렬 실행" 배지로 시각 차별화 - (순차)/(병렬) 부가 라벨 제거, 깔끔한 "Step N" 표기로 통일 - execution_order 정규화 헬퍼(renormalizeOrders) + 일괄 저장(persistOrders) 추가 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../admin/aiAssistant/workspace/page.tsx | 118 +++++++++++++++--- 1 file changed, 102 insertions(+), 16 deletions(-) diff --git a/frontend/app/(main)/admin/aiAssistant/workspace/page.tsx b/frontend/app/(main)/admin/aiAssistant/workspace/page.tsx index 9ccde387..99656ecd 100644 --- a/frontend/app/(main)/admin/aiAssistant/workspace/page.tsx +++ b/frontend/app/(main)/admin/aiAssistant/workspace/page.tsx @@ -8,7 +8,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/u 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 } from "lucide-react"; +import { Network, Plus, Pencil, Trash2, Loader2, Bot, Database, Globe, FileText, Cpu, Bug, X, Zap, ListOrdered, Shuffle, GripVertical, ArrowDown, Check, Split } 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"; @@ -217,6 +217,58 @@ export default function WorkspacePage() { } catch { toast.error("커넥터 제거 실패"); } }; + const renormalizeOrders = (members: any[]) => { + const sorted = [...members].sort((a: any, b: any) => a.execution_order - b.execution_order); + const orderMap = new Map(); + let nextOrder = 1; + for (const m of sorted) { + if (!orderMap.has(m.execution_order)) { + orderMap.set(m.execution_order, nextOrder++); + } + } + return sorted.map((m: any) => ({ ...m, execution_order: orderMap.get(m.execution_order)! })); + }; + + const persistOrders = async (newMembers: any[]) => { + if (!detailGroup) return; + setDetailGroup({ ...detailGroup, members: newMembers }); + try { + await Promise.all(newMembers.map((m: any) => + aiAgentApi.updateGroupMember(m.id, { execution_order: m.execution_order }) + )); + } catch { + toast.error("순서 변경 실패"); + loadGroupDetail(detailGroup.id); + } + }; + + const handleMergeSteps = async (orderA: number, orderB: number) => { + if (!detailGroup?.members) return; + const updated = detailGroup.members.map((m: any) => + m.execution_order === orderB ? { ...m, execution_order: orderA } : m + ); + const renormalized = renormalizeOrders(updated); + await persistOrders(renormalized); + toast.success("병렬로 합쳐졌습니다"); + }; + + const handleSplitMember = async (memberId: number) => { + if (!detailGroup?.members) return; + const target = detailGroup.members.find((m: any) => m.id === memberId); + if (!target) return; + const currentOrder = target.execution_order; + const sameStepCount = detailGroup.members.filter((m: any) => m.execution_order === currentOrder).length; + if (sameStepCount <= 1) return; + const updated = detailGroup.members.map((m: any) => { + if (m.id === memberId) return { ...m, execution_order: currentOrder + 1 }; + if (m.execution_order > currentOrder) return { ...m, execution_order: m.execution_order + 1 }; + return m; + }); + const renormalized = renormalizeOrders(updated); + await persistOrders(renormalized); + toast.success("분리되었습니다"); + }; + const activeAgents = agents.filter((a) => a.status === "active"); return ( @@ -367,20 +419,42 @@ export default function WorkspacePage() { const isParallel = membersInOrder.length > 1; return (
-
+
{orderIdx + 1} - - Step {orderIdx + 1} {isParallel ? "(병렬)" : "(순차)"} - - {isParallel && } + Step {orderIdx + 1} + {isParallel && ( + + + 병렬 실행 + + )}
{membersInOrder.map((member: any, idx: number) => ( - handleRemoveMember(member.id)} onAddConnector={(conn) => handleAddMemberConnector(member.id, conn)} onRemoveConnector={(ci) => handleRemoveMemberConnector(member.id, ci)} /> + handleRemoveMember(member.id)} + onAddConnector={(conn) => handleAddMemberConnector(member.id, conn)} + onRemoveConnector={(ci) => handleRemoveMemberConnector(member.id, ci)} + onSplit={isParallel ? () => handleSplitMember(member.id) : undefined} + /> ))}
{orderIdx < sortedOrders.length - 1 && ( -
+
+ + +
)}
); @@ -552,17 +626,18 @@ interface MemberCardProps { onRemove: () => void; onAddConnector: (conn: any) => void; onRemoveConnector: (idx: number) => void; + onSplit?: () => void; } -function MemberCard({ member, connectors, onRemove, onAddConnector, onRemoveConnector }: MemberCardProps) { +function MemberCard({ member, connectors, onRemove, onAddConnector, onRemoveConnector, onSplit }: MemberCardProps) { return (
- +
); } -function SortableMemberCard({ member, index, connectors, onRemove, onAddConnector, onRemoveConnector }: MemberCardProps & { index: number }) { +function SortableMemberCard({ member, index, connectors, onRemove, onAddConnector, onRemoveConnector, onSplit }: MemberCardProps & { index: number }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: String(member.id) }); const style = { transform: CSS.Transform.toString(transform), @@ -578,7 +653,7 @@ function SortableMemberCard({ member, index, connectors, onRemove, onAddConnecto
- +
@@ -593,7 +668,7 @@ const CONNECTOR_TYPE_OPTIONS = [ { type: "file", label: "파일", icon: FileText, color: "text-amber-500" }, ]; -function MemberCardContent({ member, connectors, onRemove, onAddConnector, onRemoveConnector }: MemberCardProps) { +function MemberCardContent({ member, connectors, onRemove, onAddConnector, onRemoveConnector, onSplit }: MemberCardProps) { const [pickerOpen, setPickerOpen] = useState(false); const [selectedType, setSelectedType] = useState(null); const memberConnectors = safeArray(member.connectors); @@ -613,9 +688,20 @@ function MemberCardContent({ member, connectors, onRemove, onAddConnector, onRem

{member.agent_model}

- +
+ {onSplit && ( + + )} + +