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[]; }