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

View File

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

View File

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

View File

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

View File

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

View File

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