- 区域级脱敏 Hook useDesensitizeSubtree:对左右侧面板、顶部标题、 业务链条选择器/列表弹窗/列表页/配置页/映射页整片脱敏(文本节点, 跳过 input/textarea 等可编辑控件) - 消息列表正文保持原文,仅智能体名称脱敏(senderName / 席位名 / 流程图节点) - Step3 总结/报告/大屏、推荐弹窗等结果面板脱敏 - 沙箱产物预览不脱敏:ArtifactFileDetail 加 disableDesensitize,会商沙箱传入 - desensitize.ts 新增 desensitizeDeep 递归脱敏结构化数据 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
398 lines
14 KiB
TypeScript
398 lines
14 KiB
TypeScript
/**
|
||
* 业务链条列表弹窗(圆桌页顶栏「业务链条」按钮入口)。
|
||
*
|
||
* 点击「业务链条」按钮后打开本弹窗:展示当前用户已配置的业务链条卡片列表。
|
||
* - 每张卡片右侧「设置」按钮 → 进入配置页并**切换到该链条**(带 `chainId`)。
|
||
* - footer「配置」按钮 → 进入配置页(空白新建,等价于旧按钮的直接跳转)。
|
||
*
|
||
* 纯导航 / 浏览用途,不参与研讨席位选择(席位选择走 `BusinessChainPicker`)。
|
||
* 卡片席位名优先用实时 agent 数据(`listAgents()`,与 `BusinessChainPicker` 一致),
|
||
* agent 改名后此弹窗也能同步显示;解析不到才回退到落库快照(`seat.name`)。
|
||
*/
|
||
import { Fragment, useCallback, useEffect, useState } from "react";
|
||
import {
|
||
ArrowRightIcon,
|
||
GlobeIcon,
|
||
LayersIcon,
|
||
Loader2Icon,
|
||
SettingsIcon,
|
||
UserIcon,
|
||
} from "lucide-react";
|
||
|
||
import { Button } from "@/components/ui/button";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@/components/ui/dialog";
|
||
import { cn } from "@/lib/utils";
|
||
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
|
||
|
||
import { listAgents } from "@/core/agents";
|
||
|
||
import { listChains, setChainPublic, type ChainScope } from "../api/chains";
|
||
import { chainTagClasses, type BusinessChain } from "../lib/business-chain";
|
||
import { getAvatarTypeFor } from "../lib/roundtable-constants";
|
||
|
||
export interface BusinessChainListDialogProps {
|
||
open: boolean;
|
||
onOpenChange: (open: boolean) => void;
|
||
/** 进入配置页。带 chainId = 打开并切到该链条;不带 = 空白新建。 */
|
||
onConfigure: (chainId?: string) => void;
|
||
}
|
||
|
||
export interface BusinessChainListBodyProps {
|
||
/**
|
||
* 是否激活(控制何时拉取列表):弹窗里传 `open`(打开才拉),
|
||
* 独立页面里恒为 `true`。
|
||
*/
|
||
active: boolean;
|
||
/** 进入配置页。带 chainId = 打开并切到该链条;不带 = 空白新建。 */
|
||
onConfigure: (chainId?: string) => void;
|
||
/** 列表为空时的「去创建」引导文案差异(弹窗说「点下方配置」,页面说「点右上配置」)。 */
|
||
emptyHint?: string;
|
||
}
|
||
|
||
/**
|
||
* 业务链条列表主体(筛选 tab + 滚动卡片列表)。
|
||
*
|
||
* 从 `BusinessChainListDialog` 抽出,供弹窗与独立配置页(`BusinessChainListPage`)
|
||
* 复用,保证两处的加载 / 发布 / 卡片渲染逻辑完全一致。外层负责标题栏与「配置」入口、
|
||
* 以及撑出 `flex flex-col` 高度(本组件用 `flex-1` 占满剩余空间)。
|
||
*/
|
||
export function BusinessChainListBody({
|
||
active,
|
||
onConfigure,
|
||
emptyHint,
|
||
}: BusinessChainListBodyProps) {
|
||
const [chains, setChains] = useState<BusinessChain[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
// 筛选:我的 / 公共(已发布)。
|
||
const [scope, setScope] = useState<ChainScope>("mine");
|
||
const [publishingId, setPublishingId] = useState<string | null>(null);
|
||
// 实时名称表(id 小写 → 最新 name):用于把席位的落库快照名刷新成最新(agent 改名同步)。
|
||
const [nameById, setNameById] = useState<Map<string, string>>(new Map());
|
||
|
||
useEffect(() => {
|
||
if (!active) return;
|
||
let cancelled = false;
|
||
void (async () => {
|
||
try {
|
||
const agents = await listAgents();
|
||
if (cancelled) return;
|
||
const m = new Map<string, string>();
|
||
for (const a of agents) if (a?.id) m.set(a.id.toLowerCase(), a.name ?? a.id);
|
||
setNameById(m);
|
||
} catch {
|
||
// 静默失败:解析不到就回退到席位快照名。
|
||
}
|
||
})();
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [active]);
|
||
|
||
const refresh = useCallback(async (s: ChainScope) => {
|
||
setLoading(true);
|
||
setError(null);
|
||
try {
|
||
const list = await listChains(s);
|
||
setChains(list);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : String(err));
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
// 每次激活 / 切换筛选都重新拉,保证配置页改动后回来看到的是最新列表。
|
||
useEffect(() => {
|
||
if (!active) return;
|
||
let cancelled = false;
|
||
void (async () => {
|
||
setLoading(true);
|
||
setError(null);
|
||
try {
|
||
const list = await listChains(scope);
|
||
if (!cancelled) setChains(list);
|
||
} catch (err) {
|
||
if (!cancelled) setError(err instanceof Error ? err.message : String(err));
|
||
} finally {
|
||
if (!cancelled) setLoading(false);
|
||
}
|
||
})();
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [active, scope]);
|
||
|
||
const handleTogglePublic = useCallback(
|
||
async (chain: BusinessChain) => {
|
||
setPublishingId(chain.id);
|
||
try {
|
||
await setChainPublic(chain.id, !chain.isPublic);
|
||
await refresh(scope);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : String(err));
|
||
} finally {
|
||
setPublishingId(null);
|
||
}
|
||
},
|
||
[refresh, scope],
|
||
);
|
||
|
||
return (
|
||
<>
|
||
{/* 筛选:我的 / 公共 */}
|
||
<div className="shrink-0 flex items-center gap-1 rounded-lg border border-border bg-muted/40 p-0.5 self-start">
|
||
{(["mine", "public"] as const).map((s) => (
|
||
<button
|
||
key={s}
|
||
type="button"
|
||
onClick={() => setScope(s)}
|
||
className={cn(
|
||
"rounded-md px-3 py-1 text-xs font-medium transition",
|
||
scope === s
|
||
? "bg-primary text-primary-foreground shadow-sm"
|
||
: "text-muted-foreground hover:text-foreground",
|
||
)}
|
||
>
|
||
{s === "mine" ? "我的" : "公共"}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div className="custom-scrollbar -mr-1 min-h-0 flex-1 overflow-y-auto py-3 pr-1">
|
||
{loading ? (
|
||
<div className="flex items-center gap-2 px-1 py-10 text-sm text-muted-foreground">
|
||
<Loader2Icon className="size-4 animate-spin" />
|
||
正在加载业务链条…
|
||
</div>
|
||
) : error ? (
|
||
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
|
||
加载业务链条失败:{error}
|
||
</div>
|
||
) : chains.length === 0 ? (
|
||
<div className="flex flex-col items-center justify-center gap-3 px-6 py-12 text-center">
|
||
<LayersIcon className="size-10 text-primary/40" />
|
||
<div className="text-sm font-semibold">
|
||
{scope === "public" ? "还没有公共链条" : "还没有业务链条"}
|
||
</div>
|
||
<p className="max-w-sm text-xs leading-relaxed text-muted-foreground">
|
||
{scope === "public"
|
||
? "公共链条是其他账号(或你自己)发布出来、所有人可见的链条。去「我的」里发布一条试试。"
|
||
: emptyHint ??
|
||
"业务链条是可复用的有序智能体编排。点击下方「配置」去创建第一条链条。"}
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-3.5">
|
||
{chains.map((chain) => (
|
||
<ChainRow
|
||
key={chain.id}
|
||
chain={chain}
|
||
nameById={nameById}
|
||
publishing={publishingId === chain.id}
|
||
onSettings={() => onConfigure(chain.id)}
|
||
onTogglePublic={() => handleTogglePublic(chain)}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
export function BusinessChainListDialog({
|
||
open,
|
||
onOpenChange,
|
||
onConfigure,
|
||
}: BusinessChainListDialogProps) {
|
||
// 整片脱敏:弹窗内链条列表(链条名/描述/席位名/标题)。
|
||
const dzRef = useDesensitizeSubtree<HTMLDivElement>();
|
||
return (
|
||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
<DialogContent ref={dzRef} className="flex max-h-[85vh] flex-col gap-0 sm:max-w-2xl">
|
||
<DialogHeader className="shrink-0">
|
||
<DialogTitle className="flex items-center gap-2">
|
||
<LayersIcon className="size-5 text-primary" />
|
||
业务链条
|
||
</DialogTitle>
|
||
<DialogDescription>
|
||
可复用的有序智能体编排。点击卡片「设置」可进入配置页编辑该链条;「发布」后所有账号都能在「公共」里查看 / 选用。
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<BusinessChainListBody active={open} onConfigure={onConfigure} />
|
||
|
||
<DialogFooter className="shrink-0 border-t pt-3 sm:justify-end">
|
||
<Button onClick={() => onConfigure()} className="gap-1.5">
|
||
<SettingsIcon className="size-4" />
|
||
配置
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
// ── 单张链条行卡片 ─────────────────────────────────────────────────────────
|
||
|
||
function ChainRow({
|
||
chain,
|
||
nameById,
|
||
publishing,
|
||
onSettings,
|
||
onTogglePublic,
|
||
}: {
|
||
chain: BusinessChain;
|
||
nameById: Map<string, string>;
|
||
publishing: boolean;
|
||
onSettings: () => void;
|
||
onTogglePublic: () => void;
|
||
}) {
|
||
const isMine = chain.isMine !== false;
|
||
// 配置者用户名:后端返回 email,展示时取 @ 前的本地部分更像「用户名」。
|
||
const ownerName = chain.ownerName ? chain.ownerName.split("@")[0] : "";
|
||
return (
|
||
<div className="rounded-2xl border border-border bg-card p-5 transition-colors hover:border-primary/40">
|
||
{/* 标题行 + 公共徽标 + 发布/设置按钮 */}
|
||
<div className="flex items-center gap-2.5">
|
||
<div className="truncate text-base font-semibold">{chain.title || "未命名链条"}</div>
|
||
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 text-xs font-medium tabular-nums text-muted-foreground">
|
||
{chain.seats.length} 席
|
||
</span>
|
||
{chain.isPublic && (
|
||
<span className="shrink-0 inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2 py-0.5 text-xs font-medium text-emerald-600 dark:text-emerald-400">
|
||
<GlobeIcon className="size-3" />
|
||
公共
|
||
</span>
|
||
)}
|
||
<div className="ml-auto flex shrink-0 items-center gap-1.5">
|
||
{/* 发布 / 取消发布:仅本人的链条可操作。 */}
|
||
{isMine && (
|
||
<Button
|
||
variant={chain.isPublic ? "secondary" : "outline"}
|
||
size="sm"
|
||
onClick={onTogglePublic}
|
||
disabled={publishing}
|
||
className="h-8 gap-1.5 px-3 text-sm"
|
||
>
|
||
{publishing ? (
|
||
<Loader2Icon className="size-4 animate-spin" />
|
||
) : (
|
||
<GlobeIcon className="size-4" />
|
||
)}
|
||
{chain.isPublic ? "取消发布" : "发布"}
|
||
</Button>
|
||
)}
|
||
{/* 设置(编辑):仅本人的链条;公共里别人的链条只读浏览。 */}
|
||
{isMine && (
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={onSettings}
|
||
className="h-8 gap-1.5 px-3 text-sm"
|
||
>
|
||
<SettingsIcon className="size-4" />
|
||
设置
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{ownerName && (
|
||
<div
|
||
className="mt-1.5 flex items-center gap-1 text-xs text-muted-foreground"
|
||
title={chain.ownerName}
|
||
>
|
||
<UserIcon className="size-3.5 shrink-0" />
|
||
<span className="truncate">配置人:{ownerName}</span>
|
||
</div>
|
||
)}
|
||
|
||
{chain.description && (
|
||
<div className="mt-1.5 line-clamp-2 text-sm text-muted-foreground">{chain.description}</div>
|
||
)}
|
||
|
||
{/* 席位编排:按 stage 分组——并行阶段竖向并列在虚线框里,阶段之间用 → 串联。 */}
|
||
<ChainSeatsFlow chain={chain} nameById={nameById} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── 链条席位编排流(体现「同层并行 · 层间串行」)─────────────────────────────
|
||
|
||
function ChainSeatsFlow({
|
||
chain,
|
||
nameById,
|
||
}: {
|
||
chain: BusinessChain;
|
||
nameById: Map<string, string>;
|
||
}) {
|
||
// 名称回退表:用落库快照(seats)解析 stage 里的 agent_id。
|
||
const seatById = new Map(chain.seats.map((s) => [s.agent_id, s] as const));
|
||
|
||
// 分层来源:带 stages(分层编排)就按 stages 渲染(过滤空阶段);否则每个席位各成
|
||
// 一段、顺序串行(线性链,与旧版展示一致)。
|
||
const stages =
|
||
chain.stages && chain.stages.length > 0
|
||
? chain.stages.filter((s) => s.length > 0)
|
||
: chain.seats.map((s) => [s.agent_id]);
|
||
|
||
// 跨阶段累加的全局序号 —— 决定头像配色,与编辑器 / 展平 seats 顺序对齐。
|
||
const orderById = new Map<string, number>();
|
||
let counter = 0;
|
||
for (const stageIds of stages) {
|
||
for (const id of stageIds) {
|
||
orderById.set(id, counter);
|
||
counter += 1;
|
||
}
|
||
}
|
||
|
||
const seatTag = (agentId: string) => {
|
||
const seat = seatById.get(agentId);
|
||
// 优先实时名称(agent 改名同步)→ 落库快照 → agent_id。
|
||
const label = nameById.get(agentId.toLowerCase()) ?? seat?.name ?? agentId;
|
||
return (
|
||
<span
|
||
key={agentId}
|
||
className={cn(
|
||
"rounded-md border px-2 py-1 text-xs font-medium",
|
||
chainTagClasses(getAvatarTypeFor(agentId, orderById.get(agentId) ?? 0)),
|
||
)}
|
||
>
|
||
{label}
|
||
</span>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<div className="mt-3.5 flex flex-wrap items-center gap-1.5">
|
||
{stages.map((stageIds, stageIdx) => (
|
||
<Fragment key={stageIdx}>
|
||
{stageIdx > 0 && (
|
||
<ArrowRightIcon className="size-3.5 shrink-0 text-muted-foreground/50" />
|
||
)}
|
||
{stageIds.length > 1 ? (
|
||
// 并行阶段:席位竖向并列在虚线框里,左上角标「并行 N」。
|
||
<div className="relative flex flex-col gap-1 rounded-lg border border-dashed border-primary/40 bg-primary/[0.04] px-1.5 pb-1.5 pt-2.5">
|
||
<span className="pointer-events-none absolute -top-2 left-1.5 rounded-full bg-card px-1.5 text-[10px] font-semibold leading-tight text-primary">
|
||
并行 {stageIds.length}
|
||
</span>
|
||
{stageIds.map((id) => seatTag(id))}
|
||
</div>
|
||
) : (
|
||
seatTag(stageIds[0])
|
||
)}
|
||
</Fragment>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|