feat: add special workflow module exclusions
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 19:16:02 +08:00
parent aaba40b2d6
commit 885708b2ec
4 changed files with 469 additions and 129 deletions

View File

@ -250,8 +250,8 @@ function scheduleWorkflowIdleTask(callback: () => void, timeoutMs = 2000) {
return () => window.cancelIdleCallback(idleId);
}
const timer = window.setTimeout(callback, timeoutMs);
return () => window.clearTimeout(timer);
const timer = globalThis.setTimeout(callback, timeoutMs);
return () => globalThis.clearTimeout(timer);
}
function BufferedInput({
@ -488,6 +488,9 @@ function createWorkflowOptionDraft(
sortOrder: optionIndex,
promptFragment: `请按选项${optionIndex + 1}执行。`,
blockedOptionKeys: [],
specialModuleEnabled: false,
specialModuleExclusionEnabled: false,
specialModuleAllowedOptionKeys: [],
children: [],
};
}
@ -610,7 +613,10 @@ function analyzeWorkflowOptionContent(
withPreview: summary.withPreview + (hasWorkflowOptionPreview(option) ? 1 : 0),
disabled: summary.disabled + (option.enabled === false ? 1 : 0),
withMutualExclusion:
summary.withMutualExclusion + ((option.blockedOptionKeys || []).length > 0 ? 1 : 0),
summary.withMutualExclusion +
((option.blockedOptionKeys || []).length > 0 || option.specialModuleExclusionEnabled
? 1
: 0),
}),
createEmptyWorkflowOptionContentSummary()
);
@ -747,6 +753,8 @@ function collectWorkflowOptionRelations(
hasContent: boolean;
disabled: boolean;
blockedCount: number;
specialModuleEnabled: boolean;
specialModuleExclusionEnabled: boolean;
}> {
return options.flatMap((option) => {
const currentPath = [...ancestors, option.buttonName || option.key];
@ -762,6 +770,8 @@ function collectWorkflowOptionRelations(
hasContent: hasWorkflowOptionConfiguredContent(option),
disabled: option.enabled === false,
blockedCount: (option.blockedOptionKeys || []).length,
specialModuleEnabled: Boolean(option.specialModuleEnabled),
specialModuleExclusionEnabled: Boolean(option.specialModuleExclusionEnabled),
},
...collectWorkflowOptionRelations(option.children || [], currentPath),
];
@ -774,6 +784,15 @@ function collectWorkflowOptionRelationsFromSteps(
key: string;
label: string;
depth: number;
childCount: number;
hasPrompt: boolean;
hasDescription: boolean;
hasPreview: boolean;
hasContent: boolean;
disabled: boolean;
blockedCount: number;
specialModuleEnabled: boolean;
specialModuleExclusionEnabled: boolean;
stepKey: string;
stepTitle: string;
}> {
@ -1017,6 +1036,7 @@ function cloneWorkflowOptionSnapshot(
return {
...option,
blockedOptionKeys: [...(option.blockedOptionKeys || [])],
specialModuleAllowedOptionKeys: [...(option.specialModuleAllowedOptionKeys || [])],
children: (option.children || []).map(cloneWorkflowOptionSnapshot),
};
}
@ -1040,6 +1060,9 @@ function remapWorkflowOptionBlockedKeys(
blockedOptionKeys: (option.blockedOptionKeys || []).map((blockedKey) =>
keyMap.get(blockedKey) || blockedKey
),
specialModuleAllowedOptionKeys: (option.specialModuleAllowedOptionKeys || []).map(
(allowedKey) => keyMap.get(allowedKey) || allowedKey
),
children: remapWorkflowOptionBlockedKeys(option.children || [], keyMap),
}));
}
@ -2090,6 +2113,21 @@ export function AgentWorkflowSettingsClient({
() => new Map(workflowStepAnalyses.map((item) => [item.key, item] as const)),
[workflowStepAnalyses]
);
const workflowSpecialModuleOptions = useMemo(
() =>
flattenAgentWorkflowSteps(deferredWorkflowSteps || []).flatMap(({ step }) =>
[...(step.options || [])]
.sort((left, right) => left.sortOrder - right.sortOrder)
.filter((option) => option.specialModuleEnabled)
.map((option) => ({
option,
stepKey: step.key,
stepTitle: step.title || step.key,
path: `${step.title || step.key} / ${option.buttonName || option.key}`,
}))
),
[deferredWorkflowSteps]
);
const workflowOptionGapDetails = useMemo(
() =>
buildWorkflowOptionGapDetails(deferredWorkflowSteps || []).sort(
@ -3924,6 +3962,10 @@ export function AgentWorkflowSettingsClient({
const selectedBlockedItems = (option.blockedOptionKeys || [])
.map((blockedKey) => workflowOptionRelationLookup.get(blockedKey))
.filter((item): item is WorkflowOptionRelation => Boolean(item));
const availableSpecialModuleOptions = workflowSpecialModuleOptions.filter(
(item) => item.option.key !== option.key && (item.option.children || []).length > 0
);
const specialAllowedKeys = new Set(option.specialModuleAllowedOptionKeys || []);
const isExpanded = isWorkflowOptionExpanded(option.key, depth);
const blockedSearchValue = blockedOptionSearches[refKey] || "";
const normalizedBlockedSearchValue = blockedSearchValue.trim().toLowerCase();
@ -3947,6 +3989,87 @@ export function AgentWorkflowSettingsClient({
: [];
const canMoveUp = depth === 1 && indexPath[0] > 1;
const canMoveDown = depth === 1 && indexPath[0] < (step.options || []).length;
const toggleSpecialModuleAllowedOption = (allowedOptionKey: string) => {
updateOption(step.key, option.key, (currentOption) => {
const nextAllowedKeys = new Set(currentOption.specialModuleAllowedOptionKeys || []);
if (nextAllowedKeys.has(allowedOptionKey)) {
nextAllowedKeys.delete(allowedOptionKey);
} else {
nextAllowedKeys.add(allowedOptionKey);
}
return {
...currentOption,
specialModuleAllowedOptionKeys: Array.from(nextAllowedKeys),
};
});
};
const setSpecialModuleAllowedBranch = (
candidateOption: AgentWorkflowOptionUiConfig,
allowed: boolean
) => {
updateOption(step.key, option.key, (currentOption) => {
const nextAllowedKeys = new Set(currentOption.specialModuleAllowedOptionKeys || []);
const branchKeys = flattenAgentWorkflowOptionTree([candidateOption]).map(
({ option: branchOption }) => branchOption.key
);
branchKeys.forEach((branchKey) => nextAllowedKeys.delete(branchKey));
if (allowed) {
nextAllowedKeys.add(candidateOption.key);
}
return {
...currentOption,
specialModuleAllowedOptionKeys: Array.from(nextAllowedKeys),
};
});
};
const renderSpecialModuleAllowedOption = (
candidateOption: AgentWorkflowOptionUiConfig,
candidateDepth: number,
inheritedAllowed = false
): ReactNode => {
const directlyAllowed = specialAllowedKeys.has(candidateOption.key);
const allowed = inheritedAllowed || directlyAllowed;
const sortedCandidateChildren = [...(candidateOption.children || [])].sort(
(left, right) => left.sortOrder - right.sortOrder
);
return (
<div key={candidateOption.key} className={cn("space-y-1", candidateDepth > 1 && "pl-3")}>
<button
type="button"
className={cn(
"flex w-full items-center justify-between gap-3 rounded-md border px-3 py-2 text-left text-xs transition-colors",
allowed
? "border-emerald-500/50 bg-emerald-500/10 text-foreground"
: "border-border/70 bg-background hover:border-foreground/20 hover:bg-muted/60"
)}
onClick={() => toggleSpecialModuleAllowedOption(candidateOption.key)}
>
<span className="min-w-0">
<span className="block break-words font-medium leading-5">
{candidateOption.buttonName || candidateOption.key}
</span>
<span className="block text-[10px] text-muted-foreground">
{getWorkflowLevelLabel(candidateDepth, "卡片")} · {candidateOption.key}
</span>
</span>
<Badge variant={allowed ? "default" : "outline"} className="shrink-0">
{directlyAllowed ? "已放行" : inheritedAllowed ? "随父级放行" : "默认排斥"}
</Badge>
</button>
{sortedCandidateChildren.length > 0 ? (
<div className="space-y-1 border-l border-dashed pl-2">
{sortedCandidateChildren.map((childOption) =>
renderSpecialModuleAllowedOption(childOption, candidateDepth + 1, allowed)
)}
</div>
) : null}
</div>
);
};
return (
<div key={option.key} className={cn("space-y-4", depth > 1 && "pl-4 sm:pl-6")}>
@ -3982,6 +4105,10 @@ export function AgentWorkflowSettingsClient({
</span>
<Badge variant="outline">{levelLabel}</Badge>
<Badge variant="secondary">{sortedChildren.length} 子卡片</Badge>
{option.specialModuleEnabled ? <Badge variant="default">特殊模块</Badge> : null}
{option.specialModuleExclusionEnabled ? (
<Badge variant="secondary">特殊模块快捷排斥</Badge>
) : null}
{!option.enabled ? <Badge variant="secondary">前台显示开发中</Badge> : null}
<span className="max-w-[240px] truncate text-sm font-medium">
{option.buttonName || "未命名卡片"}
@ -4165,6 +4292,26 @@ export function AgentWorkflowSettingsClient({
className="mt-1 scale-90"
/>
</div>
{depth === 1 ? (
<div className="flex items-start justify-between gap-4 rounded-lg border border-emerald-500/20 bg-emerald-500/5 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={Boolean(option.specialModuleEnabled)}
onCheckedChange={(checked) =>
updateOption(step.key, option.key, (currentOption) => ({
...currentOption,
specialModuleEnabled: checked,
}))
}
className="mt-1 scale-90"
/>
</div>
) : null}
<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>
@ -4210,131 +4357,212 @@ export function AgentWorkflowSettingsClient({
</div>
)}
<div className="space-y-2 rounded-md border bg-background/70 p-2">
<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"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={() =>
setExpandedBlockedOptionPanels((prev) => ({
...prev,
[refKey]: !blockedCandidatePanelExpanded,
}))
}
>
{blockedCandidatePanelExpanded ? "收起候选" : "展开候选"}
</Button>
</div>
{blockedCandidatePanelExpanded ? (
<>
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={blockedSearchValue}
onChange={(e) =>
setBlockedOptionSearches((prev) => ({
...prev,
[refKey]: e.target.value,
}))
}
placeholder={`${workflowConstraintSettings.crossLevelMutualExclusionEnabled ? "搜索所有层级卡片" : "搜索同级卡片"}(${blockedCandidates.length} 个候选)`}
className="h-8 pl-8 text-xs"
/>
</div>
<div className="max-h-72 space-y-2 overflow-y-auto">
{filteredBlockedCandidates.length > 0 ? (
<>
{filteredBlockedCandidates.map((candidate) => {
const active = (option.blockedOptionKeys || []).includes(candidate.key);
const candidateStepAnalysis = workflowStepAnalysisLookup.get(
candidate.stepKey
);
return (
<button
key={candidate.key}
type="button"
className={cn(
"flex w-full items-center justify-between rounded-md border px-3 py-2 text-left text-xs transition-colors",
active
? "border-amber-500/50 bg-amber-500/10 text-foreground"
: "border-border/70 bg-background hover:border-foreground/20 hover:bg-muted/60"
)}
onClick={() =>
updateOption(step.key, option.key, (currentOption) => {
const currentBlocked = new Set(
currentOption.blockedOptionKeys || []
);
if (currentBlocked.has(candidate.key)) {
currentBlocked.delete(candidate.key);
} else {
currentBlocked.add(candidate.key);
}
return {
...currentOption,
blockedOptionKeys: Array.from(currentBlocked),
};
})
}
>
<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 whitespace-normal break-words text-[10px] leading-4 text-muted-foreground">
所属模块:
{candidateStepAnalysis?.path || candidate.stepTitle} ·{" "}
{getWorkflowLevelLabel(candidate.depth, "卡片")} ·{" "}
{candidate.key}
</span>
</span>
<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>
);
})}
</>
) : (
<div className="px-2 py-3 text-center text-xs text-muted-foreground">
{blockedCandidates.length > 0
? "没有命中搜索结果,换个关键词试试"
: workflowConstraintSettings.crossLevelMutualExclusionEnabled
? "当前工作流下没有其他可限制的卡片"
: "当前工作流下没有其他可限制的同级卡片"}
</div>
)}
</div>
</>
) : (
<div className="rounded-md border border-dashed bg-muted/30 px-3 py-2 text-[11px] text-muted-foreground">
候选列表已折叠,需要配置互斥时再展开,避免大量卡片同时渲染导致编辑卡顿。
</div>
)}
</div>
{availableSpecialModuleOptions.length > 0 ? (
<div className="space-y-3 rounded-md border border-emerald-500/20 bg-emerald-500/5 p-3">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<Label className="text-xs">特殊模块快捷排斥</Label>
<p className="text-[11px] leading-5 text-muted-foreground">
开启后,下面特殊模块中未勾选放行的子卡片会自动进入排斥;勾选父级会放行整支子树。
</p>
</div>
<Switch
checked={Boolean(option.specialModuleExclusionEnabled)}
onCheckedChange={(checked) =>
updateOption(step.key, option.key, (currentOption) => ({
...currentOption,
specialModuleExclusionEnabled: checked,
}))
}
className="mt-1 scale-90"
/>
</div>
{option.specialModuleExclusionEnabled ? (
<div className="max-h-72 space-y-3 overflow-y-auto rounded-md border bg-background/70 p-2">
{availableSpecialModuleOptions.map((specialModule) => {
const rootAllowed = specialAllowedKeys.has(specialModule.option.key);
return (
<div key={specialModule.option.key} className="space-y-2 rounded-lg border p-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<div className="break-words text-xs font-medium leading-5">
{specialModule.path}
</div>
<div className="text-[10px] text-muted-foreground">
特殊模块 · {specialModule.option.key}
</div>
</div>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-1">
<Badge variant={rootAllowed ? "default" : "outline"}>
{rootAllowed
? "整组已放行"
: `${(specialModule.option.children || []).length} 个子卡片`}
</Badge>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 px-2 text-[11px]"
disabled={rootAllowed}
onClick={() => setSpecialModuleAllowedBranch(specialModule.option, true)}
>
整组放行
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-[11px]"
onClick={() => setSpecialModuleAllowedBranch(specialModule.option, false)}
>
恢复默认排斥
</Button>
</div>
</div>
<div className="space-y-1">
{(specialModule.option.children || []).map((childOption) =>
renderSpecialModuleAllowedOption(childOption, 2, rootAllowed)
)}
</div>
</div>
);
})}
</div>
) : (
<div className="rounded-md border border-dashed bg-background/70 px-3 py-2 text-[11px] text-muted-foreground">
当前未启用特殊模块快捷排斥;启用后只需要勾选“不排斥”的少量子卡片。
</div>
)}
</div>
) : null}
<div className="space-y-2 rounded-md border bg-background/70 p-2">
<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"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={() =>
setExpandedBlockedOptionPanels((prev) => ({
...prev,
[refKey]: !blockedCandidatePanelExpanded,
}))
}
>
{blockedCandidatePanelExpanded ? "收起候选" : "展开候选"}
</Button>
</div>
{blockedCandidatePanelExpanded ? (
<>
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={blockedSearchValue}
onChange={(e) =>
setBlockedOptionSearches((prev) => ({
...prev,
[refKey]: e.target.value,
}))
}
placeholder={`${workflowConstraintSettings.crossLevelMutualExclusionEnabled ? "搜索所有层级卡片" : "搜索同级卡片"}(${blockedCandidates.length} 个候选)`}
className="h-8 pl-8 text-xs"
/>
</div>
<div className="max-h-72 space-y-2 overflow-y-auto">
{filteredBlockedCandidates.length > 0 ? (
<>
{filteredBlockedCandidates.map((candidate) => {
const active = (option.blockedOptionKeys || []).includes(candidate.key);
const candidateStepAnalysis = workflowStepAnalysisLookup.get(
candidate.stepKey
);
return (
<button
key={candidate.key}
type="button"
className={cn(
"flex w-full items-center justify-between rounded-md border px-3 py-2 text-left text-xs transition-colors",
active
? "border-amber-500/50 bg-amber-500/10 text-foreground"
: "border-border/70 bg-background hover:border-foreground/20 hover:bg-muted/60"
)}
onClick={() =>
updateOption(step.key, option.key, (currentOption) => {
const currentBlocked = new Set(
currentOption.blockedOptionKeys || []
);
if (currentBlocked.has(candidate.key)) {
currentBlocked.delete(candidate.key);
} else {
currentBlocked.add(candidate.key);
}
return {
...currentOption,
blockedOptionKeys: Array.from(currentBlocked),
};
})
}
>
<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 whitespace-normal break-words text-[10px] leading-4 text-muted-foreground">
所属模块:
{candidateStepAnalysis?.path || candidate.stepTitle} ·{" "}
{getWorkflowLevelLabel(candidate.depth, "卡片")} ·{" "}
{candidate.key}
</span>
</span>
<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>
);
})}
</>
) : (
<div className="px-2 py-3 text-center text-xs text-muted-foreground">
{blockedCandidates.length > 0
? "没有命中搜索结果,换个关键词试试"
: workflowConstraintSettings.crossLevelMutualExclusionEnabled
? "当前工作流下没有其他可限制的卡片"
: "当前工作流下没有其他可限制的同级卡片"}
</div>
)}
</div>
</>
) : (
<div className="rounded-md border border-dashed bg-muted/30 px-3 py-2 text-[11px] text-muted-foreground">
候选列表已折叠,需要配置互斥时再展开,避免大量卡片同时渲染导致编辑卡顿。
</div>
)}
</div>
</div>
</div>
</div>

View File

@ -906,6 +906,12 @@ const agentWorkflowOptionSchema: z.ZodTypeAny = z.lazy(() =>
.nullable()
.optional(),
blockedOptionKeys: z.array(z.string().trim().min(1).max(64)).max(100).optional(),
specialModuleEnabled: z.boolean().optional(),
specialModuleExclusionEnabled: z.boolean().optional(),
specialModuleAllowedOptionKeys: z
.array(z.string().trim().min(1).max(64))
.max(5000)
.optional(),
children: z.array(agentWorkflowOptionSchema).max(100).optional(),
})
);

View File

@ -16,6 +16,7 @@ export const AGENT_WORKFLOW_UI_CONFIG_KEY = "agent_workflow_ui_config";
export const AGENT_WORKFLOW_MAX_TREE_DEPTH = 8;
export const AGENT_WORKFLOW_MAX_OPTION_DEPTH = 100;
export const AGENT_WORKFLOW_PROMPT_FRAGMENT_MAX_LENGTH = 20_000;
const AGENT_WORKFLOW_SPECIAL_MODULE_ALLOWED_KEYS_MAX = 5000;
export const defaultAgentWorkflowCategoryLabels: AgentWorkflowCategoryLabels = {
firstCategory: "品牌",
secondCategory: "店铺",
@ -57,6 +58,11 @@ function cloneOption(
0,
100
),
specialModuleEnabled: Boolean(option.specialModuleEnabled),
specialModuleExclusionEnabled: Boolean(option.specialModuleExclusionEnabled),
specialModuleAllowedOptionKeys: Array.from(
new Set((option.specialModuleAllowedOptionKeys || []).filter(Boolean))
).slice(0, AGENT_WORKFLOW_SPECIAL_MODULE_ALLOWED_KEYS_MAX),
children: (option.children || []).map(cloneOption),
};
}
@ -102,6 +108,9 @@ function createOption(
sortOrder,
promptFragment,
blockedOptionKeys: [],
specialModuleEnabled: false,
specialModuleExclusionEnabled: false,
specialModuleAllowedOptionKeys: [],
children: [],
};
}
@ -896,6 +905,9 @@ export function normalizeAgentWorkflowUiConfig(value: unknown): AgentWorkflowUiC
defaultOption?.promptFragment ||
buttonName;
const hasRawBlockedOptionKeys = Array.isArray(optionRecord.blockedOptionKeys);
const hasRawSpecialModuleAllowedOptionKeys = Array.isArray(
optionRecord.specialModuleAllowedOptionKeys
);
const hasRawChildren = Array.isArray(optionRecord.children);
const rawChildren = hasRawChildren ? (optionRecord.children as unknown[]) : [];
const defaultChildren =
@ -922,6 +934,23 @@ export function normalizeAgentWorkflowUiConfig(value: unknown): AgentWorkflowUiC
blockedOptionKeys: hasRawBlockedOptionKeys
? normalizeKeyList(optionRecord.blockedOptionKeys, 100)
: normalizeKeyList(defaultOption?.blockedOptionKeys, 100),
specialModuleEnabled: normalizeBoolean(
optionRecord.specialModuleEnabled,
defaultOption?.specialModuleEnabled ?? false
),
specialModuleExclusionEnabled: normalizeBoolean(
optionRecord.specialModuleExclusionEnabled,
defaultOption?.specialModuleExclusionEnabled ?? false
),
specialModuleAllowedOptionKeys: hasRawSpecialModuleAllowedOptionKeys
? normalizeKeyList(
optionRecord.specialModuleAllowedOptionKeys,
AGENT_WORKFLOW_SPECIAL_MODULE_ALLOWED_KEYS_MAX
)
: normalizeKeyList(
defaultOption?.specialModuleAllowedOptionKeys,
AGENT_WORKFLOW_SPECIAL_MODULE_ALLOWED_KEYS_MAX
),
children:
depth < AGENT_WORKFLOW_MAX_OPTION_DEPTH
? childSource
@ -1378,8 +1407,10 @@ export function getAgentWorkflowBlockedOptionKeys(params: {
selectionValue?: string | null;
allowCrossLevel?: boolean;
optionDepthLookup?: Map<string, number>;
specialModuleRootOptions?: AgentWorkflowOptionUiConfig[];
}) {
const selectedOptions = getAgentWorkflowSelectedOptionPath(params);
const selectedOptionKeys = new Set(selectedOptions.map((option) => option.key));
const optionDepthLookup =
params.optionDepthLookup ||
new Map(
@ -1392,7 +1423,7 @@ export function getAgentWorkflowBlockedOptionKeys(params: {
new Set(
selectedOptions.flatMap((option) => {
const currentDepth = optionDepthLookup.get(option.key);
return (option.blockedOptionKeys || []).filter((blockedKey) => {
const explicitBlockedKeys = (option.blockedOptionKeys || []).filter((blockedKey) => {
if (!blockedKey) {
return false;
}
@ -1404,11 +1435,79 @@ export function getAgentWorkflowBlockedOptionKeys(params: {
}
return optionDepthLookup.get(blockedKey) === currentDepth;
});
const specialModuleBlockedKeys = option.specialModuleExclusionEnabled
? getSpecialModuleBlockedOptionKeys({
allowedOptionKeys: option.specialModuleAllowedOptionKeys || [],
selectedOptionKeys,
sourceOptionKey: option.key,
specialModuleRootOptions: params.specialModuleRootOptions || [],
})
: [];
return [...explicitBlockedKeys, ...specialModuleBlockedKeys];
})
)
);
}
function collectAllowedSpecialModulePathKeys(
option: AgentWorkflowOptionUiConfig,
allowedOptionKeys: Set<string>,
ancestors: string[] = []
): string[] {
const currentAncestors = [...ancestors, option.key];
const descendantKeys = (option.children || []).flatMap((child) =>
collectAllowedSpecialModulePathKeys(child, allowedOptionKeys, currentAncestors)
);
if (allowedOptionKeys.has(option.key)) {
return [
...currentAncestors,
...flattenAgentWorkflowOptionTree(option.children || []).map(
({ option: childOption }) => childOption.key
),
];
}
return descendantKeys;
}
function getSpecialModuleBlockedOptionKeys(params: {
allowedOptionKeys: string[];
selectedOptionKeys: Set<string>;
sourceOptionKey: string;
specialModuleRootOptions: AgentWorkflowOptionUiConfig[];
}) {
const allowedOptionKeys = new Set(params.allowedOptionKeys.filter(Boolean));
const allowedPathKeys = new Set<string>();
for (const rootOption of params.specialModuleRootOptions) {
if (rootOption.key === params.sourceOptionKey) {
continue;
}
collectAllowedSpecialModulePathKeys(rootOption, allowedOptionKeys).forEach((key) =>
allowedPathKeys.add(key)
);
}
return params.specialModuleRootOptions.flatMap((rootOption) => {
if (rootOption.key === params.sourceOptionKey) {
return [];
}
return flattenAgentWorkflowOptionTree(rootOption.children || [])
.map(({ option }) => option.key)
.filter(
(key) =>
key &&
!allowedOptionKeys.has(key) &&
!allowedPathKeys.has(key) &&
!params.selectedOptionKeys.has(key)
);
});
}
function collectAgentWorkflowOptionBranchKeys(
options: AgentWorkflowOptionUiConfig[],
targetKey: string
@ -1445,6 +1544,9 @@ export function getAgentWorkflowBlockedOptionKeysForSelections(params: {
] as const)
)
);
const specialModuleRootOptions = visibleSteps.flatMap((step) =>
(step.options || []).filter((option) => option.specialModuleEnabled)
);
const directlyBlockedKeys = Array.from(
new Set(
@ -1454,6 +1556,7 @@ export function getAgentWorkflowBlockedOptionKeysForSelections(params: {
selectionValue: params.selections[step.key],
allowCrossLevel: constraintSettings.crossLevelMutualExclusionEnabled,
optionDepthLookup: visibleOptionDepthLookup,
specialModuleRootOptions,
})
)
)

View File

@ -88,6 +88,9 @@ export interface AgentWorkflowOptionUiConfig {
sortOrder: number;
promptFragment?: string | null;
blockedOptionKeys?: string[];
specialModuleEnabled?: boolean;
specialModuleExclusionEnabled?: boolean;
specialModuleAllowedOptionKeys?: string[];
children?: AgentWorkflowOptionUiConfig[];
}