fix(*): 质量检查 - 修复预演保存、增量同步与缺失资源恢复

This commit is contained in:
ddcat666
2026-10-12 00:37:15 +08:00
parent 298bee1fa9
commit 27c8066e6d
20 changed files with 257 additions and 71 deletions

View File

@@ -8,13 +8,7 @@ import { resolveCompatibleModel, type ModelRequirements } from "@/lib/model-sele
import type { AgentMediaSettings } from "@/services/api/agent";
import { PUBLIC_MODEL_CATALOG_ID, useEffectiveConfig } from "@/stores/use-config-store";
export function CanvasAgentVideoApprovalSettings({ initial, value, theme, disabled, onChange }: {
initial: AgentVideoApproval;
value?: AgentMediaSettings;
theme: CanvasTheme;
disabled: boolean;
onChange: (value: AgentMediaSettings) => void;
}) {
export function CanvasAgentVideoApprovalSettings({ initial, value, theme, disabled, onChange }: { initial: AgentVideoApproval; value?: AgentMediaSettings; theme: CanvasTheme; disabled: boolean; onChange: (value: AgentMediaSettings) => void }) {
const effective = useEffectiveConfig();
const config = useMemo(() => ({ ...effective, channels: effective.channels.filter((channel) => channel.scope === "system" || channel.id === PUBLIC_MODEL_CATALOG_ID) }), [effective]);
const settings = value || initial;
@@ -42,7 +36,15 @@ export function CanvasAgentVideoApprovalSettings({ initial, value, theme, disabl
const profile = modelCapabilityConfigFor(config, next).video;
if (!profile) return;
const normalized = normalizeVideoValue(profile, { seconds: duration, ratio: settings.size, resolution: settings.quality });
onChange({ ...agentApprovalModelSelection(config, next), size: normalized.ratio, quality: normalized.resolution, durationSeconds: Number(normalized.seconds), videoGenerateAudio: settings.videoGenerateAudio, videoWatermark: settings.videoWatermark, videoArkPrivateAssetUpload: settings.videoArkPrivateAssetUpload });
onChange({
...agentApprovalModelSelection(config, next),
size: normalized.ratio,
quality: normalized.resolution,
durationSeconds: Number(normalized.seconds),
videoGenerateAudio: settings.videoGenerateAudio,
videoWatermark: settings.videoWatermark,
videoArkPrivateAssetUpload: settings.videoArkPrivateAssetUpload,
});
};
const changeOption = (key: "vquality" | "size" | "videoSeconds" | "videoGenerateAudio" | "videoWatermark" | "videoArkPrivateAssetUpload", next: string) => {
@@ -75,12 +77,32 @@ export function CanvasAgentVideoApprovalSettings({ initial, value, theme, disabl
return (
<fieldset disabled={disabled} className="min-w-0 space-y-3 border-0 p-0" data-canvas-no-zoom data-canvas-wheel-scroll aria-label="视频生成设置">
<div className="space-y-1.5">
<div className="text-xs" style={{ color: theme.node.muted }}>生成模型</div>
<div className="text-xs" style={{ color: theme.node.muted }}>
生成模型
</div>
<ModelPicker config={config} capability="video" value={model} onChange={changeModel} requirements={requirements} variant="creation" fullWidth showOptionPrices placeholder="选择生成模型" popoverClassName="agent-model-picker-popover" />
</div>
{model ? <VideoSettingsPanel config={{ ...config, model, size: settings.size, videoSeconds: duration, vquality: settings.quality, videoGenerateAudio: audio, videoWatermark: watermark, videoArkPrivateAssetUpload: privateAssetUpload }} onConfigChange={changeOption} theme={theme} showTitle={false} className="min-w-0 space-y-3" /> : <p className="text-xs" style={{ color: theme.node.muted }}>当前模型不在可选目录中,可重新选择;提交时将重新校验模型与规格。</p>}
<p className="text-xs" style={{ color: theme.node.text }}>本次规格:{settings.size}{settings.quality ? ` · ${settings.quality}` : ""}{settings.durationSeconds ? ` · ${settings.durationSeconds} 秒` : ""}</p>
<p className="text-xs" style={{ color: theme.node.muted }}>修改仅用于本次生成,费用按最终模型和规格计算。</p>
{model ? (
<VideoSettingsPanel
config={{ ...config, model, size: settings.size, videoSeconds: duration, vquality: settings.quality, videoGenerateAudio: audio, videoWatermark: watermark, videoArkPrivateAssetUpload: privateAssetUpload }}
onConfigChange={changeOption}
theme={theme}
showTitle={false}
className="min-w-0 space-y-3"
/>
) : (
<p className="text-xs" style={{ color: theme.node.muted }}>
当前模型不在可选目录中,可重新选择;提交时将重新校验模型与规格。
</p>
)}
<p className="text-xs" style={{ color: theme.node.text }}>
本次规格:{settings.size}
{settings.quality ? ` · ${settings.quality}` : ""}
{settings.durationSeconds ? ` · ${settings.durationSeconds} 秒` : ""}
</p>
<p className="text-xs" style={{ color: theme.node.muted }}>
修改仅用于本次生成,费用按最终模型和规格计算。
</p>
</fieldset>
);
}

View File

@@ -571,7 +571,11 @@ export function ApprovalCard({
无法确认具体目标,继续前请重新读取画布。
</div>
)}
{imageApproval ? <CanvasAgentImageApprovalSettings initial={imageApproval} value={mediaSettings} onChange={setMediaSettings} theme={theme} disabled={submitting} /> : videoApproval ? <CanvasAgentVideoApprovalSettings initial={videoApproval} value={mediaSettings} onChange={setMediaSettings} theme={theme} disabled={submitting} /> : null}
{imageApproval ? (
<CanvasAgentImageApprovalSettings initial={imageApproval} value={mediaSettings} onChange={setMediaSettings} theme={theme} disabled={submitting} />
) : videoApproval ? (
<CanvasAgentVideoApprovalSettings initial={videoApproval} value={mediaSettings} onChange={setMediaSettings} theme={theme} disabled={submitting} />
) : null}
<button type="button" className="canvas-agent-approval-reason-toggle" aria-expanded={showReason} onClick={() => setShowReason((value) => !value)} disabled={submitting}>
{showReason ? "收起拒绝理由" : "填写拒绝理由(可选)"}
</button>

View File

@@ -79,7 +79,7 @@ export function CanvasPrevisWorkbench({
onDeleteImageNode: (nodeId: string) => void;
onFlush?: () => void | Promise<void>;
}) {
const { message } = App.useApp();
const { message, modal } = App.useApp();
const theme = canvasThemes[useActiveTheme()];
const effectiveCanvasId = useMemo(() => {
const explicit = canvasId?.trim();
@@ -241,6 +241,7 @@ export function CanvasPrevisWorkbench({
resetWorkbench();
}, [open, resetWorkbench, scene, writeDraft]);
// 打开会话时检查合法本地恢复候选
// 父级项目收到 Agent 的预演台语义补丁后,scene.id 不变但 updatedAt 会变化;
// 不能只按 scene.id 忽略,否则打开着的工作台永远看不到 Agent 的修改。
useEffect(() => {
@@ -257,7 +258,7 @@ export function CanvasPrevisWorkbench({
setSelectedBone(null);
setPlayhead(0);
message.info("Agent 已更新预演场景");
}, [message, open, scene, setPlayhead, setSelectedBone, setSelectedLightId, setSelectedObjectId, writeDraft]);
}, [message, modal, open, scene, writeDraft]);
const activeShot = draft?.shots?.find((item) => item.id === draft.activeShotId) || draft?.shots?.[0] || null;
const activeCamera = draft?.cameras?.find((item) => item.id === activeShot?.cameraId) || draft?.cameras?.[0] || null;
@@ -355,6 +356,7 @@ export function CanvasPrevisWorkbench({
[writeAndPublish],
);
// 打开会话时检查合法本地恢复候选之后,挂载暂存手势生命周期。
// 暂存手势的终止生命周期:常驻安装,非活跃时 end 为空操作。
useEffect(
() =>
@@ -466,7 +468,14 @@ export function CanvasPrevisWorkbench({
return;
}
recordPrevisDiagnostic("PREVIS_CLOSE_BLOCKED", { sceneId: draftRef.current?.id, saveOutcome: "stay", revision: saveController.progress.revision });
message.error(decision.message);
modal.confirm({
title: "保存未完成",
content: decision.message,
okText: "放弃修改并关闭",
cancelText: "留在预演台",
okButtonProps: { danger: true },
onOk: onClose,
});
closingRef.current = false;
})();
};
@@ -1246,6 +1255,12 @@ export function CanvasPrevisWorkbench({
<span className={`previs-desk-save ${saveStatusClass}`} aria-live="polite">
{saveIndicator.label}
</span>
{saveController.progress.status === "error" ? (
<button type="button" className="previs-desk-header-action" onClick={() => void saveController.retrySave()}>
<Save className="size-3.5" />
重试保存
</button>
) : null}
{onboardingScope ? (
<button type="button" className="previs-desk-icon-button" aria-label="重新开始引导" title="重新开始引导" onClick={() => setOnboardingRestartSignal((value) => value + 1)}>
<Lightbulb className="size-4" />

View File

@@ -8,6 +8,7 @@ import type { PrevisScene } from "@/types/previs";
export type PrevisSaveController = {
progress: PrevisSaveProgress;
commitScene: (scene: PrevisScene) => void;
retrySave: () => Promise<boolean>;
prepareClose: () => Promise<PrevisCloseDecision>;
handlePageHide: () => Promise<boolean>;
};
@@ -59,7 +60,10 @@ export function usePrevisSaveCoordinator(input: { sceneId: string | null; initia
coordinatorRef.current = null;
const pending = coordinator.getSnapshot();
if (pending.confirmedRevision < pending.revision) {
void coordinator.flushLatest().catch(() => undefined).finally(() => coordinator.dispose());
void coordinator
.flushLatest()
.catch(() => undefined)
.finally(() => coordinator.dispose());
return;
}
coordinator.dispose();
@@ -79,6 +83,14 @@ export function usePrevisSaveCoordinator(input: { sceneId: string | null; initia
[syncProgress],
);
const retrySave = useCallback(async () => {
const coordinator = coordinatorRef.current;
if (!coordinator) return false;
const success = await coordinator.flushLatest();
syncProgress();
return success;
}, [syncProgress]);
const prepareClose = useCallback(async () => {
const coordinator = coordinatorRef.current;
if (!coordinator) return "close" as PrevisCloseDecision;
@@ -107,5 +119,5 @@ export function usePrevisSaveCoordinator(input: { sceneId: string | null; initia
}
}, [syncProgress]);
return { progress, commitScene, prepareClose, handlePageHide };
return { progress, commitScene, retrySave, prepareClose, handlePageHide };
}