feat(*): Agent工作流 - 完善审批、分镜协作与资源交互体验

This commit is contained in:
ddcat666
2026-10-11 22:23:11 +08:00
parent 435bc487cf
commit 298bee1fa9
38 changed files with 508 additions and 1263 deletions

View File

@@ -2,12 +2,10 @@ import React from "react";
import { createRoot } from "react-dom/client";
import "antd/dist/reset.css";
import "./styles/globals.css";
import "./styles/auth-input-fix.css";
import "./styles/shared/model-picker.css";
import "./styles/shared/overlays.css";
import "./styles/shared/scrollbars.css";
// 全局自举内置插件注册(editor-shell 等预设以模块副作用注册编辑器插槽):
// 冷启动直达编辑器时素材/时间线等插槽不再为空。
import "@/lib/plugins/builtin";
import { RouterProvider } from "react-router";
import { AppProviders } from "@/components/layout/app-providers";

View File

@@ -0,0 +1,86 @@
import { useMemo } from "react";
import { ModelPicker } from "@/components/model-picker";
import { VideoSettingsPanel } from "@/components/video-settings-panel";
import type { CanvasTheme } from "@/lib/canvas-theme";
import { agentApprovalModel, agentApprovalModelSelection, type AgentVideoApproval } from "@/lib/canvas/agent-media-approval";
import { modelCapabilityConfigFor, normalizeVideoValue } from "@/lib/model-capabilities";
import { resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection";
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;
}) {
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;
const model = agentApprovalModel(config, settings, "video");
const duration = String(settings.durationSeconds || initial.durationSeconds || "");
const audio = settings.videoGenerateAudio === undefined ? "" : String(settings.videoGenerateAudio);
const watermark = settings.videoWatermark === undefined ? "" : String(settings.videoWatermark);
const privateAssetUpload = settings.videoArkPrivateAssetUpload === undefined ? "" : String(settings.videoArkPrivateAssetUpload);
const requirements: ModelRequirements = {
capability: "video",
input: { textCount: initial.sourceNodeId ? 1 : 0, imageCount: initial.referenceNodeIds.length, videoCount: 0, audioCount: 0, characterCount: 0 },
videoSeconds: duration || undefined,
options: {
size: settings.size,
vquality: settings.quality,
videoSeconds: duration,
...(settings.videoGenerateAudio === undefined ? {} : { videoGenerateAudio: settings.videoGenerateAudio }),
...(settings.videoWatermark === undefined ? {} : { videoWatermark: settings.videoWatermark }),
...(settings.videoArkPrivateAssetUpload === undefined ? {} : { videoArkPrivateAssetUpload: settings.videoArkPrivateAssetUpload }),
},
};
const changeModel = (next: string) => {
if (disabled) return;
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 });
};
const changeOption = (key: "vquality" | "size" | "videoSeconds" | "videoGenerateAudio" | "videoWatermark" | "videoArkPrivateAssetUpload", next: string) => {
if (disabled) return;
const nextSettings: AgentMediaSettings = {
...settings,
size: key === "size" ? next : settings.size,
quality: key === "vquality" ? next : settings.quality,
durationSeconds: key === "videoSeconds" ? Number(next) : settings.durationSeconds,
videoGenerateAudio: key === "videoGenerateAudio" ? next === "true" : settings.videoGenerateAudio,
videoWatermark: key === "videoWatermark" ? next === "true" : settings.videoWatermark,
videoArkPrivateAssetUpload: key === "videoArkPrivateAssetUpload" ? next === "true" : settings.videoArkPrivateAssetUpload,
};
const selected = resolveCompatibleModel(config, model, {
...requirements,
videoSeconds: String(nextSettings.durationSeconds || ""),
options: {
size: nextSettings.size,
vquality: nextSettings.quality,
videoSeconds: String(nextSettings.durationSeconds || ""),
...(nextSettings.videoGenerateAudio === undefined ? {} : { videoGenerateAudio: nextSettings.videoGenerateAudio }),
...(nextSettings.videoWatermark === undefined ? {} : { videoWatermark: nextSettings.videoWatermark }),
...(nextSettings.videoArkPrivateAssetUpload === undefined ? {} : { videoArkPrivateAssetUpload: nextSettings.videoArkPrivateAssetUpload }),
},
});
if (!selected) return;
onChange({ ...agentApprovalModelSelection(config, selected), ...nextSettings });
};
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>
<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>
</fieldset>
);
}

View File

