import { useCallback, type Dispatch, type SetStateAction } from "react"; import { App } from "antd"; import { buildNodeGenerationContext, hydrateNodeGenerationContext } from "@/components/canvas/canvas-node-generation"; import type { CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel"; import { buildGenerationConfig, isGenerationCanceled } from "@/lib/canvas/canvas-project-generation"; import { isGenerationTaskCapacityError } from "@/lib/canvas/canvas-generation-batch"; import { buildPortraitTexturePrompt } from "@/lib/canvas/canvas-portrait-texture"; import { resolveCanvasStyleExecution } from "@/lib/canvas/canvas-style-execution"; import { expandSkillMentions } from "@/lib/canvas/canvas-skill-mentions"; import { generationErrorMessage, generationFailureMetadata } from "@/lib/generation-error"; import { modelCompatibilityError, modelGroupReferenceLimits, modelRequestOptions, type ModelRequirements } from "@/lib/model-selection"; import { navigateToSettings } from "@/lib/settings-navigation"; import type { Skill } from "@/services/api/skills"; import type { GenerationTask } from "@/services/api/task-center"; import { useConfigStore, useEffectiveConfig } from "@/stores/use-config-store"; import type { Asset } from "@/stores/use-asset-store"; import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "@/types/canvas"; import { executeImageGeneration } from "./canvas-image-generation-executor"; import { executeAudioGeneration, executeVideoGeneration } from "./canvas-media-generation-executors"; import { executeTextGeneration } from "./canvas-text-generation-executor"; type UseCanvasGenerationExecutorOptions = { projectId: string; domainProjectId?: string; addedSkills: Skill[]; assets: Asset[]; nodesRef: { current: CanvasNodeData[] }; connectionsRef: { current: CanvasConnection[] }; setNodes: Dispatch>; setConnections: Dispatch>; setSelectedNodeIds: Dispatch>>; setSelectedConnectionId: Dispatch>; setDialogNodeId: Dispatch>; setRunningNodeId: Dispatch>; startGenerationRequest: (targetNodeId: string, originNodeId: string, runningId?: string, controller?: AbortController) => AbortController; finishGenerationRequest: (targetNodeId: string, controller: AbortController) => void; bindGenerationTask: (targetNodeId: string, task: GenerationTask) => void; applyGenerationTaskResult: (targetNodeId: string, task: GenerationTask) => Promise; }; const NODE_STATUS_IDLE = "idle" as const; const NODE_STATUS_LOADING = "loading" as const; const NODE_STATUS_ERROR = "error" as const; export type CanvasNodeGenerationOptions = { controller?: AbortController; waitForTaskCapacity?: boolean; context?: { conversationId?: string; messageId?: string }; retryContext?: { retryOf: string; attemptGroupId: string; clientOperationId: string }; onTaskUpdate?: (task: GenerationTask) => void; }; export function useCanvasGenerationExecutor({ projectId, domainProjectId, addedSkills, assets, nodesRef, connectionsRef, setNodes, setConnections, setSelectedNodeIds, setSelectedConnectionId, setDialogNodeId, setRunningNodeId, startGenerationRequest, finishGenerationRequest, bindGenerationTask, applyGenerationTaskResult, }: UseCanvasGenerationExecutorOptions) { const { message } = App.useApp(); const effectiveConfig = useEffectiveConfig(); const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady); return useCallback( async (nodeId: string, mode: CanvasNodeGenerationMode, prompt: string, options?: CanvasNodeGenerationOptions) => { const sourceNode = nodesRef.current.find((node) => node.id === nodeId); if (sourceNode?.type === CanvasNodeType.Video && sourceNode.metadata?.videoEditOperation === "concat") { message.info("合并成片节点不直接重新生成,请重新选择源视频合并"); return; } let generationConfig = buildGenerationConfig(effectiveConfig, sourceNode, mode); const hasLiveBatchChildren = sourceNode?.type === CanvasNodeType.Image && (sourceNode.metadata?.batchChildIds || []).some((childId) => nodesRef.current.some((node) => node.id === childId && node.metadata?.batchRootId === sourceNode.id)); const hasStaleImageBatchState = mode === "image" && sourceNode?.type === CanvasNodeType.Image && !sourceNode.metadata?.content && Boolean(sourceNode.metadata?.isBatchRoot || sourceNode.metadata?.batchChildIds?.length) && !hasLiveBatchChildren; if (hasStaleImageBatchState) { setNodes((current) => current.map((node) => { if (node.id !== sourceNode.id) return node; const metadata = { ...node.metadata }; delete metadata.isBatchRoot; delete metadata.batchChildIds; delete metadata.primaryImageId; delete metadata.imageBatchExpanded; delete metadata.batchUsesReferenceImages; return { ...node, metadata }; }), ); } if (!isAiConfigReady(generationConfig, generationConfig.model)) { navigateToSettings({ continueCreation: true }); return; } setRunningNodeId(nodeId); const controller = startGenerationRequest(nodeId, nodeId, nodeId, options?.controller); const sourceTextContent = sourceNode?.type === CanvasNodeType.Text ? sourceNode.metadata?.content?.trim() || "" : ""; const editingTextNode = mode === "text" && Boolean(sourceTextContent); const generationPrompt = mode === "image" && sourceNode?.metadata?.portraitTexture ? buildPortraitTexturePrompt(prompt, sourceNode.metadata.portraitTexture) : prompt; const isPreparingEmptyImage = mode === "image" && sourceNode?.type === CanvasNodeType.Image && !sourceNode.metadata?.content; if (isPreparingEmptyImage) { setNodes((current) => current.map((node) => node.id === nodeId ? { ...node, metadata: { ...node.metadata, prompt, status: NODE_STATUS_LOADING, taskStage: "正在准备生成任务", taskProgress: 0, taskCreatedAt: new Date().toISOString(), errorDetails: undefined, generationErrorCode: undefined, resourceReloadAvailable: undefined, failedPromptFingerprint: undefined, }, } : node, ), ); } let rawGenerationContext: Awaited>; // 视频文本只保留输入框内容;连接的媒体仍作为结构化参考传递。 const promptOnly = mode === "video"; try { const baseContext = buildNodeGenerationContext(nodeId, nodesRef.current, connectionsRef.current, editingTextNode ? `请根据要求修改以下文本。\n\n原文:\n${sourceTextContent}\n\n修改要求:\n${prompt}` : generationPrompt, assets, promptOnly); const requirements = generationModelRequirements(mode, baseContext, sourceNode, generationConfig, true); generationConfig = buildGenerationConfig(effectiveConfig, sourceNode, mode, requirements); const compatibilityError = modelCompatibilityError(generationConfig, generationConfig.model, requirements); if (compatibilityError) throw new Error(`当前模型无法支持这组输入和参数:${compatibilityError}`); const referenceLimits = modelGroupReferenceLimits(effectiveConfig, generationConfig.model, mode, requirements); rawGenerationContext = await hydrateNodeGenerationContext(baseContext, projectId, domainProjectId, mode, mode === "video" && Boolean(referenceLimits?.maxAudios), !promptOnly, referenceLimits); const hydratedRequirements = generationModelRequirements(mode, rawGenerationContext, sourceNode, generationConfig); generationConfig = buildGenerationConfig(effectiveConfig, sourceNode, mode, hydratedRequirements); const hydratedCompatibilityError = modelCompatibilityError(generationConfig, generationConfig.model, hydratedRequirements); if (hydratedCompatibilityError) throw new Error(`当前模型无法支持这组输入和参数:${hydratedCompatibilityError}`); } catch (error) { const errorDetails = generationErrorMessage(error); if (isPreparingEmptyImage) { setNodes((current) => current.map((node) => node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: controller.signal.aborted ? NODE_STATUS_IDLE : NODE_STATUS_ERROR, taskStage: undefined, taskProgress: undefined, taskCreatedAt: undefined, taskStartedAt: undefined, taskCompletedAt: undefined, taskDurationMs: undefined, errorDetails: controller.signal.aborted ? undefined : errorDetails, }, } : node, ), ); } finishGenerationRequest(nodeId, controller); setRunningNodeId(null); if (!controller.signal.aborted) message.error(errorDetails); return; } const expandedPrompt = promptOnly ? rawGenerationContext.prompt : expandSkillMentions(rawGenerationContext.prompt, addedSkills); let effectivePrompt = expandedPrompt.trim(); let styleMetadata = {}; if (mode === "image") { try { const styleRuntime = resolveCanvasStyleExecution(nodesRef.current, sourceNode, effectivePrompt, generationConfig, mode); if (styleRuntime) { effectivePrompt = styleRuntime.prompt; styleMetadata = { styleProfileJson: styleRuntime.profileJson, styleExecutionPlan: styleRuntime.plan }; } } catch (error) { const errorDetails = generationErrorMessage(error); if (isPreparingEmptyImage) setNodes((current) => current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, taskStage: undefined, taskProgress: undefined, taskCreatedAt: undefined, taskStartedAt: undefined, taskCompletedAt: undefined, taskDurationMs: undefined, errorDetails } } : node)), ); finishGenerationRequest(nodeId, controller); setRunningNodeId(null); message.error(errorDetails); return; } } const generationContext = { ...rawGenerationContext, prompt: effectivePrompt }; if (mode === "audio" && generationContext.characterReferences.length) { if (generationContext.characterReferences.length !== 1) { finishGenerationRequest(nodeId, controller); setRunningNodeId(null); message.error("角色配音一次只能引用一个角色卡"); return; } const voice = generationContext.resolvedCharacterVoices[0]; if (!voice) { finishGenerationRequest(nodeId, controller); setRunningNodeId(null); message.error("角色尚未绑定可用声音,无法创建角色配音任务"); return; } generationConfig = { ...generationConfig, audioVoice: voice.voiceKey, audioInstructions: [voice.instructions, generationConfig.audioInstructions].filter(Boolean).join(";") }; } if (controller.signal.aborted) { if (isPreparingEmptyImage) setNodes((current) => current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_IDLE, taskStage: undefined, taskProgress: undefined, taskCreatedAt: undefined, taskStartedAt: undefined, taskCompletedAt: undefined, taskDurationMs: undefined } } : node))); finishGenerationRequest(nodeId, controller); setRunningNodeId(null); return; } // 已有内容节点只是本次生成的来源;任务状态归新目标所有,不能覆盖已成功结果。 const markSourceStatus = !sourceNode?.metadata?.content && !editingTextNode; const statusPrompt = sourceNode?.type === CanvasNodeType.Config ? effectivePrompt : prompt; if (!effectivePrompt && (mode === "text" || mode === "audio")) { finishGenerationRequest(nodeId, controller); setRunningNodeId(null); return; } if (markSourceStatus) setNodes((current) => current.map((node) => node.id === nodeId ? { ...node, metadata: { ...node.metadata, prompt: statusPrompt, status: NODE_STATUS_LOADING, errorDetails: undefined, generationErrorCode: undefined, resourceReloadAvailable: undefined, failedPromptFingerprint: undefined } } : node, ), ); let pendingNodeIds: string[] = []; const execution = { projectId, nodeId, sourceNode, canvasNodes: nodesRef.current, canvasConnections: connectionsRef.current, prompt, effectivePrompt, generationConfig, generationContext, controller, editingTextNode, styleMetadata, taskContext: options?.context, retryContext: options?.retryContext, setNodes, setConnections, setSelectedNodeIds, setSelectedConnectionId, setDialogNodeId, startGenerationRequest, finishGenerationRequest, bindGenerationTask: (targetNodeId: string, task: GenerationTask) => { bindGenerationTask(targetNodeId, task); options?.onTaskUpdate?.(task); }, applyGenerationTaskResult, showError: (content: string) => message.error(content), registerPendingNodeIds: (nodeIds: string[]) => { pendingNodeIds = nodeIds; }, }; try { if (mode === "image") await executeImageGeneration(execution); else if (mode === "video") await executeVideoGeneration(execution); else if (mode === "audio") await executeAudioGeneration(execution); else await executeTextGeneration(execution); } catch (error) { if (isGenerationCanceled(error)) return; const failure = generationFailureMetadata(error, prompt); if (options?.waitForTaskCapacity && isGenerationTaskCapacityError(error)) { setNodes((current) => current.map((node) => { if (node.id !== nodeId && !pendingNodeIds.includes(node.id)) return node; const metadata = { ...(node.metadata || {}), status: NODE_STATUS_IDLE, errorDetails: undefined }; delete metadata.taskId; delete metadata.taskStatus; delete metadata.taskProgress; delete metadata.taskStage; delete metadata.taskCreatedAt; delete metadata.taskUpdatedAt; delete metadata.taskStartedAt; delete metadata.taskCompletedAt; delete metadata.taskDurationMs; return { ...node, metadata }; }), ); return; } message.error(failure.errorDetails); setNodes((current) => current.map((node) => (node.id === nodeId || pendingNodeIds.includes(node.id) ? (node.id === nodeId && !markSourceStatus ? node : { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, ...failure } }) : node)), ); } finally { finishGenerationRequest(nodeId, controller); setRunningNodeId(null); } }, [ addedSkills, applyGenerationTaskResult, bindGenerationTask, domainProjectId, effectiveConfig, finishGenerationRequest, isAiConfigReady, message, nodesRef, connectionsRef, projectId, setConnections, setDialogNodeId, setNodes, setRunningNodeId, setSelectedConnectionId, setSelectedNodeIds, startGenerationRequest, ], ); } function generationModelRequirements( mode: CanvasNodeGenerationMode, input: Pick>, "textCount" | "imageCount" | "videoCount" | "audioCount" | "characterReferences">, sourceNode: CanvasNodeData | undefined, config: ReturnType, includeCharacterMinimum = false, ): ModelRequirements { return { capability: mode, input: { textCount: input.textCount, imageCount: input.imageCount, videoCount: input.videoCount, audioCount: input.audioCount, characterCount: includeCharacterMinimum ? input.characterReferences.length : 0, }, videoOperation: sourceNode?.metadata?.videoEditOperation, videoSeconds: config.videoSeconds, options: modelRequestOptions(config, mode), }; }