feat(agent): 创作能力 - 接通专业节点发现与插件执行基础

This commit is contained in:
王志伟 committed 2026-09-09 13:01:30 +08:00
1 parent 88f4b9ccdf
commit f69535c93f
29 files changed
+617 -88

No files matched your search

+41
View File
@@ -0,0 +1,41 @@
import { afterEach, expect, test } from "bun:test";
import { registerPlugin, unregisterPlugin } from "../src/lib/plugins/plugin-registry";
import { usePluginStore } from "../src/stores/use-plugin-store";
import { listAgentCapabilities, buildAgentPluginOperations } from "../src/services/agent-capabilities";
import { applyCanvasAgentOps, type CanvasAgentSnapshot } from "../src/lib/canvas/canvas-agent-ops";
import { normalizeCreativeProposal, creativeProposalOps } from "../src/lib/creation/creative-agent-state";
import { defaultConfig } from "../src/stores/use-config-store";
const snapshot: CanvasAgentSnapshot = { projectId: "test", title: "test", nodes: [], connections: [], selectedNodeIds: [], viewport: { x: 0, y: 0, k: 1 } };
const pluginId = "agent-extension-test";
afterEach(() => { unregisterPlugin(pluginId); usePluginStore.setState({ runtimeStatuses: {} }); });
test("专业方案创建可编辑分镜与上下文连线,不伪造画风已选择或收费任务", () => {
const proposal = normalizeCreativeProposal({ title: "短剧", summary: "搭建", markdown: "完整方案", workflow: { nodes: [
{ ref: "style", kind: "styleboard", title: "项目画风" },
{ ref: "story", kind: "story_input", title: "故事", content: "两个人重逢" },
{ ref: "shots", kind: "script", title: "分镜", shots: [{ durationSeconds: 5, videoMotionPrompt: "推近人物", dialogue: "好久不见" }] },
], edges: [{ from: "style", to: "shots" }, { from: "story", to: "shots" }] }, generationItems: [] }, "p", 1, defaultConfig);
const result = applyCanvasAgentOps(snapshot, creativeProposalOps("run", proposal, snapshot, defaultConfig));
expect(result.nodes.find((node) => node.metadata?.workflowKind === "styleboard")?.metadata?.content).toBe("");
const script = result.nodes.find((node) => node.type === "script")!;
expect(script.metadata?.storyboard?.rows[0]?.dialogue).toBe("好久不见");
expect(result.connections.every((edge) => edge.toHandleId === "storyboard:context")).toBe(true);
expect(proposal.generationItems).toHaveLength(0);
});
test("插件注册后可发现和创建真实类型,停用后立即拒绝新调用", () => {
registerPlugin({ manifest: { id: pluginId, name: "测试扩展", version: "1", apiVersion: "yingce.plugin/v1", description: "测试", surfaces: ["node"], permissions: ["canvas.write"], runtime: { web: "declarative" }, contributes: { canvasNodes: [{ id: "test-special-node", label: "专用节点", defaultTitle: "测试", defaultSize: { width: 400, height: 300 }, schema: {}, renderer: "declarative" }] } }, agentActions: [{ id: "create", description: "创建", inputSchema: {}, buildOperations: () => [{ type: "add_node", nodeType: "test-special-node" }] }] });
usePluginStore.setState({ runtimeStatuses: { [pluginId]: "enabled" } });
expect(listAgentCapabilities("专用节点").length).toBeGreaterThan(0);
const ops = buildAgentPluginOperations(pluginId, "create", {}, snapshot);
expect(applyCanvasAgentOps(snapshot, ops).nodes[0]?.type).toBe("test-special-node");
usePluginStore.setState({ runtimeStatuses: { [pluginId]: "disabled" } });
expect(listAgentCapabilities("专用节点")).toHaveLength(0);
expect(() => buildAgentPluginOperations(pluginId, "create", {}, snapshot)).toThrow();
expect(() => applyCanvasAgentOps(snapshot, ops)).toThrow();
});
test("未知节点不能静默降级成文本", () => {
expect(() => applyCanvasAgentOps(snapshot, [{ type: "add_node", nodeType: "missing-plugin-node" }])).toThrow();
});
+50
View File
@@ -0,0 +1,50 @@
import { expect, test } from "bun:test";
import { buildAgentStoryboardOperations } from "../src/lib/canvas/canvas-agent-storyboard";
import { createShortDramaPipeline } from "../src/lib/canvas/canvas-short-drama";
import { createStoryboardRow } from "../src/lib/canvas/canvas-project-domain";
import { applyCanvasAgentOps, type CanvasAgentSnapshot } from "../src/lib/canvas/canvas-agent-ops";
import { prepareAnalysisNodeAction } from "../src/lib/plugins/analysis-node-action";
function fixture() {
const pipeline = createShortDramaPipeline({ x: 0, y: 0 });
const script = pipeline.nodes.find((node) => node.type === "script")!;
script.metadata!.storyboard!.rows = [createStoryboardRow(1, { id: "row-1", videoNodeId: "video-1", imageNodeId: "image-1", dialogue: "旧对白" }), createStoryboardRow(2, { id: "row-2" })];
const snapshot: CanvasAgentSnapshot = { projectId: "test", title: "test", nodes: pipeline.nodes, connections: pipeline.connections, selectedNodeIds: [], viewport: { x: 0, y: 0, k: 1 } };
return { snapshot, script };
}
test("按行修改保留其他镜头及已有生成素材关联", () => {
const { snapshot, script } = fixture();
const next = applyCanvasAgentOps(snapshot, buildAgentStoryboardOperations(snapshot, { nodeId: script.id, action: "update", rowId: "row-1", patch: { dialogue: "新对白", durationSeconds: 8 } }));
const rows = next.nodes.find((node) => node.id === script.id)!.metadata!.storyboard!.rows;
expect(rows[0]).toMatchObject({ id: "row-1", dialogue: "新对白", durationSeconds: 8, imageNodeId: "image-1", videoNodeId: "video-1" });
expect(rows[1]).toEqual(script.metadata!.storyboard!.rows[1]);
});
test("移除分镜只更新表格,保留行 ID 并重排序号", () => {
const { snapshot, script } = fixture();
const ops = buildAgentStoryboardOperations(snapshot, { nodeId: script.id, action: "remove", rowId: "row-1" });
expect(ops).toHaveLength(1);
expect(ops[0].type).toBe("update_node");
if (ops[0].type === "update_node") expect(ops[0].metadata?.storyboard?.rows[0]).toMatchObject({ id: "row-2", shotNumber: 1 });
});
test("分镜编辑拒绝伪造结果、未知行和非法时长", () => {
const { snapshot, script } = fixture();
for (const patch of [{ status: "success" }, { videoNodeId: "fake" }, { durationSeconds: -1 }]) {
expect(() => buildAgentStoryboardOperations(snapshot, { nodeId: script.id, action: "update", rowId: "row-1", patch })).toThrow();
}
expect(() => buildAgentStoryboardOperations(snapshot, { nodeId: script.id, action: "remove", rowId: "missing" })).toThrow();
});
test("分析节点准备复用相同输入且不启动模型", () => {
const { snapshot, script } = fixture();
snapshot.nodes.push({ ...script, id: "source", type: "image", metadata: { storageKey: "resource:image" } });
const action = prepareAnalysisNodeAction("test-analysis", "分析", () => ({}));
const ops = action.buildOperations({ sourceNodeId: "source" }, snapshot);
expect(ops.some((op) => op.type === "run_generation")).toBe(false);
expect(ops.some((op) => op.type === "connect_nodes")).toBe(true);
snapshot.nodes.push({ ...script, id: "analysis", type: "test-analysis" });
snapshot.connections.push({ id: "edge", fromNodeId: "source", toNodeId: "analysis" });
expect(action.buildOperations({ sourceNodeId: "source" }, snapshot)).toEqual([{ type: "select_nodes", ids: ["analysis"] }]);
});
+46
View File
@@ -0,0 +1,46 @@
import { expect, test } from "bun:test";
import { executeArtCritique } from "../src/services/art-critique-execution";
import { defaultConfig } from "../src/stores/use-config-store";
import { createCanvasNode } from "../src/lib/canvas/canvas-project-domain";
import { CanvasNodeType } from "../src/types/canvas";
import type { ArtCritiquePipelineOptions } from "../src/lib/art-critique/pipeline";
const source = createCanvasNode(CanvasNodeType.Image, { x: 0, y: 0 }, { storageKey: "resource:original" });
const options = () => ({ nodeId: "analysis", runId: "run", source, config: defaultConfig, signal: new AbortController().signal, onTaskCreated: (_stage: string, _id: string) => {} });
test("分析阶段使用资源引用、记录后台任务并拒绝重复阶段提交", async () => {
let submitted = 0;
const created: string[] = [];
const input = options();
input.onTaskCreated = (stage, id) => created.push(`${stage}:${id}`);
await executeArtCritique(input, {
inspectImage: async () => ({ storageKey: "resource:original", encodedBytes: 30 }),
runTask: async (request) => {
submitted++;
expect(JSON.stringify(request.messages)).toContain("resource:original");
expect(request.metadata).toMatchObject({ source: "art-critique", runId: "run", nodeId: "analysis" });
request.onTaskCreated?.({ id: "task-1" } as never);
return { content: "{}", toolCalls: [] };
},
runPipeline: async (config, image, opts: ArtCritiquePipelineOptions = {}) => {
expect(image.dataUrl).toBe("resource:original");
const stage = { config, messages: [{ role: "user" as const, content: [{ type: "image_url" as const, image_url: { url: image.dataUrl } }] }], tool: { type: "function" as const, name: "review", description: "review", parameters: {} }, toolName: "review", signal: input.signal };
await opts.requestStage!(stage);
await expect(opts.requestStage!(stage)).rejects.toThrow("重复");
return {} as never;
},
});
expect(submitted).toBe(1);
expect(created).toEqual(["review:task-1"]);
});
test("准备素材期间取消不会提交后台模型任务", async () => {
const controller = new AbortController();
let submitted = false;
await expect(executeArtCritique({ ...options(), signal: controller.signal }, {
inspectImage: async () => { controller.abort(); return { storageKey: "resource:image", encodedBytes: 30 }; },
runTask: async () => { submitted = true; return { content: "", toolCalls: [] }; },
runPipeline: async () => { throw new Error("不应开始分析"); },
})).rejects.toThrow();
expect(submitted).toBe(false);
});
+9
View File
@@ -54,6 +54,15 @@ function file(path: string, content: string, kind: SkillPackageFile["kind"] = "m
}
describe("skill runtime", () => {
test("可检索首屏之外的技能并分页,不暴露未加入技能", async () => {
const runtime = createSkillRuntime();
const skills = Array.from({ length: 45 }, (_, index) => skill({ skill_id: `skill-${index}`, skill_name: `技能 ${index}`, description: index === 44 ? "商品构图" : "通用" }));
skills.push(skill({ skill_id: "hidden", description: "商品构图", is_added: false }));
const first = await runtime.executeAgentTool("onlineAgent", "canvas_list_skills", {}, skills);
expect(first?.ok && first.data).toMatchObject({ total: 45, nextOffset: 40 });
const found = await runtime.executeAgentTool("onlineAgent", "canvas_list_skills", { query: "商品" }, skills);
expect(found?.ok && found.data).toMatchObject({ total: 1, nextOffset: null, items: [{ skillId: "skill-44" }] });
});
test("技能引用解析由统一规则同时支持稳定 token 和自然提及", () => {
const director = skill();
const storyboard = skill({ skill_id: "storyboard", skill_name: "小说转分镜" });