diff --git a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx index 941c5471e..5d7a4b4fd 100644 --- a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx +++ b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx @@ -60,7 +60,9 @@ import dynamic from "next/dynamic"; import { useParams, useRouter, useSearchParams } from "next/navigation"; import { type ChangeEvent, + startTransition, useCallback, + useDeferredValue, useEffect, useMemo, useRef, @@ -156,7 +158,6 @@ import { } from "@/lib/canvas-toolbar-config"; import { buildAgentWorkflowComposerContext, - getAgentWorkflowProgress, } from "@/lib/agents/workflow-composer"; import { createEmptyAgentWorkflowMaterialSelections, @@ -1357,6 +1358,29 @@ function getAgentProjectDraftStorageKey(projectId: string) { return `${AGENT_PROJECT_DRAFT_STORAGE_KEY_PREFIX}${String(projectId || "").trim()}`; } +function scheduleCanvasIdleTask(callback: () => void, timeout = 1200) { + if (typeof window === "undefined") { + callback(); + return () => {}; + } + + const idleWindow = window as Window & { + requestIdleCallback?: ( + callback: IdleRequestCallback, + options?: IdleRequestOptions + ) => number; + cancelIdleCallback?: (handle: number) => void; + }; + + if (typeof idleWindow.requestIdleCallback === "function") { + const idleId = idleWindow.requestIdleCallback(callback, { timeout }); + return () => idleWindow.cancelIdleCallback?.(idleId); + } + + const timer = window.setTimeout(callback, Math.min(timeout, 300)); + return () => window.clearTimeout(timer); +} + interface AgentProjectDraftSnapshot { savedAt: number; title?: string | null; @@ -1832,6 +1856,9 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { }), [resolvedWorkflowUiConfig, workflowSelections] ); + const deferredSanitizedWorkflowSelections = useDeferredValue( + sanitizedWorkflowSelections + ); const resolvedBrandModelConfig = useMemo( () => normalizeAgentWorkflowBrandModelConfig( @@ -3129,6 +3156,49 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { ] ); + const persistedAgentProjectResultState = useMemo(() => { + const resultCollections = { + scene_generation: normalizeResultCollectionUrls( + agentProjectResultCollections.scene_generation + ), + detail_generation: normalizeResultCollectionUrls( + agentProjectResultCollections.detail_generation + ), + detail_replacement: normalizeResultCollectionUrls( + agentProjectResultCollections.detail_replacement + ), + sku_replacement: normalizeResultCollectionUrls( + agentProjectResultCollections.sku_replacement + ), + }; + + return { + resultCollections, + deletedResultCollections: { + scene_generation: normalizeResultCollectionUrls( + agentProjectDeletedResultCollections.scene_generation + ), + detail_generation: normalizeResultCollectionUrls( + agentProjectDeletedResultCollections.detail_generation + ), + detail_replacement: normalizeResultCollectionUrls( + agentProjectDeletedResultCollections.detail_replacement + ), + sku_replacement: normalizeResultCollectionUrls( + agentProjectDeletedResultCollections.sku_replacement + ), + }, + resultImageUrls: Array.from( + new Set([ + ...resultCollections.scene_generation, + ...resultCollections.detail_generation, + ...resultCollections.detail_replacement, + ...resultCollections.sku_replacement, + ]) + ), + }; + }, [agentProjectDeletedResultCollections, agentProjectResultCollections]); + const buildPersistedAgentProjectState = useCallback(() => { const persistedMaterials = appearanceRecognitionEnabled ? workflowMaterials @@ -3157,58 +3227,14 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { return { workflowState, workspaceState: agentWorkspaceState, - resultCollections: { - scene_generation: normalizeResultCollectionUrls( - agentProjectResultCollections.scene_generation - ), - detail_generation: normalizeResultCollectionUrls( - agentProjectResultCollections.detail_generation - ), - detail_replacement: normalizeResultCollectionUrls( - agentProjectResultCollections.detail_replacement - ), - sku_replacement: normalizeResultCollectionUrls( - agentProjectResultCollections.sku_replacement - ), - }, - deletedResultCollections: { - scene_generation: normalizeResultCollectionUrls( - agentProjectDeletedResultCollections.scene_generation - ), - detail_generation: normalizeResultCollectionUrls( - agentProjectDeletedResultCollections.detail_generation - ), - detail_replacement: normalizeResultCollectionUrls( - agentProjectDeletedResultCollections.detail_replacement - ), - sku_replacement: normalizeResultCollectionUrls( - agentProjectDeletedResultCollections.sku_replacement - ), - }, - resultImageUrls: Array.from( - new Set([ - ...normalizeResultCollectionUrls( - agentProjectResultCollections.scene_generation - ), - ...normalizeResultCollectionUrls( - agentProjectResultCollections.detail_generation - ), - ...normalizeResultCollectionUrls( - agentProjectResultCollections.detail_replacement - ), - ...normalizeResultCollectionUrls( - agentProjectResultCollections.sku_replacement - ), - ]) - ), + ...persistedAgentProjectResultState, }; }, [ agentModeState, - agentProjectDeletedResultCollections, - agentProjectResultCollections, agentWorkspaceState, currentAgentStage, appearanceRecognitionEnabled, + persistedAgentProjectResultState, selectedAgentPresetId, selectedBrandModel, workflowInputs, @@ -3393,26 +3419,28 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { if (typeof window === "undefined" || workflowHydrationSource === "pending") return; - try { - const persistedMaterials = appearanceRecognitionEnabled - ? workflowMaterials - : clearAppearanceSummaryFromMaterials(workflowMaterials); - window.localStorage.setItem( - workflowStorageKey, - JSON.stringify({ - inputs: workflowInputs, - materials: persistedMaterials, - appearanceRecognitionEnabled, - selections: sanitizedWorkflowSelections, - selectedBrandModel, - selectedAgentPresetId, - currentAgentStage, - agentModeState, - }) - ); - } catch (error) { - console.warn("[Canvas] Failed to persist workflow state:", error); - } + return scheduleCanvasIdleTask(() => { + try { + const persistedMaterials = appearanceRecognitionEnabled + ? workflowMaterials + : clearAppearanceSummaryFromMaterials(workflowMaterials); + window.localStorage.setItem( + workflowStorageKey, + JSON.stringify({ + inputs: workflowInputs, + materials: persistedMaterials, + appearanceRecognitionEnabled, + selections: sanitizedWorkflowSelections, + selectedBrandModel, + selectedAgentPresetId, + currentAgentStage, + agentModeState, + }) + ); + } catch (error) { + console.warn("[Canvas] Failed to persist workflow state:", error); + } + }); }, [ isAgentMode, workflowHydrationSource, @@ -3432,39 +3460,30 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { return; if (typeof window === "undefined") return; - try { - const draftSnapshot: AgentProjectDraftSnapshot = { - savedAt: Date.now(), - title: agentProjectTitle, - thumbnail: agentProjectThumbnail, - projectState: buildPersistedAgentProjectState(), - }; + return scheduleCanvasIdleTask(() => { + try { + const draftSnapshot: AgentProjectDraftSnapshot = { + savedAt: Date.now(), + title: agentProjectTitle, + thumbnail: agentProjectThumbnail, + projectState: buildPersistedAgentProjectState(), + }; - window.localStorage.setItem( - getAgentProjectDraftStorageKey(agentProjectId), - JSON.stringify(draftSnapshot) - ); - } catch (error) { - console.warn("[Agent Project] Failed to persist local draft:", error); - } + window.localStorage.setItem( + getAgentProjectDraftStorageKey(agentProjectId), + JSON.stringify(draftSnapshot) + ); + } catch (error) { + console.warn("[Agent Project] Failed to persist local draft:", error); + } + }); }, [ - agentModeState, - agentProjectDeletedResultCollections, agentProjectId, - agentProjectResultCollections, agentProjectThumbnail, agentProjectTitle, - agentWorkspaceState, - appearanceRecognitionEnabled, buildPersistedAgentProjectState, - currentAgentStage, hasLoadedCurrentAgentProject, isAgentMode, - selectedAgentPresetId, - selectedBrandModel, - workflowInputs, - workflowMaterials, - workflowSelections, ]); const persistAgentProject = useCallback( @@ -8268,20 +8287,38 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { setWorkflowSelections(sanitizedWorkflowSelections); }, [sanitizedWorkflowSelections, workflowSelections]); - const workflowContext = useMemo(() => { - return buildAgentWorkflowComposerContext({ - config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig, - inputs: workflowInputs, - materials: workflowMaterials, - selectedBrandModel, - appearanceRecognitionEnabled, - selections: sanitizedWorkflowSelections, - }); + const [workflowContext, setWorkflowContext] = + useState(null); + useEffect(() => { + let canceled = false; + const cancelIdleTask = scheduleCanvasIdleTask(() => { + const nextWorkflowContext = buildAgentWorkflowComposerContext({ + config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig, + inputs: workflowInputs, + materials: workflowMaterials, + selectedBrandModel, + appearanceRecognitionEnabled, + selections: deferredSanitizedWorkflowSelections, + }); + + if (canceled) { + return; + } + + startTransition(() => { + setWorkflowContext(nextWorkflowContext); + }); + }, 450); + + return () => { + canceled = true; + cancelIdleTask(); + }; }, [ appearanceRecognitionEnabled, + deferredSanitizedWorkflowSelections, resolvedWorkflowUiConfig, selectedBrandModel, - sanitizedWorkflowSelections, workflowInputs, workflowMaterials, ]); @@ -8378,7 +8415,9 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { }); workflowSelectionsRef.current = clearedSelections; - setWorkflowSelections(clearedSelections); + startTransition(() => { + setWorkflowSelections(clearedSelections); + }); }, [resolvedWorkflowUiConfig] ); @@ -8469,14 +8508,6 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { selectedAgentPresetData?.requiredUploadCount, selectedAgentPresetData?.type, ]); - const workflowProgress = useMemo( - () => - getAgentWorkflowProgress({ - config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig, - selections: sanitizedWorkflowSelections, - }), - [resolvedWorkflowUiConfig, sanitizedWorkflowSelections] - ); const resolvedAgentProjectTitle = hasActiveAgentProject ? String(agentProjectTitle || "").trim() || "未命名生图项目" : "未选择项目"; @@ -8744,6 +8775,35 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { ) : null; + const workflowStylePanel = useMemo( + () => ( + + ), + [ + handleWorkflowInputsChange, + handleWorkflowMaterialsChange, + handleWorkflowSelectionsChange, + resolvedWorkflowUiConfig, + sanitizedWorkflowSelections, + siteSettingsData?.agentPsdTemplateCategoryTagIndex, + workflowInputs, + workflowMaterials, + ] + ); + if (isAgentMode) { if (!hasActiveAgentProject) { return ( @@ -8826,22 +8886,7 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { workflowFeatureSettings={workflowPageFeatureSettings} // 嵌入的风格化选择面板 workflowPromptOptimizationEnabled={appearanceRecognitionEnabled} - workflowStylePanel={ - - } + workflowStylePanel={workflowStylePanel} workspaceState={agentWorkspaceState} /> {loginDialogElement} diff --git a/components/agents/AgentGenerationWorkspaceDarkV2.tsx b/components/agents/AgentGenerationWorkspaceDarkV2.tsx index 435a39dcf..b9bd35c92 100644 --- a/components/agents/AgentGenerationWorkspaceDarkV2.tsx +++ b/components/agents/AgentGenerationWorkspaceDarkV2.tsx @@ -3650,8 +3650,9 @@ export function AgentGenerationWorkspaceDarkV2({ const detailPrompt = userSupplementPrompt || `${basePrompt},面向${detailGenerationBrief.targetAudience},在${detailGenerationBrief.usageScenario}场景解决${detailGenerationBrief.coreNeed}${demandHint}`; - const workflowHint = workflowContext?.promptText - ? `\n\n【工作流风格要求】\n${workflowContext.promptText}` + const latestWorkflowContext = getLatestWorkflowContext?.() ?? workflowContext; + const workflowHint = latestWorkflowContext?.promptText + ? `\n\n【工作流风格要求】\n${latestWorkflowContext.promptText}` : ""; const detailPromptWithWorkflow = `${detailPrompt}${workflowHint}`.trim(); const detailCount = extractAgentDetailFrameCount(detailPrompt); diff --git a/components/agents/AgentWorkflowMaterialPicker.tsx b/components/agents/AgentWorkflowMaterialPicker.tsx index e26033a32..d20b8e1e0 100644 --- a/components/agents/AgentWorkflowMaterialPicker.tsx +++ b/components/agents/AgentWorkflowMaterialPicker.tsx @@ -76,6 +76,7 @@ export function AgentWorkflowMaterialPicker({ }: AgentWorkflowMaterialPickerProps) { const isDark = theme === "dark"; const hasUploadInFlight = isUploadingNewImage || Boolean(uploadingImageId); + const canUploadMaterialImage = Boolean(onUploadImage); const [previewDisplayMode, setPreviewDisplayMode] = useState<"selected" | "all">("all"); const [hasManualSelectionInteraction, setHasManualSelectionInteraction] = useState(false); const selectedSet = useMemo( @@ -157,12 +158,14 @@ export function AgentWorkflowMaterialPicker({ const remainingSelectableCount = resolvedSelectionLimit === null ? null : Math.max(resolvedSelectionLimit - selectedCount, 0); const remainingUploadSlotCount = - resolvedSelectionLimit === null + !canUploadMaterialImage ? 0 - : Math.max( - resolvedSelectionLimit - selectedGalleryCount - uploadedImages.length, - 0 - ); + : resolvedSelectionLimit === null + ? 1 + : Math.max( + resolvedSelectionLimit - selectedGalleryCount - uploadedImages.length, + 0 + ); const totalImageCount = galleryImages.length; const hasSystemRecommendedSelection = result?.queryType === "sku" && @@ -430,7 +433,7 @@ export function AgentWorkflowMaterialPicker({ ) : null} - {!disabled && (resolvedSelectionLimit !== null || uploadedImages.length > 0) ? ( + {!disabled && canUploadMaterialImage ? (
- 还可上传 {remainingUploadSlotCount} 张 + {resolvedSelectionLimit === null + ? "可继续上传" + : `还可上传 ${remainingUploadSlotCount} 张`} ) : null}
diff --git a/components/agents/AgentWorkflowPanelDarkV2.tsx b/components/agents/AgentWorkflowPanelDarkV2.tsx index 310464cfb..199bfc910 100644 --- a/components/agents/AgentWorkflowPanelDarkV2.tsx +++ b/components/agents/AgentWorkflowPanelDarkV2.tsx @@ -1,6 +1,17 @@ "use client"; -import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { + memo, + startTransition, + useCallback, + useDeferredValue, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; import { motion } from "framer-motion"; import { ArrowRight, Check, Copy, Sparkles, ChevronRight } from "lucide-react"; import { toast } from "sonner"; @@ -26,7 +37,6 @@ import { getRenderableAgentWorkflowOptionTree, getRenderableAgentWorkflowStepTree, isAgentWorkflowStepSelectionComplete, - sanitizeAgentWorkflowSelectionsForBlockedOptions, serializeAgentWorkflowSelectionPath, } from "@/lib/agents/workflow-ui-config"; import { AgentCanvasTopNavDark } from "@/components/agents/AgentCanvasTopNavDark"; @@ -41,7 +51,6 @@ import { type AgentPsdTemplateCategoryTagIndex, } from "@/lib/agents/psd-template-category-tag-config"; import type { - AgentWorkflowComposerSelection, AgentWorkflowMaterialSelections, AgentWorkflowShowcaseConfig, AgentWorkflowStepKey, @@ -90,31 +99,94 @@ interface AgentWorkflowBrandStorePanelProps { description?: string; } -function buildSelectionSummary(params: { - config: AgentWorkflowUiConfig; - selections: Partial>; -}) { - const result: AgentWorkflowComposerSelection[] = []; - for (const { step } of flattenVisibleAgentWorkflowSteps(params.config.steps, params.selections)) { - const selectedOptions = getAgentWorkflowSelectedOptionPath({ - step, - selectionValue: params.selections[step.key], - }); - selectedOptions.forEach((option) => { - result.push({ - stepKey: step.key, - stepTitle: step.title, - optionKey: option.key, - optionName: option.buttonName, - promptFragment: option.promptFragment || "", - }); - }); +type WorkflowSelectionState = Partial>; +type WorkflowStep = AgentWorkflowUiConfig["steps"][number]; +type WorkflowOption = WorkflowStep["options"][number]; + +const VIRTUAL_OPTION_GRID_GAP = 16; +const VIRTUAL_OPTION_GRID_MAX_HEIGHT = 640; +const VIRTUAL_OPTION_GRID_MIN_CARD_HEIGHT = 122; +const VIRTUAL_OPTION_GRID_THRESHOLD = 18; + +interface WorkflowPanelDerivedState { + composerContext: ReturnType; + progress: ReturnType; +} + +function scheduleWorkflowIdleTask(callback: () => void, timeout = 450) { + if (typeof window === "undefined") { + callback(); + return () => {}; } - return result; + + const idleWindow = window as Window & { + requestIdleCallback?: ( + callback: IdleRequestCallback, + options?: IdleRequestOptions + ) => number; + cancelIdleCallback?: (handle: number) => void; + }; + + if (typeof idleWindow.requestIdleCallback === "function") { + const idleId = idleWindow.requestIdleCallback(callback, { timeout }); + return () => idleWindow.cancelIdleCallback?.(idleId); + } + + const timer = window.setTimeout(callback, Math.min(timeout, 120)); + return () => window.clearTimeout(timer); +} + +function buildWorkflowPanelDerivedState(params: { + config: AgentWorkflowUiConfig; + inputs: AgentWorkflowInputs; + materials: AgentWorkflowMaterialSelections; + selections: WorkflowSelectionState; +}): WorkflowPanelDerivedState { + return { + composerContext: buildAgentWorkflowComposerContext({ + config: params.config, + inputs: params.inputs, + materials: params.materials, + selections: params.selections, + }), + progress: getAgentWorkflowProgress({ + config: params.config, + selections: params.selections, + }), + }; +} + +function areWorkflowSelectionStatesEqual( + left?: WorkflowSelectionState | null, + right?: WorkflowSelectionState | null +) { + const leftEntries = Object.entries(left || {}).filter(([, value]) => + Boolean(String(value || "").trim()) + ); + const rightEntries = Object.entries(right || {}).filter(([, value]) => + Boolean(String(value || "").trim()) + ); + + if (leftEntries.length !== rightEntries.length) { + return false; + } + + return leftEntries.every( + ([key, value]) => + String(right?.[key as AgentWorkflowStepKey] || "") === String(value || "") + ); +} + +function getWorkflowOptionGridColumnCount(width: number) { + if (width >= 1280) return 5; + if (width >= 1024) return 4; + if (width >= 640) return 3; + if (width >= 420) return 2; + return 1; } function getWorkflowStepSelectionSummary(params: { - step: AgentWorkflowUiConfig["steps"][number]; + step: WorkflowStep; selectionValue?: string; }) { const selectedOptions = getAgentWorkflowSelectedOptionPath({ @@ -130,14 +202,14 @@ function getWorkflowStepSelectionSummary(params: { } function getWorkflowStepOptionLevels(params: { - step: AgentWorkflowUiConfig["steps"][number]; + step: WorkflowStep; selectionValue?: string; }) { const levels: Array<{ depth: number; - options: AgentWorkflowUiConfig["steps"][number]["options"]; + options: WorkflowStep["options"]; selectedKey: string; - selectedOption: AgentWorkflowUiConfig["steps"][number]["options"][number] | null; + selectedOption: WorkflowOption | null; }> = []; const selectedPath = getAgentWorkflowSelectedOptionPath({ step: params.step, @@ -145,7 +217,7 @@ function getWorkflowStepOptionLevels(params: { }); let depth = 1; - let currentOptions = getRenderableAgentWorkflowOptionTree(params.step.options || []); + let currentOptions = params.step.options || []; while (currentOptions.length > 0) { const selectedKey = selectedPath[depth - 1]?.key || ""; @@ -159,9 +231,7 @@ function getWorkflowStepOptionLevels(params: { selectedOption, }); - const nextOptions = getRenderableAgentWorkflowOptionTree( - selectedOption?.children || [] - ); + const nextOptions = selectedOption?.children || []; if (!selectedOption || nextOptions.length === 0) { break; } @@ -181,13 +251,13 @@ function getWorkflowOptionDepthShortLabel(depth: number) { return depth === 1 ? "主" : `${depth}级`; } -function StepOptionCard({ +const StepOptionCard = memo(function StepOptionCard({ option, step, selected, disabled = false, disabledReason = null, - onClick, + onSelect, index, }: { option: { key: string; buttonName: string; description?: string | null; previewImageUrl?: string | null }; @@ -195,19 +265,18 @@ function StepOptionCard({ selected: boolean; disabled?: boolean; disabledReason?: "blocked" | "developing" | null; - onClick: () => void; + onSelect: (optionKey: string) => void; index: number; }) { + const handleClick = useCallback(() => { + onSelect(option.key); + }, [onSelect, option.key]); + return ( - +
- +
)} {disabled && !selected ? ( @@ -281,10 +349,137 @@ function StepOptionCard({ {selected && (
)} - + ); +}); + +interface VirtualizedOptionGridProps { + options: WorkflowStep["options"]; + step: WorkflowStep; + selectedKey: string; + blockedOptionKeys: Set; + onSelect: (optionKey: string) => void; } +const VirtualizedOptionGrid = memo(function VirtualizedOptionGrid({ + options, + step, + selectedKey, + blockedOptionKeys, + onSelect, +}: VirtualizedOptionGridProps) { + const scrollParentRef = useRef(null); + const [containerWidth, setContainerWidth] = useState(0); + const fallbackColumnCount = 5; + const columnCount = + containerWidth > 0 ? getWorkflowOptionGridColumnCount(containerWidth) : fallbackColumnCount; + const rowCount = Math.ceil(options.length / columnCount); + const cardWidth = + containerWidth > 0 + ? (containerWidth - VIRTUAL_OPTION_GRID_GAP * (columnCount - 1)) / columnCount + : 220; + const cardHeight = Math.max( + VIRTUAL_OPTION_GRID_MIN_CARD_HEIGHT, + Math.round(cardWidth * 0.625) + ); + const rowHeight = cardHeight + VIRTUAL_OPTION_GRID_GAP; + const shouldVirtualize = options.length > VIRTUAL_OPTION_GRID_THRESHOLD; + + useEffect(() => { + const element = scrollParentRef.current; + if (!element || typeof ResizeObserver === "undefined") { + return; + } + + const updateWidth = () => { + setContainerWidth(Math.max(0, Math.floor(element.clientWidth))); + }; + updateWidth(); + + const resizeObserver = new ResizeObserver(updateWidth); + resizeObserver.observe(element); + return () => resizeObserver.disconnect(); + }, []); + + const rowVirtualizer = useVirtualizer({ + count: rowCount, + getScrollElement: () => scrollParentRef.current, + estimateSize: () => rowHeight, + overscan: 3, + }); + + const renderCard = (option: WorkflowOption, optionIndex: number) => { + const isDeveloping = option.enabled === false; + const isBlocked = + !isDeveloping && + selectedKey !== option.key && + blockedOptionKeys.has(option.key); + const isDisabled = isDeveloping || isBlocked; + + return ( + + ); + }; + + if (!shouldVirtualize) { + return ( +
+ {options.map((option, optionIndex) => renderCard(option, optionIndex))} +
+ ); + } + + return ( +
+
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const startIndex = virtualRow.index * columnCount; + const rowOptions = options.slice(startIndex, startIndex + columnCount); + + return ( +
+ {rowOptions.map((option, rowOptionIndex) => + renderCard(option, startIndex + rowOptionIndex) + )} +
+ ); + })} +
+
+ ); +}); + export function AgentWorkflowBrandStorePanel({ config, inputs, @@ -571,7 +766,7 @@ export function AgentWorkflowBrandStorePanel({ ); } -export function AgentWorkflowPanelDarkV2({ +function AgentWorkflowPanelDarkV2Component({ config, inputs, onInputsChange, @@ -614,53 +809,131 @@ export function AgentWorkflowPanelDarkV2({ () => getAgentWorkflowPageFeatureSettings(config), [config] ); + const [optimisticSelections, setOptimisticSelections] = + useState(selections); + const deferredSelections = useDeferredValue(optimisticSelections); + const latestExternalSelectionsRef = useRef(selections); + const selectionCommitTimerRef = useRef(null); + const pendingSelectionCommitRef = useRef(null); + + useEffect(() => { + if (latestExternalSelectionsRef.current === selections) { + return; + } + + latestExternalSelectionsRef.current = selections; + setOptimisticSelections((current) => + areWorkflowSelectionStatesEqual(current, selections) ? current : selections + ); + }, [selections]); + + const flushSelectionCommit = useCallback(() => { + const pendingSelections = pendingSelectionCommitRef.current; + if (!pendingSelections) { + return; + } + + pendingSelectionCommitRef.current = null; + if (selectionCommitTimerRef.current !== null && typeof window !== "undefined") { + window.clearTimeout(selectionCommitTimerRef.current); + } + selectionCommitTimerRef.current = null; + startTransition(() => { + onSelectionsChange(pendingSelections); + }); + }, [onSelectionsChange]); + + const emitSelectionsChange = useCallback( + (nextSelections: WorkflowSelectionState) => { + setOptimisticSelections(nextSelections); + pendingSelectionCommitRef.current = nextSelections; + + if (typeof window === "undefined") { + flushSelectionCommit(); + return; + } + + if (selectionCommitTimerRef.current !== null) { + window.clearTimeout(selectionCommitTimerRef.current); + } + + selectionCommitTimerRef.current = window.setTimeout(() => { + flushSelectionCommit(); + }, 90); + }, + [flushSelectionCommit] + ); + + useEffect(() => { + return () => { + flushSelectionCommit(); + }; + }, [flushSelectionCommit]); + const flattenedResolvedSteps = useMemo( () => - flattenVisibleAgentWorkflowSteps(displayConfig.steps, selections).filter( + flattenVisibleAgentWorkflowSteps(displayConfig.steps, optimisticSelections).filter( ({ step }) => Array.isArray(step.options) && step.options.length > 0 ), - [displayConfig.steps, selections] + [displayConfig.steps, optimisticSelections] ); - const summary = useMemo( - () => buildSelectionSummary({ config: resolvedConfig, selections }), - [resolvedConfig, selections] - ); - - const composerContext = useMemo( - () => - buildAgentWorkflowComposerContext({ + const [workflowDerivedState, setWorkflowDerivedState] = + useState(() => + buildWorkflowPanelDerivedState({ config: resolvedConfig, inputs, materials, - selections, - }), - [inputs, materials, resolvedConfig, selections] - ); + selections: deferredSelections, + }) + ); + const workflowDerivedVersionRef = useRef(0); - const progress = useMemo( - () => getAgentWorkflowProgress({ config: resolvedConfig, selections }), - [resolvedConfig, selections] + useEffect( + () => { + const version = workflowDerivedVersionRef.current + 1; + workflowDerivedVersionRef.current = version; + + return scheduleWorkflowIdleTask(() => { + const nextDerivedState = buildWorkflowPanelDerivedState({ + config: resolvedConfig, + inputs, + materials, + selections: deferredSelections, + }); + + if (workflowDerivedVersionRef.current !== version) { + return; + } + + startTransition(() => { + setWorkflowDerivedState(nextDerivedState); + }); + }); + }, + [deferredSelections, inputs, materials, resolvedConfig] ); + const composerContext = workflowDerivedState.composerContext; + const progress = workflowDerivedState.progress; const stepSelectionSummaryMap = useMemo(() => { const entries = flattenAgentWorkflowSteps(resolvedConfig.steps).map(({ step }) => [ step.key, getWorkflowStepSelectionSummary({ step, - selectionValue: selections[step.key], + selectionValue: deferredSelections[step.key], }), ]); return Object.fromEntries(entries) as Record; - }, [resolvedConfig.steps, selections]); + }, [deferredSelections, resolvedConfig.steps]); const blockedOptionKeys = useMemo( () => new Set( getAgentWorkflowBlockedOptionKeysForSelections({ config: resolvedConfig, - selections, + selections: deferredSelections, }) ), - [resolvedConfig, selections] + [deferredSelections, resolvedConfig] ); const [autoAdvanceNotice, setAutoAdvanceNotice] = useState<{ completedStepTitle: string; @@ -681,20 +954,20 @@ export function AgentWorkflowPanelDarkV2({ activeEmbeddedStepEntry ? getWorkflowStepOptionLevels({ step: activeEmbeddedStepEntry.step, - selectionValue: selections[activeEmbeddedStepEntry.step.key], + selectionValue: optimisticSelections[activeEmbeddedStepEntry.step.key], }) : [], - [activeEmbeddedStepEntry, selections] + [activeEmbeddedStepEntry, optimisticSelections] ); const activeEmbeddedSelectedPath = useMemo( () => activeEmbeddedStepEntry ? getAgentWorkflowSelectedOptionPath({ step: activeEmbeddedStepEntry.step, - selectionValue: selections[activeEmbeddedStepEntry.step.key], + selectionValue: optimisticSelections[activeEmbeddedStepEntry.step.key], }) : [], - [activeEmbeddedStepEntry, selections] + [activeEmbeddedStepEntry, optimisticSelections] ); const activeEmbeddedOptionLevel = activeEmbeddedOptionLevels.find((level) => level.depth === activeEmbeddedOptionDepth) || @@ -713,9 +986,7 @@ export function AgentWorkflowPanelDarkV2({ return false; } - const renderableChildren = getRenderableAgentWorkflowOptionTree( - activeEmbeddedDeepestSelectedOption.children || [] - ); + const renderableChildren = activeEmbeddedDeepestSelectedOption.children || []; if (renderableChildren.length === 0) { return false; } @@ -783,7 +1054,7 @@ export function AgentWorkflowPanelDarkV2({ const currentStepComplete = isAgentWorkflowStepSelectionComplete({ step: activeEmbeddedStepEntry.step, - selectionValue: selections[activeEmbeddedStepEntry.step.key], + selectionValue: optimisticSelections[activeEmbeddedStepEntry.step.key], }); if (!currentStepComplete) { return; @@ -800,7 +1071,7 @@ export function AgentWorkflowPanelDarkV2({ ({ step }) => !isAgentWorkflowStepSelectionComplete({ step, - selectionValue: selections[step.key], + selectionValue: optimisticSelections[step.key], }) ); @@ -824,7 +1095,7 @@ export function AgentWorkflowPanelDarkV2({ embedded, flattenedResolvedSteps, pendingAutoAdvanceStepKey, - selections, + optimisticSelections, stepSelectionSummaryMap, ]); @@ -848,15 +1119,42 @@ export function AgentWorkflowPanelDarkV2({ ): ReactNode => { const selectedPathKeys = getAgentWorkflowSelectedOptionPath({ step, - selectionValue: selections[step.key], + selectionValue: optimisticSelections[step.key], }).map((option) => option.key); const currentSelectedKey = selectedPathKeys[depth - 1] || ""; const currentSelectedOption = options.find((option) => option.key === currentSelectedKey) || null; - const visibleChildren = getRenderableAgentWorkflowOptionTree( - currentSelectedOption?.children || [] - ); + const visibleChildren = currentSelectedOption?.children || []; const levelLabel = depth === 1 ? "主卡片" : depth === 2 ? "二级卡片" : "三级卡片"; + const handleSelectOption = (optionKey: string) => { + const option = options.find((item) => item.key === optionKey); + if (!option) { + return; + } + + const isDeveloping = option.enabled === false; + const isBlocked = + !isDeveloping && + currentSelectedKey !== option.key && + blockedOptionKeys.has(option.key); + if (isDeveloping || isBlocked) { + return; + } + + const prefix = parentPath.slice(0, depth - 1); + const nextKeys = + currentSelectedKey === option.key ? prefix : [...prefix, option.key]; + const nextSelections = { + ...optimisticSelections, + [step.key]: + nextKeys.length > 0 + ? serializeAgentWorkflowSelectionPath(nextKeys) + : undefined, + }; + + setPendingAutoAdvanceStepKey(step.key); + emitSelectionsChange(nextSelections); + }; return (
@@ -871,50 +1169,13 @@ export function AgentWorkflowPanelDarkV2({ ) : null}
-
- {options.map((option, optionIndex) => { - const isDeveloping = option.enabled === false; - const isBlocked = - !isDeveloping && - currentSelectedKey !== option.key && - blockedOptionKeys.has(option.key); - const isDisabled = isDeveloping || isBlocked; - - return ( - { - if (isDisabled) { - return; - } - const prefix = parentPath.slice(0, depth - 1); - const nextKeys = - currentSelectedKey === option.key ? prefix : [...prefix, option.key]; - const nextSelections = { - ...selections, - [step.key]: - nextKeys.length > 0 - ? serializeAgentWorkflowSelectionPath(nextKeys) - : undefined, - }; - setPendingAutoAdvanceStepKey(step.key); - onSelectionsChange( - sanitizeAgentWorkflowSelectionsForBlockedOptions({ - config: resolvedConfig, - selections: nextSelections, - }) - ); - }} - index={optionIndex} - /> - ); - })} -
+ {currentSelectedOption && visibleChildren.length > 0 ? renderOptionLevels(step, visibleChildren, depth + 1, [ @@ -939,6 +1200,62 @@ export function AgentWorkflowPanelDarkV2({ } }; + const handleEmbeddedOptionSelect = useCallback( + (optionKey: string) => { + if (!activeEmbeddedOptionLevel || !activeEmbeddedStepEntry) { + return; + } + + const option = activeEmbeddedOptionLevel.options.find( + (item) => item.key === optionKey + ); + if (!option) { + return; + } + + const currentSelectedKey = activeEmbeddedOptionLevel.selectedKey; + const isDeveloping = option.enabled === false; + const isBlocked = + !isDeveloping && + currentSelectedKey !== option.key && + blockedOptionKeys.has(option.key); + if (isDeveloping || isBlocked) { + return; + } + + const selectedPathKeys = activeEmbeddedOptionLevels + .slice(0, activeEmbeddedOptionLevel.depth - 1) + .map((level) => level.selectedKey) + .filter(Boolean); + const nextKeys = + currentSelectedKey === option.key + ? selectedPathKeys + : [...selectedPathKeys, option.key]; + const nextSelections = { + ...optimisticSelections, + [activeEmbeddedStepEntry.step.key]: + nextKeys.length > 0 ? serializeAgentWorkflowSelectionPath(nextKeys) : undefined, + }; + + setPendingAutoAdvanceStepKey(activeEmbeddedStepEntry.step.key); + emitSelectionsChange(nextSelections); + + if (currentSelectedKey !== option.key && (option.children || []).length > 0) { + setActiveEmbeddedOptionDepth(activeEmbeddedOptionLevel.depth + 1); + } else { + setActiveEmbeddedOptionDepth(activeEmbeddedOptionLevel.depth); + } + }, + [ + activeEmbeddedOptionLevel, + activeEmbeddedOptionLevels, + activeEmbeddedStepEntry, + blockedOptionKeys, + emitSelectionsChange, + optimisticSelections, + ] + ); + const workflowContent = (
{showBrandStoreSection ? ( @@ -979,17 +1296,17 @@ export function AgentWorkflowPanelDarkV2({ >
-
- {flattenedResolvedSteps.map(({ step }, stepIndex) => { - const completed = isAgentWorkflowStepSelectionComplete({ - step, - selectionValue: selections[step.key], - }); - const active = step.key === activeEmbeddedStepEntry.step.key; - const stepSelectedPath = getAgentWorkflowSelectedOptionPath({ - step, - selectionValue: selections[step.key], - }); +
+ {flattenedResolvedSteps.map(({ step }, stepIndex) => { + const completed = isAgentWorkflowStepSelectionComplete({ + step, + selectionValue: optimisticSelections[step.key], + }); + const active = step.key === activeEmbeddedStepEntry.step.key; + const stepSelectedPath = getAgentWorkflowSelectedOptionPath({ + step, + selectionValue: optimisticSelections[step.key], + }); const primarySelectedOption = stepSelectedPath[0] || null; const selectedCountLabel = stepSelectedPath.length > 0 @@ -1186,72 +1503,13 @@ export function AgentWorkflowPanelDarkV2({
-
- {activeEmbeddedOptionLevel.options.map((option, optionIndex) => { - const currentSelectedKey = activeEmbeddedOptionLevel.selectedKey; - const isDeveloping = option.enabled === false; - const isBlocked = - !isDeveloping && - currentSelectedKey !== option.key && - blockedOptionKeys.has(option.key); - const isDisabled = isDeveloping || isBlocked; - const nextChildren = getRenderableAgentWorkflowOptionTree( - option.children || [] - ); - - return ( - { - if (isDisabled) { - return; - } - const selectedPathKeys = activeEmbeddedOptionLevels - .slice(0, activeEmbeddedOptionLevel.depth - 1) - .map((level) => level.selectedKey) - .filter(Boolean); - const nextKeys = - currentSelectedKey === option.key - ? selectedPathKeys - : [...selectedPathKeys, option.key]; - const nextSelections = { - ...selections, - [activeEmbeddedStepEntry.step.key]: - nextKeys.length > 0 - ? serializeAgentWorkflowSelectionPath(nextKeys) - : undefined, - }; - - setPendingAutoAdvanceStepKey(activeEmbeddedStepEntry.step.key); - onSelectionsChange( - sanitizeAgentWorkflowSelectionsForBlockedOptions({ - config: resolvedConfig, - selections: nextSelections, - }) - ); - - if (currentSelectedKey !== option.key && nextChildren.length > 0) { - setActiveEmbeddedOptionDepth( - activeEmbeddedOptionLevel.depth + 1 - ); - } else { - setActiveEmbeddedOptionDepth( - activeEmbeddedOptionLevel.depth - ); - } - }} - index={optionIndex} - /> - ); - })} -
+
) : null} @@ -1287,7 +1545,7 @@ export function AgentWorkflowPanelDarkV2({ {step.description && (

{step.description}

)} - {renderOptionLevels(step, getRenderableAgentWorkflowOptionTree(step.options || []))} + {renderOptionLevels(step, step.options || [])} )) )} @@ -1439,3 +1697,5 @@ export function AgentWorkflowPanelDarkV2({
); } + +export const AgentWorkflowPanelDarkV2 = memo(AgentWorkflowPanelDarkV2Component); diff --git a/lib/agents/workflow-ui-config.ts b/lib/agents/workflow-ui-config.ts index 540e0993c..e9bf8b88a 100644 --- a/lib/agents/workflow-ui-config.ts +++ b/lib/agents/workflow-ui-config.ts @@ -503,10 +503,61 @@ export function normalizeAgentWorkflowStepOrdering( })); } +export interface FlattenedAgentWorkflowOption { + option: AgentWorkflowOptionUiConfig; + depth: number; + parentKeys: string[]; +} + +const enabledStepTreeCache = new WeakMap< + AgentWorkflowStepUiConfig[], + AgentWorkflowStepUiConfig[] +>(); +const renderableStepTreeCache = new WeakMap< + AgentWorkflowStepUiConfig[], + AgentWorkflowStepUiConfig[] +>(); +const enabledOptionTreeCache = new WeakMap< + AgentWorkflowOptionUiConfig[], + AgentWorkflowOptionUiConfig[] +>(); +const renderableOptionTreeCache = new WeakMap< + AgentWorkflowOptionUiConfig[], + AgentWorkflowOptionUiConfig[] +>(); +const flattenedOptionTreeCache = new WeakMap< + AgentWorkflowOptionUiConfig[], + FlattenedAgentWorkflowOption[] +>(); +const optionLookupCache = new WeakMap< + AgentWorkflowOptionUiConfig[], + Map +>(); + +function getWorkflowOptionLookup(options: AgentWorkflowOptionUiConfig[]) { + const cached = optionLookupCache.get(options); + if (cached) { + return cached; + } + + const lookup = new Map( + options + .map((option) => [option.key, option] as const) + .filter(([key]) => Boolean(String(key || "").trim())) + ); + optionLookupCache.set(options, lookup); + return lookup; +} + export function getEnabledAgentWorkflowStepTree( steps: AgentWorkflowStepUiConfig[] ): AgentWorkflowStepUiConfig[] { - return [...steps] + const cached = enabledStepTreeCache.get(steps); + if (cached) { + return cached; + } + + const nextSteps = [...steps] .sort((left, right) => left.sortOrder - right.sortOrder) .filter((step) => step.enabled !== false) .map((step) => ({ @@ -515,23 +566,37 @@ export function getEnabledAgentWorkflowStepTree( children: getEnabledAgentWorkflowStepTree(step.children || []), })) .filter((step) => step.options.length > 0 || (step.children || []).length > 0); + enabledStepTreeCache.set(steps, nextSteps); + return nextSteps; } export function getRenderableAgentWorkflowOptionTree( options: AgentWorkflowOptionUiConfig[] ): AgentWorkflowOptionUiConfig[] { - return [...options] + const cached = renderableOptionTreeCache.get(options); + if (cached) { + return cached; + } + + const nextOptions = [...options] .sort((left, right) => left.sortOrder - right.sortOrder) .map((option) => ({ ...option, children: getRenderableAgentWorkflowOptionTree(option.children || []), })); + renderableOptionTreeCache.set(options, nextOptions); + return nextOptions; } export function getRenderableAgentWorkflowStepTree( steps: AgentWorkflowStepUiConfig[] ): AgentWorkflowStepUiConfig[] { - return [...steps] + const cached = renderableStepTreeCache.get(steps); + if (cached) { + return cached; + } + + const nextSteps = [...steps] .sort((left, right) => left.sortOrder - right.sortOrder) .filter((step) => step.enabled !== false) .map((step) => ({ @@ -544,12 +609,8 @@ export function getRenderableAgentWorkflowStepTree( getEnabledAgentWorkflowOptionTree(step.options || []).length > 0 || (step.children || []).length > 0 ); -} - -export interface FlattenedAgentWorkflowOption { - option: AgentWorkflowOptionUiConfig; - depth: number; - parentKeys: string[]; + renderableStepTreeCache.set(steps, nextSteps); + return nextSteps; } export function flattenAgentWorkflowOptionTree( @@ -557,7 +618,15 @@ export function flattenAgentWorkflowOptionTree( depth = 1, parentKeys: string[] = [] ): FlattenedAgentWorkflowOption[] { - return options.flatMap((option) => [ + const canUseRootCache = depth === 1 && parentKeys.length === 0; + if (canUseRootCache) { + const cached = flattenedOptionTreeCache.get(options); + if (cached) { + return cached; + } + } + + const flattened = options.flatMap((option) => [ { option, depth, @@ -569,6 +638,10 @@ export function flattenAgentWorkflowOptionTree( [...parentKeys, option.key] ), ]); + if (canUseRootCache) { + flattenedOptionTreeCache.set(options, flattened); + } + return flattened; } export function countAgentWorkflowOptionTree( @@ -600,13 +673,20 @@ export function normalizeAgentWorkflowOptionOrdering( export function getEnabledAgentWorkflowOptionTree( options: AgentWorkflowOptionUiConfig[] ): AgentWorkflowOptionUiConfig[] { - return [...options] + const cached = enabledOptionTreeCache.get(options); + if (cached) { + return cached; + } + + const nextOptions = [...options] .sort((left, right) => left.sortOrder - right.sortOrder) .filter((option) => option.enabled !== false) .map((option) => ({ ...option, children: getEnabledAgentWorkflowOptionTree(option.children || []), })); + enabledOptionTreeCache.set(options, nextOptions); + return nextOptions; } export function findAgentWorkflowOptionByKey( @@ -1533,9 +1613,10 @@ export function getAgentWorkflowSelectedOptionPath(params: { let currentOptions = params.step.options || []; for (const key of keys) { - const matchedOption = currentOptions.find( - (option) => option.key === key && option.enabled !== false - ); + const matchedOption = getWorkflowOptionLookup(currentOptions).get(key); + if (matchedOption?.enabled === false) { + break; + } if (!matchedOption) break; selectedOptions.push(matchedOption); currentOptions = matchedOption.children || []; diff --git a/package.json b/package.json index b2a9a761d..f682264e5 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.5.0", "@sparkjsdev/spark": "^0.1.10", + "@tanstack/react-virtual": "^3.13.24", "@types/form-data": "^2.5.2", "@vercel/analytics": "^1.3.1", "@vercel/blob": "^0.24.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 69c1b5128..6a76b17af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -128,6 +128,9 @@ importers: '@sparkjsdev/spark': specifier: ^0.1.10 version: 0.1.10 + '@tanstack/react-virtual': + specifier: ^3.13.24 + version: 3.13.24(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021) '@types/form-data': specifier: ^2.5.2 version: 2.5.2 @@ -3073,6 +3076,15 @@ packages: peerDependencies: tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' + '@tanstack/react-virtual@3.13.24': + resolution: {integrity: sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/virtual-core@3.14.0': + resolution: {integrity: sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==} + '@tediousjs/connection-string@0.6.0': resolution: {integrity: sha512-GxlsW354Vi6QqbUgdPyQVcQjI7cZBdGV5vOYVYuCVDTylx2wl3WHR2HlhcxxHTrMigbelpXsdcZso+66uxPfow==} @@ -9450,6 +9462,14 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 4.1.16 + '@tanstack/react-virtual@3.13.24(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)': + dependencies: + '@tanstack/virtual-core': 3.14.0 + react: 19.0.0-rc-45804af1-20241021 + react-dom: 19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021) + + '@tanstack/virtual-core@3.14.0': {} + '@tediousjs/connection-string@0.6.0': {} '@tokenlens/core@1.3.0': {}