feat(*): Agent工作流 - 完善审批、分镜协作与资源交互体验
This commit is contained in:
@@ -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"> {
|
||||
|
||||
@@ -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 } })),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user