feat(roundtable): 业务链条左栏卡片拖动手柄 + 加入按钮 + 智能体详情抽屉
- 左栏候选卡片加拖动手柄(与右栏席位一致),仅点手柄才拖动 - 加入改为显式「加入」按钮,点卡片本体改为打开详情抽屉 - 右栏席位操作区加「查看详情」按钮,打开同一抽屉 - 新增 AgentDetailSheet:按需拉取(useAgent)展示名称/模型/描述/ 标签/技能+工具组/人设,对齐智能体详情页内容 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
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}
|
||||
|
||||
Reference in new issue
Block a user