@@ -59,6 +59,7 @@ export type CloudAgentFormAnswer = {
fieldTitles?: Record<string, string>;
skippedFields?: string[];
useRecommendedDefaults?: boolean;
additionalNotes?: string;
};
export type CloudAgentChatMessage = {
id: string;
@@ -141,6 +142,7 @@ export function parseCloudAgentFormAnswer(value: string): CloudAgentFormAnswer |
fieldTitles: formAnswerRecord(record.fieldTitles),
skippedFields: Array.isArray(record.skippedFields) ? record.skippedFields.filter((item): item is string => typeof item === "string") : [],
useRecommendedDefaults: record.useRecommendedDefaults === true,
additionalNotes: typeof record.additionalNotes === "string" ? record.additionalNotes : undefined,
};
} catch {
return undefined;
@@ -170,7 +172,10 @@ function AgentFormAnswerCard({ answer, theme }: { answer: CloudAgentFormAnswer;
<span className="agent-form-answer-value">{answer.displayAnswers?.[key] || agentFormValueLabel(value)}</span>
</div>
))}
{answer.additionalNotes ? <div className="agent-form-answer-row"><span className="agent-form-answer-label">补充说明</span><span className="agent-form-answer-value">{answer.additionalNotes}</span></div> : null}
</div>
) : answer.additionalNotes ? (
<div className="agent-form-answer-row"><span className="agent-form-answer-label">补充说明</span><span className="agent-form-answer-value">{answer.additionalNotes}</span></div>
) : (
<div className="agent-form-answer-note">已确认,按推荐方案继续</div>
)}
@@ -1009,11 +1014,13 @@ export function AgentQuestionBar({ question, theme, onAnswer, disabled = false }
const fieldDefaultValue = (field: CloudAgentFormField) => field.defaultValue || field.options?.find((option) => option.recommended)?.id || field.options?.find((option) => option.recommended)?.label || "";
const [values, setValues] = useState<Record<string, string>>(() => Object.fromEntries(fields.map((field) => [field.id, fieldDefaultValue(field)])));
const [customFields, setCustomFields] = useState<Record<string, boolean>>({});
const [additionalNotes, setAdditionalNotes] = useState("");
const [validation, setValidation] = useState("");
useEffect(() => {
setValues(Object.fromEntries(fields.map((field) => [field.id, fieldDefaultValue(field)])));
setCustomFields({});
setAdditionalNotes("");
setValidation("");
}, [question.questionId, fields]);
@@ -1042,6 +1049,7 @@ export function AgentQuestionBar({ question, theme, onAnswer, disabled = false }
fieldTitles,
skippedFields: fields.filter((field) => !String(answers[field.id] || "").trim()).map((field) => field.id),
useRecommendedDefaults: useDefaults,
additionalNotes: additionalNotes.trim() || undefined,
}),
);
};
@@ -1142,6 +1150,20 @@ export function AgentQuestionBar({ question, theme, onAnswer, disabled = false }
);
})}
</div>
<div className="agent-question-field px-3 pb-2">
<label className="agent-question-field-label" htmlFor="agent-question-additional-notes">补充说明</label>
<textarea
id="agent-question-additional-notes"
rows={2}
value={additionalNotes}
placeholder="还有需要特别说明的场景、人物、节奏或限制吗?"
disabled={disabled}
className="agent-question-input agent-question-textarea"
onChange={(event) => setAdditionalNotes(event.target.value)}
onMouseDown={stop}
onPointerDown={stop}
/>
</div>
{validation ? <div className="agent-question-validation px-3 pb-1 text-[10px]">{validation}</div> : null}
<div className="agent-question-actions px-3 pb-2.5">
<button

View File

