diff --git a/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx b/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx index 406fba752..c773295e2 100644 --- a/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx +++ b/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { useEffect, useMemo, useRef, useState, type ChangeEvent, type ReactNode } from "react"; +import { useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent, type ReactNode } from "react"; import useSWR from "swr"; import { toast } from "sonner"; import { motion, AnimatePresence } from "framer-motion"; @@ -11,7 +11,10 @@ import { ArrowUp, ChevronDown, ChevronRight, + ClipboardPaste, + Copy, Download, + GripVertical, Image as ImageIcon, Layers, Loader2, @@ -138,6 +141,33 @@ const CHINESE_LEVEL_NUMERALS = ["一", "二", "三", "四", "五", "六", "七", const WORKFLOW_LEVEL_LABELS = CHINESE_LEVEL_NUMERALS.map((item) => `${item}级模块`); const WORKFLOW_OPTION_LEVEL_LABELS = CHINESE_LEVEL_NUMERALS.map((item) => `${item}级卡片`); const PRESET_LIBRARY_LEVEL_LABELS = ["一级分类", "二级分类", "三级分类"]; +const WORKFLOW_STEP_DRAG_DATA_TYPE = "application/x-agent-workflow-step"; +const WORKFLOW_SETTINGS_DRAFT_STORAGE_KEY = "agent-workflow-settings-draft:v1"; +const WORKFLOW_SETTINGS_DRAFT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; + +type WorkflowStepDropPosition = "before" | "after" | "inside"; + +interface WorkflowStepDropTarget { + stepKey: string | null; + position: WorkflowStepDropPosition; +} + +interface AgentWorkflowSettingsDraftState { + presetLibraryConfig: AgentPresetLibraryConfig; + config: AgentWorkflowUiConfig; + materialApiConfig: AgentWorkflowMaterialApiConfig; + goodsInfoApiConfig: AgentWorkflowGoodsInfoApiConfig; + brandModelConfig: AgentWorkflowBrandModelConfig; + showcaseConfig: AgentWorkflowShowcaseConfig; + psdTextColorConfig: AgentPsdTextColorConfig; + psdTemplateSecondaryCategoryConfig: AgentPsdTemplateSecondaryCategoryConfig; +} + +interface AgentWorkflowSettingsDraft { + version: 1; + updatedAt: number; + state: AgentWorkflowSettingsDraftState; +} function getWorkflowLevelLabel(depth: number, kind: "模块" | "卡片") { const labels = @@ -349,6 +379,294 @@ function collectDescendantStepKeys(step: AgentWorkflowStepUiConfig): string[] { return [step.key, ...collectWorkflowStepKeys(step.children || [])]; } +function collectWorkflowOptionKeysFromSteps(steps: AgentWorkflowStepUiConfig[]): string[] { + return flattenAgentWorkflowSteps(steps).flatMap(({ step }) => + collectWorkflowOptionKeys(step.options || []) + ); +} + +function isWorkflowStepTargetInsideSource( + sourceStep: AgentWorkflowStepUiConfig, + targetStepKey: string | null +) { + if (!targetStepKey) { + return false; + } + return collectDescendantStepKeys(sourceStep).includes(targetStepKey); +} + +function getWorkflowStepSubtreeDepth(step: AgentWorkflowStepUiConfig): number { + const childDepths = (step.children || []).map(getWorkflowStepSubtreeDepth); + return 1 + (childDepths.length > 0 ? Math.max(...childDepths) : 0); +} + +function getWorkflowStepInsertionDepth( + steps: AgentWorkflowStepUiConfig[], + targetStepKey: string | null, + position: WorkflowStepDropPosition +): number | null { + if (!targetStepKey) { + return 1; + } + + const targetMeta = flattenAgentWorkflowSteps(steps).find( + ({ step }) => step.key === targetStepKey + ); + if (!targetMeta) { + return null; + } + + return position === "inside" ? targetMeta.depth + 1 : targetMeta.depth; +} + +function extractWorkflowStepFromTree( + steps: AgentWorkflowStepUiConfig[], + stepKey: string +): { + steps: AgentWorkflowStepUiConfig[]; + extractedStep: AgentWorkflowStepUiConfig | null; +} { + let extractedStep: AgentWorkflowStepUiConfig | null = null; + + const nextSteps = steps + .filter((step) => { + if (step.key === stepKey) { + extractedStep = step; + return false; + } + return true; + }) + .map((step) => { + if (extractedStep) { + return step; + } + + const childResult = extractWorkflowStepFromTree(step.children || [], stepKey); + if (childResult.extractedStep) { + extractedStep = childResult.extractedStep; + return { + ...step, + children: childResult.steps, + }; + } + + return step; + }); + + return { + steps: nextSteps, + extractedStep, + }; +} + +function insertWorkflowStepIntoTree( + steps: AgentWorkflowStepUiConfig[], + targetStepKey: string | null, + position: WorkflowStepDropPosition, + stepToInsert: AgentWorkflowStepUiConfig +): { + steps: AgentWorkflowStepUiConfig[]; + inserted: boolean; +} { + if (!targetStepKey) { + return { + steps: [...steps, stepToInsert], + inserted: true, + }; + } + + let inserted = false; + const nextSteps = steps.flatMap((step) => { + if (step.key === targetStepKey) { + inserted = true; + if (position === "before") { + return [stepToInsert, step]; + } + if (position === "after") { + return [step, stepToInsert]; + } + return [ + { + ...step, + children: [...(step.children || []), stepToInsert], + }, + ]; + } + + const childResult = insertWorkflowStepIntoTree( + step.children || [], + targetStepKey, + position, + stepToInsert + ); + if (childResult.inserted) { + inserted = true; + return [ + { + ...step, + children: childResult.steps, + }, + ]; + } + + return [step]; + }); + + return { + steps: nextSteps, + inserted, + }; +} + +function cloneWorkflowOptionSnapshot( + option: AgentWorkflowOptionUiConfig +): AgentWorkflowOptionUiConfig { + return { + ...option, + blockedOptionKeys: [...(option.blockedOptionKeys || [])], + children: (option.children || []).map(cloneWorkflowOptionSnapshot), + }; +} + +function cloneWorkflowStepSnapshot( + step: AgentWorkflowStepUiConfig +): AgentWorkflowStepUiConfig { + return { + ...step, + options: (step.options || []).map(cloneWorkflowOptionSnapshot), + children: (step.children || []).map(cloneWorkflowStepSnapshot), + }; +} + +function remapWorkflowOptionBlockedKeys( + options: AgentWorkflowOptionUiConfig[], + keyMap: Map +): AgentWorkflowOptionUiConfig[] { + return options.map((option) => ({ + ...option, + blockedOptionKeys: (option.blockedOptionKeys || []).map((blockedKey) => + keyMap.get(blockedKey) || blockedKey + ), + children: remapWorkflowOptionBlockedKeys(option.children || [], keyMap), + })); +} + +function remapWorkflowStepBlockedKeys( + step: AgentWorkflowStepUiConfig, + keyMap: Map +): AgentWorkflowStepUiConfig { + return { + ...step, + options: remapWorkflowOptionBlockedKeys(step.options || [], keyMap), + children: (step.children || []).map((childStep) => + remapWorkflowStepBlockedKeys(childStep, keyMap) + ), + }; +} + +function cloneWorkflowOptionsForPaste( + options: AgentWorkflowOptionUiConfig[], + stepKey: string, + usedOptionKeys: Set, + keyMap: Map +): AgentWorkflowOptionUiConfig[] { + return options.map((option, optionIndex) => { + const nextKey = ensureUniqueWorkflowKey( + `${stepKey}_${option.key || `option_${optionIndex + 1}`}_copy`, + Array.from(usedOptionKeys) + ); + usedOptionKeys.add(nextKey); + keyMap.set(option.key, nextKey); + return { + ...option, + key: nextKey, + sortOrder: option.sortOrder ?? optionIndex, + children: cloneWorkflowOptionsForPaste(option.children || [], stepKey, usedOptionKeys, keyMap), + }; + }); +} + +function cloneWorkflowStepForPaste( + step: AgentWorkflowStepUiConfig, + usedStepKeys: Set, + usedOptionKeys: Set +): AgentWorkflowStepUiConfig { + const optionKeyMap = new Map(); + + const cloneStep = ( + currentStep: AgentWorkflowStepUiConfig, + isRootCopy: boolean + ): AgentWorkflowStepUiConfig => { + const nextStepKey = ensureUniqueWorkflowKey( + `${currentStep.key}_copy`, + Array.from(usedStepKeys) + ); + usedStepKeys.add(nextStepKey); + + return { + ...currentStep, + key: nextStepKey, + title: isRootCopy ? `${currentStep.title || "未命名模块"} 副本` : currentStep.title, + sortOrder: 0, + options: cloneWorkflowOptionsForPaste( + currentStep.options || [], + nextStepKey, + usedOptionKeys, + optionKeyMap + ), + children: (currentStep.children || []).map((childStep) => cloneStep(childStep, false)), + }; + }; + + return remapWorkflowStepBlockedKeys(cloneStep(step, true), optionKeyMap); +} + +function readAgentWorkflowSettingsDraft(): AgentWorkflowSettingsDraft | null { + if (typeof window === "undefined") { + return null; + } + + try { + const rawDraft = window.localStorage.getItem(WORKFLOW_SETTINGS_DRAFT_STORAGE_KEY); + if (!rawDraft) { + return null; + } + + const parsed = JSON.parse(rawDraft) as Partial; + if ( + parsed?.version !== 1 || + typeof parsed.updatedAt !== "number" || + !parsed.state || + typeof parsed.state !== "object" + ) { + return null; + } + + if (Date.now() - parsed.updatedAt > WORKFLOW_SETTINGS_DRAFT_MAX_AGE_MS) { + removeAgentWorkflowSettingsDraft(); + return null; + } + + return parsed as AgentWorkflowSettingsDraft; + } catch { + return null; + } +} + +function writeAgentWorkflowSettingsDraft(draft: AgentWorkflowSettingsDraft) { + if (typeof window === "undefined") { + return; + } + window.localStorage.setItem(WORKFLOW_SETTINGS_DRAFT_STORAGE_KEY, JSON.stringify(draft)); +} + +function removeAgentWorkflowSettingsDraft() { + if (typeof window === "undefined") { + return; + } + window.localStorage.removeItem(WORKFLOW_SETTINGS_DRAFT_STORAGE_KEY); +} + function createPresetLibraryId(value: string, fallback: string) { const normalized = value .trim() @@ -852,49 +1170,88 @@ export function AgentWorkflowSettingsClient({ const [fifthCategoryDrafts, setFifthCategoryDrafts] = useState>({}); const [blockedOptionSearches, setBlockedOptionSearches] = useState>({}); const [isImportingBrandSheet, setIsImportingBrandSheet] = useState(false); + const [draggedStepKey, setDraggedStepKey] = useState(null); + const [stepDropTarget, setStepDropTarget] = useState(null); + const [copiedWorkflowStep, setCopiedWorkflowStep] = useState( + null + ); + const [autoDraftUpdatedAt, setAutoDraftUpdatedAt] = useState(null); + const [autoDraftStatus, setAutoDraftStatus] = useState<"idle" | "restored" | "saved">("idle"); const fileInputRefs = useRef>({}); const brandImportInputRef = useRef(null); + const hasAppliedInitialSettingsRef = useRef(false); + const hasHydratedSettingsRef = useRef(false); + const isApplyingSettingsRef = useRef(false); useEffect(() => { if (!data) return; - setPresetLibraryConfig( - normalizeAgentPresetLibraryConfig(data.agentPresetLibraryConfig) - ); - const normalizedUiConfig = normalizeAgentWorkflowUiConfig(data.agentWorkflowUiConfig); - const flattenedUiConfig = normalizeConfigOrdering({ - ...normalizedUiConfig, - steps: flattenAgentWorkflowSteps(normalizedUiConfig.steps).map(({ step }, index) => ({ - ...step, - sortOrder: index, - children: [], - })), - }); - setConfig(flattenedUiConfig); - setMaterialApiConfig( - normalizeAgentWorkflowMaterialApiConfig(data.agentWorkflowMaterialApiConfig) - ); - setGoodsInfoApiConfig( - normalizeAgentWorkflowGoodsInfoApiConfig(data.agentWorkflowGoodsInfoApiConfig) - ); - setShowcaseConfig( - normalizeAgentWorkflowShowcaseConfig(data.agentWorkflowShowcaseConfig) - ); - setPsdTextColorConfig( - normalizeAgentPsdTextColorConfig(data.agentPsdTextColorConfig) - ); - setPsdTemplateSecondaryCategoryConfig( - normalizeAgentPsdTemplateSecondaryCategoryConfig( + if (hasAppliedInitialSettingsRef.current) return; + + hasAppliedInitialSettingsRef.current = true; + hasHydratedSettingsRef.current = false; + isApplyingSettingsRef.current = true; + + const serverState: AgentWorkflowSettingsDraftState = { + presetLibraryConfig: normalizeAgentPresetLibraryConfig(data.agentPresetLibraryConfig), + config: normalizeConfigOrdering(normalizeAgentWorkflowUiConfig(data.agentWorkflowUiConfig)), + materialApiConfig: normalizeAgentWorkflowMaterialApiConfig(data.agentWorkflowMaterialApiConfig), + goodsInfoApiConfig: normalizeAgentWorkflowGoodsInfoApiConfig( + data.agentWorkflowGoodsInfoApiConfig + ), + showcaseConfig: normalizeAgentWorkflowShowcaseConfig(data.agentWorkflowShowcaseConfig), + psdTextColorConfig: normalizeAgentPsdTextColorConfig(data.agentPsdTextColorConfig), + psdTemplateSecondaryCategoryConfig: normalizeAgentPsdTemplateSecondaryCategoryConfig( data.agentPsdTemplateSecondaryCategoryConfig - ) - ); - setBrandModelConfig( - normalizeAgentWorkflowBrandModelConfig(data.agentWorkflowBrandModelConfig) - ); + ), + brandModelConfig: normalizeAgentWorkflowBrandModelConfig(data.agentWorkflowBrandModelConfig), + }; + + const draft = readAgentWorkflowSettingsDraft(); + const nextState: AgentWorkflowSettingsDraftState = draft + ? { + presetLibraryConfig: normalizeAgentPresetLibraryConfig(draft.state.presetLibraryConfig), + config: normalizeConfigOrdering(normalizeAgentWorkflowUiConfig(draft.state.config)), + materialApiConfig: normalizeAgentWorkflowMaterialApiConfig( + draft.state.materialApiConfig + ), + goodsInfoApiConfig: normalizeAgentWorkflowGoodsInfoApiConfig( + draft.state.goodsInfoApiConfig + ), + showcaseConfig: normalizeAgentWorkflowShowcaseConfig(draft.state.showcaseConfig), + psdTextColorConfig: normalizeAgentPsdTextColorConfig(draft.state.psdTextColorConfig), + psdTemplateSecondaryCategoryConfig: normalizeAgentPsdTemplateSecondaryCategoryConfig( + draft.state.psdTemplateSecondaryCategoryConfig + ), + brandModelConfig: normalizeAgentWorkflowBrandModelConfig(draft.state.brandModelConfig), + } + : serverState; + + setPresetLibraryConfig(nextState.presetLibraryConfig); + setConfig(nextState.config); + setMaterialApiConfig(nextState.materialApiConfig); + setGoodsInfoApiConfig(nextState.goodsInfoApiConfig); + setShowcaseConfig(nextState.showcaseConfig); + setPsdTextColorConfig(nextState.psdTextColorConfig); + setPsdTemplateSecondaryCategoryConfig(nextState.psdTemplateSecondaryCategoryConfig); + setBrandModelConfig(nextState.brandModelConfig); setExpandedWorkflowOptions({}); setExpandedPresetLibraryNodes({}); - if (flattenedUiConfig.steps[0]) { - setExpandedSteps(new Set([flattenedUiConfig.steps[0].key])); + if (nextState.config.steps[0]) { + setExpandedSteps(new Set([nextState.config.steps[0].key])); } + + if (draft) { + setAutoDraftUpdatedAt(draft.updatedAt); + setAutoDraftStatus("restored"); + toast.success("已恢复本地自动保存草稿,确认无误后点击保存配置写入服务器"); + } + + const hydrationTimer = window.setTimeout(() => { + isApplyingSettingsRef.current = false; + hasHydratedSettingsRef.current = true; + }, 0); + + return () => window.clearTimeout(hydrationTimer); }, [data]); const brandStoreEntries = useMemo( @@ -967,6 +1324,52 @@ export function AgentWorkflowSettingsClient({ } }, [psdTextColorBrandOptions, selectedPsdTextColorBrand]); + const autoDraftState = useMemo( + () => ({ + presetLibraryConfig, + config, + materialApiConfig, + goodsInfoApiConfig, + brandModelConfig, + showcaseConfig, + psdTextColorConfig, + psdTemplateSecondaryCategoryConfig, + }), + [ + presetLibraryConfig, + config, + materialApiConfig, + goodsInfoApiConfig, + brandModelConfig, + showcaseConfig, + psdTextColorConfig, + psdTemplateSecondaryCategoryConfig, + ] + ); + + useEffect(() => { + if (!hasHydratedSettingsRef.current || isApplyingSettingsRef.current) { + return; + } + + const draftTimer = window.setTimeout(() => { + const updatedAt = Date.now(); + try { + writeAgentWorkflowSettingsDraft({ + version: 1, + updatedAt, + state: autoDraftState, + }); + setAutoDraftUpdatedAt(updatedAt); + setAutoDraftStatus("saved"); + } catch { + toast.error("本地自动保存失败,请及时手动保存配置"); + } + }, 700); + + return () => window.clearTimeout(draftTimer); + }, [autoDraftState]); + const sortedSteps = useMemo( () => [...config.steps].sort((left, right) => left.sortOrder - right.sortOrder), [config.steps] @@ -984,6 +1387,10 @@ export function AgentWorkflowSettingsClient({ () => new Map(workflowOptionRelations.map((item) => [item.key, item] as const)), [workflowOptionRelations] ); + const canPasteCopiedWorkflowStepAsRoot = Boolean( + copiedWorkflowStep && + getWorkflowStepSubtreeDepth(copiedWorkflowStep) <= AGENT_WORKFLOW_MAX_TREE_DEPTH + ); const updateInputOptions = (nextEntries: AgentWorkflowBrandStoreEntry[]) => { setConfig((prev) => ({ ...prev, @@ -1797,6 +2204,40 @@ export function AgentWorkflowSettingsClient({ setActiveTab("workflow"); }; + const handleAddChildStep = (parentStepKey: string, parentDepth: number) => { + if (parentDepth >= AGENT_WORKFLOW_MAX_TREE_DEPTH) { + toast.error(`模块层级最多支持 ${AGENT_WORKFLOW_MAX_TREE_DEPTH} 级`); + return; + } + + let newStepKey = ""; + setConfig((prev) => { + const parentStep = findAgentWorkflowStepByKey(prev.steps, parentStepKey); + if (!parentStep) { + return prev; + } + + const existingStepKeys = collectWorkflowStepKeys(prev.steps); + const newStep = createWorkflowStepDraft( + parentDepth + 1, + (parentStep.children || []).length, + existingStepKeys + ); + newStepKey = newStep.key; + return normalizeConfigOrdering({ + ...prev, + steps: updateWorkflowStepTree(prev.steps, parentStepKey, (step) => ({ + ...step, + children: [...(step.children || []), newStep], + })), + }); + }); + + if (newStepKey) { + setExpandedSteps((prevExpanded) => new Set([...prevExpanded, parentStepKey, newStepKey])); + } + }; + const handleRemoveStep = (stepKey: string) => { const targetStep = findAgentWorkflowStepByKey(config.steps, stepKey); setConfig((prev) => @@ -1814,6 +2255,227 @@ export function AgentWorkflowSettingsClient({ }); }; + const canMoveWorkflowStep = ( + sourceStepKey: string | null, + targetStepKey: string | null, + position: WorkflowStepDropPosition + ) => { + if (!sourceStepKey) { + return false; + } + + const sourceStep = findAgentWorkflowStepByKey(config.steps, sourceStepKey); + if (!sourceStep || sourceStepKey === targetStepKey) { + return false; + } + + if (isWorkflowStepTargetInsideSource(sourceStep, targetStepKey)) { + return false; + } + + const insertionDepth = getWorkflowStepInsertionDepth(config.steps, targetStepKey, position); + if (!insertionDepth) { + return false; + } + + return insertionDepth + getWorkflowStepSubtreeDepth(sourceStep) - 1 <= AGENT_WORKFLOW_MAX_TREE_DEPTH; + }; + + const handleMoveWorkflowStep = ( + sourceStepKey: string, + targetStepKey: string | null, + position: WorkflowStepDropPosition + ) => { + let moved = false; + let failureMessage = "无法移动到这个位置"; + + setConfig((prev) => { + const sourceStep = findAgentWorkflowStepByKey(prev.steps, sourceStepKey); + if (!sourceStep) { + failureMessage = "找不到要移动的模块"; + return prev; + } + + if (sourceStepKey === targetStepKey || isWorkflowStepTargetInsideSource(sourceStep, targetStepKey)) { + failureMessage = "不能把模块移动到自己或自己的子模块下面"; + return prev; + } + + const insertionDepth = getWorkflowStepInsertionDepth(prev.steps, targetStepKey, position); + if (!insertionDepth) { + failureMessage = "找不到目标模块"; + return prev; + } + + if ( + insertionDepth + getWorkflowStepSubtreeDepth(sourceStep) - 1 > + AGENT_WORKFLOW_MAX_TREE_DEPTH + ) { + failureMessage = `模块层级最多支持 ${AGENT_WORKFLOW_MAX_TREE_DEPTH} 级`; + return prev; + } + + const removalResult = extractWorkflowStepFromTree(prev.steps, sourceStepKey); + if (!removalResult.extractedStep) { + return prev; + } + + const insertionResult = insertWorkflowStepIntoTree( + removalResult.steps, + targetStepKey, + position, + removalResult.extractedStep + ); + if (!insertionResult.inserted) { + failureMessage = "没有找到可放置的目标位置"; + return prev; + } + + moved = true; + return normalizeConfigOrdering({ + ...prev, + steps: insertionResult.steps, + }); + }); + + if (!moved) { + toast.error(failureMessage); + return; + } + + setExpandedSteps((prev) => { + const next = new Set(prev); + next.add(sourceStepKey); + if (targetStepKey && position === "inside") { + next.add(targetStepKey); + } + return next; + }); + }; + + const handleWorkflowStepDragStart = ( + event: DragEvent, + stepKey: string + ) => { + setDraggedStepKey(stepKey); + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData(WORKFLOW_STEP_DRAG_DATA_TYPE, stepKey); + }; + + const handleWorkflowStepDragOver = ( + event: DragEvent, + targetStepKey: string | null, + position: WorkflowStepDropPosition + ) => { + const sourceStepKey = draggedStepKey; + if (!sourceStepKey || !canMoveWorkflowStep(sourceStepKey, targetStepKey, position)) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = "move"; + setStepDropTarget({ stepKey: targetStepKey, position }); + }; + + const handleWorkflowStepDrop = ( + event: DragEvent, + targetStepKey: string | null, + position: WorkflowStepDropPosition + ) => { + event.preventDefault(); + event.stopPropagation(); + + const sourceStepKey = + draggedStepKey || event.dataTransfer.getData(WORKFLOW_STEP_DRAG_DATA_TYPE); + setStepDropTarget(null); + setDraggedStepKey(null); + if (!sourceStepKey) { + return; + } + + handleMoveWorkflowStep(sourceStepKey, targetStepKey, position); + }; + + const handleWorkflowStepDragEnd = () => { + setDraggedStepKey(null); + setStepDropTarget(null); + }; + + const handleCopyWorkflowStep = (stepKey: string) => { + const targetStep = findAgentWorkflowStepByKey(config.steps, stepKey); + if (!targetStep) { + toast.error("找不到要复制的模块"); + return; + } + + setCopiedWorkflowStep(cloneWorkflowStepSnapshot(targetStep)); + toast.success(`已复制模块「${targetStep.title || targetStep.key}」及其子模块`); + }; + + const handlePasteWorkflowStep = (targetStepKey: string | null) => { + if (!copiedWorkflowStep) { + toast.error("请先复制一个模块"); + return; + } + + let pastedStepKey = ""; + let failureMessage = "无法粘贴到这个位置"; + + setConfig((prev) => { + const insertionDepth = getWorkflowStepInsertionDepth(prev.steps, targetStepKey, "inside"); + if (!insertionDepth) { + failureMessage = "找不到目标模块"; + return prev; + } + + if ( + insertionDepth + getWorkflowStepSubtreeDepth(copiedWorkflowStep) - 1 > + AGENT_WORKFLOW_MAX_TREE_DEPTH + ) { + failureMessage = `粘贴后会超过 ${AGENT_WORKFLOW_MAX_TREE_DEPTH} 级模块限制`; + return prev; + } + + const pastedStep = cloneWorkflowStepForPaste( + copiedWorkflowStep, + new Set(collectWorkflowStepKeys(prev.steps)), + new Set(collectWorkflowOptionKeysFromSteps(prev.steps)) + ); + pastedStepKey = pastedStep.key; + const insertionResult = insertWorkflowStepIntoTree( + prev.steps, + targetStepKey, + "inside", + pastedStep + ); + + if (!insertionResult.inserted) { + return prev; + } + + return normalizeConfigOrdering({ + ...prev, + steps: insertionResult.steps, + }); + }); + + if (!pastedStepKey) { + toast.error(failureMessage); + return; + } + + setExpandedSteps((prev) => { + const next = new Set(prev); + next.add(pastedStepKey); + if (targetStepKey) { + next.add(targetStepKey); + } + return next; + }); + toast.success("已粘贴模块副本"); + }; + const handleAddOption = (stepKey: string, parentOptionKey?: string) => { updateStep(stepKey, (step) => { const existingOptionKeys = collectWorkflowOptionKeys(step.options || []); @@ -2056,6 +2718,9 @@ export function AgentWorkflowSettingsClient({ } toast.success("风格化工作流配置已保存"); + removeAgentWorkflowSettingsDraft(); + setAutoDraftUpdatedAt(null); + setAutoDraftStatus("idle"); await mutate(); } catch (error) { toast.error(error instanceof Error ? error.message : "保存失败"); @@ -2728,6 +3393,50 @@ export function AgentWorkflowSettingsClient({ ); }; + const renderWorkflowStepDropZone = ( + targetStepKey: string | null, + position: WorkflowStepDropPosition, + depth = 1 + ) => { + const active = + stepDropTarget?.stepKey === targetStepKey && stepDropTarget.position === position; + const visible = Boolean(draggedStepKey); + const label = targetStepKey + ? position === "before" + ? "放到此模块前面" + : position === "after" + ? "放到此模块后面" + : "放入此模块作为子模块" + : "放到这里成为一级模块"; + + return ( +
handleWorkflowStepDragOver(event, targetStepKey, position)} + onDrop={(event) => handleWorkflowStepDrop(event, targetStepKey, position)} + onDragLeave={(event) => { + event.stopPropagation(); + if (active) { + setStepDropTarget(null); + } + }} + className={cn( + "transition-all", + visible + ? "my-2 flex min-h-8 items-center rounded-lg border border-dashed px-3 text-xs" + : "h-1", + depth > 1 && "ml-4 sm:ml-6", + active + ? "border-emerald-500 bg-emerald-500/10 text-emerald-700" + : visible + ? "border-border/70 bg-muted/20 text-muted-foreground" + : "border-transparent" + )} + > + {visible ? label : null} +
+ ); + }; + const renderWorkflowStepCard = ( step: AgentWorkflowStepUiConfig, depth: number, @@ -2737,161 +3446,274 @@ export function AgentWorkflowSettingsClient({ const stepLabel = indexPath.join("."); const levelLabel = getWorkflowLevelLabel(depth, "模块"); const rootOptions = [...(step.options || [])].sort((left, right) => left.sortOrder - right.sortOrder); + const sortedChildSteps = [...(step.children || [])].sort( + (left, right) => left.sortOrder - right.sortOrder + ); const optionCardCount = countAgentWorkflowOptionTree(step.options || []); const isTopLevelStep = depth === 1; const canMoveTopLevelStepUp = isTopLevelStep && indexPath[0] > 1; const canMoveTopLevelStepDown = isTopLevelStep && indexPath[0] < siblingCount; + const canAddChildStep = depth < AGENT_WORKFLOW_MAX_TREE_DEPTH; + const canPasteAsChild = + Boolean(copiedWorkflowStep) && + depth + getWorkflowStepSubtreeDepth(copiedWorkflowStep as AgentWorkflowStepUiConfig) <= + AGENT_WORKFLOW_MAX_TREE_DEPTH; + const insideDropActive = + stepDropTarget?.stepKey === step.key && stepDropTarget.position === "inside"; return ( - 1 && "pl-4 sm:pl-6")} - > - toggleStepExpansion(step.key)} +
+ {renderWorkflowStepDropZone(step.key, "before", depth)} + 1 && "pl-4 sm:pl-6")} > - 1 && "border-l-4 border-l-emerald-500/30" - )} + toggleStepExpansion(step.key)} > - -
-
- - - + + + -
- {stepLabel} +
+ {stepLabel} +
+
+ +
+
+ + updateStep(step.key, (currentStep) => ({ + ...currentStep, + title: e.target.value, + })) + } + className="h-8 min-w-[180px] max-w-[260px] border-0 bg-transparent px-0 text-base font-medium shadow-none focus-visible:ring-0" + placeholder="模块标题" + /> + {levelLabel} + {sortedChildSteps.length} 子模块 + + {step.key} + +
+
+ +
+ {optionCardCount} 张卡片 + 卡片树结构 + {isTopLevelStep ? ( + <> + + + + ) : null} + {canAddChildStep ? ( + + ) : null} + + + + updateStep(step.key, (currentStep) => ({ + ...currentStep, + enabled: checked, + })) + } + /> +
+ -
-
- + +
+ +