feat(roundtable): 业务链条左栏卡片拖动手柄 + 加入按钮 + 智能体详情抽屉

- 左栏候选卡片加拖动手柄(与右栏席位一致),仅点手柄才拖动
- 加入改为显式「加入」按钮,点卡片本体改为打开详情抽屉
- 右栏席位操作区加「查看详情」按钮,打开同一抽屉
- 新增 AgentDetailSheet:按需拉取(useAgent)展示名称/模型/描述/
  标签/技能+工具组/人设,对齐智能体详情页内容

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
董宏凯andClaude Opus 4.8 committed 2026-06-08 18:00:42 +08:00
1 parent c05e7df48e
commit b101ee7fc9
2 files changed
+241 -25

No files matched your search

@@ -0,0 +1,151 @@
/**
* 智能体详情抽屉(右侧 Sheet)。
*
* 业务链条配置页:左栏候选卡片点击卡片本体、右栏席位点「查看详情」都打开这个抽屉,
* 展示该智能体的详情(与「智能体」详情页展示的内容差不多):名称 / 模型 / 描述 /
* 标签 / 技能 + 工具组 / 人设(SOUL.md)。
*
* 详情按需拉取:`useAgent(open ? agentId : null)` —— 抽屉打开时才请求 `GET /api/agents/{id}`
* (该接口才返回 soul),关闭不请求。字段渲染对齐 `components/workspace/agents/agent-card.tsx`。
*/
import { Bot, Globe, Loader2, Lock } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { TagChipList } from "@/components/workspace/tags/tag-chip";
import { useAgent } from "@/core/agents";
import { desensitize } from "@/lib/desensitize";
export function AgentDetailSheet({
agentId,
/** 卡片/席位上的名称快照,详情还没拉回来时先用它占位。 */
fallbackName,
open,
onOpenChange,
}: {
agentId: string | null;
fallbackName?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
// 抽屉打开时才拉详情(含 soul);关闭传 null 让 useAgent 不发请求。
const { agent, isLoading } = useAgent(open ? agentId : null);
const name = agent?.name || fallbackName || agentId || "";
const skillItems = agent?.skills ?? [];
const toolGroups = agent?.tool_groups ?? [];
const hasSkillsOrTools = skillItems.length > 0 || toolGroups.length > 0;
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full gap-0 p-0 sm:max-w-md">
<SheetHeader className="border-b border-border px-5 py-4">
<SheetTitle className="flex items-center gap-2.5">
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
<Bot className="size-4" />
</span>
<span className="min-w-0 flex-1 truncate text-base font-bold">{name}</span>
{agent?.model && (
<Badge variant="outline" className="shrink-0 text-[10px]">
{desensitize(agent.model)}
</Badge>
)}
</SheetTitle>
<SheetDescription className="sr-only">智能体详情</SheetDescription>
</SheetHeader>
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-5 py-4 text-sm">
{isLoading && !agent ? (
<div className="flex items-center gap-2 py-8 text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
正在加载详情…
</div>
) : !agent ? (
<div className="py-8 text-center text-muted-foreground">
未找到该智能体的详情(可能已被删除)。
</div>
) : (
<>
{/* 状态徽标 */}
<div className="flex flex-wrap items-center gap-1.5">
{agent.builtin && (
<Badge variant="secondary" className="text-[10px]">内置</Badge>
)}
{agent.published && (
<Badge variant="outline" className="gap-1 text-[10px]">
<Globe className="size-3" />
已发布
</Badge>
)}
{agent.is_private && (
<Badge variant="outline" className="gap-1 text-[10px]">
<Lock className="size-3" />
私密
</Badge>
)}
</div>
{/* 描述 */}
<section className="space-y-1.5">
<h4 className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
描述
</h4>
<p className="whitespace-pre-wrap leading-relaxed text-foreground">
{agent.description?.trim() || "(无描述)"}
</p>
</section>
{/* 标签 */}
{agent.tags && agent.tags.length > 0 && (
<section className="space-y-1.5">
<h4 className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
标签
</h4>
<TagChipList tags={agent.tags} />
</section>
)}
{/* 技能 / 工具组 */}
{hasSkillsOrTools && (
<section className="space-y-1.5">
<h4 className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
技能与工具
</h4>
<div className="flex flex-wrap gap-1.5">
{toolGroups.map((group) => (
<Badge key={`tg:${group}`} variant="outline" className="text-xs">
{desensitize(group)}
</Badge>
))}
{skillItems.map((skill) => (
<Badge key={`sk:${skill}`} variant="secondary" className="text-xs">
{desensitize(skill)}
</Badge>
))}
</div>
</section>
)}
{/* 人设(SOUL.md) */}
{agent.soul?.trim() && (
<section className="space-y-1.5">
<h4 className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
人设
</h4>
<pre className="whitespace-pre-wrap break-words rounded-md border border-border bg-muted/30 p-3 text-[12px] leading-relaxed text-foreground">
{agent.soul}
</pre>
</section>
)}
</>
)}
</div>
</SheetContent>
</Sheet>
);
}
@@ -30,6 +30,7 @@ import {
GitBranch,
Globe,
GripVertical,
Info,
Layers,
Loader2,
Network,
@@ -110,6 +111,7 @@ import {
} from "@/roundtable-planning/lib/chain-stages";
import { getAvatarTypeFor } from "@/roundtable-planning/lib/roundtable-constants";
import { DagFlowChart } from "@/roundtable-planning/components/DagFlowChart";
import { AgentDetailSheet } from "@/roundtable-planning/components/AgentDetailSheet";
const POOL_PREFIX = "pool::";
const SEAT_PREFIX = "seat::";
@@ -155,6 +157,11 @@ export default function BusinessChainEditorPage() {
const [activeDrag, setActiveDrag] = useState<ActiveDrag>(null);
const [dropdownOpen, setDropdownOpen] = useState(false);
const [flowOpen, setFlowOpen] = useState(false);
// 详情抽屉:左栏点卡片本体、右栏点「查看详情」都打开它,展示该智能体详情。
const [detailAgent, setDetailAgent] = useState<{ id: string; name?: string } | null>(null);
const showDetail = useCallback((id: string, name?: string) => {
setDetailAgent({ id, name });
}, []);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
@@ -763,7 +770,7 @@ export default function BusinessChainEditorPage() {
{pool.length}
</span>
</div>
<span className="text-[11px] font-medium text-muted-foreground">拖到右侧 / 点击加入</span>
<span className="text-[11px] font-medium text-muted-foreground">拖手柄到右侧 / 点卡片看详情</span>
</div>
<div className="shrink-0 border-b border-border px-4 py-3">
@@ -794,6 +801,7 @@ export default function BusinessChainEditorPage() {
agent={a}
inChain={seatIdSet.has(a.agent_id)}
onAdd={() => addAgentAsNewStage(a.agent_id)}
onShowDetail={() => showDetail(a.agent_id, a.name)}
/>
))
)}
@@ -813,6 +821,7 @@ export default function BusinessChainEditorPage() {
onMergeSeat={mergeSeatToStage}
onSplitSeat={splitSeat}
onStageGoalChange={setGoal}
onSeatDetail={showDetail}
/>
</main>
@@ -873,6 +882,16 @@ export default function BusinessChainEditorPage() {
</Dialog>
)}
{/* 智能体详情抽屉(左栏卡片 / 右栏席位共用) */}
<AgentDetailSheet
agentId={detailAgent?.id ?? null}
fallbackName={detailAgent?.name}
open={detailAgent !== null}
onOpenChange={(o) => {
if (!o) setDetailAgent(null);
}}
/>
{/* 页脚 */}
<footer className="shrink-0 select-none border-t border-border bg-card px-6 py-3 text-center text-[11px] text-muted-foreground">
分层编排:同一 Stage 内的席位并行研讨,Stage 之间串行。全单席位 = 线性链。保存后自动同步至圆桌。
@@ -887,11 +906,14 @@ function PoolAgentCard({
agent,
inChain,
onAdd,
onShowDetail,
}: {
agent: RecommendCandidate;
inChain: boolean;
onAdd: () => void;
onShowDetail: () => void;
}) {
// 只把拖拽 listeners 挂到左侧手柄上 —— 点手柄才拖动;点卡片本体打开详情抽屉。
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: POOL_PREFIX + agent.agent_id,
disabled: inChain,
@@ -899,55 +921,79 @@ function PoolAgentCard({
return (
<div
ref={setNodeRef}
onClick={inChain ? undefined : onAdd}
onClick={onShowDetail}
className={cn(
"group relative flex items-start gap-3 border p-3.5 transition-all duration-150",
"group relative flex items-stretch overflow-hidden border transition-all duration-150",
R_CARD,
inChain
? "cursor-default border-primary/30 bg-primary/[0.03]"
? "cursor-pointer border-primary/30 bg-primary/[0.03]"
: "cursor-pointer border-border bg-card hover:border-primary hover:shadow-[0_2px_8px_rgba(0,0,0,0.04)]",
isDragging && "opacity-30",
)}
{...(inChain ? {} : listeners)}
{...attributes}
>
<div
{/* 拖拽手柄(与右栏席位一致):点手柄才开始拖动;已加入则禁用拖拽。 */}
<button
type="button"
onClick={(e) => e.stopPropagation()}
className={cn(
"mt-0.5 flex size-6 shrink-0 items-center justify-center transition-colors",
R_CARD,
"flex w-7 shrink-0 items-center justify-center border-r border-border bg-muted/40 text-muted-foreground/60 transition-colors",
inChain
? "bg-primary/10 text-primary"
: "bg-muted text-muted-foreground group-hover:bg-primary/10 group-hover:text-primary",
? "cursor-not-allowed opacity-40"
: "cursor-grab touch-none hover:text-foreground active:cursor-grabbing",
)}
title={inChain ? "已在链条中" : "拖动到右侧加入 / 排序"}
{...(inChain ? {} : listeners)}
>
<Bot className="size-3.5" />
</div>
<GripVertical className="size-3.5" />
</button>
<div className="min-w-0 flex-1 pr-16">
<div className="truncate text-xs font-bold leading-tight text-foreground transition-colors group-hover:text-primary">
{agent.name || agent.agent_id}
<div className="flex min-w-0 flex-1 items-start gap-3 p-3 pr-2">
<div
className={cn(
"mt-0.5 flex size-6 shrink-0 items-center justify-center transition-colors",
R_CARD,
inChain
? "bg-primary/10 text-primary"
: "bg-muted text-muted-foreground group-hover:bg-primary/10 group-hover:text-primary",
)}
>
<Bot className="size-3.5" />
</div>
{agent.description && (
<div className="mt-1 line-clamp-2 text-[11px] font-medium leading-normal text-muted-foreground">
{agent.description}
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-bold leading-tight text-foreground transition-colors group-hover:text-primary">
{agent.name || agent.agent_id}
</div>
)}
{agent.description && (
<div className="mt-1 line-clamp-2 text-[11px] font-medium leading-normal text-muted-foreground">
{agent.description}
</div>
)}
</div>
</div>
<div className="absolute right-3.5 top-3.5 shrink-0 select-none">
{/* 加入按钮:只有点它才加入(点卡片其它区域是看详情)。已加入显示「已加入」。 */}
<div className="flex shrink-0 items-center pr-3 pl-1 select-none">
{inChain ? (
<span className={cn("border border-primary/30 bg-primary/10 px-2 py-1 text-[10px] font-bold text-primary", R_CTRL)}>
已加入
</span>
) : (
<span
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onAdd();
}}
className={cn(
"border border-transparent bg-muted px-2 py-1 text-[10px] font-semibold text-muted-foreground transition-all group-hover:border-primary/30 group-hover:bg-primary/10 group-hover:text-primary",
"inline-flex items-center gap-1 border border-primary/30 bg-primary/5 px-2 py-1 text-[10px] font-semibold text-primary transition-all hover:bg-primary/10",
R_CTRL,
)}
>
点击加入
</span>
<Plus className="size-3" />
加入
</button>
)}
</div>
</div>
@@ -968,6 +1014,7 @@ function StagesColumn({
onMergeSeat,
onSplitSeat,
onStageGoalChange,
onSeatDetail,
}: {
stageGroups: StageGroups;
seatCount: number;
@@ -980,6 +1027,7 @@ function StagesColumn({
onMergeSeat: (agentId: string, targetStageIdx: number) => void;
onSplitSeat: (agentId: string) => void;
onStageGoalChange: (stageIdx: number, goal: string) => void;
onSeatDetail: (agentId: string, name?: string) => void;
}) {
// 跨 Stage 的全局序号(头像编号):每个 Stage 起始累加。
const stageStartIndex = useMemo(() => {
@@ -1073,6 +1121,7 @@ function StagesColumn({
onMergeSeat={onMergeSeat}
onSplitSeat={onSplitSeat}
onGoalChange={(goal) => onStageGoalChange(stageIdx, goal)}
onSeatDetail={onSeatDetail}
/>
))}
<NewStageDropZone hasEmptyStage={hasEmptyStage} onAddStage={onAddStage} />
@@ -1097,6 +1146,7 @@ function StageSection({
onMergeSeat,
onSplitSeat,
onGoalChange,
onSeatDetail,
}: {
stageIdx: number;
group: StageGroup;
@@ -1110,6 +1160,7 @@ function StageSection({
onMergeSeat: (agentId: string, targetStageIdx: number) => void;
onSplitSeat: (agentId: string) => void;
onGoalChange: (goal: string) => void;
onSeatDetail: (agentId: string, name?: string) => void;
}) {
const { setNodeRef, isOver } = useDroppable({ id: STAGE_PREFIX + stageIdx });
const seats = group.seats;
@@ -1238,6 +1289,7 @@ function StageSection({
onMergeUp={() => onMergeSeat(seat.agent_id, stageIdx - 1)}
onMergeDown={() => onMergeSeat(seat.agent_id, stageIdx + 1)}
onSplit={() => onSplitSeat(seat.agent_id)}
onDetail={() => onSeatDetail(seat.agent_id, seat.name)}
/>
))}
</SortableContext>
@@ -1259,6 +1311,7 @@ function SeatStrip({
onMergeUp,
onMergeDown,
onSplit,
onDetail,
}: {
seat: ChainSeat;
globalIndex: number;
@@ -1270,6 +1323,7 @@ function SeatStrip({
onMergeUp: () => void;
onMergeDown: () => void;
onSplit: () => void;
onDetail: () => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: SEAT_PREFIX + seat.agent_id });
@@ -1319,8 +1373,19 @@ function SeatStrip({
)}
</div>
{/* 控制:并入上一阶段 / 独立成阶段 / 并入下一阶段 / 移除 */}
{/* 控制:查看详情 / 并入上一阶段 / 独立成阶段 / 并入下一阶段 / 移除 */}
<div className="flex shrink-0 items-center gap-0.5 border-l border-border px-2">
<button
type="button"
onClick={onDetail}
title="查看详情"
className={cn(
"p-1 text-muted-foreground transition hover:bg-muted hover:text-foreground",
R_CTRL,
)}
>
<Info className="size-3.5" />
</button>
<button
type="button"
onClick={onMergeUp}