Implement agent node functionality in canvas, including connection policies and UI updates. Add support for agent node type in node catalog, templates, and connection validation. Update documentation to reflect new agent capabilities and ensure proper handling of input sources. Enhance adjustment preview to include crop node. Add tests for agent connection policies.

This commit is contained in:
2026-04-09 10:06:53 +02:00
parent b7f24223f2
commit 6d0c7b1ff6
18 changed files with 749 additions and 5 deletions

View File

@@ -0,0 +1,143 @@
// @vitest-environment jsdom
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { buildGraphSnapshot, type CanvasGraphSnapshot } from "@/lib/canvas-render-preview";
import { DEFAULT_CURVES_DATA } from "@/lib/image-pipeline/adjustment-types";
const pipelinePreviewMock = vi.fn();
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
let currentGraph: (CanvasGraphSnapshot & { previewNodeDataOverrides: Map<string, unknown> }) | null = null;
vi.mock("@/components/canvas/canvas-graph-context", () => ({
useCanvasGraph: () => {
if (!currentGraph) {
throw new Error("Graph not configured for test");
}
return currentGraph;
},
}));
vi.mock("@/hooks/use-pipeline-preview", () => ({
usePipelinePreview: (options: unknown) => {
pipelinePreviewMock(options);
return {
canvasRef: { current: null },
histogram: {
rgb: Array.from({ length: 256 }, () => 0),
red: Array.from({ length: 256 }, () => 0),
green: Array.from({ length: 256 }, () => 0),
blue: Array.from({ length: 256 }, () => 0),
max: 0,
},
isRendering: false,
hasSource: true,
previewAspectRatio: 1,
error: null,
};
},
}));
import AdjustmentPreview from "@/components/canvas/nodes/adjustment-preview";
describe("AdjustmentPreview", () => {
let container: HTMLDivElement | null = null;
let root: Root | null = null;
beforeEach(() => {
pipelinePreviewMock.mockClear();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
if (root) {
await act(async () => {
root?.unmount();
});
}
container?.remove();
container = null;
root = null;
currentGraph = null;
});
it("includes upstream crop steps for adjustment previews", async () => {
currentGraph = {
...buildGraphSnapshot(
[
{
id: "image-1",
type: "image",
data: { url: "https://cdn.example.com/source.png" },
},
{
id: "crop-1",
type: "crop",
data: {
crop: { x: 0.1, y: 0.2, width: 0.5, height: 0.4 },
resize: { mode: "source", fit: "cover", keepAspect: true },
},
},
{
id: "curves-1",
type: "curves",
data: DEFAULT_CURVES_DATA,
},
],
[
{ source: "image-1", target: "crop-1" },
{ source: "crop-1", target: "curves-1" },
],
),
previewNodeDataOverrides: new Map(),
};
const currentParams = {
...DEFAULT_CURVES_DATA,
levels: {
...DEFAULT_CURVES_DATA.levels,
gamma: 1.4,
},
};
await act(async () => {
root?.render(
React.createElement(AdjustmentPreview, {
nodeId: "curves-1",
nodeWidth: 320,
currentType: "curves",
currentParams,
}),
);
});
expect(pipelinePreviewMock).toHaveBeenCalledTimes(1);
expect(pipelinePreviewMock.mock.calls[0]?.[0]).toMatchObject({
sourceUrl: "https://cdn.example.com/source.png",
steps: [
{
nodeId: "crop-1",
type: "crop",
params: {
crop: { x: 0.1, y: 0.2, width: 0.5, height: 0.4 },
resize: { mode: "source", fit: "cover", keepAspect: true },
},
},
{
nodeId: "curves-1",
type: "curves",
params: currentParams,
},
],
});
});
});

110
tests/agent-node.test.ts Normal file
View File