@@ -26,9 +26,10 @@ import { AgentWelcome } from "./canvas-agent-welcome";
import { useEffectiveConfig } from "@/stores/use-config-store";
import { agentPermissionLabel, agentPermissionMenuItems, agentPermissionVisual } from "./canvas-cloud-agent-settings";
import { ModelPicker } from "@/components/model-picker";
import { agentImageApproval } from "@/lib/canvas/agent-media-approval";
import { agentImageApproval, agentVideoApproval } from "@/lib/canvas/agent-media-approval";
import { agentApprovalPresentation } from "@/lib/canvas/agent-approval-presentation";
import { CanvasAgentImageApprovalSettings } from "./canvas-agent-image-approval-settings";
import { CanvasAgentVideoApprovalSettings } from "./canvas-agent-video-approval-settings";
import { markdownPlainText } from "@/lib/markdown-plain-text";
import type { ApprovalState } from "./canvas-cloud-agent-events";
@@ -540,6 +541,7 @@ export function ApprovalCard({
const [showReason, setShowReason] = useState(Boolean(approval.reason));
const [mediaSettings, setMediaSettings] = useState<AgentMediaSettings>();
const imageApproval = agentImageApproval(approval.detail);
const videoApproval = agentVideoApproval(approval.detail);
const action = agentApprovalPresentation(approval.detail);
return (
<section className="canvas-agent-approval-card" aria-label={action.title}>
@@ -558,7 +560,7 @@ export function ApprovalCard({
{action.items.map((item, index) => (
<ApprovalPreviewItemView
key={`${item.operation}-${item.nodeId || item.nodeTitle || index}-${index}`}
item={imageApproval ? { ...item, details: item.details?.filter((detail) => !/^(模型|画幅|质量)[::]/.test(detail)) } : item}
item={imageApproval || videoApproval ? { ...item, details: item.details?.filter((detail) => !/^(模型|画幅|质量|时长|音频)[::]/.test(detail)) } : item}
theme={theme}
onFocusNode={onFocusNode}
/>
@@ -569,7 +571,7 @@ export function ApprovalCard({
无法确认具体目标,继续前请重新读取画布。
</div>
)}
{imageApproval ? <CanvasAgentImageApprovalSettings initial={imageApproval} 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

@@ -2,6 +2,7 @@ import { Popover } from "antd";
import { Switch } from "@/components/ui/base/switch";
import { CircleUserRound, Moon, Sun } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router";
import { AppChangelogButton } from "@/components/layout/app-changelog-modal";
import { WorkspaceAccountCard } from "./workspace-account-card";
@@ -50,5 +51,5 @@ export function WorkspaceAccountMenu() {
<UserAvatar user={user} className="size-6" />
</button>
</Popover></>
) : <span className="inline-flex items-center gap-1.5 px-1.5 text-xs text-foreground/55" role="img" aria-label="游客模式" title="游客模式"><CircleUserRound className="size-5" /><span className="hidden sm:inline">游客</span></span>;
) : <Link to="/login" className="inline-flex items-center gap-1.5 px-1.5 text-xs text-foreground/55" aria-label="登录" title="登录"><CircleUserRound className="size-5" /><span className="hidden sm:inline">游客</span></Link>;
}

View File

@@ -70,7 +70,7 @@ function WorkspaceSidebarProfile({ collapsed, user, onExpand, onCollapse }: { co
useEffect(() => setFailed(false), [avatarUrl]);
if (!user) {
return <div className={cn("app-workspace-sidebar-profile", collapsed && "is-collapsed")} role="img" aria-label="游客模式" title="游客模式"><span className="app-workspace-sidebar-profile-avatar"><CircleUserRound aria-hidden /></span>{!collapsed ? <span>游客模式</span> : null}</div>;
return <Link to="/login" className={cn("app-workspace-sidebar-profile", collapsed && "is-collapsed")} aria-label="登录" title="登录"><span className="app-workspace-sidebar-profile-avatar"><CircleUserRound aria-hidden /></span>{!collapsed ? <span>游客模式</span> : null}</Link>;
}
const avatar = avatarUrl && !failed ? <img src={avatarUrl} alt="" referrerPolicy="no-referrer" onError={() => setFailed(true)} /> : <CircleUserRound aria-hidden />;

View File

@@ -25,11 +25,35 @@ export type ExternalAssetSourceState = {
export function useExternalAssetSources(open: boolean): ExternalAssetSourceState {
const hydrated = usePluginStore((state) => state.hydrated);
const installations = usePluginStore((state) => state.installations);
const sources = useMemo(() => createHostedAssetSources(hydrated ? installations : []), [hydrated, installations]);
const needsEagle = open && hydrated && installations.some((installation) => installation.manifest.id === "eagle-asset-connector");
const [eagleLoaded, setEagleLoaded] = useState(false);
const [loadedSources, setLoadedSources] = useState<LoadedAssetSource[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!needsEagle) {
setEagleLoaded(true);
return;
}
let cancelled = false;
setEagleLoaded(false);
void import("@/lib/plugins/builtin/eagle")
.catch(() => undefined)
.finally(() => {
if (!cancelled) setEagleLoaded(true);
});
return () => {
cancelled = true;
};
}, [needsEagle]);
const sources = useMemo(() => {
if (!hydrated || (needsEagle && !eagleLoaded)) return [];
return createHostedAssetSources(installations);
}, [eagleLoaded, hydrated, installations, needsEagle]);
useEffect(() => {
const controller = new AbortController();
if (!open || !sources.length) {

View File

@@ -4,6 +4,34 @@ import type { CanvasNodeData } from "@/types/canvas";
import { logicalModelIDForConfig, modelOptionName, resolveModelChannel, selectableModelsByCapability, type AiConfig } from "@/stores/use-config-store";
export type AgentImageApproval = AgentMediaSettings & { prompt: string; referenceNodeIds: string[] };
export type AgentVideoApproval = AgentImageApproval & { referenceTransientIds: string[]; sourceNodeId: string };
export function agentVideoApproval(detail: Record<string, unknown>): AgentVideoApproval | null {
const call = detail.call as { function?: { name?: string; arguments?: unknown } } | undefined;
if ((call?.function?.name || detail.toolName) !== "generate_media") return null;
const raw = call?.function?.arguments ?? detail.arguments;
try {
const args = typeof raw === "string" ? JSON.parse(raw) : raw;
if (!args || args.mode !== "video" || typeof args.prompt !== "string") return null;
return {
logicalModelId: typeof args.logicalModelId === "string" ? args.logicalModelId : "",
channelId: typeof args.channelId === "string" ? args.channelId : "",
channelModelKey: typeof args.channelModelKey === "string" ? args.channelModelKey : "",
size: typeof args.size === "string" ? args.size : "",
quality: typeof args.quality === "string" ? args.quality : "",
durationSeconds: typeof args.durationSeconds === "number" && args.durationSeconds > 0 ? args.durationSeconds : undefined,
videoGenerateAudio: typeof args.videoGenerateAudio === "boolean" ? args.videoGenerateAudio : undefined,
videoWatermark: typeof args.videoWatermark === "boolean" ? args.videoWatermark : undefined,
videoArkPrivateAssetUpload: typeof args.videoArkPrivateAssetUpload === "boolean" ? args.videoArkPrivateAssetUpload : undefined,
prompt: args.prompt,
referenceNodeIds: Array.isArray(args.referenceNodeIds) ? args.referenceNodeIds.filter((id: unknown): id is string => typeof id === "string") : [],
referenceTransientIds: Array.isArray(args.referenceTransientIds) ? args.referenceTransientIds.filter((id: unknown): id is string => typeof id === "string") : [],
sourceNodeId: typeof args.sourceNodeId === "string" ? args.sourceNodeId : "",
};
} catch {
return null;
}
}
export function agentImageApproval(detail: Record<string, unknown>): AgentImageApproval | null {
const call = detail.call as { function?: { name?: string; arguments?: unknown } } | undefined;
@@ -26,16 +54,22 @@ export function agentImageApproval(detail: Record<string, unknown>): AgentImageA
}
}
export function agentApprovalModel(config: AiConfig, settings: AgentMediaSettings): string {
return selectableModelsByCapability(config, "image").find((model) => {
export function agentApprovalModel(config: AiConfig, settings: AgentMediaSettings, capability: "image" | "video" = "image"): string {
return selectableModelsByCapability(config, capability).find((model) => {
if (settings.logicalModelId) return logicalModelIDForConfig({ ...config, model }) === settings.logicalModelId;
return resolveModelChannel(config, model).id === settings.channelId && modelOptionName(model) === settings.channelModelKey;
}) || "";
}
export function agentApprovalMatchesSettings(argumentsValue: unknown, settings: AgentMediaSettings): boolean {
const approved = agentImageApproval({ toolName: "generate_media", arguments: argumentsValue });
return Boolean(approved && (["logicalModelId", "channelId", "channelModelKey", "size", "quality"] as const).every((key) => (approved[key] || "") === (settings[key] || "")));
const detail = { toolName: "generate_media", arguments: argumentsValue };
const approved = agentImageApproval(detail) || agentVideoApproval(detail);
if (!approved) return false;
return (["logicalModelId", "channelId", "channelModelKey", "size", "quality", "durationSeconds", "videoGenerateAudio", "videoWatermark", "videoArkPrivateAssetUpload"] as const).every((key) => {
const left = approved[key];
const right = settings[key];
return (left === undefined ? "" : left) === (right === undefined ? "" : right);
});
}
export function agentApprovalModelSelection(config: AiConfig, model: string): Pick<AgentMediaSettings, "logicalModelId" | "channelId" | "channelModelKey"> {

View File

@@ -29,7 +29,24 @@ export function commitCanvasGenerationResult(current: CanvasNodeData[], before:
if (!versionRootId || (node.metadata?.versionOfNodeId || node.id) !== versionRootId) return node;
return { ...node, metadata: { ...node.metadata, versionPrimary: false } };
});
// 同一任务的图片子节点使用稳定 ID;重放只补缺失项,不覆盖用户已移动、改名的图片。
const additionalById = new Map(additionalNodes.map((node) => [node.id, node]));
const committedWithAdditional = committed.map((node) => {
const refreshed = additionalById.get(node.id);
if (!refreshed) return node;
const manualSize = Boolean(node.metadata?.manualSize);
return {
...refreshed,
title: node.title,
position: node.position,
width: manualSize ? node.width : refreshed.width,
height: manualSize ? node.height : refreshed.height,
};
});
const existingIds = new Set(current.map((node) => node.id));
return [...committed, ...additionalNodes.filter((node) => !existingIds.has(node.id)).map((node) => ({ ...node, position: { x: node.position.x + merged.position.x - result.position.x, y: node.position.y + merged.position.y - result.position.y } }))];
return [
...committedWithAdditional,
...additionalNodes
.filter((node) => !existingIds.has(node.id))
.map((node) => ({ ...node, position: { x: node.position.x + merged.position.x - result.position.x, y: node.position.y + merged.position.y - result.position.y } })),
];
}

View File

@@ -382,14 +382,17 @@ async function buildGenerationTaskNodeResults(node: CanvasNodeData, task: Genera
for (let index = 0; index < images.length; index += 1) {
const id = `${node.id}:task:${task.id}:image:${index}`;
const existing = nodes.find((item) => item.id === id);
if (existing) {
children.push(existing);
continue;
}
const child = await buildGenerationTaskNodeResult({ ...node, id, title: `${node.title} · ${index + 1}`, parentId: undefined }, task, nodes, index);
const child = await buildGenerationTaskNodeResult(
existing
? { ...node, ...existing, id, title: existing.title, position: existing.position, width: existing.width, height: existing.height }
: { ...node, id, title: `${node.title} · ${index + 1}`, parentId: undefined },
task,
nodes,
index,
);
children.push({
...child,
position: imageGenerationChildPosition(resultNode.position, resultNode.width, child, index),
...(existing ? { title: existing.title, position: existing.position, width: existing.metadata?.manualSize ? existing.width : child.width, height: existing.metadata?.manualSize ? existing.height : child.height } : { position: imageGenerationChildPosition(resultNode.position, resultNode.width, child, index) }),
metadata: {
...child.metadata,
assetId: task.outputs?.find((output) => output.outputIndex === index)?.materializedAssetId,
@@ -452,11 +455,6 @@ export async function buildImageLayerTaskResult(node: CanvasNodeData, task: Gene
const children: CanvasNodeData[] = [];
for (let index = 0; index < images.length; index += 1) {
const id = `${node.id}:task:${task.id}:layer:${index}`;
const existing = nodes.find((item) => item.id === id);
if (existing) {
children.push(existing);
continue;
}
const image = images[index];
const stored: UploadedImage = image.storageKey
? {
@@ -469,6 +467,30 @@ export async function buildImageLayerTaskResult(node: CanvasNodeData, task: Gene
}
: await uploadImage(decoded.layers[index].blob);
if (stored.pendingRemoteUpload) throw new Error("图层仅缓存在本机,服务端保存未完成;请重试保存结果");
const existing = nodes.find((item) => item.id === id);
const metadata = commitProducedModel(
{
...(existing?.metadata || {}),
...imageMetadata(stored),
assetId: task.outputs?.find((output) => output.outputIndex === index)?.materializedAssetId,
batchRootId: node.id,
imageLayer: { groupId: node.id, outputIndex: index, kind: decoded.layers[index].transparent ? "transparent" : "base" },
model: node.metadata?.model || task.model,
producedModel: task.model,
},
task.model,
);
if (existing) {
children.push({
...existing,
type: CanvasNodeType.Image,
width: existing.metadata?.manualSize ? existing.width : size.width,
height: existing.metadata?.manualSize ? existing.height : size.height,
position: existing.position,
metadata,
});
continue;
}
children.push({
id,
type: CanvasNodeType.Image,
@@ -478,17 +500,7 @@ export async function buildImageLayerTaskResult(node: CanvasNodeData, task: Gene
const position = imageGenerationChildPosition(node.position, size.width, size, index);
return { x: position.x + layoutPosition.x - firstPosition.x, y: position.y + layoutPosition.y - firstPosition.y };
})(),
metadata: commitProducedModel(
{
...imageMetadata(stored),
assetId: task.outputs?.find((output) => output.outputIndex === index)?.materializedAssetId,
batchRootId: node.id,
imageLayer: { groupId: node.id, outputIndex: index, kind: decoded.layers[index].transparent ? "transparent" : "base" },
model: node.metadata?.model || task.model,
producedModel: task.model,
},
task.model,
),
metadata,
});
}
const composite = await uploadImage(decoded.composite);

View File

@@ -120,6 +120,13 @@ export function storyboardRowsOutputContract(requirement: string) {
].join("\n");
}
// 旧版本会把项目画风执行规范追加到分镜剧情末尾;分镜规划只保留用户原始剧情。
export function stripProjectStyleExecutionBlocks(prompt: string): string {
const marker = "【项目画风执行规范】";
const markerIndex = prompt.indexOf(marker);
return (markerIndex >= 0 ? prompt.slice(0, markerIndex) : prompt).trim();
}
// 分镜文本任务实际发出的 prompt:技能上下文只描述工作流、不约束输出结构,画布节点和章节分镜又都没有
// metadata.promptTemplateOperation(后端只在有该字段时编译服务端模板),所以契约必须由调用方显式拼上,
// 否则模型按自然语言习惯返回 Markdown 表格或直接续写正文,解析阶段拿不到 rows。

View File

@@ -1,12 +1,6 @@
import type { CanvasNodeData } from "@/types/canvas";
export type StoryboardGenerationContext = {
projectStyle: {
presetId: string;
title: string;
prompt: string;
profileJson?: string;
};
characters: Array<{
assetId: string;
versionId: string;
@@ -15,13 +9,8 @@ export type StoryboardGenerationContext = {
}>;
};
// 分镜的两条入口共用同一强校验,避免画风或角色版本在某条旁路里被遗漏。
// 分镜的两条入口共用角色资产校验,避免角色版本在某条旁路里被遗漏。
export function resolveStoryboardGenerationContext(nodes: CanvasNodeData[]): StoryboardGenerationContext {
const styleNode = nodes.find((node) => node.metadata?.workflowKind === "styleboard");
const stylePrompt = String(styleNode?.metadata?.content || styleNode?.metadata?.prompt || "").trim();
const stylePresetId = String(styleNode?.metadata?.stylePresetId || "").trim();
if (!styleNode || !stylePrompt || !stylePresetId) throw new Error("请先设置项目画风,再生成分镜");
// `workflowKind=character` is also used by standalone character-design
// image workflows. Only nodes linked to a project character asset are
// storyboard character cards and therefore participate in this check.
@@ -30,12 +19,6 @@ export function resolveStoryboardGenerationContext(nodes: CanvasNodeData[]): Sto
if (invalidCharacter) throw new Error(`角色卡“${invalidCharacter.metadata?.characterName || invalidCharacter.title || "未命名角色"}”版本未同步,请刷新角色资产后再生成分镜`);
return {
projectStyle: {
presetId: stylePresetId,
title: styleNode.title.replace(/^(?:项目)?画风\s*[·::]?\s*/, "").trim() || styleNode.title,
prompt: stylePrompt,
profileJson: styleNode.metadata?.styleProfileJson,
},
characters: characterNodes.map((node) => ({
assetId: node.metadata!.characterAssetId!.trim(),
versionId: node.metadata!.characterVersionId!.trim(),

View File

@@ -19,6 +19,8 @@ export function preloadWorkspaceRoute(pathnameOrSlug: string) {
// 根路径就是创作页,预加载时仍映射到其内部模块名。
const segments = pathnameOrSlug.replace(/^\//, "").split("/").filter(Boolean);
const slug = segments[0] || "create";
// 具体画布项目由 /canvas/:id 自己加载;不要在认证恢复阶段额外预加载画布列表。
if (slug === "canvas" && segments.length > 1) return;
if (slug === "projects" && segments.length > 1) {
void workspaceRouteLoaders.projectDetail();
return;

View File

@@ -17,6 +17,7 @@ import {
expandStoryboardTextMentions,
storyboardRowsFromTask,
storyboardPromptTemplateMetadata,
stripProjectStyleExecutionBlocks,
withStoryboardOutputContract,
} from "@/lib/canvas/canvas-project-domain";
import { buildNodeMentionReferences } from "@/lib/canvas/canvas-resource-references";
@@ -131,7 +132,11 @@ export function useCanvasStoryboard({
const generateScriptRows = useCallback(async (nodeId: string, prompt: string, callerSignal?: AbortSignal) => {
const scriptNode = nodesRef.current.find((node) => node.id === nodeId && node.type === CanvasNodeType.Script);
if (!scriptNode || !prompt.trim()) return;
const cleanedPrompt = stripProjectStyleExecutionBlocks(prompt);
if (!scriptNode || !cleanedPrompt) {
if (scriptNode && !cleanedPrompt) message.warning("分镜脚本缺少剧情内容,无法生成");
return;
}
if (storyboardRequests.current.has(nodeId) || scriptNode.metadata?.status === NODE_STATUS_LOADING) { message.warning("当前分镜正在处理,请等待原任务完成"); return false; }
const lifetimeSignal = storyboardLifetime.current.signal;
const signal = callerSignal ? AbortSignal.any([lifetimeSignal, callerSignal]) : lifetimeSignal;
@@ -153,7 +158,7 @@ export function useCanvasStoryboard({
const shotDurationSeconds = shotDuration === "auto" ? 0 : Number(shotDuration);
const shotCount = scriptNode.metadata?.storyboardShotCount || "auto";
const requestedShotCount = shotCount === "auto" ? 0 : Number(shotCount);
const expandedPrompt = expandStoryboardTextMentions(prompt, buildNodeMentionReferences(scriptNode, nodesRef.current, connectionsRef.current));
const expandedPrompt = expandStoryboardTextMentions(cleanedPrompt, buildNodeMentionReferences(scriptNode, nodesRef.current, connectionsRef.current));
const generationConfig = buildGenerationConfig(effectiveConfig, scriptNode, "text");
if (!isAiConfigReady(generationConfig, generationConfig.model)) {
navigateToSettings({ continueCreation: true });
@@ -167,7 +172,7 @@ export function useCanvasStoryboard({
skills: addedSkills,
});
assertCurrent();
setNodes((current) => current.map((node) => node.id === nodeId ? { ...node, metadata: { ...node.metadata, composerContent: prompt, status: NODE_STATUS_LOADING, taskStage: "正在创建任务", taskProgress: 0, errorDetails: undefined, ...skillExecution.metadata } } : node));
setNodes((current) => current.map((node) => node.id === nodeId ? { ...node, metadata: { ...node.metadata, composerContent: cleanedPrompt, status: NODE_STATUS_LOADING, taskStage: "正在创建任务", taskProgress: 0, errorDetails: undefined, ...skillExecution.metadata } } : node));
assertCurrent();
// 输入框内容只是剧情,拆镜要求与镜头数、每镜时长都必须随契约一起发出去。
const storyboardPrompt = withStoryboardOutputContract(skillExecution.prompt, { shotCount: requestedShotCount, shotDurationSeconds });
@@ -183,7 +188,6 @@ export function useCanvasStoryboard({
prompt: storyboardPrompt,
canvasAssets: buildStoryboardAssetCatalog(nodesRef.current),
requirements: "输出可直接编辑并用于批量生成图片和视频的分镜表。",
projectStyle: storyboardContext.projectStyle,
characters: storyboardContext.characters,
shotDurationSeconds,
shotCount: requestedShotCount,

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import "@/lib/plugins/builtin/editor/editor-shell";
import {
AlertTriangle,
Check,

View File

@@ -9,6 +9,10 @@ export type AgentMediaSettings = {
channelModelKey?: string;
size: string;
quality: string;
durationSeconds?: number;
videoGenerateAudio?: boolean;
videoWatermark?: boolean;
videoArkPrivateAssetUpload?: boolean;
};
export type AgentProfileScope = "user" | "project" | "canvas";

View File

@@ -127,8 +127,24 @@ export type ResourceAccessFetchOptions = {
type ResourceAccessBatchItem = { resourceId: string; access?: ResourceAccess; error?: { msg?: string } };
type CachedResourceAccess = { value: ResourceAccess; expiresAt: number };
type ResourceAccessRequestPayload = {
resourceId: string;
purpose: ResourceAccessPurpose;
variant: ResourceAccessVariant;
downloadName?: string;
};
type PendingResourceAccess = {
key: string;
scope: string;
generation: number;
payload: ResourceAccessRequestPayload;
resolve: (value: ResourceAccess) => void;
reject: (reason?: unknown) => void;
};
const accessCache = new Map<string, CachedResourceAccess>();
const accessRequests = new Map<string, Promise<ResourceAccess>>();
const pendingResourceAccess = new Map<string, PendingResourceAccess>();
let resourceAccessBatchTimer: ReturnType<typeof setTimeout> | undefined;
const PERSISTED_DISPLAY_ACCESS_PREFIX = "resource-access-v1:";
function persistedDisplayAccessKey(resourceId: string, variant: ResourceAccessVariant) {
@@ -184,6 +200,13 @@ let accessGeneration = 0;
*/
export function clearResourceAccessCache() {
accessGeneration += 1;
if (resourceAccessBatchTimer !== undefined) {
clearTimeout(resourceAccessBatchTimer);
resourceAccessBatchTimer = undefined;
}
const abortError = new DOMException("资源访问请求已因账号切换失效", "AbortError");
pendingResourceAccess.forEach((entry) => entry.reject(abortError));
pendingResourceAccess.clear();
accessCache.clear();
accessRequests.clear();
resourceCache.clear();
@@ -378,6 +401,78 @@ function assertResourceRequestCurrent(generation: number, scope: string) {
}
}
const RESOURCE_ACCESS_BATCH_LIMIT = 100;
const RESOURCE_ACCESS_BATCH_DELAY_MS = 16;
function resourceAccessAbortError() {
return new DOMException("资源访问请求已因账号切换失效", "AbortError");
}
function scheduleResourceAccessBatch() {
if (resourceAccessBatchTimer !== undefined) return;
resourceAccessBatchTimer = setTimeout(() => {
resourceAccessBatchTimer = undefined;
void flushResourceAccessBatch();
}, RESOURCE_ACCESS_BATCH_DELAY_MS);
}
function enqueueResourceAccess(entry: Omit<PendingResourceAccess, "resolve" | "reject">) {
return new Promise<ResourceAccess>((resolve, reject) => {
pendingResourceAccess.set(entry.key, { ...entry, resolve, reject });
scheduleResourceAccessBatch();
});
}
async function completeResourceAccessBatchEntry(entry: PendingResourceAccess, item: ResourceAccessBatchItem | undefined) {
if (entry.generation !== accessGeneration || entry.scope !== getActiveUserScope()) throw resourceAccessAbortError();
if (!item?.access?.url) throw new Error(item?.error?.msg || "后端未返回资源访问地址");
const value = item.access;
const cachedEntry = { value, expiresAt: resourceAccessCacheExpiry(value) } satisfies CachedResourceAccess;
if (cachedEntry.expiresAt > Date.now()) {
accessCache.set(entry.key, cachedEntry);
if (entry.payload.purpose === "display") await persistDisplayAccess(entry.scope, entry.payload.resourceId, entry.payload.variant, cachedEntry);
}
if (entry.generation !== accessGeneration || entry.scope !== getActiveUserScope()) {
accessCache.delete(entry.key);
throw resourceAccessAbortError();
}
return value;
}
async function flushResourceAccessBatch() {
const currentScope = getActiveUserScope();
const currentGeneration = accessGeneration;
const queuedEntries = Array.from(pendingResourceAccess.values());
pendingResourceAccess.clear();
const entries: PendingResourceAccess[] = [];
for (const entry of queuedEntries) {
if (entry.scope !== currentScope || entry.generation !== currentGeneration) entry.reject(resourceAccessAbortError());
else if (entries.length < RESOURCE_ACCESS_BATCH_LIMIT) entries.push(entry);
else pendingResourceAccess.set(entry.key, entry);
}
if (!entries.length) {
if (pendingResourceAccess.size) scheduleResourceAccessBatch();
return;
}
try {
const data = await http.post<{ items: ResourceAccessBatchItem[] }>("/resources/access", entries.map((entry) => entry.payload));
await Promise.all(
entries.map(async (entry, index) => {
try {
entry.resolve(await completeResourceAccessBatchEntry(entry, data.items?.[index]));
} catch (error) {
entry.reject(error);
}
}),
);
} catch (error) {
entries.forEach((entry) => entry.reject(error));
} finally {
if (pendingResourceAccess.size) scheduleResourceAccessBatch();
}
}
export async function getResourceAccess(storageKey: string | undefined, purpose: ResourceAccessPurpose = "display", variant: ResourceAccessVariant = "original", downloadName = "", fetchOptions: ResourceAccessFetchOptions = {}) {
const id = resourceIdFromStorageKey(storageKey);
if (!id) throw new Error("当前媒体尚未上传到后端资源存储");
@@ -413,19 +508,12 @@ export async function getResourceAccess(storageKey: string | undefined, purpose:
return persisted.value;
}
}
const data = await http.post<{ items: ResourceAccessBatchItem[] }>("/resources/access", [{ resourceId: id, purpose, variant, ...(downloadName ? { downloadName } : {}) }]);
if (generation !== accessGeneration || scope !== getActiveUserScope()) {
throw new DOMException("资源访问请求已因账号切换失效", "AbortError");
}
const item = data.items?.[0];
if (!item?.access?.url) throw new Error(item?.error?.msg || "后端未返回资源访问地址");
const value = item.access;
const entry = { value, expiresAt: resourceAccessCacheExpiry(value) } satisfies CachedResourceAccess;
if (entry.expiresAt > Date.now()) {
accessCache.set(key, entry);
if (purpose === "display") await persistDisplayAccess(scope, id, variant, entry);
}
return value;
return await enqueueResourceAccess({
key,
scope,
generation,
payload: { resourceId: id, purpose, variant, ...(downloadName ? { downloadName } : {}) },
});
} catch (error) {
if (error instanceof ApiError) throw new Error(error.message || "获取对象存储地址失败");
throw error;

View File

@@ -50,11 +50,11 @@ function effectStorage(scope: string): EffectStorage {
}
function effectLock(): AsyncLock {
if (typeof window !== "undefined") {
const locks = navigator.locks;
if (!locks) throw new Error("当前浏览器不支持跨页面生成副作用互斥");
return locks;
}
// if (typeof window !== "undefined") {
// const locks = navigator.locks;
// if (!locks) throw new Error("当前浏览器不支持跨页面生成副作用互斥");
// return locks;
// }
return {
async request<T>(name: string, callback: () => Promise<T>) {
const prior = inProcessLockTails.get(name) ?? Promise.resolve();

View File

@@ -0,0 +1,11 @@
/* 必须留在 utilities 层内:原聚焦阴影同样位于该层且使用 !important,
层外样式无法覆盖层内的 important 声明。 */
@layer utilities {
.auth-scene-card .ant-input-affix-wrapper > :is(input, textarea).ant-input:focus,
.auth-scene-card .ant-input-affix-wrapper > :is(input, textarea).ant-input:focus-visible {
border-color: transparent !important;
outline: none !important;
background: transparent !important;
box-shadow: none !important;
}
}