feat(canvas): 生成合同 - 统一节点参数并补充模型上下文能力

This commit is contained in:
ddCat
2026-09-19 20:32:05 +08:00
parent a59b77cf70
commit 3bf21d79af
18 changed files with 965 additions and 40 deletions

View File

@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
import { canvasConnectionError } from "../src/lib/canvas/canvas-connection-policy";
import { assertCanvasImageReferenceLimit, buildGenerationConfig, canvasImageReferenceLimitError, resolveCanvasGenerationModel } from "../src/lib/canvas/canvas-project-generation";
import { readNodeGenerationSpec, synchronizeGenerationSpec } from "../src/lib/canvas/generation-contract";
import { defaultModelCapabilityConfig } from "../src/lib/model-capabilities";
import { groupModelsByDisplayName, inferVideoOperation, modelCompatibilityError, modelGroupReferenceLimits, modelPromptLengthError, resolveCompatibleModel, resolveModelGenerationDefaults, resolveModelVideoBooleanOptions } from "../src/lib/model-selection";
import { defaultConfig, normalizeModelOptionValue, type AiConfig, type ModelChannel } from "../src/stores/use-config-store";
@@ -53,6 +54,87 @@ function node(id: string, type: CanvasNodeType, generationMode?: "image" | "vide
}
describe("逻辑模型选择", () => {
test("generationSpec 的显式 false 和 0 覆盖陈旧镜像及全局默认", () => {
const config = policyConfig();
config.videoSeconds = "9";
config.videoGenerateAudio = "true";
config.videoWatermark = "true";
const videoNode: CanvasNodeData = {
...node("video", CanvasNodeType.Video),
metadata: {
model: "relay::cinema-image",
seconds: "15",
generateAudio: "true",
watermark: "true",
generationSpec: {
version: 1,
mode: "video",
prompt: "canonical prompt",
modelSelection: { kind: "channel", channelId: "relay", modelKey: "cinema-text" },
options: { durationSeconds: 0, generateAudio: false, watermark: false },
referenceBindings: [],
textInputMode: "prompt-only",
},
},
};
const generation = buildGenerationConfig(config, videoNode, "video");
expect(generation.model).toBe("relay::cinema-text");
expect(generation.videoSeconds).toBe("1");
expect(generation.videoGenerateAudio).toBe("false");
expect(generation.videoWatermark).toBe("false");
});
test("编辑兼容字段时原子更新 canonical 合同和全部镜像", () => {
const original: CanvasNodeData = {
...node("video", CanvasNodeType.Video),
metadata: {
seconds: "6",
generateAudio: "false",
generationSpec: {
version: 1,
mode: "video",
prompt: "before",
options: { durationSeconds: 6, generateAudio: false },
referenceBindings: [],
textInputMode: "prompt-only",
},
},
};
const edited = synchronizeGenerationSpec(original, { seconds: "9", generateAudio: "true", composerContent: "after" });
const spec = readNodeGenerationSpec(edited);
expect(spec?.prompt).toBe("after");
expect(spec?.options.durationSeconds).toBe(9);
expect(spec?.options.generateAudio).toBe(true);
expect(edited.metadata?.seconds).toBe("9");
expect(edited.metadata?.generateAudio).toBe("true");
});
test("逻辑模型合同不会被旧 metadata.model 覆盖", () => {
const config = policyConfig();
config.channels[0]!.modelCosts![0]!.logicalModelId = "cinema-logical";
const videoNode: CanvasNodeData = {
...node("video", CanvasNodeType.Video),
metadata: {
model: "relay::cinema-image",
generationSpec: {
version: 1,
mode: "video",
prompt: "logical selection",
modelSelection: { kind: "logical", logicalModelId: "cinema-logical" },
options: {},
referenceBindings: [],
textInputMode: "prompt-only",
},
},
};
expect(buildGenerationConfig(config, videoNode, "video").model).toBe("relay::cinema-text");
});
test("技能上下文展开后按最终视频模型字符上限拒绝提交", () => {
const config = policyConfig();
const model = config.videoModels[0]!;

View File

@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test";
import { buildNodeGenerationContext } from "../src/components/canvas/canvas-node-generation";
import { buildCanvasResourceReferences } from "../src/lib/canvas/canvas-resource-references";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "../src/types/canvas";
function node(id: string, type: CanvasNodeType, content: string): CanvasNodeData {
@@ -32,6 +33,28 @@ function connection(fromNodeId: string): CanvasConnection {
}
describe("canvas node generation position mentions", () => {
test("Agent 保存的素材引用块在编辑器和再次提交时保持相同编号", () => {
const target = targetNode();
const character = node("character", CanvasNodeType.Image, "data:image/png;base64,a");
const wig = node("wig", CanvasNodeType.Image, "data:image/png;base64,b");
const voice = node("voice", CanvasNodeType.Audio, "data:audio/mpeg;base64,c");
const note = node("note", CanvasNodeType.Markdown, "导演方案");
const prompt = "一镜到底\n\n【资产参考】\n人物:@图片1\n假发:@图片2\n声音:@音频1";
target.metadata = { composerContent: prompt, prompt, referenceNodeIds: [character.id, voice.id, wig.id] };
const nodes = [target, wig, voice, note, character];
const connections = [character, voice, wig, note].map((source) => connection(source.id));
const references = buildCanvasResourceReferences(nodes, connections, target.id).filter((reference) => reference.active);
expect(Object.fromEntries(references.map((reference) => [reference.nodeId, reference.label]))).toEqual({
character: "图片1", wig: "图片2", voice: "音频1", note: "文本1",
});
const context = buildNodeGenerationContext(target.id, nodes, connections, prompt, [], true);
expect(context.referenceImages.map((reference) => reference.id)).toEqual([character.id, wig.id]);
expect(context.referenceAudios.map((reference) => reference.id)).toEqual([voice.id]);
expect(context.prompt).toBe(prompt);
expect(context.textCount).toBe(0);
});
test("已有图片节点显式引用自身时作为图生图参考图提交", () => {
const source = node("image-self", CanvasNodeType.Image, "data:image/png;base64,a");
source.metadata.composerContent = "将 @图片1 图片变清晰";