@@ -0,0 +1,110 @@
// @vitest-environment jsdom
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const handleCalls: Array<{ type: string; id?: string }> = [];
vi.mock("@/components/canvas/nodes/base-node-wrapper", () => ({
default: ({ children }: { children: React.ReactNode }) => React.createElement("div", null, children),
}));
vi.mock("@xyflow/react", () => ({
Handle: ({ type, id }: { type: string; id?: string }) => {
handleCalls.push({ type, id });
return React.createElement("div", {
"data-handle-type": type,
"data-handle-id": id,
});
},
Position: { Left: "left", Right: "right" },
}));
import AgentNode from "@/components/canvas/nodes/agent-node";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
describe("AgentNode", () => {
let container: HTMLDivElement | null = null;
let root: Root | null = null;
beforeEach(() => {
handleCalls.length = 0;
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
container?.remove();
container = null;
root = null;
});
it("renders campaign distributor metadata and input-only handle", async () => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
React.createElement(AgentNode, {
id: "agent-1",
selected: false,
dragging: false,
draggable: true,
selectable: true,
deletable: true,
zIndex: 1,
isConnectable: true,
type: "agent",
data: {
templateId: "campaign-distributor",
_status: "idle",
} as Record<string, unknown>,
positionAbsoluteX: 0,
positionAbsoluteY: 0,
}),
);
});
expect(container.textContent).toContain("Campaign Distributor");
expect(container.textContent).toContain("Instagram Feed");
expect(container.textContent).toContain("Caption-Pakete");
expect(handleCalls.filter((call) => call.type === "target")).toHaveLength(1);
expect(handleCalls.filter((call) => call.type === "source")).toHaveLength(0);
});
it("falls back to the default template when templateId is missing", async () => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
React.createElement(AgentNode, {
id: "agent-2",
selected: true,
dragging: false,
draggable: true,
selectable: true,
deletable: true,
zIndex: 1,
isConnectable: true,
type: "agent",
data: {
_status: "done",
} as Record<string, unknown>,
positionAbsoluteX: 0,
positionAbsoluteY: 0,
}),
);
});
expect(container.textContent).toContain("Campaign Distributor");
});
});

View File

@@ -159,4 +159,42 @@ describe("canvas connection policy", () => {
getCanvasConnectionValidationMessage("ai-video-source-invalid"),
).toBe("KI-Video-Ausgabe akzeptiert nur Eingaben von KI-Video.");
});
it("allows render to agent", () => {
expect(
validateCanvasConnectionPolicy({
sourceType: "render",
targetType: "agent",
targetIncomingCount: 0,
}),
).toBeNull();
});
it("allows compare to agent", () => {
expect(
validateCanvasConnectionPolicy({
sourceType: "compare",
targetType: "agent",
targetIncomingCount: 0,
}),
).toBeNull();
});
it("blocks prompt to agent", () => {
expect(
validateCanvasConnectionPolicy({
sourceType: "prompt",
targetType: "agent",
targetIncomingCount: 0,
}),
).toBe("agent-source-invalid");
});
it("describes invalid agent source message", () => {
expect(
getCanvasConnectionValidationMessage("agent-source-invalid"),
).toBe(
"Agent-Nodes akzeptieren nur Content- und Kontext-Inputs, keine Generierungs-Steuerknoten wie Prompt.",
);
});
});

View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { AGENT_TEMPLATES, getAgentTemplate } from "@/lib/agent-templates";
describe("agent templates", () => {
it("registers the campaign distributor template", () => {
expect(AGENT_TEMPLATES.map((template) => template.id)).toEqual([
"campaign-distributor",
]);
});
it("exposes normalized metadata needed by the canvas node", () => {
const template = getAgentTemplate("campaign-distributor");
expect(template?.name).toBe("Campaign Distributor");
expect(template?.color).toBe("yellow");
expect(template?.tools).toContain("WebFetch");
expect(template?.channels).toContain("Instagram Feed");
expect(template?.expectedInputs).toContain("Render-Node-Export");
expect(template?.expectedOutputs).toContain("Caption-Pakete");
});
});

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { nodeTypes } from "@/components/canvas/node-types";
import { CANVAS_NODE_TEMPLATES } from "@/lib/canvas-node-templates";
import { NODE_CATALOG, isNodePaletteEnabled } from "@/lib/canvas-node-catalog";
import { NODE_DEFAULTS, NODE_HANDLE_MAP } from "@/lib/canvas-utils";
describe("canvas agent config", () => {
it("registers the agent node type", () => {
expect(nodeTypes.agent).toBeTypeOf("function");
});
it("adds a campaign distributor palette template", () => {
expect(CANVAS_NODE_TEMPLATES.find((template) => template.type === "agent")?.label).toBe(
"Campaign Distributor",
);
});
it("enables the agent in the catalog", () => {
const entry = NODE_CATALOG.find((item) => item.type === "agent");
expect(entry).toBeDefined();
expect(entry && isNodePaletteEnabled(entry)).toBe(true);
});
it("keeps the agent input-only in MVP", () => {
expect(NODE_HANDLE_MAP.agent?.target).toBe("agent-in");
expect(NODE_HANDLE_MAP.agent?.source).toBeUndefined();
expect(NODE_DEFAULTS.agent?.data).toMatchObject({
templateId: "campaign-distributor",
});
});
});