Optimize stylization workflow selection performance
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-27 16:49:02 +08:00
parent 4eaab49776
commit 1870f87beb
7 changed files with 769 additions and 356 deletions

View File

@ -60,7 +60,9 @@ import dynamic from "next/dynamic";
import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useParams, useRouter, useSearchParams } from "next/navigation";
import { import {
type ChangeEvent, type ChangeEvent,
startTransition,
useCallback, useCallback,
useDeferredValue,
useEffect, useEffect,
useMemo, useMemo,
useRef, useRef,
@ -156,7 +158,6 @@ import {
} from "@/lib/canvas-toolbar-config"; } from "@/lib/canvas-toolbar-config";
import { import {
buildAgentWorkflowComposerContext, buildAgentWorkflowComposerContext,
getAgentWorkflowProgress,
} from "@/lib/agents/workflow-composer"; } from "@/lib/agents/workflow-composer";
import { import {
createEmptyAgentWorkflowMaterialSelections, createEmptyAgentWorkflowMaterialSelections,
@ -1357,6 +1358,29 @@ function getAgentProjectDraftStorageKey(projectId: string) {
return `${AGENT_PROJECT_DRAFT_STORAGE_KEY_PREFIX}${String(projectId || "").trim()}`; 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 { interface AgentProjectDraftSnapshot {
savedAt: number; savedAt: number;
title?: string | null; title?: string | null;
@ -1832,6 +1856,9 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
}), }),
[resolvedWorkflowUiConfig, workflowSelections] [resolvedWorkflowUiConfig, workflowSelections]
); );
const deferredSanitizedWorkflowSelections = useDeferredValue(
sanitizedWorkflowSelections
);
const resolvedBrandModelConfig = useMemo( const resolvedBrandModelConfig = useMemo(
() => () =>
normalizeAgentWorkflowBrandModelConfig( 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 buildPersistedAgentProjectState = useCallback(() => {
const persistedMaterials = appearanceRecognitionEnabled const persistedMaterials = appearanceRecognitionEnabled
? workflowMaterials ? workflowMaterials
@ -3157,58 +3227,14 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
return { return {
workflowState, workflowState,
workspaceState: agentWorkspaceState, workspaceState: agentWorkspaceState,
resultCollections: { ...persistedAgentProjectResultState,
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
),
])
),
}; };
}, [ }, [
agentModeState, agentModeState,
agentProjectDeletedResultCollections,
agentProjectResultCollections,
agentWorkspaceState, agentWorkspaceState,
currentAgentStage, currentAgentStage,
appearanceRecognitionEnabled, appearanceRecognitionEnabled,
persistedAgentProjectResultState,
selectedAgentPresetId, selectedAgentPresetId,
selectedBrandModel, selectedBrandModel,
workflowInputs, workflowInputs,
@ -3393,26 +3419,28 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
if (typeof window === "undefined" || workflowHydrationSource === "pending") if (typeof window === "undefined" || workflowHydrationSource === "pending")
return; return;
try { return scheduleCanvasIdleTask(() => {
const persistedMaterials = appearanceRecognitionEnabled try {
? workflowMaterials const persistedMaterials = appearanceRecognitionEnabled
: clearAppearanceSummaryFromMaterials(workflowMaterials); ? workflowMaterials
window.localStorage.setItem( : clearAppearanceSummaryFromMaterials(workflowMaterials);
workflowStorageKey, window.localStorage.setItem(
JSON.stringify({ workflowStorageKey,
inputs: workflowInputs, JSON.stringify({
materials: persistedMaterials, inputs: workflowInputs,
appearanceRecognitionEnabled, materials: persistedMaterials,
selections: sanitizedWorkflowSelections, appearanceRecognitionEnabled,
selectedBrandModel, selections: sanitizedWorkflowSelections,
selectedAgentPresetId, selectedBrandModel,
currentAgentStage, selectedAgentPresetId,
agentModeState, currentAgentStage,
}) agentModeState,
); })
} catch (error) { );
console.warn("[Canvas] Failed to persist workflow state:", error); } catch (error) {
} console.warn("[Canvas] Failed to persist workflow state:", error);
}
});
}, [ }, [
isAgentMode, isAgentMode,
workflowHydrationSource, workflowHydrationSource,
@ -3432,39 +3460,30 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
return; return;
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
try { return scheduleCanvasIdleTask(() => {
const draftSnapshot: AgentProjectDraftSnapshot = { try {
savedAt: Date.now(), const draftSnapshot: AgentProjectDraftSnapshot = {
title: agentProjectTitle, savedAt: Date.now(),
thumbnail: agentProjectThumbnail, title: agentProjectTitle,
projectState: buildPersistedAgentProjectState(), thumbnail: agentProjectThumbnail,
}; projectState: buildPersistedAgentProjectState(),
};
window.localStorage.setItem( window.localStorage.setItem(
getAgentProjectDraftStorageKey(agentProjectId), getAgentProjectDraftStorageKey(agentProjectId),
JSON.stringify(draftSnapshot) JSON.stringify(draftSnapshot)
); );
} catch (error) { } catch (error) {
console.warn("[Agent Project] Failed to persist local draft:", error); console.warn("[Agent Project] Failed to persist local draft:", error);
} }
});
}, [ }, [
agentModeState,
agentProjectDeletedResultCollections,
agentProjectId, agentProjectId,
agentProjectResultCollections,
agentProjectThumbnail, agentProjectThumbnail,
agentProjectTitle, agentProjectTitle,
agentWorkspaceState,
appearanceRecognitionEnabled,
buildPersistedAgentProjectState, buildPersistedAgentProjectState,
currentAgentStage,
hasLoadedCurrentAgentProject, hasLoadedCurrentAgentProject,
isAgentMode, isAgentMode,
selectedAgentPresetId,
selectedBrandModel,
workflowInputs,
workflowMaterials,
workflowSelections,
]); ]);
const persistAgentProject = useCallback( const persistAgentProject = useCallback(
@ -8268,20 +8287,38 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
setWorkflowSelections(sanitizedWorkflowSelections); setWorkflowSelections(sanitizedWorkflowSelections);
}, [sanitizedWorkflowSelections, workflowSelections]); }, [sanitizedWorkflowSelections, workflowSelections]);
const workflowContext = useMemo<AgentWorkflowComposerContext | null>(() => { const [workflowContext, setWorkflowContext] =
return buildAgentWorkflowComposerContext({ useState<AgentWorkflowComposerContext | null>(null);
config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig, useEffect(() => {
inputs: workflowInputs, let canceled = false;
materials: workflowMaterials, const cancelIdleTask = scheduleCanvasIdleTask(() => {
selectedBrandModel, const nextWorkflowContext = buildAgentWorkflowComposerContext({
appearanceRecognitionEnabled, config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig,
selections: sanitizedWorkflowSelections, inputs: workflowInputs,
}); materials: workflowMaterials,
selectedBrandModel,
appearanceRecognitionEnabled,
selections: deferredSanitizedWorkflowSelections,
});
if (canceled) {
return;
}
startTransition(() => {
setWorkflowContext(nextWorkflowContext);
});
}, 450);
return () => {
canceled = true;
cancelIdleTask();
};
}, [ }, [
appearanceRecognitionEnabled, appearanceRecognitionEnabled,
deferredSanitizedWorkflowSelections,
resolvedWorkflowUiConfig, resolvedWorkflowUiConfig,
selectedBrandModel, selectedBrandModel,
sanitizedWorkflowSelections,
workflowInputs, workflowInputs,
workflowMaterials, workflowMaterials,
]); ]);
@ -8378,7 +8415,9 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
}); });
workflowSelectionsRef.current = clearedSelections; workflowSelectionsRef.current = clearedSelections;
setWorkflowSelections(clearedSelections); startTransition(() => {
setWorkflowSelections(clearedSelections);
});
}, },
[resolvedWorkflowUiConfig] [resolvedWorkflowUiConfig]
); );
@ -8469,14 +8508,6 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
selectedAgentPresetData?.requiredUploadCount, selectedAgentPresetData?.requiredUploadCount,
selectedAgentPresetData?.type, selectedAgentPresetData?.type,
]); ]);
const workflowProgress = useMemo(
() =>
getAgentWorkflowProgress({
config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig,
selections: sanitizedWorkflowSelections,
}),
[resolvedWorkflowUiConfig, sanitizedWorkflowSelections]
);
const resolvedAgentProjectTitle = hasActiveAgentProject const resolvedAgentProjectTitle = hasActiveAgentProject
? String(agentProjectTitle || "").trim() || "未命名生图项目" ? String(agentProjectTitle || "").trim() || "未命名生图项目"
: "未选择项目"; : "未选择项目";
@ -8744,6 +8775,35 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
</div> </div>
) : null; ) : null;
const workflowStylePanel = useMemo(
() => (
<AgentWorkflowPanelDarkV2
config={resolvedWorkflowUiConfig}
embedded
inputs={workflowInputs}
materials={workflowMaterials}
onInputsChange={handleWorkflowInputsChange}
onMaterialsChange={handleWorkflowMaterialsChange}
onSelectionsChange={handleWorkflowSelectionsChange}
psdTemplateCategoryTagIndex={
siteSettingsData?.agentPsdTemplateCategoryTagIndex
}
selections={sanitizedWorkflowSelections}
showBrandStoreSection={false}
/>
),
[
handleWorkflowInputsChange,
handleWorkflowMaterialsChange,
handleWorkflowSelectionsChange,
resolvedWorkflowUiConfig,
sanitizedWorkflowSelections,
siteSettingsData?.agentPsdTemplateCategoryTagIndex,
workflowInputs,
workflowMaterials,
]
);
if (isAgentMode) { if (isAgentMode) {
if (!hasActiveAgentProject) { if (!hasActiveAgentProject) {
return ( return (
@ -8826,22 +8886,7 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
workflowFeatureSettings={workflowPageFeatureSettings} workflowFeatureSettings={workflowPageFeatureSettings}
// 嵌入的风格化选择面板 // 嵌入的风格化选择面板
workflowPromptOptimizationEnabled={appearanceRecognitionEnabled} workflowPromptOptimizationEnabled={appearanceRecognitionEnabled}
workflowStylePanel={ workflowStylePanel={workflowStylePanel}
<AgentWorkflowPanelDarkV2
config={resolvedWorkflowUiConfig}
embedded
inputs={workflowInputs}
materials={workflowMaterials}
onInputsChange={handleWorkflowInputsChange}
onMaterialsChange={handleWorkflowMaterialsChange}
onSelectionsChange={handleWorkflowSelectionsChange}
psdTemplateCategoryTagIndex={
siteSettingsData?.agentPsdTemplateCategoryTagIndex
}
selections={sanitizedWorkflowSelections}
showBrandStoreSection={false}
/>
}
workspaceState={agentWorkspaceState} workspaceState={agentWorkspaceState}
/> />
{loginDialogElement} {loginDialogElement}

View File

@ -3650,8 +3650,9 @@ export function AgentGenerationWorkspaceDarkV2({
const detailPrompt = const detailPrompt =
userSupplementPrompt || userSupplementPrompt ||
`${basePrompt},面向${detailGenerationBrief.targetAudience},在${detailGenerationBrief.usageScenario}场景解决${detailGenerationBrief.coreNeed}${demandHint}`; `${basePrompt},面向${detailGenerationBrief.targetAudience},在${detailGenerationBrief.usageScenario}场景解决${detailGenerationBrief.coreNeed}${demandHint}`;
const workflowHint = workflowContext?.promptText const latestWorkflowContext = getLatestWorkflowContext?.() ?? workflowContext;
? `\n\n【工作流风格要求】\n${workflowContext.promptText}` const workflowHint = latestWorkflowContext?.promptText
? `\n\n【工作流风格要求】\n${latestWorkflowContext.promptText}`
: ""; : "";
const detailPromptWithWorkflow = `${detailPrompt}${workflowHint}`.trim(); const detailPromptWithWorkflow = `${detailPrompt}${workflowHint}`.trim();
const detailCount = extractAgentDetailFrameCount(detailPrompt); const detailCount = extractAgentDetailFrameCount(detailPrompt);

View File

@ -76,6 +76,7 @@ export function AgentWorkflowMaterialPicker({
}: AgentWorkflowMaterialPickerProps) { }: AgentWorkflowMaterialPickerProps) {
const isDark = theme === "dark"; const isDark = theme === "dark";
const hasUploadInFlight = isUploadingNewImage || Boolean(uploadingImageId); const hasUploadInFlight = isUploadingNewImage || Boolean(uploadingImageId);
const canUploadMaterialImage = Boolean(onUploadImage);
const [previewDisplayMode, setPreviewDisplayMode] = useState<"selected" | "all">("all"); const [previewDisplayMode, setPreviewDisplayMode] = useState<"selected" | "all">("all");
const [hasManualSelectionInteraction, setHasManualSelectionInteraction] = useState(false); const [hasManualSelectionInteraction, setHasManualSelectionInteraction] = useState(false);
const selectedSet = useMemo( const selectedSet = useMemo(
@ -157,12 +158,14 @@ export function AgentWorkflowMaterialPicker({
const remainingSelectableCount = const remainingSelectableCount =
resolvedSelectionLimit === null ? null : Math.max(resolvedSelectionLimit - selectedCount, 0); resolvedSelectionLimit === null ? null : Math.max(resolvedSelectionLimit - selectedCount, 0);
const remainingUploadSlotCount = const remainingUploadSlotCount =
resolvedSelectionLimit === null !canUploadMaterialImage
? 0 ? 0
: Math.max( : resolvedSelectionLimit === null
resolvedSelectionLimit - selectedGalleryCount - uploadedImages.length, ? 1
0 : Math.max(
); resolvedSelectionLimit - selectedGalleryCount - uploadedImages.length,
0
);
const totalImageCount = galleryImages.length; const totalImageCount = galleryImages.length;
const hasSystemRecommendedSelection = const hasSystemRecommendedSelection =
result?.queryType === "sku" && result?.queryType === "sku" &&
@ -430,7 +433,7 @@ export function AgentWorkflowMaterialPicker({
</div> </div>
) : null} ) : null}
{!disabled && (resolvedSelectionLimit !== null || uploadedImages.length > 0) ? ( {!disabled && canUploadMaterialImage ? (
<div <div
className={cn( className={cn(
"space-y-3 rounded-[24px] border p-3", "space-y-3 rounded-[24px] border p-3",
@ -472,7 +475,9 @@ export function AgentWorkflowMaterialPicker({
isDark ? "bg-emerald-500/[0.12] text-emerald-200" : "bg-emerald-50 text-emerald-700" isDark ? "bg-emerald-500/[0.12] text-emerald-200" : "bg-emerald-50 text-emerald-700"
)} )}
> >
还可上传 {remainingUploadSlotCount} 张 {resolvedSelectionLimit === null
? "可继续上传"
: `还可上传 ${remainingUploadSlotCount} 张`}
</span> </span>
) : null} ) : null}
</div> </div>

View File

@ -1,6 +1,17 @@
"use client"; "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 { motion } from "framer-motion";
import { ArrowRight, Check, Copy, Sparkles, ChevronRight } from "lucide-react"; import { ArrowRight, Check, Copy, Sparkles, ChevronRight } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@ -26,7 +37,6 @@ import {
getRenderableAgentWorkflowOptionTree, getRenderableAgentWorkflowOptionTree,
getRenderableAgentWorkflowStepTree, getRenderableAgentWorkflowStepTree,
isAgentWorkflowStepSelectionComplete, isAgentWorkflowStepSelectionComplete,
sanitizeAgentWorkflowSelectionsForBlockedOptions,
serializeAgentWorkflowSelectionPath, serializeAgentWorkflowSelectionPath,
} from "@/lib/agents/workflow-ui-config"; } from "@/lib/agents/workflow-ui-config";
import { AgentCanvasTopNavDark } from "@/components/agents/AgentCanvasTopNavDark"; import { AgentCanvasTopNavDark } from "@/components/agents/AgentCanvasTopNavDark";
@ -41,7 +51,6 @@ import {
type AgentPsdTemplateCategoryTagIndex, type AgentPsdTemplateCategoryTagIndex,
} from "@/lib/agents/psd-template-category-tag-config"; } from "@/lib/agents/psd-template-category-tag-config";
import type { import type {
AgentWorkflowComposerSelection,
AgentWorkflowMaterialSelections, AgentWorkflowMaterialSelections,
AgentWorkflowShowcaseConfig, AgentWorkflowShowcaseConfig,
AgentWorkflowStepKey, AgentWorkflowStepKey,
@ -90,31 +99,94 @@ interface AgentWorkflowBrandStorePanelProps {
description?: string; description?: string;
} }
function buildSelectionSummary(params: { type WorkflowSelectionState = Partial<Record<AgentWorkflowStepKey, string>>;
config: AgentWorkflowUiConfig; type WorkflowStep = AgentWorkflowUiConfig["steps"][number];
selections: Partial<Record<AgentWorkflowStepKey, string>>; type WorkflowOption = WorkflowStep["options"][number];
}) {
const result: AgentWorkflowComposerSelection[] = []; const VIRTUAL_OPTION_GRID_GAP = 16;
for (const { step } of flattenVisibleAgentWorkflowSteps(params.config.steps, params.selections)) { const VIRTUAL_OPTION_GRID_MAX_HEIGHT = 640;
const selectedOptions = getAgentWorkflowSelectedOptionPath({ const VIRTUAL_OPTION_GRID_MIN_CARD_HEIGHT = 122;
step, const VIRTUAL_OPTION_GRID_THRESHOLD = 18;
selectionValue: params.selections[step.key],
}); interface WorkflowPanelDerivedState {
selectedOptions.forEach((option) => { composerContext: ReturnType<typeof buildAgentWorkflowComposerContext>;
result.push({ progress: ReturnType<typeof getAgentWorkflowProgress>;
stepKey: step.key, }
stepTitle: step.title,
optionKey: option.key, function scheduleWorkflowIdleTask(callback: () => void, timeout = 450) {
optionName: option.buttonName, if (typeof window === "undefined") {
promptFragment: option.promptFragment || "", 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: { function getWorkflowStepSelectionSummary(params: {
step: AgentWorkflowUiConfig["steps"][number]; step: WorkflowStep;
selectionValue?: string; selectionValue?: string;
}) { }) {
const selectedOptions = getAgentWorkflowSelectedOptionPath({ const selectedOptions = getAgentWorkflowSelectedOptionPath({
@ -130,14 +202,14 @@ function getWorkflowStepSelectionSummary(params: {
} }
function getWorkflowStepOptionLevels(params: { function getWorkflowStepOptionLevels(params: {
step: AgentWorkflowUiConfig["steps"][number]; step: WorkflowStep;
selectionValue?: string; selectionValue?: string;
}) { }) {
const levels: Array<{ const levels: Array<{
depth: number; depth: number;
options: AgentWorkflowUiConfig["steps"][number]["options"]; options: WorkflowStep["options"];
selectedKey: string; selectedKey: string;
selectedOption: AgentWorkflowUiConfig["steps"][number]["options"][number] | null; selectedOption: WorkflowOption | null;
}> = []; }> = [];
const selectedPath = getAgentWorkflowSelectedOptionPath({ const selectedPath = getAgentWorkflowSelectedOptionPath({
step: params.step, step: params.step,
@ -145,7 +217,7 @@ function getWorkflowStepOptionLevels(params: {
}); });
let depth = 1; let depth = 1;
let currentOptions = getRenderableAgentWorkflowOptionTree(params.step.options || []); let currentOptions = params.step.options || [];
while (currentOptions.length > 0) { while (currentOptions.length > 0) {
const selectedKey = selectedPath[depth - 1]?.key || ""; const selectedKey = selectedPath[depth - 1]?.key || "";
@ -159,9 +231,7 @@ function getWorkflowStepOptionLevels(params: {
selectedOption, selectedOption,
}); });
const nextOptions = getRenderableAgentWorkflowOptionTree( const nextOptions = selectedOption?.children || [];
selectedOption?.children || []
);
if (!selectedOption || nextOptions.length === 0) { if (!selectedOption || nextOptions.length === 0) {
break; break;
} }
@ -181,13 +251,13 @@ function getWorkflowOptionDepthShortLabel(depth: number) {
return depth === 1 ? "主" : `${depth}级`; return depth === 1 ? "主" : `${depth}级`;
} }
function StepOptionCard({ const StepOptionCard = memo(function StepOptionCard({
option, option,
step, step,
selected, selected,
disabled = false, disabled = false,
disabledReason = null, disabledReason = null,
onClick, onSelect,
index, index,
}: { }: {
option: { key: string; buttonName: string; description?: string | null; previewImageUrl?: string | null }; option: { key: string; buttonName: string; description?: string | null; previewImageUrl?: string | null };
@ -195,19 +265,18 @@ function StepOptionCard({
selected: boolean; selected: boolean;
disabled?: boolean; disabled?: boolean;
disabledReason?: "blocked" | "developing" | null; disabledReason?: "blocked" | "developing" | null;
onClick: () => void; onSelect: (optionKey: string) => void;
index: number; index: number;
}) { }) {
const handleClick = useCallback(() => {
onSelect(option.key);
}, [onSelect, option.key]);
return ( return (
<motion.button <button
type="button" type="button"
disabled={disabled} disabled={disabled}
onClick={onClick} onClick={handleClick}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: index * 0.05 }}
whileHover={disabled ? undefined : { y: -4, scale: 1.02 }}
whileTap={disabled ? undefined : { scale: 0.98 }}
className={cn( className={cn(
"group relative overflow-hidden rounded-2xl text-left", "group relative overflow-hidden rounded-2xl text-left",
"aspect-[16/10] w-full", "aspect-[16/10] w-full",
@ -227,6 +296,9 @@ function StepOptionCard({
<img <img
src={option.previewImageUrl} src={option.previewImageUrl}
alt={option.buttonName} alt={option.buttonName}
loading={selected || index < 4 ? "eager" : "lazy"}
decoding="async"
draggable={false}
className={cn( className={cn(
"h-full w-full object-cover transition-transform duration-700", "h-full w-full object-cover transition-transform duration-700",
!disabled && "group-hover:scale-110" !disabled && "group-hover:scale-110"
@ -243,13 +315,9 @@ function StepOptionCard({
{/* 选中标记 */} {/* 选中标记 */}
{selected && ( {selected && (
<motion.div <div className="absolute right-3 top-3 flex h-6 w-6 items-center justify-center rounded-full bg-white text-black">
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className="absolute right-3 top-3 flex h-6 w-6 items-center justify-center rounded-full bg-white text-black"
>
<Check className="h-4 w-4" /> <Check className="h-4 w-4" />
</motion.div> </div>
)} )}
{disabled && !selected ? ( {disabled && !selected ? (
@ -281,10 +349,137 @@ function StepOptionCard({
{selected && ( {selected && (
<div className="absolute inset-0 rounded-2xl ring-2 ring-inset ring-white/30" /> <div className="absolute inset-0 rounded-2xl ring-2 ring-inset ring-white/30" />
)} )}
</motion.button> </button>
); );
});
interface VirtualizedOptionGridProps {
options: WorkflowStep["options"];
step: WorkflowStep;
selectedKey: string;
blockedOptionKeys: Set<string>;
onSelect: (optionKey: string) => void;
} }
const VirtualizedOptionGrid = memo(function VirtualizedOptionGrid({
options,
step,
selectedKey,
blockedOptionKeys,
onSelect,
}: VirtualizedOptionGridProps) {
const scrollParentRef = useRef<HTMLDivElement | null>(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<HTMLDivElement, HTMLDivElement>({
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 (
<StepOptionCard
key={option.key}
disabled={isDisabled}
disabledReason={isDeveloping ? "developing" : isBlocked ? "blocked" : null}
index={optionIndex}
onSelect={onSelect}
option={option}
selected={selectedKey === option.key}
step={step}
/>
);
};
if (!shouldVirtualize) {
return (
<div
className="grid grid-cols-1 gap-4 min-[420px]:grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5"
ref={scrollParentRef}
>
{options.map((option, optionIndex) => renderCard(option, optionIndex))}
</div>
);
}
return (
<div
className="overflow-y-auto overscroll-contain pr-1"
ref={scrollParentRef}
style={{ maxHeight: VIRTUAL_OPTION_GRID_MAX_HEIGHT }}
>
<div
className="relative w-full"
style={{
height: rowVirtualizer.getTotalSize(),
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const startIndex = virtualRow.index * columnCount;
const rowOptions = options.slice(startIndex, startIndex + columnCount);
return (
<div
className="absolute left-0 top-0 grid w-full gap-4"
data-index={virtualRow.index}
key={virtualRow.key}
ref={rowVirtualizer.measureElement}
style={{
gridTemplateColumns: `repeat(${columnCount}, minmax(0, 1fr))`,
minHeight: cardHeight,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{rowOptions.map((option, rowOptionIndex) =>
renderCard(option, startIndex + rowOptionIndex)
)}
</div>
);
})}
</div>
</div>
);
});
export function AgentWorkflowBrandStorePanel({ export function AgentWorkflowBrandStorePanel({
config, config,
inputs, inputs,
@ -571,7 +766,7 @@ export function AgentWorkflowBrandStorePanel({
); );
} }
export function AgentWorkflowPanelDarkV2({ function AgentWorkflowPanelDarkV2Component({
config, config,
inputs, inputs,
onInputsChange, onInputsChange,
@ -614,53 +809,131 @@ export function AgentWorkflowPanelDarkV2({
() => getAgentWorkflowPageFeatureSettings(config), () => getAgentWorkflowPageFeatureSettings(config),
[config] [config]
); );
const [optimisticSelections, setOptimisticSelections] =
useState<WorkflowSelectionState>(selections);
const deferredSelections = useDeferredValue(optimisticSelections);
const latestExternalSelectionsRef = useRef(selections);
const selectionCommitTimerRef = useRef<number | null>(null);
const pendingSelectionCommitRef = useRef<WorkflowSelectionState | null>(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( const flattenedResolvedSteps = useMemo(
() => () =>
flattenVisibleAgentWorkflowSteps(displayConfig.steps, selections).filter( flattenVisibleAgentWorkflowSteps(displayConfig.steps, optimisticSelections).filter(
({ step }) => Array.isArray(step.options) && step.options.length > 0 ({ step }) => Array.isArray(step.options) && step.options.length > 0
), ),
[displayConfig.steps, selections] [displayConfig.steps, optimisticSelections]
); );
const summary = useMemo( const [workflowDerivedState, setWorkflowDerivedState] =
() => buildSelectionSummary({ config: resolvedConfig, selections }), useState<WorkflowPanelDerivedState>(() =>
[resolvedConfig, selections] buildWorkflowPanelDerivedState({
);
const composerContext = useMemo(
() =>
buildAgentWorkflowComposerContext({
config: resolvedConfig, config: resolvedConfig,
inputs, inputs,
materials, materials,
selections, selections: deferredSelections,
}), })
[inputs, materials, resolvedConfig, selections] );
); const workflowDerivedVersionRef = useRef(0);
const progress = useMemo( useEffect(
() => getAgentWorkflowProgress({ config: resolvedConfig, selections }), () => {
[resolvedConfig, selections] 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 stepSelectionSummaryMap = useMemo(() => {
const entries = flattenAgentWorkflowSteps(resolvedConfig.steps).map(({ step }) => [ const entries = flattenAgentWorkflowSteps(resolvedConfig.steps).map(({ step }) => [
step.key, step.key,
getWorkflowStepSelectionSummary({ getWorkflowStepSelectionSummary({
step, step,
selectionValue: selections[step.key], selectionValue: deferredSelections[step.key],
}), }),
]); ]);
return Object.fromEntries(entries) as Record<string, string>; return Object.fromEntries(entries) as Record<string, string>;
}, [resolvedConfig.steps, selections]); }, [deferredSelections, resolvedConfig.steps]);
const blockedOptionKeys = useMemo( const blockedOptionKeys = useMemo(
() => () =>
new Set( new Set(
getAgentWorkflowBlockedOptionKeysForSelections({ getAgentWorkflowBlockedOptionKeysForSelections({
config: resolvedConfig, config: resolvedConfig,
selections, selections: deferredSelections,
}) })
), ),
[resolvedConfig, selections] [deferredSelections, resolvedConfig]
); );
const [autoAdvanceNotice, setAutoAdvanceNotice] = useState<{ const [autoAdvanceNotice, setAutoAdvanceNotice] = useState<{
completedStepTitle: string; completedStepTitle: string;
@ -681,20 +954,20 @@ export function AgentWorkflowPanelDarkV2({
activeEmbeddedStepEntry activeEmbeddedStepEntry
? getWorkflowStepOptionLevels({ ? getWorkflowStepOptionLevels({
step: activeEmbeddedStepEntry.step, step: activeEmbeddedStepEntry.step,
selectionValue: selections[activeEmbeddedStepEntry.step.key], selectionValue: optimisticSelections[activeEmbeddedStepEntry.step.key],
}) })
: [], : [],
[activeEmbeddedStepEntry, selections] [activeEmbeddedStepEntry, optimisticSelections]
); );
const activeEmbeddedSelectedPath = useMemo( const activeEmbeddedSelectedPath = useMemo(
() => () =>
activeEmbeddedStepEntry activeEmbeddedStepEntry
? getAgentWorkflowSelectedOptionPath({ ? getAgentWorkflowSelectedOptionPath({
step: activeEmbeddedStepEntry.step, step: activeEmbeddedStepEntry.step,
selectionValue: selections[activeEmbeddedStepEntry.step.key], selectionValue: optimisticSelections[activeEmbeddedStepEntry.step.key],
}) })
: [], : [],
[activeEmbeddedStepEntry, selections] [activeEmbeddedStepEntry, optimisticSelections]
); );
const activeEmbeddedOptionLevel = const activeEmbeddedOptionLevel =
activeEmbeddedOptionLevels.find((level) => level.depth === activeEmbeddedOptionDepth) || activeEmbeddedOptionLevels.find((level) => level.depth === activeEmbeddedOptionDepth) ||
@ -713,9 +986,7 @@ export function AgentWorkflowPanelDarkV2({
return false; return false;
} }
const renderableChildren = getRenderableAgentWorkflowOptionTree( const renderableChildren = activeEmbeddedDeepestSelectedOption.children || [];
activeEmbeddedDeepestSelectedOption.children || []
);
if (renderableChildren.length === 0) { if (renderableChildren.length === 0) {
return false; return false;
} }
@ -783,7 +1054,7 @@ export function AgentWorkflowPanelDarkV2({
const currentStepComplete = isAgentWorkflowStepSelectionComplete({ const currentStepComplete = isAgentWorkflowStepSelectionComplete({
step: activeEmbeddedStepEntry.step, step: activeEmbeddedStepEntry.step,
selectionValue: selections[activeEmbeddedStepEntry.step.key], selectionValue: optimisticSelections[activeEmbeddedStepEntry.step.key],
}); });
if (!currentStepComplete) { if (!currentStepComplete) {
return; return;
@ -800,7 +1071,7 @@ export function AgentWorkflowPanelDarkV2({
({ step }) => ({ step }) =>
!isAgentWorkflowStepSelectionComplete({ !isAgentWorkflowStepSelectionComplete({
step, step,
selectionValue: selections[step.key], selectionValue: optimisticSelections[step.key],
}) })
); );
@ -824,7 +1095,7 @@ export function AgentWorkflowPanelDarkV2({
embedded, embedded,
flattenedResolvedSteps, flattenedResolvedSteps,
pendingAutoAdvanceStepKey, pendingAutoAdvanceStepKey,
selections, optimisticSelections,
stepSelectionSummaryMap, stepSelectionSummaryMap,
]); ]);
@ -848,15 +1119,42 @@ export function AgentWorkflowPanelDarkV2({
): ReactNode => { ): ReactNode => {
const selectedPathKeys = getAgentWorkflowSelectedOptionPath({ const selectedPathKeys = getAgentWorkflowSelectedOptionPath({
step, step,
selectionValue: selections[step.key], selectionValue: optimisticSelections[step.key],
}).map((option) => option.key); }).map((option) => option.key);
const currentSelectedKey = selectedPathKeys[depth - 1] || ""; const currentSelectedKey = selectedPathKeys[depth - 1] || "";
const currentSelectedOption = const currentSelectedOption =
options.find((option) => option.key === currentSelectedKey) || null; options.find((option) => option.key === currentSelectedKey) || null;
const visibleChildren = getRenderableAgentWorkflowOptionTree( const visibleChildren = currentSelectedOption?.children || [];
currentSelectedOption?.children || []
);
const levelLabel = depth === 1 ? "主卡片" : depth === 2 ? "二级卡片" : "三级卡片"; 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 ( return (
<div className="space-y-4"> <div className="space-y-4">
@ -871,50 +1169,13 @@ export function AgentWorkflowPanelDarkV2({
) : null} ) : null}
</div> </div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5"> <VirtualizedOptionGrid
{options.map((option, optionIndex) => { blockedOptionKeys={blockedOptionKeys}
const isDeveloping = option.enabled === false; onSelect={handleSelectOption}
const isBlocked = options={options}
!isDeveloping && selectedKey={currentSelectedKey}
currentSelectedKey !== option.key && step={step}
blockedOptionKeys.has(option.key); />
const isDisabled = isDeveloping || isBlocked;
return (
<StepOptionCard
key={`${option.key}-${depth}`}
option={option}
step={step}
selected={currentSelectedKey === option.key}
disabled={isDisabled}
disabledReason={isDeveloping ? "developing" : isBlocked ? "blocked" : null}
onClick={() => {
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}
/>
);
})}
</div>
{currentSelectedOption && visibleChildren.length > 0 {currentSelectedOption && visibleChildren.length > 0
? renderOptionLevels(step, visibleChildren, depth + 1, [ ? 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 = ( const workflowContent = (
<div className="space-y-8"> <div className="space-y-8">
{showBrandStoreSection ? ( {showBrandStoreSection ? (
@ -979,17 +1296,17 @@ export function AgentWorkflowPanelDarkV2({
> >
<div className="mb-5 space-y-4"> <div className="mb-5 space-y-4">
<div className="mt-3"> <div className="mt-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5">
{flattenedResolvedSteps.map(({ step }, stepIndex) => { {flattenedResolvedSteps.map(({ step }, stepIndex) => {
const completed = isAgentWorkflowStepSelectionComplete({ const completed = isAgentWorkflowStepSelectionComplete({
step, step,
selectionValue: selections[step.key], selectionValue: optimisticSelections[step.key],
}); });
const active = step.key === activeEmbeddedStepEntry.step.key; const active = step.key === activeEmbeddedStepEntry.step.key;
const stepSelectedPath = getAgentWorkflowSelectedOptionPath({ const stepSelectedPath = getAgentWorkflowSelectedOptionPath({
step, step,
selectionValue: selections[step.key], selectionValue: optimisticSelections[step.key],
}); });
const primarySelectedOption = stepSelectedPath[0] || null; const primarySelectedOption = stepSelectedPath[0] || null;
const selectedCountLabel = const selectedCountLabel =
stepSelectedPath.length > 0 stepSelectedPath.length > 0
@ -1186,72 +1503,13 @@ export function AgentWorkflowPanelDarkV2({
</span> </span>
</div> </div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5"> <VirtualizedOptionGrid
{activeEmbeddedOptionLevel.options.map((option, optionIndex) => { blockedOptionKeys={blockedOptionKeys}
const currentSelectedKey = activeEmbeddedOptionLevel.selectedKey; onSelect={handleEmbeddedOptionSelect}
const isDeveloping = option.enabled === false; options={activeEmbeddedOptionLevel.options}
const isBlocked = selectedKey={activeEmbeddedOptionLevel.selectedKey}
!isDeveloping && step={activeEmbeddedStepEntry.step}
currentSelectedKey !== option.key && />
blockedOptionKeys.has(option.key);
const isDisabled = isDeveloping || isBlocked;
const nextChildren = getRenderableAgentWorkflowOptionTree(
option.children || []
);
return (
<StepOptionCard
key={`${option.key}-${activeEmbeddedOptionLevel.depth}`}
option={option}
step={activeEmbeddedStepEntry.step}
selected={currentSelectedKey === option.key}
disabled={isDisabled}
disabledReason={
isDeveloping ? "developing" : isBlocked ? "blocked" : null
}
onClick={() => {
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}
/>
);
})}
</div>
</div> </div>
) : null} ) : null}
</motion.section> </motion.section>
@ -1287,7 +1545,7 @@ export function AgentWorkflowPanelDarkV2({
{step.description && ( {step.description && (
<p className="mb-4 text-sm text-white/40">{step.description}</p> <p className="mb-4 text-sm text-white/40">{step.description}</p>
)} )}
{renderOptionLevels(step, getRenderableAgentWorkflowOptionTree(step.options || []))} {renderOptionLevels(step, step.options || [])}
</motion.section> </motion.section>
)) ))
)} )}
@ -1439,3 +1697,5 @@ export function AgentWorkflowPanelDarkV2({
</div> </div>
); );
} }
export const AgentWorkflowPanelDarkV2 = memo(AgentWorkflowPanelDarkV2Component);

View File

@ -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<string, AgentWorkflowOptionUiConfig>
>();
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( export function getEnabledAgentWorkflowStepTree(
steps: AgentWorkflowStepUiConfig[] steps: AgentWorkflowStepUiConfig[]
): AgentWorkflowStepUiConfig[] { ): AgentWorkflowStepUiConfig[] {
return [...steps] const cached = enabledStepTreeCache.get(steps);
if (cached) {
return cached;
}
const nextSteps = [...steps]
.sort((left, right) => left.sortOrder - right.sortOrder) .sort((left, right) => left.sortOrder - right.sortOrder)
.filter((step) => step.enabled !== false) .filter((step) => step.enabled !== false)
.map((step) => ({ .map((step) => ({
@ -515,23 +566,37 @@ export function getEnabledAgentWorkflowStepTree(
children: getEnabledAgentWorkflowStepTree(step.children || []), children: getEnabledAgentWorkflowStepTree(step.children || []),
})) }))
.filter((step) => step.options.length > 0 || (step.children || []).length > 0); .filter((step) => step.options.length > 0 || (step.children || []).length > 0);
enabledStepTreeCache.set(steps, nextSteps);
return nextSteps;
} }
export function getRenderableAgentWorkflowOptionTree( export function getRenderableAgentWorkflowOptionTree(
options: AgentWorkflowOptionUiConfig[] options: AgentWorkflowOptionUiConfig[]
): AgentWorkflowOptionUiConfig[] { ): AgentWorkflowOptionUiConfig[] {
return [...options] const cached = renderableOptionTreeCache.get(options);
if (cached) {
return cached;
}
const nextOptions = [...options]
.sort((left, right) => left.sortOrder - right.sortOrder) .sort((left, right) => left.sortOrder - right.sortOrder)
.map((option) => ({ .map((option) => ({
...option, ...option,
children: getRenderableAgentWorkflowOptionTree(option.children || []), children: getRenderableAgentWorkflowOptionTree(option.children || []),
})); }));
renderableOptionTreeCache.set(options, nextOptions);
return nextOptions;
} }
export function getRenderableAgentWorkflowStepTree( export function getRenderableAgentWorkflowStepTree(
steps: AgentWorkflowStepUiConfig[] steps: AgentWorkflowStepUiConfig[]
): AgentWorkflowStepUiConfig[] { ): AgentWorkflowStepUiConfig[] {
return [...steps] const cached = renderableStepTreeCache.get(steps);
if (cached) {
return cached;
}
const nextSteps = [...steps]
.sort((left, right) => left.sortOrder - right.sortOrder) .sort((left, right) => left.sortOrder - right.sortOrder)
.filter((step) => step.enabled !== false) .filter((step) => step.enabled !== false)
.map((step) => ({ .map((step) => ({
@ -544,12 +609,8 @@ export function getRenderableAgentWorkflowStepTree(
getEnabledAgentWorkflowOptionTree(step.options || []).length > 0 || getEnabledAgentWorkflowOptionTree(step.options || []).length > 0 ||
(step.children || []).length > 0 (step.children || []).length > 0
); );
} renderableStepTreeCache.set(steps, nextSteps);
return nextSteps;
export interface FlattenedAgentWorkflowOption {
option: AgentWorkflowOptionUiConfig;
depth: number;
parentKeys: string[];
} }
export function flattenAgentWorkflowOptionTree( export function flattenAgentWorkflowOptionTree(
@ -557,7 +618,15 @@ export function flattenAgentWorkflowOptionTree(
depth = 1, depth = 1,
parentKeys: string[] = [] parentKeys: string[] = []
): FlattenedAgentWorkflowOption[] { ): 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, option,
depth, depth,
@ -569,6 +638,10 @@ export function flattenAgentWorkflowOptionTree(
[...parentKeys, option.key] [...parentKeys, option.key]
), ),
]); ]);
if (canUseRootCache) {
flattenedOptionTreeCache.set(options, flattened);
}
return flattened;
} }
export function countAgentWorkflowOptionTree( export function countAgentWorkflowOptionTree(
@ -600,13 +673,20 @@ export function normalizeAgentWorkflowOptionOrdering(
export function getEnabledAgentWorkflowOptionTree( export function getEnabledAgentWorkflowOptionTree(
options: AgentWorkflowOptionUiConfig[] options: AgentWorkflowOptionUiConfig[]
): AgentWorkflowOptionUiConfig[] { ): AgentWorkflowOptionUiConfig[] {
return [...options] const cached = enabledOptionTreeCache.get(options);
if (cached) {
return cached;
}
const nextOptions = [...options]
.sort((left, right) => left.sortOrder - right.sortOrder) .sort((left, right) => left.sortOrder - right.sortOrder)
.filter((option) => option.enabled !== false) .filter((option) => option.enabled !== false)
.map((option) => ({ .map((option) => ({
...option, ...option,
children: getEnabledAgentWorkflowOptionTree(option.children || []), children: getEnabledAgentWorkflowOptionTree(option.children || []),
})); }));
enabledOptionTreeCache.set(options, nextOptions);
return nextOptions;
} }
export function findAgentWorkflowOptionByKey( export function findAgentWorkflowOptionByKey(
@ -1533,9 +1613,10 @@ export function getAgentWorkflowSelectedOptionPath(params: {
let currentOptions = params.step.options || []; let currentOptions = params.step.options || [];
for (const key of keys) { for (const key of keys) {
const matchedOption = currentOptions.find( const matchedOption = getWorkflowOptionLookup(currentOptions).get(key);
(option) => option.key === key && option.enabled !== false if (matchedOption?.enabled === false) {
); break;
}
if (!matchedOption) break; if (!matchedOption) break;
selectedOptions.push(matchedOption); selectedOptions.push(matchedOption);
currentOptions = matchedOption.children || []; currentOptions = matchedOption.children || [];

View File

@ -60,6 +60,7 @@
"@react-three/drei": "^10.7.7", "@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0", "@react-three/fiber": "^9.5.0",
"@sparkjsdev/spark": "^0.1.10", "@sparkjsdev/spark": "^0.1.10",
"@tanstack/react-virtual": "^3.13.24",
"@types/form-data": "^2.5.2", "@types/form-data": "^2.5.2",
"@vercel/analytics": "^1.3.1", "@vercel/analytics": "^1.3.1",
"@vercel/blob": "^0.24.1", "@vercel/blob": "^0.24.1",

20
pnpm-lock.yaml generated
View File

@ -128,6 +128,9 @@ importers:
'@sparkjsdev/spark': '@sparkjsdev/spark':
specifier: ^0.1.10 specifier: ^0.1.10
version: 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': '@types/form-data':
specifier: ^2.5.2 specifier: ^2.5.2
version: 2.5.2 version: 2.5.2
@ -3073,6 +3076,15 @@ packages:
peerDependencies: peerDependencies:
tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' 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': '@tediousjs/connection-string@0.6.0':
resolution: {integrity: sha512-GxlsW354Vi6QqbUgdPyQVcQjI7cZBdGV5vOYVYuCVDTylx2wl3WHR2HlhcxxHTrMigbelpXsdcZso+66uxPfow==} resolution: {integrity: sha512-GxlsW354Vi6QqbUgdPyQVcQjI7cZBdGV5vOYVYuCVDTylx2wl3WHR2HlhcxxHTrMigbelpXsdcZso+66uxPfow==}
@ -9450,6 +9462,14 @@ snapshots:
postcss-selector-parser: 6.0.10 postcss-selector-parser: 6.0.10
tailwindcss: 4.1.16 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': {} '@tediousjs/connection-string@0.6.0': {}
'@tokenlens/core@1.3.0': {} '@tokenlens/core@1.3.0': {}