fix(*): 创作工作台 - 修复 Agent、资源引用、附件交互与 Agnes 视频协议
This commit is contained in:
71
web/test/canvas-node-generation-mentions.test.ts
Normal file
71
web/test/canvas-node-generation-mentions.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { buildNodeGenerationContext } from "../src/components/canvas/canvas-node-generation";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "../src/types/canvas";
|
||||
|
||||
function node(id: string, type: CanvasNodeType, content: string): CanvasNodeData {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
title: id,
|
||||
position: { x: 0, y: 0 },
|
||||
width: 100,
|
||||
height: 100,
|
||||
metadata: { content },
|
||||
};
|
||||
}
|
||||
|
||||
function targetNode(): CanvasNodeData {
|
||||
return {
|
||||
id: "target",
|
||||
type: CanvasNodeType.Video,
|
||||
title: "target",
|
||||
position: { x: 0, y: 0 },
|
||||
width: 100,
|
||||
height: 100,
|
||||
metadata: { composerContent: "让 @图片1 进入画面" },
|
||||
};
|
||||
}
|
||||
|
||||
function connection(fromNodeId: string): CanvasConnection {
|
||||
return { id: `connection-${fromNodeId}`, fromNodeId, toNodeId: "target" };
|
||||
}
|
||||
|
||||
describe("canvas node generation position mentions", () => {
|
||||
test("同一个 @图片1 在换线后自动指向新的第一张图片", () => {
|
||||
const target = targetNode();
|
||||
const imageA = node("image-a", CanvasNodeType.Image, "data:image/png;base64,a");
|
||||
const imageB = node("image-b", CanvasNodeType.Image, "data:image/png;base64,b");
|
||||
|
||||
const before = buildNodeGenerationContext(target.id, [imageA, target], [connection(imageA.id)], "让 @图片1 进入画面", []);
|
||||
const after = buildNodeGenerationContext(target.id, [imageB, target], [connection(imageB.id)], "让 @图片1 进入画面", []);
|
||||
|
||||
expect(before.referenceImages.map((image) => image.id)).toEqual(["image-a"]);
|
||||
expect(after.referenceImages.map((image) => image.id)).toEqual(["image-b"]);
|
||||
expect(before.prompt).toBe("让 图片1 进入画面");
|
||||
expect(after.prompt).toBe("让 图片1 进入画面");
|
||||
});
|
||||
|
||||
test("按类型位置选择资源,提示词出现顺序不会改变槽位含义", () => {
|
||||
const target = targetNode();
|
||||
const imageA = node("image-a", CanvasNodeType.Image, "data:image/png;base64,a");
|
||||
const audioA = node("audio-a", CanvasNodeType.Audio, "data:audio/mpeg;base64,a");
|
||||
const imageB = node("image-b", CanvasNodeType.Image, "data:image/png;base64,b");
|
||||
const connections = [connection(imageA.id), connection(audioA.id), connection(imageB.id)];
|
||||
const context = buildNodeGenerationContext(target.id, [imageA, audioA, imageB, target], connections, "让 @图片2 配合 @音频1", []);
|
||||
|
||||
expect(context.referenceImages.map((image) => image.id)).toEqual(["image-b"]);
|
||||
expect(context.referenceAudios.map((audio) => audio.id)).toEqual(["audio-a"]);
|
||||
expect(context.prompt).toBe("让 图片1 配合 音频1");
|
||||
});
|
||||
|
||||
test("旧节点 token 只做读取迁移,不再进入生成提示词", () => {
|
||||
const target = targetNode();
|
||||
const image = node("image-a", CanvasNodeType.Image, "data:image/png;base64,a");
|
||||
const context = buildNodeGenerationContext(target.id, [image, target], [connection(image.id)], "让 @[node:image-a] 进入画面", []);
|
||||
|
||||
expect(context.referenceImages.map((item) => item.id)).toEqual(["image-a"]);
|
||||
expect(context.prompt).toBe("让 图片1 进入画面");
|
||||
expect(context.prompt).not.toContain("@[node:");
|
||||
});
|
||||
});
|
||||
@@ -26,4 +26,31 @@ describe("canvas resource mention editor", () => {
|
||||
expect(previewRule).toContain("flex: 0 0 var(--canvas-mention-chip-preview-size)");
|
||||
expect(previewRule).toContain("object-fit: cover");
|
||||
});
|
||||
|
||||
test("exposes inline image references as replacement drop targets", () => {
|
||||
const component = source("../src/components/canvas/canvas-resource-mention-textarea.tsx");
|
||||
const css = source("../src/styles/globals.css");
|
||||
|
||||
expect(component).toContain("activeDropReferenceId?: string | null");
|
||||
expect(component).toContain("onReferenceFilesDrop?:");
|
||||
expect(component).toContain("chip.dataset.mentionReferenceId = reference.id");
|
||||
expect(component).toContain('chip.classList.toggle("is-replace-target"');
|
||||
expect(component).toContain("onReferenceFilesDrop(reference, files)");
|
||||
expect(css).toContain(".canvas-resource-inline-mention.is-replace-target");
|
||||
expect(css).toContain('content: "替换"');
|
||||
});
|
||||
|
||||
test("resolves storage-backed previews and renders a visible loading spinner", () => {
|
||||
const editor = source("../src/components/canvas/canvas-resource-mention-textarea.tsx");
|
||||
const panel = source("../src/components/canvas/canvas-node-prompt-panel.tsx");
|
||||
const configComposer = source("../src/components/canvas/canvas-config-composer.tsx");
|
||||
const project = source("../src/pages/canvas/project.tsx");
|
||||
|
||||
expect(editor).toContain("useResolvedCanvasResourceReferences");
|
||||
expect(panel).toContain("<LoaderCircle className=\"size-3 animate-spin");
|
||||
expect(panel).not.toContain("isRunning ? theme.accent.danger");
|
||||
expect(configComposer).toContain("wrapper.dataset.referenceToken");
|
||||
expect(configComposer).not.toContain('result += `@[node:');
|
||||
expect(project).not.toContain("removeCanvasResourceMention");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { collectUpstreamVideoNodes } from "../src/lib/canvas/canvas-resource-references";
|
||||
import { buildNodeMentionReferences, canvasResourceMentionToken, collectUpstreamVideoNodes } from "../src/lib/canvas/canvas-resource-references";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "../src/types/canvas";
|
||||
|
||||
function videoNode(id: string): CanvasNodeData {
|
||||
@@ -27,6 +27,30 @@ function textNode(id: string): CanvasNodeData {
|
||||
};
|
||||
}
|
||||
|
||||
function imageNode(id: string): CanvasNodeData {
|
||||
return {
|
||||
id,
|
||||
type: CanvasNodeType.Image,
|
||||
title: id,
|
||||
position: { x: 0, y: 0 },
|
||||
width: 100,
|
||||
height: 100,
|
||||
metadata: { content: `data:image/png;base64,${id}` },
|
||||
};
|
||||
}
|
||||
|
||||
function audioNode(id: string): CanvasNodeData {
|
||||
return {
|
||||
id,
|
||||
type: CanvasNodeType.Audio,
|
||||
title: id,
|
||||
position: { x: 0, y: 0 },
|
||||
width: 100,
|
||||
height: 60,
|
||||
metadata: { content: `data:audio/mpeg;base64,${id}` },
|
||||
};
|
||||
}
|
||||
|
||||
function connection(fromNodeId: string, toNodeId: string): CanvasConnection {
|
||||
return { id: `conn-${fromNodeId}-${toNodeId}`, fromNodeId, toNodeId };
|
||||
}
|
||||
@@ -50,3 +74,35 @@ describe("collectUpstreamVideoNodes", () => {
|
||||
expect(collectUpstreamVideoNodes("a", nodes, connections).length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canvas resource mention slots", () => {
|
||||
test("画布节点引用只保存类型位置,不保存节点 ID", () => {
|
||||
const target = videoNode("target");
|
||||
const image = imageNode("image-a");
|
||||
const [reference] = buildNodeMentionReferences(target, [image, target], [connection(image.id, target.id)]);
|
||||
|
||||
expect(reference.label).toBe("图片1");
|
||||
expect(canvasResourceMentionToken(reference)).toBe("@图片1");
|
||||
expect(canvasResourceMentionToken(reference)).not.toContain(image.id);
|
||||
});
|
||||
|
||||
test("图片、音频和文本分别按各自类型顺序编号", () => {
|
||||
const target = videoNode("target");
|
||||
const nodes = [imageNode("image-a"), audioNode("audio-a"), imageNode("image-b"), textNode("text-a"), target];
|
||||
const connections = nodes.slice(0, -1).map((node) => connection(node.id, target.id));
|
||||
|
||||
expect(buildNodeMentionReferences(target, nodes, connections).map((reference) => reference.label)).toEqual(["图片1", "音频1", "图片2", "文本1"]);
|
||||
});
|
||||
|
||||
test("素材库身份 token 保持稳定", () => {
|
||||
expect(canvasResourceMentionToken({
|
||||
id: "asset:asset-a",
|
||||
nodeId: "",
|
||||
assetId: "asset-a",
|
||||
kind: "image",
|
||||
label: "场景图",
|
||||
title: "场景图",
|
||||
active: false,
|
||||
})).toBe("@[asset:asset-a]");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,10 +67,10 @@ describe("storyboard target materializer", () => {
|
||||
const nodes = [script, node("project-style", CanvasNodeType.Image, { content: "style" }), node("prop", CanvasNodeType.Image, { content: "prop" }), node("character", CanvasNodeType.Image, { workflowKind: "character", characterAssetId: "character-asset" }), node("manual", CanvasNodeType.Video, { content: "video" }), node("direct-manual", CanvasNodeType.Audio, { content: "audio" }), node("first-frame", CanvasNodeType.Image, { content: "frame", workflowKind: "shot" }), node("target", CanvasNodeType.Video)];
|
||||
const connections: CanvasConnection[] = [{ id: "manual-row-input", fromNodeId: "manual", toNodeId: "script", toHandleId: "row:row-1" }];
|
||||
|
||||
it("combines stable bindings and produces standard node mention tokens", () => {
|
||||
it("combines stable bindings and produces position mention tokens", () => {
|
||||
const references = storyboardRowReferenceNodeIds(script, row, nodes, connections, true);
|
||||
expect(references).toEqual(["project-style", "prop", "character", "manual", "first-frame"]);
|
||||
expect(storyboardComposerContent("快速跟拍", references)).toBe("参考资产:@[node:project-style] @[node:prop] @[node:character] @[node:manual] @[node:first-frame]\n快速跟拍");
|
||||
expect(storyboardComposerContent("快速跟拍", references, nodes)).toBe("参考资产:@图片1 @图片2 @角色1 @视频1 @图片3\n快速跟拍");
|
||||
|
||||
const withDirectManualInput = storyboardRowReferenceNodeIds(script, row, nodes, [...connections, { id: "direct", fromNodeId: "direct-manual", toNodeId: "target" }], false, "target");
|
||||
expect(withDirectManualInput).toContain("direct-manual");
|
||||
|
||||
@@ -23,7 +23,7 @@ describe("creation library button", () => {
|
||||
expect(attachmentIndex).toBeGreaterThan(modePickerIndex);
|
||||
expect(libraryIndex).toBeGreaterThan(attachmentIndex);
|
||||
expect(dockSource).toContain("onClick={props.onOpenLibrary}");
|
||||
expect(dockSource).toContain("disabled={props.busy || !referencesSupported}");
|
||||
expect(dockSource).toContain("disabled={interactionBusy || !referencesSupported}");
|
||||
});
|
||||
|
||||
test("uploads from the library without adding a reference before confirmation", () => {
|
||||
@@ -63,6 +63,7 @@ describe("creation library button", () => {
|
||||
expect(source).toContain('axis="x"');
|
||||
expect(source).toContain("values={props.attachments}");
|
||||
expect(source).toContain("onReorder={props.onReorderAttachments}");
|
||||
expect(source).toContain('className="creation-reference-card-remove" onPointerDown={(event) => event.stopPropagation()}');
|
||||
expect(source).toContain("<Reorder.Item");
|
||||
expect(source).toContain('layout="position"');
|
||||
expect(source).toContain("isExpanded");
|
||||
@@ -74,6 +75,10 @@ describe("creation library button", () => {
|
||||
expect(source).toContain("creation-reference-stack-card");
|
||||
expect(source).toContain("creation-reference-add-button");
|
||||
expect(source).toContain("creation-reference-track-button");
|
||||
expect(source).toContain("imageReferenceAtPoint");
|
||||
expect(source).toContain("setDropTargetReferenceId");
|
||||
expect(source).toContain("props.onReplaceAttachment(target.attachmentId, item)");
|
||||
expect(source).toContain("onReferenceFilesDrop=");
|
||||
expect(source).toContain("CanvasPromptOptimizerDrawer");
|
||||
expect(source).toContain("promptOptimizerOpen");
|
||||
expect(source).toContain("provider={props.promptOptimizerProvider}");
|
||||
|
||||
@@ -9,5 +9,6 @@ describe("create page sidebar theme", () => {
|
||||
expect(styles).not.toContain(".app-workspace-shell.is-creation-workspace .app-workspace-sidebar");
|
||||
expect(styles).toContain(".app-workspace-shell.is-creation-workspace .app-workspace-stage");
|
||||
expect(styles).toContain("background: color-mix(in srgb, var(--workspace-sidebar) 96%, var(--foreground) 4%);");
|
||||
expect(styles.match(/--workspace-sidebar: var\(--workspace-navigation\);/g)?.length).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { canvasResourceMentionToken } from "../src/lib/canvas/canvas-resource-references";
|
||||
import { creationAttachmentKind, creationFileAccepted, creationMediaAspectRatio, creationUploadAccept, type CreationAttachment } from "../src/pages/create/creation-assets";
|
||||
import { buildCreationMentionReferences, reconcileCreationAttachmentLimit, removeCreationReferenceTokens } from "../src/pages/create/creation-references";
|
||||
import { buildCreationMentionReferences, displayCreationPrompt, reconcileCreationAttachmentLimit, removeCreationReferenceTokens, replaceCreationAttachmentReference, selectedCreationReferences } from "../src/pages/create/creation-references";
|
||||
|
||||
function imageAttachment(id: string): CreationAttachment {
|
||||
return {
|
||||
@@ -53,6 +53,13 @@ describe("creation references", () => {
|
||||
expect(reference.label).toBe("文件1");
|
||||
});
|
||||
|
||||
test("不同附件类型分别按自己的引入顺序编号", () => {
|
||||
const audio: CreationAttachment = { id: "audio", name: "audio.mp3", type: "audio/mpeg", url: "blob:audio", storageKey: "audio:key", previewUrl: "" };
|
||||
const references = buildCreationMentionReferences([], [imageAttachment("first"), audio, imageAttachment("second")]);
|
||||
|
||||
expect(references.map((reference) => reference.label)).toEqual(["图片1", "音频1", "图片2"]);
|
||||
});
|
||||
|
||||
test("媒体占位按本次选择的画幅展示并为异常值提供模式回退", () => {
|
||||
expect(creationMediaAspectRatio("16:9", "video")).toBe("16 / 9");
|
||||
expect(creationMediaAspectRatio("1:1", "image")).toBe("1 / 1");
|
||||
@@ -60,4 +67,53 @@ describe("creation references", () => {
|
||||
expect(creationMediaAspectRatio("auto", "video")).toBe("16 / 9");
|
||||
expect(creationMediaAspectRatio("auto", "image")).toBe("1 / 1");
|
||||
});
|
||||
|
||||
test("替换图片时保留目标位置且提示词无需修改", () => {
|
||||
const attachments = [imageAttachment("first"), imageAttachment("second"), imageAttachment("third")];
|
||||
const references = buildCreationMentionReferences([], attachments);
|
||||
const oldToken = canvasResourceMentionToken(references[1]);
|
||||
const replacement = imageAttachment("replacement");
|
||||
|
||||
const result = replaceCreationAttachmentReference(`让 ${oldToken} 靠近 ${oldToken}`, attachments, "second", replacement);
|
||||
const nextReferences = buildCreationMentionReferences([], result.attachments);
|
||||
const replacementReference = nextReferences.find((reference) => reference.attachmentId === replacement.id)!;
|
||||
const replacementToken = canvasResourceMentionToken(replacementReference);
|
||||
|
||||
expect(result.attachments.map((attachment) => attachment.id)).toEqual(["first", "replacement", "third"]);
|
||||
expect(replacementReference.label).toBe("图片2");
|
||||
expect(result.prompt).toBe(`让 ${replacementToken} 靠近 ${replacementToken}`);
|
||||
expect(result.prompt).not.toContain(oldToken);
|
||||
expect(displayCreationPrompt(result.prompt, nextReferences)).toBe("让 @图片2 靠近 @图片2");
|
||||
});
|
||||
|
||||
test("删除中间附件后只移除该附件引用,后续附件仍按稳定身份保留", () => {
|
||||
const attachments = ["first", "second", "third", "fourth", "fifth"].map(imageAttachment);
|
||||
const references = buildCreationMentionReferences([], attachments);
|
||||
const third = references[2];
|
||||
const fifth = references[4];
|
||||
const prompt = `删除前比较 ${canvasResourceMentionToken(third)} 和 ${canvasResourceMentionToken(fifth)}`;
|
||||
const remaining = attachments.filter((attachment) => attachment.id !== "third");
|
||||
const nextPrompt = removeCreationReferenceTokens(prompt, [third]);
|
||||
const nextReferences = buildCreationMentionReferences([], remaining);
|
||||
|
||||
expect(nextPrompt).not.toContain(canvasResourceMentionToken(third));
|
||||
expect(nextPrompt).toContain(canvasResourceMentionToken(fifth));
|
||||
expect(selectedCreationReferences(nextPrompt, nextReferences).map((reference) => reference.attachmentId)).toEqual(["fifth"]);
|
||||
expect(displayCreationPrompt(nextPrompt, nextReferences)).toBe("删除前比较 和 @图片4");
|
||||
});
|
||||
|
||||
test("替换会归一化可见标签并去除已存在的重复附件", () => {
|
||||
const attachments = [imageAttachment("source"), imageAttachment("target"), imageAttachment("third")];
|
||||
const result = replaceCreationAttachmentReference("让 @图片2 参考 @图片2。", attachments, "target", attachments[0]);
|
||||
const references = buildCreationMentionReferences([], result.attachments);
|
||||
const sourceReference = references.find((reference) => reference.attachmentId === "source")!;
|
||||
|
||||
expect(result.attachments.map((attachment) => attachment.id)).toEqual(["third", "source"]);
|
||||
expect(sourceReference.label).toBe("图片2");
|
||||
expect(result.prompt).toBe(`让 ${canvasResourceMentionToken(sourceReference)} 参考 ${canvasResourceMentionToken(sourceReference)}。`);
|
||||
});
|
||||
|
||||
test("目标附件不存在时拒绝替换", () => {
|
||||
expect(() => replaceCreationAttachmentReference("", [imageAttachment("first")], "missing", imageAttachment("next"))).toThrow("要替换的参考内容不存在");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user