feat(ai-workspace): 혼합 모드 병렬 그루핑 UI + 카드별 분리
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m3s
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m3s
- 스텝 사이 화살표에 "병렬로 합치기" 버튼 추가 → 두 스텝을 같은 execution_order 로 묶음 - 병렬 스텝 카드별 분리 버튼 (trash 왼쪽) → 해당 카드만 다음 스텝으로 빼냄 - 병렬 스텝 헤더 amber 배경 + ⚡ "병렬 실행" 배지로 시각 차별화 - (순차)/(병렬) 부가 라벨 제거, 깔끔한 "Step N" 표기로 통일 - execution_order 정규화 헬퍼(renormalizeOrders) + 일괄 저장(persistOrders) 추가 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<number, number>();
|
||||
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 (
|
||||
<div key={order}>
|
||||
<div className="mb-1.5 flex items-center gap-1.5">
|
||||
<div className={`mb-1.5 flex items-center gap-1.5 rounded px-1.5 py-1 ${isParallel ? "border border-amber-200 bg-amber-50 dark:border-amber-900/40 dark:bg-amber-950/20" : ""}`}>
|
||||
<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" />}
|
||||
<span className="text-[10px] text-muted-foreground">Step {orderIdx + 1}</span>
|
||||
{isParallel && (
|
||||
<span className="flex items-center gap-0.5 text-[10px] font-semibold text-amber-600 dark:text-amber-500">
|
||||
<Zap className="h-3 w-3" />
|
||||
병렬 실행
|
||||
</span>
|
||||
)}
|
||||
</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)} />
|
||||
<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)}
|
||||
onSplit={isParallel ? () => handleSplitMember(member.id) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{orderIdx < sortedOrders.length - 1 && (
|
||||
<div className="flex justify-center py-1"><ArrowDown className="h-4 w-4 text-primary/30" /></div>
|
||||
<div className="flex flex-col items-center gap-1 py-1.5">
|
||||
<ArrowDown className="h-3.5 w-3.5 text-primary/30" />
|
||||
<button
|
||||
onClick={() => handleMergeSteps(order, sortedOrders[orderIdx + 1])}
|
||||
className="flex items-center gap-1 rounded-full border border-dashed border-amber-300 bg-amber-50/50 px-2 py-0.5 text-[10px] text-amber-700 transition-colors hover:border-amber-500 hover:bg-amber-100 dark:border-amber-800 dark:bg-amber-950/20 dark:text-amber-400 dark:hover:bg-amber-900/30"
|
||||
title="이 두 스텝을 같은 단계(병렬)로 합치기"
|
||||
>
|
||||
<Zap className="h-3 w-3" />
|
||||
병렬로 합치기
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -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 (
|
||||
<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} />
|
||||
<MemberCardContent member={member} connectors={connectors} onRemove={onRemove} onAddConnector={onAddConnector} onRemoveConnector={onRemoveConnector} onSplit={onSplit} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
<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} />
|
||||
<MemberCardContent member={member} connectors={connectors} onRemove={onRemove} onAddConnector={onAddConnector} onRemoveConnector={onRemoveConnector} onSplit={onSplit} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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<string | null>(null);
|
||||
const memberConnectors = safeArray<any>(member.connectors);
|
||||
@@ -613,9 +688,20 @@ function MemberCardContent({ member, connectors, onRemove, onAddConnector, onRem
|
||||
</div>
|
||||
<p className="mt-0.5 ml-5 text-[10px] text-muted-foreground font-mono truncate" title={member.agent_model}>{member.agent_model}</p>
|
||||
</div>
|
||||
<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 className="flex shrink-0 items-center gap-0.5">
|
||||
{onSplit && (
|
||||
<button
|
||||
className="flex h-5 w-5 items-center justify-center rounded text-amber-600 hover:bg-amber-50 dark:text-amber-500 dark:hover:bg-amber-950/30"
|
||||
onClick={onSplit}
|
||||
title="이 에이전트를 다음 스텝으로 분리"
|
||||
>
|
||||
<Split className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
<button className="flex h-5 w-5 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">
|
||||
|
||||
Reference in New Issue
Block a user