diff --git a/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx b/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx
index de2ff5c14..0590a0d5b 100644
--- a/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx
+++ b/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx
@@ -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 (
+
1 && "pl-3")}>
+
+ {sortedCandidateChildren.length > 0 ? (
+
+ {sortedCandidateChildren.map((childOption) =>
+ renderSpecialModuleAllowedOption(childOption, candidateDepth + 1, allowed)
+ )}
+
+ ) : null}
+
+ );
+ };
return (
1 && "pl-4 sm:pl-6")}>
@@ -3982,6 +4105,10 @@ export function AgentWorkflowSettingsClient({
{levelLabel}
{sortedChildren.length} 子卡片
+ {option.specialModuleEnabled ? 特殊模块 : null}
+ {option.specialModuleExclusionEnabled ? (
+ 特殊模块快捷排斥
+ ) : null}
{!option.enabled ? 前台显示开发中 : null}
{option.buttonName || "未命名卡片"}
@@ -4165,6 +4292,26 @@ export function AgentWorkflowSettingsClient({
className="mt-1 scale-90"
/>
+ {depth === 1 ? (
+
+
+
+
+ 开启后,这张一级卡片会出现在互斥配置的“特殊模块快捷排斥”里。其它卡片可只勾选少量放行子卡片,未放行的子卡片会自动排斥。
+
+
+
+ updateOption(step.key, option.key, (currentOption) => ({
+ ...currentOption,
+ specialModuleEnabled: checked,
+ }))
+ }
+ className="mt-1 scale-90"
+ />
+
+ ) : null}
@@ -4210,131 +4357,212 @@ export function AgentWorkflowSettingsClient({
)}
-
-
-
- 候选卡片列表
- {blockedCandidatePanelExpanded
- ? ` · 已展示 ${filteredBlockedCandidates.length}/${blockedCandidates.length}`
- : ""}
-
-
-
- {blockedCandidatePanelExpanded ? (
- <>
-
-
-
- setBlockedOptionSearches((prev) => ({
- ...prev,
- [refKey]: e.target.value,
- }))
- }
- placeholder={`${workflowConstraintSettings.crossLevelMutualExclusionEnabled ? "搜索所有层级卡片" : "搜索同级卡片"}(${blockedCandidates.length} 个候选)`}
- className="h-8 pl-8 text-xs"
- />
-
-
- {filteredBlockedCandidates.length > 0 ? (
- <>
- {filteredBlockedCandidates.map((candidate) => {
- const active = (option.blockedOptionKeys || []).includes(candidate.key);
- const candidateStepAnalysis = workflowStepAnalysisLookup.get(
- candidate.stepKey
- );
- return (
-
- );
- })}
- >
- ) : (
-
- {blockedCandidates.length > 0
- ? "没有命中搜索结果,换个关键词试试"
- : workflowConstraintSettings.crossLevelMutualExclusionEnabled
- ? "当前工作流下没有其他可限制的卡片"
- : "当前工作流下没有其他可限制的同级卡片"}
-
- )}
-
- >
- ) : (
-
- 候选列表已折叠,需要配置互斥时再展开,避免大量卡片同时渲染导致编辑卡顿。
-
- )}
-
+ {availableSpecialModuleOptions.length > 0 ? (
+
+
+
+
+
+ 开启后,下面特殊模块中未勾选放行的子卡片会自动进入排斥;勾选父级会放行整支子树。
+
+
+
+ updateOption(step.key, option.key, (currentOption) => ({
+ ...currentOption,
+ specialModuleExclusionEnabled: checked,
+ }))
+ }
+ className="mt-1 scale-90"
+ />
+
+
+ {option.specialModuleExclusionEnabled ? (
+
+ {availableSpecialModuleOptions.map((specialModule) => {
+ const rootAllowed = specialAllowedKeys.has(specialModule.option.key);
+
+ return (
+
+
+
+
+ {specialModule.path}
+
+
+ 特殊模块 · {specialModule.option.key}
+
+
+
+
+ {rootAllowed
+ ? "整组已放行"
+ : `${(specialModule.option.children || []).length} 个子卡片`}
+
+
+
+
+
+
+ {(specialModule.option.children || []).map((childOption) =>
+ renderSpecialModuleAllowedOption(childOption, 2, rootAllowed)
+ )}
+
+
+ );
+ })}
+
+ ) : (
+
+ 当前未启用特殊模块快捷排斥;启用后只需要勾选“不排斥”的少量子卡片。
+
+ )}
+
+ ) : null}
+
+
+
+
+ 候选卡片列表
+ {blockedCandidatePanelExpanded
+ ? ` · 已展示 ${filteredBlockedCandidates.length}/${blockedCandidates.length}`
+ : ""}
+
+
+
+ {blockedCandidatePanelExpanded ? (
+ <>
+
+
+
+ setBlockedOptionSearches((prev) => ({
+ ...prev,
+ [refKey]: e.target.value,
+ }))
+ }
+ placeholder={`${workflowConstraintSettings.crossLevelMutualExclusionEnabled ? "搜索所有层级卡片" : "搜索同级卡片"}(${blockedCandidates.length} 个候选)`}
+ className="h-8 pl-8 text-xs"
+ />
+
+
+ {filteredBlockedCandidates.length > 0 ? (
+ <>
+ {filteredBlockedCandidates.map((candidate) => {
+ const active = (option.blockedOptionKeys || []).includes(candidate.key);
+ const candidateStepAnalysis = workflowStepAnalysisLookup.get(
+ candidate.stepKey
+ );
+ return (
+
+ );
+ })}
+ >
+ ) : (
+
+ {blockedCandidates.length > 0
+ ? "没有命中搜索结果,换个关键词试试"
+ : workflowConstraintSettings.crossLevelMutualExclusionEnabled
+ ? "当前工作流下没有其他可限制的卡片"
+ : "当前工作流下没有其他可限制的同级卡片"}
+
+ )}
+
+ >
+ ) : (
+
+ 候选列表已折叠,需要配置互斥时再展开,避免大量卡片同时渲染导致编辑卡顿。
+
+ )}
+
diff --git a/app/api/site-settings/route.ts b/app/api/site-settings/route.ts
index 1ff6fc316..37becf163 100644
--- a/app/api/site-settings/route.ts
+++ b/app/api/site-settings/route.ts
@@ -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(),
})
);
diff --git a/lib/agents/workflow-ui-config.ts b/lib/agents/workflow-ui-config.ts
index a904642f7..4af6984dd 100644
--- a/lib/agents/workflow-ui-config.ts
+++ b/lib/agents/workflow-ui-config.ts
@@ -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;
+ 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,
+ 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;
+ sourceOptionKey: string;
+ specialModuleRootOptions: AgentWorkflowOptionUiConfig[];
+}) {
+ const allowedOptionKeys = new Set(params.allowedOptionKeys.filter(Boolean));
+ const allowedPathKeys = new Set();
+
+ 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,
})
)
)
diff --git a/types/agent.ts b/types/agent.ts
index 42e064539..ed1cc4b1a 100644
--- a/types/agent.ts
+++ b/types/agent.ts
@@ -88,6 +88,9 @@ export interface AgentWorkflowOptionUiConfig {
sortOrder: number;
promptFragment?: string | null;
blockedOptionKeys?: string[];
+ specialModuleEnabled?: boolean;
+ specialModuleExclusionEnabled?: boolean;
+ specialModuleAllowedOptionKeys?: string[];
children?: AgentWorkflowOptionUiConfig[];
}