feat(*): 创作工作台 - 完善画布生成、模型定价与创建流程
This commit is contained in:
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;
|
||||
|
||||
@@ -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 : "未知错误"}`);
|
||||
|
||||
Reference in new issue
Block a user