fix: improve workflow settings typing performance
This commit is contained in:
parent
8a722ed849
commit
7a2169576d
@ -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<Record<string, string>>({});
|
||||
const [fifthCategoryDrafts, setFifthCategoryDrafts] = useState<Record<string, string>>({});
|
||||
const [blockedOptionSearches, setBlockedOptionSearches] = useState<Record<string, string>>({});
|
||||
const [expandedBlockedOptionPanels, setExpandedBlockedOptionPanels] = useState<Record<string, boolean>>({});
|
||||
const [isImportingBrandSheet, setIsImportingBrandSheet] = useState(false);
|
||||
const [draggedStepKey, setDraggedStepKey] = useState<string | null>(null);
|
||||
const [stepDropTarget, setStepDropTarget] = useState<WorkflowStepDropTarget | null>(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<number, WorkflowOptionRelation[]>();
|
||||
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({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 rounded-md border bg-background/70 p-2">
|
||||
<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-44 space-y-2 overflow-y-auto">
|
||||
{filteredBlockedCandidates.length > 0 ? (
|
||||
filteredBlockedCandidates.map((candidate) => {
|
||||
const active = (option.blockedOptionKeys || []).includes(candidate.key);
|
||||
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">
|
||||
<span className="block truncate font-medium">
|
||||
{candidate.label}
|
||||
</span>
|
||||
<span className="block text-[10px] text-muted-foreground">
|
||||
{getWorkflowLevelLabel(candidate.depth, "卡片")} · {candidate.key}
|
||||
</span>
|
||||
</span>
|
||||
<Badge variant={active ? "default" : "outline"}>
|
||||
{active ? "已限制" : "可限制"}
|
||||
</Badge>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="px-2 py-3 text-center text-xs text-muted-foreground">
|
||||
{blockedCandidates.length > 0
|
||||
? "没有命中搜索结果,换个关键词试试"
|
||||
: workflowConstraintSettings.crossLevelMutualExclusionEnabled
|
||||
? "当前工作流下没有其他可限制的卡片"
|
||||
: "当前工作流下没有其他可限制的同级卡片"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</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">
|
||||
候选卡片列表
|
||||
</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-44 space-y-2 overflow-y-auto">
|
||||
{filteredBlockedCandidates.length > 0 ? (
|
||||
<>
|
||||
{visibleBlockedCandidates.map((candidate) => {
|
||||
const active = (option.blockedOptionKeys || []).includes(candidate.key);
|
||||
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">
|
||||
<span className="block truncate font-medium">
|
||||
{candidate.label}
|
||||
</span>
|
||||
<span className="block text-[10px] text-muted-foreground">
|
||||
{getWorkflowLevelLabel(candidate.depth, "卡片")} · {candidate.key}
|
||||
</span>
|
||||
</span>
|
||||
<Badge variant={active ? "default" : "outline"}>
|
||||
{active ? "已限制" : "可限制"}
|
||||
</Badge>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{hiddenBlockedCandidateCount > 0 ? (
|
||||
<div className="rounded-md border border-dashed bg-muted/40 px-3 py-2 text-center text-[11px] text-muted-foreground">
|
||||
还有 {hiddenBlockedCandidateCount} 个候选未显示,请输入关键词缩小范围
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user