feat: add workflow configuration dashboard
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-22 11:19:39 +08:00
parent 1103ff957f
commit aaba40b2d6

View File

@ -159,7 +159,6 @@ const WORKFLOW_STEP_DRAG_DATA_TYPE = "application/x-agent-workflow-step";
const WORKFLOW_SETTINGS_DRAFT_STORAGE_KEY = "agent-workflow-settings-draft:v1";
const WORKFLOW_SETTINGS_STALE_DRAFT_STORAGE_KEY = "agent-workflow-settings-stale-draft:v1";
const WORKFLOW_SETTINGS_DRAFT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
const WORKFLOW_BLOCKED_CANDIDATE_RENDER_LIMIT = 60;
type WorkflowStepDropPosition = "before" | "after" | "inside";
@ -187,6 +186,42 @@ interface AgentWorkflowSettingsDraft {
state: AgentWorkflowSettingsDraftState;
}
interface WorkflowOptionContentSummary {
total: number;
withPrompt: number;
withDescription: number;
withPreview: number;
disabled: number;
withMutualExclusion: number;
}
interface WorkflowStepAnalysis {
key: string;
title: string;
path: string;
depth: number;
childStepCount: number;
directRootOptionCount: number;
optionStats: WorkflowOptionContentSummary;
subtreeStepCount: number;
subtreeOptionCount: number;
hasOwnContent: boolean;
hasSubtreeContent: boolean;
enabled: boolean;
}
interface WorkflowOptionGapAnalysis {
key: string;
label: string;
path: string;
stepKey: string;
stepPath: string;
depth: number;
missingFields: string[];
disabled: boolean;
childCount: number;
}
type BufferedInputProps = Omit<
ComponentProps<typeof Input>,
"defaultValue" | "onChange" | "value"
@ -513,6 +548,191 @@ function collectWorkflowOptionKeys(options: AgentWorkflowOptionUiConfig[]): stri
return flattenAgentWorkflowOptionTree(options).map(({ option }) => option.key);
}
function hasWorkflowOptionPrompt(option: AgentWorkflowOptionUiConfig) {
return Boolean(String(option.promptFragment || "").trim());
}
function hasWorkflowOptionDescription(option: AgentWorkflowOptionUiConfig) {
return Boolean(String(option.description || "").trim());
}
function hasWorkflowOptionPreview(option: AgentWorkflowOptionUiConfig) {
return Boolean(String(option.previewImageUrl || "").trim());
}
function hasWorkflowOptionConfiguredContent(option: AgentWorkflowOptionUiConfig) {
return (
hasWorkflowOptionPrompt(option) ||
hasWorkflowOptionDescription(option) ||
hasWorkflowOptionPreview(option)
);
}
function getWorkflowOptionMissingFields(option: AgentWorkflowOptionUiConfig): string[] {
const missingFields: string[] = [];
if (!String(option.buttonName || "").trim()) {
missingFields.push("名称");
}
if (!hasWorkflowOptionPrompt(option)) {
missingFields.push("Prompt");
}
if (!hasWorkflowOptionDescription(option)) {
missingFields.push("说明");
}
if (!hasWorkflowOptionPreview(option)) {
missingFields.push("预览图");
}
return missingFields;
}
function createEmptyWorkflowOptionContentSummary(): WorkflowOptionContentSummary {
return {
total: 0,
withPrompt: 0,
withDescription: 0,
withPreview: 0,
disabled: 0,
withMutualExclusion: 0,
};
}
function analyzeWorkflowOptionContent(
options: AgentWorkflowOptionUiConfig[]
): WorkflowOptionContentSummary {
return flattenAgentWorkflowOptionTree(options).reduce<WorkflowOptionContentSummary>(
(summary, { option }) => ({
total: summary.total + 1,
withPrompt: summary.withPrompt + (hasWorkflowOptionPrompt(option) ? 1 : 0),
withDescription:
summary.withDescription + (hasWorkflowOptionDescription(option) ? 1 : 0),
withPreview: summary.withPreview + (hasWorkflowOptionPreview(option) ? 1 : 0),
disabled: summary.disabled + (option.enabled === false ? 1 : 0),
withMutualExclusion:
summary.withMutualExclusion + ((option.blockedOptionKeys || []).length > 0 ? 1 : 0),
}),
createEmptyWorkflowOptionContentSummary()
);
}
function buildWorkflowStepAnalysisBranch(
step: AgentWorkflowStepUiConfig,
depth: number,
parentPath: string[]
): {
items: WorkflowStepAnalysis[];
subtreeStepCount: number;
subtreeOptionCount: number;
hasSubtreeContent: boolean;
} {
const title = step.title || step.key;
const pathParts = [...parentPath, title];
const childBranches = [...(step.children || [])]
.sort((left, right) => left.sortOrder - right.sortOrder)
.map((childStep) => buildWorkflowStepAnalysisBranch(childStep, depth + 1, pathParts));
const optionStats = analyzeWorkflowOptionContent(step.options || []);
const childStepCount = (step.children || []).length;
const subtreeStepCount =
1 + childBranches.reduce((total, child) => total + child.subtreeStepCount, 0);
const subtreeOptionCount =
optionStats.total +
childBranches.reduce((total, child) => total + child.subtreeOptionCount, 0);
const hasOwnContent =
optionStats.withPrompt > 0 || optionStats.withDescription > 0 || optionStats.withPreview > 0;
const hasSubtreeContent =
hasOwnContent || childBranches.some((child) => child.hasSubtreeContent);
const current: WorkflowStepAnalysis = {
key: step.key,
title,
path: pathParts.join(" / "),
depth,
childStepCount,
directRootOptionCount: (step.options || []).length,
optionStats,
subtreeStepCount,
subtreeOptionCount,
hasOwnContent,
hasSubtreeContent,
enabled: step.enabled !== false,
};
return {
items: [current, ...childBranches.flatMap((child) => child.items)],
subtreeStepCount,
subtreeOptionCount,
hasSubtreeContent,
};
}
function buildWorkflowStepAnalyses(
steps: AgentWorkflowStepUiConfig[]
): WorkflowStepAnalysis[] {
return [...steps]
.sort((left, right) => left.sortOrder - right.sortOrder)
.flatMap((step) => buildWorkflowStepAnalysisBranch(step, 1, []).items);
}
function buildWorkflowOptionGapDetailsFromOptions(
options: AgentWorkflowOptionUiConfig[],
stepKey: string,
stepPath: string,
ancestors: string[] = []
): WorkflowOptionGapAnalysis[] {
return [...options]
.sort((left, right) => left.sortOrder - right.sortOrder)
.flatMap((option) => {
const label = option.buttonName || option.key;
const pathParts = [...ancestors, label];
const missingFields = getWorkflowOptionMissingFields(option);
const current =
missingFields.length > 0
? [
{
key: option.key,
label,
path: pathParts.join(" / "),
stepKey,
stepPath,
depth: pathParts.length,
missingFields,
disabled: option.enabled === false,
childCount: (option.children || []).length,
},
]
: [];
return [
...current,
...buildWorkflowOptionGapDetailsFromOptions(
option.children || [],
stepKey,
stepPath,
pathParts
),
];
});
}
function buildWorkflowOptionGapDetails(
steps: AgentWorkflowStepUiConfig[],
parentPath: string[] = []
): WorkflowOptionGapAnalysis[] {
return [...steps]
.sort((left, right) => left.sortOrder - right.sortOrder)
.flatMap((step) => {
const stepTitle = step.title || step.key;
const stepPathParts = [...parentPath, stepTitle];
const stepPath = stepPathParts.join(" / ");
return [
...buildWorkflowOptionGapDetailsFromOptions(step.options || [], step.key, stepPath),
...buildWorkflowOptionGapDetails(step.children || [], stepPathParts),
];
});
}
function collectWorkflowOptionRelations(
options: AgentWorkflowOptionUiConfig[],
ancestors: string[] = []
@ -520,6 +740,13 @@ function collectWorkflowOptionRelations(
key: string;
label: string;
depth: number;
childCount: number;
hasPrompt: boolean;
hasDescription: boolean;
hasPreview: boolean;
hasContent: boolean;
disabled: boolean;
blockedCount: number;
}> {
return options.flatMap((option) => {
const currentPath = [...ancestors, option.buttonName || option.key];
@ -528,6 +755,13 @@ function collectWorkflowOptionRelations(
key: option.key,
label: currentPath.join(" / "),
depth: currentPath.length,
childCount: (option.children || []).length,
hasPrompt: hasWorkflowOptionPrompt(option),
hasDescription: hasWorkflowOptionDescription(option),
hasPreview: hasWorkflowOptionPreview(option),
hasContent: hasWorkflowOptionConfiguredContent(option),
disabled: option.enabled === false,
blockedCount: (option.blockedOptionKeys || []).length,
},
...collectWorkflowOptionRelations(option.children || [], currentPath),
];
@ -1848,6 +2082,83 @@ export function AgentWorkflowSettingsClient({
() => new Map(workflowOptionRelations.map((item) => [item.key, item] as const)),
[workflowOptionRelations]
);
const workflowStepAnalyses = useMemo(
() => buildWorkflowStepAnalyses(deferredWorkflowSteps || []),
[deferredWorkflowSteps]
);
const workflowStepAnalysisLookup = useMemo(
() => new Map(workflowStepAnalyses.map((item) => [item.key, item] as const)),
[workflowStepAnalyses]
);
const workflowOptionGapDetails = useMemo(
() =>
buildWorkflowOptionGapDetails(deferredWorkflowSteps || []).sort(
(left, right) =>
right.missingFields.length - left.missingFields.length ||
right.depth - left.depth ||
left.stepPath.localeCompare(right.stepPath, "zh-CN") ||
left.path.localeCompare(right.path, "zh-CN")
),
[deferredWorkflowSteps]
);
const workflowDashboardMetrics = useMemo(() => {
const levelCounts = new Map<number, number>();
let totalCards = 0;
let promptCards = 0;
let disabledCards = 0;
let mutualExclusionCards = 0;
for (const item of workflowStepAnalyses) {
levelCounts.set(item.depth, (levelCounts.get(item.depth) || 0) + 1);
totalCards += item.optionStats.total;
promptCards += item.optionStats.withPrompt;
disabledCards += item.optionStats.disabled;
mutualExclusionCards += item.optionStats.withMutualExclusion;
}
const emptyOwnContentSteps = workflowStepAnalyses.filter((item) => !item.hasOwnContent);
const emptyBranchSteps = workflowStepAnalyses.filter((item) => !item.hasSubtreeContent);
const leafEmptySteps = workflowStepAnalyses.filter(
(item) => item.childStepCount === 0 && !item.hasOwnContent
);
const denseSteps = [...workflowStepAnalyses]
.sort(
(left, right) =>
right.subtreeOptionCount +
right.subtreeStepCount -
(left.subtreeOptionCount + left.subtreeStepCount)
)
.slice(0, 5);
const deepestSteps = [...workflowStepAnalyses]
.sort((left, right) => right.depth - left.depth)
.slice(0, 5);
return {
totalSteps: workflowStepAnalyses.length,
totalCards,
promptCards,
disabledCards,
mutualExclusionCards,
missingCardDetails: workflowOptionGapDetails,
missingPromptCards: workflowOptionGapDetails.filter((item) =>
item.missingFields.includes("Prompt")
).length,
fullyEmptyCards: workflowOptionGapDetails.filter(
(item) =>
item.missingFields.includes("Prompt") &&
item.missingFields.includes("说明") &&
item.missingFields.includes("预览图")
).length,
maxDepth: workflowStepAnalyses.reduce((max, item) => Math.max(max, item.depth), 0),
promptCompletionRate: totalCards > 0 ? Math.round((promptCards / totalCards) * 100) : 0,
levelCounts: Array.from(levelCounts.entries()).sort(([left], [right]) => left - right),
emptyOwnContentSteps,
emptyBranchSteps,
leafEmptySteps,
denseSteps,
deepestSteps,
};
}, [workflowOptionGapDetails, workflowStepAnalyses]);
const canPasteCopiedWorkflowStepAsRoot = Boolean(
copiedWorkflowStep &&
getWorkflowStepSubtreeDepth(copiedWorkflowStep) <= AGENT_WORKFLOW_MAX_TREE_DEPTH
@ -3622,17 +3933,18 @@ export function AgentWorkflowSettingsClient({
return true;
}
return [candidate.label, candidate.stepTitle, candidate.key].some((value) =>
value.toLowerCase().includes(normalizedBlockedSearchValue)
);
const candidateStepAnalysis = workflowStepAnalysisLookup.get(candidate.stepKey);
return [
candidate.label,
candidate.stepTitle,
candidate.key,
candidateStepAnalysis?.path || "",
candidateStepAnalysis?.hasOwnContent ? "模块有内容" : "模块空内容",
candidate.hasContent ? "卡片有内容" : "卡片空内容",
candidate.hasPrompt ? "有prompt" : "空prompt",
].some((value) => value.toLowerCase().includes(normalizedBlockedSearchValue));
})
: [];
const visibleBlockedCandidates = filteredBlockedCandidates.slice(
0,
WORKFLOW_BLOCKED_CANDIDATE_RENDER_LIMIT
);
const hiddenBlockedCandidateCount =
filteredBlockedCandidates.length - visibleBlockedCandidates.length;
const canMoveUp = depth === 1 && indexPath[0] > 1;
const canMoveDown = depth === 1 && indexPath[0] < (step.options || []).length;
@ -3902,6 +4214,9 @@ export function AgentWorkflowSettingsClient({
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium text-muted-foreground">
候选卡片列表
{blockedCandidatePanelExpanded
? ` · 已展示 ${filteredBlockedCandidates.length}/${blockedCandidates.length}`
: ""}
</span>
<Button
type="button"
@ -3934,11 +4249,14 @@ export function AgentWorkflowSettingsClient({
className="h-8 pl-8 text-xs"
/>
</div>
<div className="max-h-44 space-y-2 overflow-y-auto">
<div className="max-h-72 space-y-2 overflow-y-auto">
{filteredBlockedCandidates.length > 0 ? (
<>
{visibleBlockedCandidates.map((candidate) => {
{filteredBlockedCandidates.map((candidate) => {
const active = (option.blockedOptionKeys || []).includes(candidate.key);
const candidateStepAnalysis = workflowStepAnalysisLookup.get(
candidate.stepKey
);
return (
<button
key={candidate.key}
@ -3966,25 +4284,39 @@ export function AgentWorkflowSettingsClient({
})
}
>
<span className="min-w-0">
<span className="block truncate font-medium">
<span className="min-w-0 flex-1 space-y-1 pr-3">
<span className="block whitespace-normal break-words font-medium leading-5">
{candidate.label}
</span>
<span className="block text-[10px] text-muted-foreground">
{getWorkflowLevelLabel(candidate.depth, "卡片")} · {candidate.key}
<span className="block whitespace-normal break-words text-[10px] leading-4 text-muted-foreground">
所属模块:
{candidateStepAnalysis?.path || candidate.stepTitle} ·{" "}
{getWorkflowLevelLabel(candidate.depth, "卡片")} ·{" "}
{candidate.key}
</span>
</span>
<Badge variant={active ? "default" : "outline"}>
{active ? "已限制" : "可限制"}
</Badge>
<span className="flex shrink-0 flex-wrap justify-end gap-1">
<Badge variant={active ? "default" : "outline"}>
{active ? "已限制" : "可限制"}
</Badge>
<Badge
variant={
candidateStepAnalysis?.hasOwnContent
? "secondary"
: "outline"
}
>
{candidateStepAnalysis?.hasOwnContent
? "模块有内容"
: "模块空内容"}
</Badge>
<Badge variant={candidate.hasContent ? "secondary" : "outline"}>
{candidate.hasContent ? "卡片有内容" : "卡片空内容"}
</Badge>
</span>
</button>
);
})}
{hiddenBlockedCandidateCount > 0 ? (
<div className="rounded-md border border-dashed bg-muted/40 px-3 py-2 text-center text-[11px] text-muted-foreground">
还有 {hiddenBlockedCandidateCount} 个候选未显示,请输入关键词缩小范围
</div>
) : null}
</>
) : (
<div className="px-2 py-3 text-center text-xs text-muted-foreground">
@ -4038,6 +4370,223 @@ export function AgentWorkflowSettingsClient({
);
};
const renderWorkflowDashboard = () => {
const completionRate = workflowDashboardMetrics.promptCompletionRate;
const priorityEmptySteps =
workflowDashboardMetrics.emptyBranchSteps.length > 0
? workflowDashboardMetrics.emptyBranchSteps
: workflowDashboardMetrics.leafEmptySteps.length > 0
? workflowDashboardMetrics.leafEmptySteps
: workflowDashboardMetrics.emptyOwnContentSteps;
const dashboardCards = [
{
label: "模块总数",
value: workflowDashboardMetrics.totalSteps,
hint: `最大 ${workflowDashboardMetrics.maxDepth || 0} 级`,
},
{
label: "卡片总数",
value: workflowDashboardMetrics.totalCards,
hint: `${workflowDashboardMetrics.promptCards} 张已有 Prompt`,
},
{
label: "空缺卡片",
value: workflowDashboardMetrics.missingCardDetails.length,
hint: `${workflowDashboardMetrics.fullyEmptyCards} 张完全空内容`,
},
{
label: "空内容模块",
value: workflowDashboardMetrics.emptyOwnContentSteps.length,
hint: `${workflowDashboardMetrics.emptyBranchSteps.length} 个整支为空`,
},
{
label: "互斥覆盖",
value: workflowDashboardMetrics.mutualExclusionCards,
hint: `${workflowDashboardMetrics.disabledCards} 张禁用/开发中`,
},
];
return (
<motion.div variants={itemVariants}>
<Card className="overflow-hidden border-emerald-500/20 bg-gradient-to-br from-emerald-500/5 via-background to-sky-500/5">
<CardHeader className="pb-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<CardTitle className="flex items-center gap-2 text-base">
<Sparkles className="h-4 w-4 text-emerald-600" />
工作流配置体检仪表盘
</CardTitle>
<CardDescription>
自动分析模块层级、卡片内容、Prompt 完成度和空模块,帮助你从蜘蛛网里找到下一步要补的地方。
</CardDescription>
</div>
<Badge variant={workflowDashboardMetrics.emptyBranchSteps.length > 0 ? "outline" : "secondary"}>
{workflowDashboardMetrics.emptyBranchSteps.length > 0
? `${workflowDashboardMetrics.emptyBranchSteps.length} 个分支完全空`
: "没有完全空分支"}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-5">
<div className="grid gap-3 md:grid-cols-5">
{dashboardCards.map((item) => (
<div key={item.label} className="rounded-xl border bg-background/80 p-4 shadow-sm">
<div className="text-xs text-muted-foreground">{item.label}</div>
<div className="mt-2 text-2xl font-semibold">{item.value}</div>
<div className="mt-1 text-xs text-muted-foreground">{item.hint}</div>
</div>
))}
</div>
<div className="grid gap-4 lg:grid-cols-[1.05fr_1fr]">
<div className="space-y-4 rounded-xl border bg-background/80 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">Prompt 完成率</div>
<div className="text-xs text-muted-foreground">
以所有层级卡片是否填写 Prompt 片段统计。
</div>
</div>
<div className="text-2xl font-semibold text-emerald-600">{completionRate}%</div>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-emerald-500 transition-all"
style={{ width: `${completionRate}%` }}
/>
</div>
<div className="flex flex-wrap gap-2">
{workflowDashboardMetrics.levelCounts.length > 0 ? (
workflowDashboardMetrics.levelCounts.map(([level, count]) => (
<Badge key={level} variant="outline">
{getWorkflowLevelLabel(level, "模块")}:{count}
</Badge>
))
) : (
<span className="text-xs text-muted-foreground">暂无模块层级数据</span>
)}
</div>
</div>
<div className="space-y-3 rounded-xl border bg-background/80 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">优先补内容的模块</div>
<div className="text-xs text-muted-foreground">
优先显示整支为空或本级没有 Prompt/图片/说明的模块。
</div>
</div>
<Badge variant="outline">{priorityEmptySteps.length} 个待看</Badge>
</div>
{priorityEmptySteps.length > 0 ? (
<div className="max-h-48 space-y-2 overflow-y-auto pr-1">
{priorityEmptySteps.slice(0, 8).map((item) => (
<div key={item.key} className="rounded-lg border bg-muted/20 px-3 py-2">
<div className="break-words text-xs font-medium leading-5">{item.path}</div>
<div className="mt-1 flex flex-wrap gap-1">
<Badge variant="outline">{getWorkflowLevelLabel(item.depth, "模块")}</Badge>
<Badge variant={item.hasSubtreeContent ? "secondary" : "outline"}>
{item.hasSubtreeContent ? "子级有内容" : "整支空内容"}
</Badge>
<Badge variant={item.hasOwnContent ? "secondary" : "outline"}>
{item.hasOwnContent ? "本级有内容" : "本级空内容"}
</Badge>
<Badge variant="outline">{item.optionStats.total} 张卡片</Badge>
</div>
</div>
))}
</div>
) : (
<div className="rounded-lg border border-dashed bg-muted/20 px-3 py-6 text-center text-xs text-muted-foreground">
当前模块内容完整度不错,可以继续检查互斥关系和禁用状态。
</div>
)}
</div>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<div className="rounded-xl border bg-background/80 p-4">
<div className="mb-3 text-sm font-medium">最复杂分支</div>
<div className="space-y-2">
{workflowDashboardMetrics.denseSteps.map((item) => (
<div key={item.key} className="flex items-start justify-between gap-3 text-xs">
<span className="min-w-0 break-words leading-5">{item.path}</span>
<Badge variant="outline" className="shrink-0">
{item.subtreeStepCount} 模块 / {item.subtreeOptionCount} 卡片
</Badge>
</div>
))}
</div>
</div>
<div className="rounded-xl border bg-background/80 p-4">
<div className="mb-3 text-sm font-medium">最深路径</div>
<div className="space-y-2">
{workflowDashboardMetrics.deepestSteps.map((item) => (
<div key={item.key} className="flex items-start justify-between gap-3 text-xs">
<span className="min-w-0 break-words leading-5">{item.path}</span>
<Badge variant="outline" className="shrink-0">
{getWorkflowLevelLabel(item.depth, "模块")}
</Badge>
</div>
))}
</div>
</div>
</div>
<div className="rounded-xl border bg-background/80 p-4">
<div className="mb-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="text-sm font-medium">空缺卡片明细</div>
<div className="text-xs text-muted-foreground">
按缺失项数量排序,直接标出每张卡片缺名称、Prompt、说明或预览图。
</div>
</div>
<div className="flex flex-wrap gap-2">
<Badge variant="outline">
{workflowDashboardMetrics.missingCardDetails.length} 张有空缺
</Badge>
<Badge variant="outline">
{workflowDashboardMetrics.missingPromptCards} 张缺 Prompt
</Badge>
<Badge variant="outline">
{workflowDashboardMetrics.fullyEmptyCards} 张完全空内容
</Badge>
</div>
</div>
{workflowDashboardMetrics.missingCardDetails.length > 0 ? (
<div className="max-h-72 space-y-2 overflow-y-auto pr-1">
{workflowDashboardMetrics.missingCardDetails.map((item) => (
<div key={`${item.stepKey}:${item.key}`} className="rounded-lg border bg-muted/20 px-3 py-2">
<div className="break-words text-xs font-medium leading-5">
{item.stepPath} / {item.path}
</div>
<div className="mt-1 flex flex-wrap gap-1">
<Badge variant="outline">{getWorkflowLevelLabel(item.depth, "卡片")}</Badge>
<Badge variant={item.disabled ? "secondary" : "outline"}>
{item.disabled ? "开发中" : "已启用"}
</Badge>
<Badge variant="outline">{item.childCount} 子卡片</Badge>
{item.missingFields.map((field) => (
<Badge key={field} variant="outline">
缺{field}
</Badge>
))}
</div>
</div>
))}
</div>
) : (
<div className="rounded-lg border border-dashed bg-muted/20 px-3 py-6 text-center text-xs text-muted-foreground">
所有卡片的名称、Prompt、说明和预览图都已补齐。
</div>
)}
</div>
</CardContent>
</Card>
</motion.div>
);
};
const renderWorkflowStepDropZone = (
targetStepKey: string | null,
position: WorkflowStepDropPosition,
@ -6069,6 +6618,8 @@ export function AgentWorkflowSettingsClient({
variants={containerVariants}
className="space-y-4"
>
{renderWorkflowDashboard()}
<motion.div
variants={itemVariants}
className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between"