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

@@ -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;