Files
ttstd-infinite-canvas/web/src/pages/canvas/use-canvas-generation-executor.ts
2026-09-14 01:11:56 +08:00

417 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { isCanvasNodeGenerating } from "@/lib/canvas/canvas-node-task-state";
import { useCallback, useRef, 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 { canvasGenerationPromptMetadata, canvasGenerationRequestFingerprint, runCanvasGenerationSubmissionOnce } from "@/lib/canvas/canvas-generation-submission";
import { isGenerationTaskCapacityError } from "@/lib/canvas/canvas-generation-batch";
import { buildPortraitTexturePrompt } from "@/lib/canvas/canvas-portrait-texture";
import { buildCameraPrompt } from "@/lib/canvas/camera-prompt-library";
import { buildTextRewritePrompt } from "@/lib/prompts";
import { resolveCanvasStyleExecution } from "@/lib/canvas/canvas-style-execution";
import { generationErrorMessage, generationFailureMetadata } from "@/lib/generation-error";
import { modelCompatibilityError, modelGroupReferenceLimits, modelPromptLengthError, modelRequestOptions, type ModelRequirements } from "@/lib/model-selection";
import { navigateToSettings } from "@/lib/settings-navigation";
import type { Skill } from "@/services/api/skills";
import { skillRuntime } from "@/services/skill-runtime";
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<SetStateAction<CanvasNodeData[]>>;
setConnections: Dispatch<SetStateAction<CanvasConnection[]>>;
setSelectedNodeIds: Dispatch<SetStateAction<Set<string>>>;
setSelectedConnectionId: Dispatch<SetStateAction<string | null>>;
setDialogNodeId: Dispatch<SetStateAction<string | null>>;
setRunningNodeId: Dispatch<SetStateAction<string | null>>;
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<void>;
};
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;
skipDuplicateConfirmation?: boolean;
};
export function useCanvasGenerationExecutor({
projectId,
domainProjectId,
addedSkills,
assets,
nodesRef,
connectionsRef,
setNodes,
setConnections,
setSelectedNodeIds,
setSelectedConnectionId,
setDialogNodeId,
setRunningNodeId,
startGenerationRequest,
finishGenerationRequest,
bindGenerationTask,
applyGenerationTaskResult,
}: UseCanvasGenerationExecutorOptions) {
const { message, modal } = App.useApp();
const effectiveConfig = useEffectiveConfig();
const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
const submissionLocksRef = useRef(new Map<string, Promise<unknown>>());
const confirmDuplicateSubmission = useCallback(
() =>
new Promise<boolean>((resolve) => {
modal.confirm({
title: "再次生成相同内容?",
content: "当前节点已使用相同提示词、模型、参数和参考素材提交过任务。再次生成会新建任务,并可能再次消耗积分。",
okText: "仍然生成",
cancelText: "取消",
centered: true,
onOk: () => resolve(true),
onCancel: () => resolve(false),
});
}),
[modal],
);
return useCallback(
(nodeId: string, mode: CanvasNodeGenerationMode, prompt: string, options?: CanvasNodeGenerationOptions) =>
runCanvasGenerationSubmissionOnce(
submissionLocksRef.current,
nodeId,
async () => {
const sourceNode = nodesRef.current.find((node) => node.id === nodeId);
if (isCanvasNodeGenerating(sourceNode)) {
message.info("该节点的生成任务仍在进行中,请等待完成后再生成");
return;
}
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;
}
const sourceTextContent = sourceNode?.type === CanvasNodeType.Text ? sourceNode.metadata?.content?.trim() || "" : "";
const editingTextNode = mode === "text" && Boolean(sourceTextContent);
let generationPrompt = mode === "image" && sourceNode?.metadata?.portraitTexture ? buildPortraitTexturePrompt(prompt, sourceNode.metadata.portraitTexture) : prompt;
if (mode === "image" && sourceNode?.metadata?.cameraControl?.enabled) {
const cameraControl = sourceNode.metadata.cameraControl;
const cameraPrompt = buildCameraPrompt({ cameraId: cameraControl.camera, lensId: cameraControl.lens, focalLengthMm: cameraControl.focalLength, apertureF: cameraControl.aperture });
generationPrompt = `${generationPrompt}\n${cameraPrompt}`;
}
const isPreparingEmptyImage = mode === "image" && sourceNode?.type === CanvasNodeType.Image && !sourceNode.metadata?.content;
let rawGenerationContext: Awaited<ReturnType<typeof hydrateNodeGenerationContext>>;
// AutoDL/其他声明式视频协议需要结构化参考素材;只有普通
// 模型视频接口才把提示词视为纯文本输入。
const usesWorkflowProvider = Boolean(mode !== "text" && generationConfig.taskWorkflowProvider && generationConfig.taskWorkflowProvider !== "model");
// 普通视频协议只保留输入框文本(显式 @文本 引用仍会展开为真实内容);声明式工作流还要保留连接媒体。
const promptOnly = mode === "video" && !usesWorkflowProvider;
try {
const baseContext = buildNodeGenerationContext(
nodeId,
nodesRef.current,
connectionsRef.current,
editingTextNode ? buildTextRewritePrompt(sourceTextContent, prompt) : generationPrompt,
assets,
promptOnly,
);
const requirements = generationModelRequirements(mode, baseContext, sourceNode, generationConfig, true);
generationConfig = buildGenerationConfig(effectiveConfig, sourceNode, mode, requirements);
const compatibilityError = usesWorkflowProvider ? "" : modelCompatibilityError(generationConfig, generationConfig.model, requirements);
if (compatibilityError) throw new Error(`当前模型无法支持这组输入和参数:${compatibilityError}`);
const referenceLimits = usesWorkflowProvider ? undefined : 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 = usesWorkflowProvider ? "" : modelCompatibilityError(generationConfig, generationConfig.model, hydratedRequirements);
if (hydratedCompatibilityError) throw new Error(`当前模型无法支持这组输入和参数:${hydratedCompatibilityError}`);
} catch (error) {
const errorDetails = generationErrorMessage(error);
message.error(errorDetails);
return;
}
let skillExecution: Awaited<ReturnType<typeof skillRuntime.prepare<"canvas">>>;
try {
skillExecution = await skillRuntime.prepare({ profile: "canvas", prompt: rawGenerationContext.prompt, skills: addedSkills });
} catch (error) {
message.error(error instanceof Error ? error.message : "技能上下文加载失败");
return;
}
let effectivePrompt = skillExecution.prompt.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);
message.error(errorDetails);
return;
}
}
const promptLengthError = mode === "video" ? modelPromptLengthError(generationConfig, generationConfig.model, mode, effectivePrompt) : "";
if (promptLengthError) {
message.error(promptLengthError);
return;
}
const generationContext = { ...rawGenerationContext, prompt: effectivePrompt };
if (mode === "audio" && generationContext.characterReferences.length) {
if (generationContext.characterReferences.length !== 1) {
message.error("角色配音一次只能引用一个角色卡");
return;
}
const voice = generationContext.resolvedCharacterVoices[0];
if (!voice) {
message.error("角色尚未绑定可用声音,无法创建角色配音任务");
return;
}
generationConfig = { ...generationConfig, audioVoice: voice.voiceKey, audioInstructions: [voice.instructions, generationConfig.audioInstructions].filter(Boolean).join(";") };
}
if (!effectivePrompt && (mode === "text" || mode === "audio")) {
return;
}
const requestFingerprint = canvasGenerationRequestFingerprint({
nodeId,
mode,
prompt: effectivePrompt,
model: generationConfig.model,
options: modelRequestOptions(generationConfig, mode),
workflow:
generationConfig.taskWorkflowProvider && generationConfig.taskWorkflowProvider !== "model"
? {
provider: generationConfig.taskWorkflowProvider,
workflowId: generationConfig.runningHub.workflowId,
workflowKind: generationConfig.runningHub.selectedKind,
parameters: sourceNode?.metadata?.workflowParameters,
}
: undefined,
operation: sourceNode?.metadata?.videoEditOperation,
audioInstructions: generationConfig.audioInstructions,
promptTemplateOperation: sourceNode?.metadata?.promptTemplateOperation,
promptTemplateVariables: sourceNode?.metadata?.promptTemplateVariables,
context: generationContext,
});
const duplicateConfirmationRequired = !options?.skipDuplicateConfirmation && !options?.retryContext && sourceNode?.metadata?.lastGenerationRequestFingerprint === requestFingerprint;
if (duplicateConfirmationRequired && !(await confirmDuplicateSubmission())) return;
setRunningNodeId(nodeId);
const controller = startGenerationRequest(nodeId, nodeId, nodeId, options?.controller);
if (controller.signal.aborted) {
finishGenerationRequest(nodeId, controller);
setRunningNodeId(null);
return;
}
if (isPreparingEmptyImage) {
setNodes((current) =>
current.map((node) =>
node.id === nodeId
? {
...node,
metadata: {
...node.metadata,
prompt,
composerContent: prompt,
status: NODE_STATUS_LOADING,
taskStage: "正在准备生成任务",
taskProgress: 0,
taskCreatedAt: new Date().toISOString(),
errorDetails: undefined,
generationErrorCode: undefined,
resourceReloadAvailable: undefined,
failedPromptFingerprint: undefined,
},
}
: node,
),
);
}
// 已有内容节点只是本次生成的来源;任务状态归新目标所有,不能覆盖已成功结果。
const markSourceStatus = !sourceNode?.metadata?.content && !editingTextNode;
const statusPrompt = sourceNode?.type === CanvasNodeType.Config ? effectivePrompt : prompt;
if (markSourceStatus)
setNodes((current) =>
current.map((node) =>
node.id === nodeId
? {
...node,
metadata: { ...node.metadata, ...canvasGenerationPromptMetadata(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,
skillMetadata: skillExecution.metadata,
taskContext: options?.context,
retryContext: options?.retryContext,
setNodes,
setConnections,
setSelectedNodeIds,
setSelectedConnectionId,
setDialogNodeId,
startGenerationRequest,
finishGenerationRequest,
bindGenerationTask: (targetNodeId: string, task: GenerationTask) => {
bindGenerationTask(targetNodeId, task);
setNodes((current) => {
const source = current.find((node) => node.id === nodeId);
if (!source || source.metadata?.lastGenerationRequestFingerprint === requestFingerprint) return current;
return current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, lastGenerationRequestFingerprint: requestFingerprint } } : node));
});
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);
}
},
() => message.info({ key: `canvas-generation-submission-${nodeId}`, content: "该节点已有生成请求正在提交或执行,请勿重复点击" }),
),
[
addedSkills,
applyGenerationTaskResult,
bindGenerationTask,
confirmDuplicateSubmission,
domainProjectId,
effectiveConfig,
finishGenerationRequest,
isAiConfigReady,
message,
nodesRef,
connectionsRef,
projectId,
setConnections,
setDialogNodeId,
setNodes,
setRunningNodeId,
setSelectedConnectionId,
setSelectedNodeIds,
startGenerationRequest,
],
);
}
function generationModelRequirements(
mode: CanvasNodeGenerationMode,
input: Pick<Awaited<ReturnType<typeof hydrateNodeGenerationContext>>, "textCount" | "imageCount" | "videoCount" | "audioCount" | "characterReferences">,
sourceNode: CanvasNodeData | undefined,
config: ReturnType<typeof useEffectiveConfig>,
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: config.taskWorkflowProvider === "model" ? modelRequestOptions(config, mode) : undefined,
};
}