feat: 支持工作流跨级互斥与开发中卡片态
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-03 13:30:45 +08:00
parent d7d33ebc4a
commit a197a21337
6 changed files with 244 additions and 34 deletions

View File

@ -28,6 +28,7 @@ import {
Video,
} from "lucide-react";
import {
defaultAgentWorkflowConstraintSettings,
AGENT_WORKFLOW_MAX_OPTION_DEPTH,
AGENT_WORKFLOW_MAX_TREE_DEPTH,
countAgentWorkflowOptions,
@ -40,6 +41,7 @@ import {
flattenAgentWorkflowOptionTree,
getEnabledAgentWorkflowOptionTree,
getAgentWorkflowCategoryLabels,
getAgentWorkflowConstraintSettings,
normalizeAgentWorkflowOptionOrdering,
flattenAgentWorkflowSteps,
normalizeAgentWorkflowStepOrdering,
@ -913,6 +915,10 @@ export function AgentWorkflowSettingsClient({
() => getAgentWorkflowCategoryLabels(config),
[config]
);
const workflowConstraintSettings = useMemo(
() => getAgentWorkflowConstraintSettings(config),
[config]
);
const firstCategoryLabel = categoryLabels.firstCategory;
const secondCategoryLabel = categoryLabels.secondCategory;
const thirdCategoryLabel = categoryLabels.thirdCategory;
@ -2339,7 +2345,9 @@ export function AgentWorkflowSettingsClient({
);
const canAddChild = depth < AGENT_WORKFLOW_MAX_OPTION_DEPTH;
const blockedCandidates = workflowOptionRelations.filter(
(item) => item.key !== option.key && item.depth === depth
(item) =>
item.key !== option.key &&
(workflowConstraintSettings.crossLevelMutualExclusionEnabled || item.depth === depth)
);
const selectedBlockedItems = (option.blockedOptionKeys || [])
.map((blockedKey) => workflowOptionRelationLookup.get(blockedKey))
@ -2393,6 +2401,7 @@ export function AgentWorkflowSettingsClient({
</span>
<Badge variant="outline">{levelLabel}</Badge>
<Badge variant="secondary">{sortedChildren.length} 子卡片</Badge>
{!option.enabled ? <Badge variant="secondary">前台显示开发中</Badge> : null}
<span className="max-w-[240px] truncate text-sm font-medium">
{option.buttonName || "未命名卡片"}
</span>
@ -2454,16 +2463,6 @@ export function AgentWorkflowSettingsClient({
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
<Switch
checked={option.enabled}
onCheckedChange={(checked) =>
updateOption(step.key, option.key, (currentOption) => ({
...currentOption,
enabled: checked,
}))
}
className="scale-75"
/>
</div>
</div>
@ -2560,11 +2559,31 @@ export function AgentWorkflowSettingsClient({
className="resize-none text-xs"
/>
</div>
<div className="flex items-start justify-between gap-4 rounded-lg border border-dashed bg-muted/20 p-3">
<div className="space-y-1">
<Label className="text-xs">前台禁用状态</Label>
<p className="text-[11px] leading-5 text-muted-foreground">
关闭后前端依然会展示这张卡片,但会标记为“开发中”且不可点击。
</p>
</div>
<Switch
checked={option.enabled}
onCheckedChange={(checked) =>
updateOption(step.key, option.key, (currentOption) => ({
...currentOption,
enabled: checked,
}))
}
className="mt-1 scale-90"
/>
</div>
<div className="space-y-2 rounded-lg border border-dashed bg-muted/20 p-3">
<div className="space-y-1">
<Label className="text-xs">互斥卡片限制</Label>
<p className="text-[11px] leading-5 text-muted-foreground">
当前只展示同级卡片供你配置互斥。一级只与所有一级卡片互斥,二级只与所有二级卡片互斥;生效时会自动覆盖目标卡片下的全部子级。
{workflowConstraintSettings.crossLevelMutualExclusionEnabled
? "当前已开启跨级互斥,可直接限制任意层级卡片;生效时会自动覆盖目标卡片下的全部子级。"
: "当前仅展示同级卡片供你配置互斥。一级只与所有一级卡片互斥,二级只与所有二级卡片互斥;生效时会自动覆盖目标卡片下的全部子级。"}
</p>
</div>
@ -2614,7 +2633,7 @@ export function AgentWorkflowSettingsClient({
[refKey]: e.target.value,
}))
}
placeholder={`搜索同级卡片(${blockedCandidates.length} 个候选)`}
placeholder={`${workflowConstraintSettings.crossLevelMutualExclusionEnabled ? "搜索所有层级卡片" : "搜索同级卡片"}(${blockedCandidates.length} 个候选)`}
className="h-8 pl-8 text-xs"
/>
</div>
@ -2667,7 +2686,9 @@ export function AgentWorkflowSettingsClient({
<div className="px-2 py-3 text-center text-xs text-muted-foreground">
{blockedCandidates.length > 0
? "没有命中搜索结果,换个关键词试试"
: "当前工作流下没有其他可限制的同级卡片"}
: workflowConstraintSettings.crossLevelMutualExclusionEnabled
? "当前工作流下没有其他可限制的卡片"
: "当前工作流下没有其他可限制的同级卡片"}
</div>
)}
</div>
@ -4421,19 +4442,47 @@ export function AgentWorkflowSettingsClient({
variants={containerVariants}
className="space-y-4"
>
<motion.div variants={itemVariants} className="flex items-center justify-between">
<div className="flex items-center gap-2">
<motion.div
variants={itemVariants}
className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between"
>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline" className="font-mono">
{countAgentWorkflowSteps(sortedSteps)} 模块
</Badge>
<Badge variant="outline" className="font-mono">
{countAgentWorkflowOptions(sortedSteps)} 选项
</Badge>
<Badge variant={workflowConstraintSettings.crossLevelMutualExclusionEnabled ? "default" : "secondary"}>
{workflowConstraintSettings.crossLevelMutualExclusionEnabled ? "跨级互斥已开启" : "跨级互斥已关闭"}
</Badge>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="flex items-start justify-between gap-4 rounded-lg border bg-background px-4 py-3 sm:min-w-[380px]">
<div className="space-y-1">
<div className="text-sm font-medium">跨级互斥</div>
<div className="text-xs leading-5 text-muted-foreground">
关闭时仅允许同级卡片互斥;开启后任意级别卡片都可以互相限制。
</div>
</div>
<Switch
checked={workflowConstraintSettings.crossLevelMutualExclusionEnabled}
onCheckedChange={(checked) =>
setConfig((prev) => ({
...prev,
constraintSettings: {
...(prev.constraintSettings || defaultAgentWorkflowConstraintSettings),
crossLevelMutualExclusionEnabled: checked,
},
}))
}
/>
</div>
<Button onClick={() => handleAddStep()} size="sm">
<Plus className="mr-2 h-4 w-4" />
新增一级模块
</Button>
</div>
<Button onClick={() => handleAddStep()} size="sm">
<Plus className="mr-2 h-4 w-4" />
新增一级模块
</Button>
</motion.div>
{sortedSteps.length > 0 ? (

View File

@ -817,6 +817,11 @@ const updateSchema = z.object({
.optional(),
})
.optional(),
constraintSettings: z
.object({
crossLevelMutualExclusionEnabled: z.boolean().optional(),
})
.optional(),
steps: z
.array(
z.object({

View File

@ -88,6 +88,7 @@ import { cn, fetcher } from "@/lib/utils";
import {
defaultAgentWorkflowUiConfig,
flattenVisibleAgentWorkflowSteps,
getEnabledAgentWorkflowStepTree,
normalizeAgentWorkflowUiConfig,
sanitizeAgentWorkflowSelections,
} from "@/lib/agents/workflow-ui-config";
@ -1755,7 +1756,9 @@ export default function CanvasEditorPage({ params }: PageProps) {
});
const orderedStepKeys = flattenVisibleAgentWorkflowSteps(
(resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig).steps,
getEnabledAgentWorkflowStepTree(
(resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig).steps
),
normalizedCurrentSelections
).map(({ step }) => step.key);
const earliestChangedIndex = orderedStepKeys.findIndex((stepKey) => {

View File

@ -23,6 +23,8 @@ import {
getAgentWorkflowStoreOptionsByBrand,
getEnabledAgentWorkflowOptionTree,
getEnabledAgentWorkflowStepTree,
getRenderableAgentWorkflowOptionTree,
getRenderableAgentWorkflowStepTree,
isAgentWorkflowStepSelectionComplete,
serializeAgentWorkflowSelectionPath,
} from "@/lib/agents/workflow-ui-config";
@ -135,7 +137,7 @@ function getWorkflowStepOptionLevels(params: {
});
let depth = 1;
let currentOptions = getEnabledAgentWorkflowOptionTree(params.step.options || []);
let currentOptions = getRenderableAgentWorkflowOptionTree(params.step.options || []);
while (currentOptions.length > 0) {
const selectedKey = selectedPath[depth - 1]?.key || "";
@ -149,7 +151,7 @@ function getWorkflowStepOptionLevels(params: {
selectedOption,
});
const nextOptions = getEnabledAgentWorkflowOptionTree(
const nextOptions = getRenderableAgentWorkflowOptionTree(
selectedOption?.children || []
);
if (!selectedOption || nextOptions.length === 0) {
@ -176,6 +178,7 @@ function StepOptionCard({
step,
selected,
disabled = false,
disabledReason = null,
onClick,
index,
}: {
@ -183,6 +186,7 @@ function StepOptionCard({
step: { title: string };
selected: boolean;
disabled?: boolean;
disabledReason?: "blocked" | "developing" | null;
onClick: () => void;
index: number;
}) {
@ -242,7 +246,7 @@ function StepOptionCard({
{disabled && !selected ? (
<div className="absolute right-3 top-3 rounded-full bg-black/65 px-2 py-1 text-[10px] font-medium text-white/80 backdrop-blur-md">
已限制
{disabledReason === "developing" ? "开发中" : "已限制"}
</div>
) : null}
@ -595,15 +599,23 @@ export function AgentWorkflowPanelDarkV2({
const resolvedConfig = useMemo(() => {
const source = config || defaultAgentWorkflowUiConfig;
return {
...source,
steps: getEnabledAgentWorkflowStepTree(source.steps || []),
};
}, [config]);
const displayConfig = useMemo(() => {
const source = config || defaultAgentWorkflowUiConfig;
return {
...source,
steps: getRenderableAgentWorkflowStepTree(source.steps || []),
};
}, [config]);
const flattenedResolvedSteps = useMemo(
() =>
flattenVisibleAgentWorkflowSteps(resolvedConfig.steps, selections).filter(
flattenVisibleAgentWorkflowSteps(displayConfig.steps, selections).filter(
({ step }) => Array.isArray(step.options) && step.options.length > 0
),
[resolvedConfig.steps, selections]
[displayConfig.steps, selections]
);
const summary = useMemo(
@ -675,6 +687,27 @@ export function AgentWorkflowPanelDarkV2({
activeEmbeddedOptionLevels[activeEmbeddedOptionLevels.length - 1] ||
activeEmbeddedOptionLevels[0] ||
null;
const activeEmbeddedDeepestSelectedOption = useMemo(
() =>
[...activeEmbeddedOptionLevels]
.reverse()
.find((level) => level.selectedOption)?.selectedOption || null,
[activeEmbeddedOptionLevels]
);
const activeEmbeddedSelectedOptionHasDevelopingChildren = useMemo(() => {
if (!activeEmbeddedDeepestSelectedOption) {
return false;
}
const renderableChildren = getRenderableAgentWorkflowOptionTree(
activeEmbeddedDeepestSelectedOption.children || []
);
if (renderableChildren.length === 0) {
return false;
}
return getEnabledAgentWorkflowOptionTree(activeEmbeddedDeepestSelectedOption.children || []).length === 0;
}, [activeEmbeddedDeepestSelectedOption]);
useEffect(() => {
if (!embedded) {
@ -742,6 +775,11 @@ export function AgentWorkflowPanelDarkV2({
return;
}
if (activeEmbeddedSelectedOptionHasDevelopingChildren) {
setPendingAutoAdvanceStepKey(null);
return;
}
const nextStep = flattenedResolvedSteps
.slice(activeEmbeddedStepIndex + 1)
.find(
@ -768,6 +806,7 @@ export function AgentWorkflowPanelDarkV2({
activeEmbeddedStepEntry,
activeEmbeddedStepIndex,
activeEmbeddedStepKey,
activeEmbeddedSelectedOptionHasDevelopingChildren,
embedded,
flattenedResolvedSteps,
pendingAutoAdvanceStepKey,
@ -800,7 +839,7 @@ export function AgentWorkflowPanelDarkV2({
const currentSelectedKey = selectedPathKeys[depth - 1] || "";
const currentSelectedOption =
options.find((option) => option.key === currentSelectedKey) || null;
const visibleChildren = getEnabledAgentWorkflowOptionTree(
const visibleChildren = getRenderableAgentWorkflowOptionTree(
currentSelectedOption?.children || []
);
const levelLabel = depth === 1 ? "主卡片" : depth === 2 ? "二级卡片" : "三级卡片";
@ -820,8 +859,12 @@ export function AgentWorkflowPanelDarkV2({
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{options.map((option, optionIndex) => {
const isDisabled =
currentSelectedKey !== option.key && blockedOptionKeys.has(option.key);
const isDeveloping = option.enabled === false;
const isBlocked =
!isDeveloping &&
currentSelectedKey !== option.key &&
blockedOptionKeys.has(option.key);
const isDisabled = isDeveloping || isBlocked;
return (
<StepOptionCard
@ -830,6 +873,7 @@ export function AgentWorkflowPanelDarkV2({
step={step}
selected={currentSelectedKey === option.key}
disabled={isDisabled}
disabledReason={isDeveloping ? "developing" : isBlocked ? "blocked" : null}
onClick={() => {
if (isDisabled) {
return;
@ -1077,10 +1121,13 @@ export function AgentWorkflowPanelDarkV2({
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{activeEmbeddedOptionLevel.options.map((option, optionIndex) => {
const currentSelectedKey = activeEmbeddedOptionLevel.selectedKey;
const isDisabled =
const isDeveloping = option.enabled === false;
const isBlocked =
!isDeveloping &&
currentSelectedKey !== option.key &&
blockedOptionKeys.has(option.key);
const nextChildren = getEnabledAgentWorkflowOptionTree(
const isDisabled = isDeveloping || isBlocked;
const nextChildren = getRenderableAgentWorkflowOptionTree(
option.children || []
);
@ -1091,6 +1138,9 @@ export function AgentWorkflowPanelDarkV2({
step={activeEmbeddedStepEntry.step}
selected={currentSelectedKey === option.key}
disabled={isDisabled}
disabledReason={
isDeveloping ? "developing" : isBlocked ? "blocked" : null
}
onClick={() => {
if (isDisabled) {
return;
@ -1163,7 +1213,7 @@ export function AgentWorkflowPanelDarkV2({
{step.description && (
<p className="mb-4 text-sm text-white/40">{step.description}</p>
)}
{renderOptionLevels(step, getEnabledAgentWorkflowOptionTree(step.options || []))}
{renderOptionLevels(step, getRenderableAgentWorkflowOptionTree(step.options || []))}
</motion.section>
))
)}

View File

@ -1,6 +1,7 @@
import type {
AgentWorkflowBrandStoreEntry,
AgentWorkflowCategoryLabels,
AgentWorkflowConstraintSettings,
AgentWorkflowFourthCategoryEntry,
AgentWorkflowOptionUiConfig,
AgentWorkflowStoreEntry,
@ -20,6 +21,9 @@ export const defaultAgentWorkflowCategoryLabels: AgentWorkflowCategoryLabels = {
fourthCategory: "四级分类",
fifthCategory: "五级分类",
};
export const defaultAgentWorkflowConstraintSettings: AgentWorkflowConstraintSettings = {
crossLevelMutualExclusionEnabled: false,
};
export interface AgentWorkflowBrandCategoryOption {
value: string;
@ -163,6 +167,7 @@ export const defaultAgentWorkflowUiConfig: AgentWorkflowUiConfig = {
priceBands: [],
brandStoreEntries: [],
},
constraintSettings: defaultAgentWorkflowConstraintSettings,
steps: defaultSteps.map((step, index) =>
cloneStep({
...step,
@ -208,6 +213,22 @@ export function normalizeAgentWorkflowCategoryLabels(
};
}
export function normalizeAgentWorkflowConstraintSettings(
value: unknown
): AgentWorkflowConstraintSettings {
const record =
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
return {
crossLevelMutualExclusionEnabled: normalizeBoolean(
record.crossLevelMutualExclusionEnabled ?? record.enableCrossLevelMutualExclusion,
defaultAgentWorkflowConstraintSettings.crossLevelMutualExclusionEnabled
),
};
}
export function flattenAgentWorkflowSteps(
steps: AgentWorkflowStepUiConfig[],
depth = 1,
@ -318,6 +339,35 @@ export function getEnabledAgentWorkflowStepTree(
.filter((step) => step.options.length > 0 || (step.children || []).length > 0);
}
export function getRenderableAgentWorkflowOptionTree(
options: AgentWorkflowOptionUiConfig[]
): AgentWorkflowOptionUiConfig[] {
return [...options]
.sort((left, right) => left.sortOrder - right.sortOrder)
.map((option) => ({
...option,
children: getRenderableAgentWorkflowOptionTree(option.children || []),
}));
}
export function getRenderableAgentWorkflowStepTree(
steps: AgentWorkflowStepUiConfig[]
): AgentWorkflowStepUiConfig[] {
return [...steps]
.sort((left, right) => left.sortOrder - right.sortOrder)
.filter((step) => step.enabled !== false)
.map((step) => ({
...step,
options: getRenderableAgentWorkflowOptionTree(step.options || []),
children: getRenderableAgentWorkflowStepTree(step.children || []),
}))
.filter(
(step) =>
getEnabledAgentWorkflowOptionTree(step.options || []).length > 0 ||
(step.children || []).length > 0
);
}
export interface FlattenedAgentWorkflowOption {
option: AgentWorkflowOptionUiConfig;
depth: number;
@ -980,6 +1030,11 @@ export function normalizeAgentWorkflowUiConfig(value: unknown): AgentWorkflowUiC
priceBands: normalizedPriceBands,
brandStoreEntries: fallbackBrandStoreEntries,
},
constraintSettings: normalizeAgentWorkflowConstraintSettings(
raw.constraintSettings ?? {
crossLevelMutualExclusionEnabled: raw.crossLevelMutualExclusionEnabled,
}
),
steps: sanitizedSteps,
};
}
@ -994,6 +1049,12 @@ export function getAgentWorkflowCategoryLabels(
return normalizeAgentWorkflowCategoryLabels(config?.inputOptions?.categoryLabels);
}
export function getAgentWorkflowConstraintSettings(
config?: AgentWorkflowUiConfig | null
) {
return normalizeAgentWorkflowConstraintSettings(config?.constraintSettings);
}
export function getAgentWorkflowCategoryOptionsByBrand(params: {
config?: AgentWorkflowUiConfig | null;
brand?: string | null;
@ -1214,7 +1275,9 @@ export function getAgentWorkflowSelectedOptionPath(params: {
let currentOptions = params.step.options || [];
for (const key of keys) {
const matchedOption = currentOptions.find((option) => option.key === key);
const matchedOption = currentOptions.find(
(option) => option.key === key && option.enabled !== false
);
if (!matchedOption) break;
selectedOptions.push(matchedOption);
currentOptions = matchedOption.children || [];
@ -1262,11 +1325,35 @@ export function sanitizeAgentWorkflowSelections(params: {
export function getAgentWorkflowBlockedOptionKeys(params: {
step: AgentWorkflowStepUiConfig;
selectionValue?: string | null;
allowCrossLevel?: boolean;
optionDepthLookup?: Map<string, number>;
}) {
const selectedOptions = getAgentWorkflowSelectedOptionPath(params);
const optionDepthLookup =
params.optionDepthLookup ||
new Map(
flattenAgentWorkflowOptionTree(params.step.options || []).map(({ option, depth }) => [
option.key,
depth,
] as const)
);
return Array.from(
new Set(
selectedOptions.flatMap((option) => (option.blockedOptionKeys || []).filter(Boolean))
selectedOptions.flatMap((option) => {
const currentDepth = optionDepthLookup.get(option.key);
return (option.blockedOptionKeys || []).filter((blockedKey) => {
if (!blockedKey) {
return false;
}
if (params.allowCrossLevel) {
return true;
}
if (typeof currentDepth !== "number") {
return false;
}
return optionDepthLookup.get(blockedKey) === currentDepth;
});
})
)
);
}
@ -1294,10 +1381,19 @@ export function getAgentWorkflowBlockedOptionKeysForSelections(params: {
selections: Partial<Record<AgentWorkflowStepKey, string>>;
}) {
const config = params.config || defaultAgentWorkflowUiConfig;
const constraintSettings = getAgentWorkflowConstraintSettings(config);
const visibleSteps = flattenVisibleAgentWorkflowSteps(
getEnabledAgentWorkflowStepTree(Array.isArray(config.steps) ? config.steps : []),
params.selections
).map(({ step }) => step);
const visibleOptionDepthLookup = new Map(
visibleSteps.flatMap((step) =>
flattenAgentWorkflowOptionTree(step.options || []).map(({ option, depth }) => [
option.key,
depth,
] as const)
)
);
const directlyBlockedKeys = Array.from(
new Set(
@ -1305,6 +1401,8 @@ export function getAgentWorkflowBlockedOptionKeysForSelections(params: {
getAgentWorkflowBlockedOptionKeys({
step,
selectionValue: params.selections[step.key],
allowCrossLevel: constraintSettings.crossLevelMutualExclusionEnabled,
optionDepthLookup: visibleOptionDepthLookup,
})
)
)

View File

@ -101,6 +101,10 @@ export interface AgentWorkflowStepUiConfig {
children?: AgentWorkflowStepUiConfig[];
}
export interface AgentWorkflowConstraintSettings {
crossLevelMutualExclusionEnabled: boolean;
}
export interface AgentWorkflowUiConfig {
inputOptions?: {
brands: string[];
@ -112,6 +116,7 @@ export interface AgentWorkflowUiConfig {
priceBands?: string[];
brandStoreEntries?: AgentWorkflowBrandStoreEntry[];
};
constraintSettings?: AgentWorkflowConstraintSettings;
steps: AgentWorkflowStepUiConfig[];
}