feat(*): 创作工作台 - 完善画布生成、模型定价与创建流程

This commit is contained in:
ddcat666 committed 2026-10-10 04:40:20 +08:00
1 parent 508cad2b4e
commit 611949d6cc
39 files changed
+735 -242

No files matched your search

@@ -114,6 +114,10 @@
gap: 8px;
padding: 0 17px 15px;
}
.canvas-sync-panel__conflict-actions {
display: grid;
gap: 8px;
}
.canvas-sync-panel__actions .ant-btn { border-radius: 9px; }
.canvas-sync-panel__secondary-actions {
display: flex;
+33 -28
View File
@@ -1,13 +1,13 @@
import { App, Button, Popover } from "antd";
import { CloudCheck, CloudOff, FileClock, LoaderCircle, RefreshCw } from "lucide-react";
import { CloudCheck, CloudOff, FileClock, LoaderCircle, RefreshCw, UploadCloud } from "lucide-react";
import { useState } from "react";
import { flushCanvasStorePersistence } from "@/stores/canvas/use-canvas-store";
import { useSyncProgressStore } from "@/stores/use-sync-progress-store";
import { retryRemoteUserDataSync } from "@/services/user-data-sync";
import { overwriteRemoteCanvasProject, retryRemoteUserDataSync } from "@/services/user-data-sync";
import "./canvas-sync-status.css";
export function CanvasSyncStatus({ projectId, onLoadLatest, onOpenVersions }: { projectId: string; onLoadLatest: () => Promise<void>; onOpenVersions?: () => void }) {
const { message, modal } = App.useApp();
const { message } = App.useApp();
const progress = useSyncProgressStore((state) => state.syncingProjects[projectId]);
const [busy, setBusy] = useState(false);
const [statusOpen, setStatusOpen] = useState(false);
@@ -78,26 +78,20 @@ export function CanvasSyncStatus({ projectId, onLoadLatest, onOpenVersions }: {
}
};
const confirmLoadLatest = () => {
modal.confirm({
title: "加载云端最新版本?",
icon: <CloudOff className="canvas-sync-modal-icon" aria-hidden="true" />,
content: (
<div className="canvas-sync-confirm-copy">
<p>这不是合并操作:当前编辑器内容会替换为云端版本。</p>
<p>系统会先把本地修改保存为草稿,之后可从“版本记录”查看,不会直接丢失。</p>
</div>
),
okText: "保留草稿并加载",
cancelText: "继续编辑",
okButtonProps: { danger: true },
onOk: () =>
run(async () => {
await onLoadLatest();
setStatusOpen(false);
message.success("云端版本已加载,本地内容已保留为草稿");
}),
});
const loadLatest = () => {
void run(async () => {
await onLoadLatest();
setStatusOpen(false);
message.success("云端版本已加载,本地内容已保留为草稿");
}).catch(() => undefined);
};
const overwriteCloud = () => {
void run(async () => {
await overwriteRemoteCanvasProject(projectId);
setStatusOpen(false);
message.success("本地版本已覆盖云端");
}).catch(() => undefined);
};
return (
@@ -140,8 +134,8 @@ export function CanvasSyncStatus({ projectId, onLoadLatest, onOpenVersions }: {
</div>
) : conflict ? (
<div className="canvas-sync-panel__callout">
<strong>先保护本地,再决定版本</strong>
<span>{progress?.draftCount ? `已保留 ${progress.draftCount} 份本地草稿。` : "加载云端版前会先保留本地草稿。"} 这是替换,不是合并。</span>
<strong>请选择保留哪一版</strong>
<span>{progress?.draftCount ? `已保留 ${progress.draftCount} 份本地草稿。` : "本地内容仍在编辑器中。"} 加载云端版会替换当前编辑内容;用本地版覆盖会替换云端内容。</span>
</div>
) : cloudError ? (
<div className="canvas-sync-panel__callout">
@@ -184,9 +178,20 @@ export function CanvasSyncStatus({ projectId, onLoadLatest, onOpenVersions }: {
立即重试本地保存
</Button>
) : null}
<Button block icon={<CloudOff className="size-3.5" />} disabled={busy || saving || localPending || localError} onClick={confirmLoadLatest}>
加载云端版
</Button>
{conflict ? (
<div className="canvas-sync-panel__conflict-actions">
<Button block icon={<CloudOff className="size-3.5" />} disabled={busy || saving || localPending || localError} onClick={loadLatest}>
加载云端版
</Button>
<Button type="primary" block icon={<UploadCloud className="size-3.5" />} danger disabled={busy || saving || localPending || localError || Boolean(progress?.draftError)} onClick={overwriteCloud}>
用本地版覆盖云端
</Button>
</div>
) : (
<Button block icon={<CloudOff className="size-3.5" />} disabled={busy || saving || localPending || localError} onClick={loadLatest}>
加载云端版
</Button>
)}
<div className="canvas-sync-panel__secondary-actions">
{onOpenVersions ? (
<Button
@@ -136,6 +136,7 @@ export function useCanvasGenerationExecutor({
const sourceTextContent = sourceNode?.type === CanvasNodeType.Text ? sourceNode.metadata?.content?.trim() || "" : "";
const editingTextNode = mode === "text" && Boolean(sourceTextContent);
const markSourceStatus = !sourceNode?.metadata?.content && !editingTextNode;
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;
@@ -143,6 +144,30 @@ export function useCanvasGenerationExecutor({
generationPrompt = `${generationPrompt}\n${cameraPrompt}`;
}
const isPreparingEmptyImage = mode === "image" && sourceNode?.type === CanvasNodeType.Image && !sourceNode.metadata?.content;
const preparationController = startGenerationRequest(nodeId, nodeId, nodeId, options?.controller);
setRunningNodeId(nodeId);
if (markSourceStatus)
setNodes((current) =>
current.map((node) =>
node.id === nodeId
? {
...node,
metadata: {
...node.metadata,
...canvasGenerationPromptMetadata(prompt, prompt),
status: NODE_STATUS_LOADING,
taskStage: "正在准备参考素材",
taskProgress: 0,
taskCreatedAt: new Date().toISOString(),
errorDetails: undefined,
generationErrorCode: undefined,
resourceReloadAvailable: undefined,
failedPromptFingerprint: undefined,
},
}
: node,
),
);
let rawGenerationContext: Awaited<ReturnType<typeof hydrateNodeGenerationContext>>;
// AutoDL/其他声明式视频协议需要结构化参考素材;只有普通
@@ -164,7 +189,7 @@ export function useCanvasGenerationExecutor({
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);
rawGenerationContext = await hydrateNodeGenerationContext(baseContext, projectId, domainProjectId, mode, mode === "video" && Boolean(referenceLimits?.maxAudios), !promptOnly, referenceLimits, false);
const hydratedRequirements = generationModelRequirements(mode, rawGenerationContext, sourceNode, generationConfig);
generationConfig = buildGenerationConfig(effectiveConfig, sourceNode, mode, hydratedRequirements);
const hydratedCompatibilityError = usesWorkflowProvider ? "" : modelCompatibilityError(generationConfig, generationConfig.model, hydratedRequirements);
@@ -172,6 +197,8 @@ export function useCanvasGenerationExecutor({
} catch (error) {
const errorDetails = generationErrorMessage(error);
message.error(errorDetails);
finishGenerationRequest(nodeId, preparationController);
setRunningNodeId(null);
return;
}
@@ -180,6 +207,8 @@ export function useCanvasGenerationExecutor({
skillExecution = await skillRuntime.prepare({ profile: "canvas", prompt: rawGenerationContext.prompt, skills: addedSkills });
} catch (error) {
message.error(error instanceof Error ? error.message : "技能上下文加载失败");
finishGenerationRequest(nodeId, preparationController);
setRunningNodeId(null);
return;
}
let effectivePrompt = skillExecution.prompt.trim();
@@ -194,28 +223,38 @@ export function useCanvasGenerationExecutor({
} catch (error) {
const errorDetails = generationErrorMessage(error);
message.error(errorDetails);
finishGenerationRequest(nodeId, preparationController);
setRunningNodeId(null);
return;
}
}
const promptLengthError = mode === "video" ? modelPromptLengthError(generationConfig, generationConfig.model, mode, effectivePrompt) : "";
if (promptLengthError) {
message.error(promptLengthError);
finishGenerationRequest(nodeId, preparationController);
setRunningNodeId(null);
return;
}
const generationContext = { ...rawGenerationContext, prompt: effectivePrompt };
if (mode === "audio" && generationContext.characterReferences.length) {
if (generationContext.characterReferences.length !== 1) {
message.error("角色配音一次只能引用一个角色卡");
finishGenerationRequest(nodeId, preparationController);
setRunningNodeId(null);
return;
}
const voice = generationContext.resolvedCharacterVoices[0];
if (!voice) {
message.error("角色尚未绑定可用声音,无法创建角色配音任务");
finishGenerationRequest(nodeId, preparationController);
setRunningNodeId(null);
return;
}
generationConfig = { ...generationConfig, audioVoice: voice.voiceKey, audioInstructions: [voice.instructions, generationConfig.audioInstructions].filter(Boolean).join(";") };
}
if (!effectivePrompt && (mode === "text" || mode === "audio")) {
finishGenerationRequest(nodeId, preparationController);
setRunningNodeId(null);
return;
}
@@ -251,10 +290,13 @@ export function useCanvasGenerationExecutor({
context: generationContext,
});
const duplicateConfirmationRequired = !options?.skipDuplicateConfirmation && !options?.retryContext && sourceNode?.metadata?.lastGenerationRequestFingerprint === requestFingerprint;
if (duplicateConfirmationRequired && !(await confirmDuplicateSubmission())) return;
if (duplicateConfirmationRequired && !(await confirmDuplicateSubmission())) {
finishGenerationRequest(nodeId, preparationController);
setRunningNodeId(null);
return;
}
setRunningNodeId(nodeId);
const controller = startGenerationRequest(nodeId, nodeId, nodeId, options?.controller);
const controller = preparationController;
if (controller.signal.aborted) {
finishGenerationRequest(nodeId, controller);
setRunningNodeId(null);
@@ -286,7 +328,6 @@ export function useCanvasGenerationExecutor({
}
// 已有内容节点只是本次生成的来源;任务状态归新目标所有,不能覆盖已成功结果。
const markSourceStatus = !sourceNode?.metadata?.content && !editingTextNode;
const statusPrompt = sourceNode?.type === CanvasNodeType.Config ? effectivePrompt : prompt;
if (markSourceStatus)
setNodes((current) =>
@@ -146,7 +146,7 @@ export function useCanvasGenerationRetry({
rawContext =
hasSavedImageMetadata && !baseContext.characterReferences.length
? null
: await hydrateNodeGenerationContext(baseContext, projectId, domainProjectId, retryMode, retryMode === "video" && supportsVideoReferenceAudio(generationConfig), !promptOnly);
: await hydrateNodeGenerationContext(baseContext, projectId, domainProjectId, retryMode, retryMode === "video" && supportsVideoReferenceAudio(generationConfig), !promptOnly, undefined, false);
} catch (error) {
const failure = generationFailureMetadata(error, retryPromptSource);
message.error(failure.errorDetails);
@@ -393,7 +393,7 @@ export function useCanvasProjectLifecycle({
const forceSaveCanvasProject = useCallback(async (): Promise<boolean> => {
try { await persistLocalEdits(); } catch { message.error("本地保存失败,请重试"); return false; }
try {
const result = await forceOverwriteRemoteCanvasSync();
const result = await forceOverwriteRemoteCanvasSync(projectId);
message.success(result.reboundNodes > 0 ? `已保存,并修复 ${result.reboundNodes} 处媒体与素材的绑定` : "素材关联已核对,画布已保存");
} catch (error) {
message.error(`修复并保存失败:${error instanceof Error ? error.message : "未知错误"}`);