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 9baa92ceb..13d691c92 100644 --- a/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx +++ b/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent, type ReactNode } from "react"; +import { useDeferredValue, useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent, type ReactNode } from "react"; import useSWR from "swr"; import { toast } from "sonner"; import { motion, AnimatePresence } from "framer-motion"; @@ -148,6 +148,7 @@ const WORKFLOW_STEP_DRAG_DATA_TYPE = "application/x-agent-workflow-step"; const WORKFLOW_SETTINGS_DRAFT_STORAGE_KEY = "agent-workflow-settings-draft:v1"; const WORKFLOW_SETTINGS_STALE_DRAFT_STORAGE_KEY = "agent-workflow-settings-stale-draft:v1"; const WORKFLOW_SETTINGS_DRAFT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; +const WORKFLOW_BLOCKED_CANDIDATE_RENDER_LIMIT = 60; type WorkflowStepDropPosition = "before" | "after" | "inside"; @@ -324,15 +325,33 @@ function updateWorkflowStepTree( stepKey: string, updater: (step: AgentWorkflowStepUiConfig) => AgentWorkflowStepUiConfig ): AgentWorkflowStepUiConfig[] { - return steps.map((step) => { + let changed = false; + const nextSteps = steps.map((step) => { if (step.key === stepKey) { - return updater(step); + const updatedStep = updater(step); + if (updatedStep !== step) { + changed = true; + } + return updatedStep; } + + if (!step.children || step.children.length === 0) { + return step; + } + + const nextChildren = updateWorkflowStepTree(step.children, stepKey, updater); + if (nextChildren === step.children) { + return step; + } + + changed = true; return { ...step, - children: updateWorkflowStepTree(step.children || [], stepKey, updater), + children: nextChildren, }; }); + + return changed ? nextSteps : steps; } function collectWorkflowOptionKeys(options: AgentWorkflowOptionUiConfig[]): string[] { @@ -386,15 +405,33 @@ function updateWorkflowOptionTree( optionKey: string, updater: (option: AgentWorkflowOptionUiConfig) => AgentWorkflowOptionUiConfig ): AgentWorkflowOptionUiConfig[] { - return options.map((option) => { + let changed = false; + const nextOptions = options.map((option) => { if (option.key === optionKey) { - return updater(option); + const updatedOption = updater(option); + if (updatedOption !== option) { + changed = true; + } + return updatedOption; } + + if (!option.children || option.children.length === 0) { + return option; + } + + const nextChildren = updateWorkflowOptionTree(option.children, optionKey, updater); + if (nextChildren === option.children) { + return option; + } + + changed = true; return { ...option, - children: updateWorkflowOptionTree(option.children || [], optionKey, updater), + children: nextChildren, }; }); + + return changed ? nextOptions : options; } function removeWorkflowOptionTree( @@ -1362,6 +1399,7 @@ export function AgentWorkflowSettingsClient({ const [fourthCategoryDrafts, setFourthCategoryDrafts] = useState>({}); const [fifthCategoryDrafts, setFifthCategoryDrafts] = useState>({}); const [blockedOptionSearches, setBlockedOptionSearches] = useState>({}); + const [expandedBlockedOptionPanels, setExpandedBlockedOptionPanels] = useState>({}); const [isImportingBrandSheet, setIsImportingBrandSheet] = useState(false); const [draggedStepKey, setDraggedStepKey] = useState(null); const [stepDropTarget, setStepDropTarget] = useState(null); @@ -1621,15 +1659,25 @@ export function AgentWorkflowSettingsClient({ () => [...config.steps].sort((left, right) => left.sortOrder - right.sortOrder), [config.steps] ); + const deferredWorkflowSteps = useDeferredValue(config.steps); const sortedPresetLibrarySections = useMemo( () => [...presetLibraryConfig.sections].sort((left, right) => left.sortOrder - right.sortOrder), [presetLibraryConfig.sections] ); const workflowOptionRelations = useMemo( - () => collectWorkflowOptionRelationsFromSteps(config.steps || []), - [config.steps] + () => collectWorkflowOptionRelationsFromSteps(deferredWorkflowSteps || []), + [deferredWorkflowSteps] ); + const workflowOptionRelationsByDepth = useMemo(() => { + const next = new Map(); + for (const relation of workflowOptionRelations) { + const current = next.get(relation.depth) || []; + current.push(relation); + next.set(relation.depth, current); + } + return next; + }, [workflowOptionRelations]); const workflowOptionRelationLookup = useMemo( () => new Map(workflowOptionRelations.map((item) => [item.key, item] as const)), [workflowOptionRelations] @@ -2417,12 +2465,10 @@ export function AgentWorkflowSettingsClient({ stepKey: string, updater: (step: AgentWorkflowUiConfig["steps"][number]) => AgentWorkflowUiConfig["steps"][number] ) => { - setConfig((prev) => - normalizeConfigOrdering({ - ...prev, - steps: updateWorkflowStepTree(prev.steps, stepKey, updater), - }) - ); + setConfig((prev) => ({ + ...prev, + steps: updateWorkflowStepTree(prev.steps, stepKey, updater), + })); }; const updateOption = ( @@ -3373,26 +3419,36 @@ export function AgentWorkflowSettingsClient({ (left, right) => left.sortOrder - right.sortOrder ); const canAddChild = depth < AGENT_WORKFLOW_MAX_OPTION_DEPTH; - const blockedCandidates = workflowOptionRelations.filter( - (item) => - item.key !== option.key && - (workflowConstraintSettings.crossLevelMutualExclusionEnabled || item.depth === depth) - ); + const blockedCandidatePanelExpanded = expandedBlockedOptionPanels[refKey] ?? false; + const blockedCandidatePool = workflowConstraintSettings.crossLevelMutualExclusionEnabled + ? workflowOptionRelations + : workflowOptionRelationsByDepth.get(depth) || []; + const blockedCandidates = blockedCandidatePanelExpanded + ? blockedCandidatePool.filter((item) => item.key !== option.key) + : []; const selectedBlockedItems = (option.blockedOptionKeys || []) .map((blockedKey) => workflowOptionRelationLookup.get(blockedKey)) .filter((item): item is WorkflowOptionRelation => Boolean(item)); const isExpanded = isWorkflowOptionExpanded(option.key, depth); const blockedSearchValue = blockedOptionSearches[refKey] || ""; const normalizedBlockedSearchValue = blockedSearchValue.trim().toLowerCase(); - const filteredBlockedCandidates = blockedCandidates.filter((candidate) => { - if (!normalizedBlockedSearchValue) { - return true; - } + const filteredBlockedCandidates = blockedCandidatePanelExpanded + ? blockedCandidates.filter((candidate) => { + if (!normalizedBlockedSearchValue) { + return true; + } - return [candidate.label, candidate.stepTitle, candidate.key].some((value) => - value.toLowerCase().includes(normalizedBlockedSearchValue) - ); - }); + return [candidate.label, candidate.stepTitle, candidate.key].some((value) => + value.toLowerCase().includes(normalizedBlockedSearchValue) + ); + }) + : []; + const visibleBlockedCandidates = filteredBlockedCandidates.slice( + 0, + WORKFLOW_BLOCKED_CANDIDATE_RENDER_LIMIT + ); + const hiddenBlockedCandidateCount = + filteredBlockedCandidates.length - visibleBlockedCandidates.length; const canMoveUp = depth === 1 && indexPath[0] > 1; const canMoveDown = depth === 1 && indexPath[0] < (step.options || []).length; @@ -3658,77 +3714,111 @@ export function AgentWorkflowSettingsClient({ )} -
-
- - - 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); - return ( - - ); - }) - ) : ( -
- {blockedCandidates.length > 0 - ? "没有命中搜索结果,换个关键词试试" - : workflowConstraintSettings.crossLevelMutualExclusionEnabled - ? "当前工作流下没有其他可限制的卡片" - : "当前工作流下没有其他可限制的同级卡片"} -
- )} -
-
+
+
+ + 候选卡片列表 + + +
+ {blockedCandidatePanelExpanded ? ( + <> +
+ + + setBlockedOptionSearches((prev) => ({ + ...prev, + [refKey]: e.target.value, + })) + } + placeholder={`${workflowConstraintSettings.crossLevelMutualExclusionEnabled ? "搜索所有层级卡片" : "搜索同级卡片"}(${blockedCandidates.length} 个候选)`} + className="h-8 pl-8 text-xs" + /> +
+
+ {filteredBlockedCandidates.length > 0 ? ( + <> + {visibleBlockedCandidates.map((candidate) => { + const active = (option.blockedOptionKeys || []).includes(candidate.key); + return ( + + ); + })} + {hiddenBlockedCandidateCount > 0 ? ( +
+ 还有 {hiddenBlockedCandidateCount} 个候选未显示,请输入关键词缩小范围 +
+ ) : null} + + ) : ( +
+ {blockedCandidates.length > 0 + ? "没有命中搜索结果,换个关键词试试" + : workflowConstraintSettings.crossLevelMutualExclusionEnabled + ? "当前工作流下没有其他可限制的卡片" + : "当前工作流下没有其他可限制的同级卡片"} +
+ )} +
+ + ) : ( +
+ 候选列表已折叠,需要配置互斥时再展开,避免大量卡片同时渲染导致编辑卡顿。 +
+ )} +