@nebutra/atelier-canvas 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +33 -0
- package/.turbo/turbo-test.log +15 -0
- package/.turbo/turbo-typecheck.log +4 -0
- package/CHANGELOG.md +50 -0
- package/LICENSE +676 -0
- package/dist/agent/index.d.ts +69 -0
- package/dist/agent/index.js +166 -0
- package/dist/agent/index.js.map +1 -0
- package/dist/chunk-AVQEQBLX.js +43 -0
- package/dist/chunk-AVQEQBLX.js.map +1 -0
- package/dist/chunk-JYV7NRGL.js +96 -0
- package/dist/chunk-JYV7NRGL.js.map +1 -0
- package/dist/chunk-X5VVODXM.js +71 -0
- package/dist/chunk-X5VVODXM.js.map +1 -0
- package/dist/index.d.ts +88 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -0
- package/dist/store/memory.d.ts +22 -0
- package/dist/store/memory.js +7 -0
- package/dist/store/memory.js.map +1 -0
- package/dist/store/prisma.d.ts +69 -0
- package/dist/store/prisma.js +7 -0
- package/dist/store/prisma.js.map +1 -0
- package/dist/types-Dw9o09h2.d.ts +80 -0
- package/package.json +57 -0
- package/src/__tests__/atelier-canvas.test.ts +96 -0
- package/src/agent/__tests__/agent.test.ts +74 -0
- package/src/agent/agent.ts +34 -0
- package/src/agent/index.ts +16 -0
- package/src/agent/prompts.ts +64 -0
- package/src/agent/tools.ts +148 -0
- package/src/index.ts +33 -0
- package/src/lock.ts +39 -0
- package/src/placement.ts +67 -0
- package/src/service.ts +115 -0
- package/src/store/memory.ts +59 -0
- package/src/store/prisma.ts +119 -0
- package/src/types.ts +92 -0
- package/tsconfig.json +12 -0
- package/tsup.config.ts +11 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { a as CanvasStore, A as AtelierCanvas, d as CanvasScene } from '../types-Dw9o09h2.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* In-memory CanvasStore — the default for tests and the flag-gated demo.
|
|
5
|
+
*
|
|
6
|
+
* Storage mechanics (tenant-keyed map, never returning rows across tenants,
|
|
7
|
+
* tenant-filtered listing) are delegated to `InMemoryTenantStore` from
|
|
8
|
+
* `@nebutra/tenant-store`; this class only adds atelier's typed `create` /
|
|
9
|
+
* `save` domain shape, mirroring what RLS does in the Prisma store.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
declare class InMemoryCanvasStore implements CanvasStore {
|
|
13
|
+
private readonly base;
|
|
14
|
+
get(tenantId: string, canvasId: string): Promise<AtelierCanvas | null>;
|
|
15
|
+
create(tenantId: string, canvasId: string, name: string): Promise<AtelierCanvas>;
|
|
16
|
+
save(tenantId: string, canvasId: string, scene: CanvasScene, thumbnail?: string): Promise<AtelierCanvas>;
|
|
17
|
+
list(tenantId: string): Promise<readonly AtelierCanvas[]>;
|
|
18
|
+
/** Test helper. */
|
|
19
|
+
_clear(): void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export { InMemoryCanvasStore };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { a as CanvasStore, A as AtelierCanvas, d as CanvasScene } from '../types-Dw9o09h2.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Prisma-backed CanvasStore — the production persistence adapter.
|
|
5
|
+
*
|
|
6
|
+
* Decoupled from `@nebutra/db`'s generated client by a narrow structural
|
|
7
|
+
* interface, so this package typechecks and tests without running
|
|
8
|
+
* `prisma generate`. Wire it in the app by passing a `getDb` that returns
|
|
9
|
+
* `getTenantDb(tenantId)` (RLS-scoped) — that is what enforces tenant
|
|
10
|
+
* isolation at the database layer; this adapter never calls `getSystemDb()`.
|
|
11
|
+
*
|
|
12
|
+
* The `AtelierCanvas` Prisma model (see packages/platform/db/prisma/
|
|
13
|
+
* schema.prisma) stores the scene as a single JSON column — the same
|
|
14
|
+
* one-blob-per-canvas shape the source product used, but with an
|
|
15
|
+
* `organization_id` column + RLS instead of an implicit single user.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Row shape as persisted (scene/files as JSON). */
|
|
19
|
+
interface AtelierCanvasRow {
|
|
20
|
+
id: string;
|
|
21
|
+
organizationId: string;
|
|
22
|
+
name: string;
|
|
23
|
+
scene: unknown;
|
|
24
|
+
thumbnail: string | null;
|
|
25
|
+
updatedAt: Date;
|
|
26
|
+
}
|
|
27
|
+
/** The subset of the generated delegate this adapter needs. */
|
|
28
|
+
interface AtelierCanvasDelegate {
|
|
29
|
+
findUnique(args: {
|
|
30
|
+
where: {
|
|
31
|
+
organizationId_id: {
|
|
32
|
+
organizationId: string;
|
|
33
|
+
id: string;
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
}): Promise<AtelierCanvasRow | null>;
|
|
37
|
+
upsert(args: {
|
|
38
|
+
where: {
|
|
39
|
+
organizationId_id: {
|
|
40
|
+
organizationId: string;
|
|
41
|
+
id: string;
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
create: Omit<AtelierCanvasRow, "updatedAt">;
|
|
45
|
+
update: Partial<Omit<AtelierCanvasRow, "id" | "organizationId">>;
|
|
46
|
+
}): Promise<AtelierCanvasRow>;
|
|
47
|
+
findMany(args: {
|
|
48
|
+
where: {
|
|
49
|
+
organizationId: string;
|
|
50
|
+
};
|
|
51
|
+
}): Promise<AtelierCanvasRow[]>;
|
|
52
|
+
}
|
|
53
|
+
interface TenantDbLike {
|
|
54
|
+
atelierCanvas: AtelierCanvasDelegate;
|
|
55
|
+
}
|
|
56
|
+
declare class PrismaCanvasStore implements CanvasStore {
|
|
57
|
+
private readonly getDb;
|
|
58
|
+
/**
|
|
59
|
+
* @param getDb returns an RLS-scoped tenant client. In the app:
|
|
60
|
+
* `new PrismaCanvasStore((t) => getTenantDb(t) as unknown as TenantDbLike)`
|
|
61
|
+
*/
|
|
62
|
+
constructor(getDb: (tenantId: string) => Promise<TenantDbLike> | TenantDbLike);
|
|
63
|
+
get(tenantId: string, canvasId: string): Promise<AtelierCanvas | null>;
|
|
64
|
+
create(tenantId: string, canvasId: string, name: string): Promise<AtelierCanvas>;
|
|
65
|
+
save(tenantId: string, canvasId: string, scene: CanvasScene, thumbnail?: string): Promise<AtelierCanvas>;
|
|
66
|
+
list(tenantId: string): Promise<readonly AtelierCanvas[]>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export { type AtelierCanvasDelegate, PrismaCanvasStore, type TenantDbLike };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scene model for the creative canvas.
|
|
3
|
+
*
|
|
4
|
+
* A narrow, transport-stable subset of the Excalidraw element shape — enough
|
|
5
|
+
* to place agent-generated media deterministically on the server without
|
|
6
|
+
* pulling Excalidraw as a server dependency. The browser holds the full
|
|
7
|
+
* Excalidraw scene; the server is authoritative only for *placement* and
|
|
8
|
+
* *persistence*, exactly the property that makes refresh == realtime.
|
|
9
|
+
*/
|
|
10
|
+
type CanvasElementType = "image" | "embeddable" | "text";
|
|
11
|
+
interface CanvasElement {
|
|
12
|
+
readonly id: string;
|
|
13
|
+
readonly type: CanvasElementType;
|
|
14
|
+
/** Top-left x in scene coordinates. */
|
|
15
|
+
readonly x: number;
|
|
16
|
+
/** Top-left y in scene coordinates. */
|
|
17
|
+
readonly y: number;
|
|
18
|
+
readonly width: number;
|
|
19
|
+
readonly height: number;
|
|
20
|
+
/**
|
|
21
|
+
* For `image` — a file id resolvable in `scene.files`.
|
|
22
|
+
* For `embeddable` — the media URL (video).
|
|
23
|
+
* For `text` — the literal string.
|
|
24
|
+
*/
|
|
25
|
+
readonly ref: string;
|
|
26
|
+
/** Free-form, provider/agent attribution (prompt, model, etc.). */
|
|
27
|
+
readonly meta?: Readonly<Record<string, unknown>>;
|
|
28
|
+
}
|
|
29
|
+
interface CanvasFile {
|
|
30
|
+
readonly id: string;
|
|
31
|
+
readonly mimeType: string;
|
|
32
|
+
/** `data:` URI or remote URL. */
|
|
33
|
+
readonly dataURL: string;
|
|
34
|
+
}
|
|
35
|
+
interface CanvasScene {
|
|
36
|
+
readonly elements: readonly CanvasElement[];
|
|
37
|
+
readonly files: readonly CanvasFile[];
|
|
38
|
+
}
|
|
39
|
+
interface AtelierCanvas {
|
|
40
|
+
readonly id: string;
|
|
41
|
+
/** Owning organization — every read/write is scoped by this. */
|
|
42
|
+
readonly tenantId: string;
|
|
43
|
+
readonly name: string;
|
|
44
|
+
readonly scene: CanvasScene;
|
|
45
|
+
/** Latest generated asset, for list/grid previews. */
|
|
46
|
+
readonly thumbnail?: string;
|
|
47
|
+
readonly updatedAt: Date;
|
|
48
|
+
}
|
|
49
|
+
/** Size of an element to be placed. */
|
|
50
|
+
interface ElementSize {
|
|
51
|
+
readonly width: number;
|
|
52
|
+
readonly height: number;
|
|
53
|
+
}
|
|
54
|
+
/** Resolved top-left position in scene coordinates. */
|
|
55
|
+
interface Placement {
|
|
56
|
+
readonly x: number;
|
|
57
|
+
readonly y: number;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The minimal patch produced by a server-side placement. Persisted *before*
|
|
61
|
+
* it is broadcast, so the websocket message is a pure UI optimization: a
|
|
62
|
+
* client that missed it recovers the identical state on reload.
|
|
63
|
+
*/
|
|
64
|
+
interface ScenePatch {
|
|
65
|
+
readonly canvasId: string;
|
|
66
|
+
readonly tenantId: string;
|
|
67
|
+
readonly element: CanvasElement;
|
|
68
|
+
readonly file?: CanvasFile;
|
|
69
|
+
readonly thumbnail?: string;
|
|
70
|
+
}
|
|
71
|
+
/** Tenant-scoped persistence boundary (repository pattern). */
|
|
72
|
+
interface CanvasStore {
|
|
73
|
+
get(tenantId: string, canvasId: string): Promise<AtelierCanvas | null>;
|
|
74
|
+
create(tenantId: string, canvasId: string, name: string): Promise<AtelierCanvas>;
|
|
75
|
+
/** Replace the persisted scene + thumbnail for a canvas. */
|
|
76
|
+
save(tenantId: string, canvasId: string, scene: CanvasScene, thumbnail?: string): Promise<AtelierCanvas>;
|
|
77
|
+
list(tenantId: string): Promise<readonly AtelierCanvas[]>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type { AtelierCanvas as A, CanvasElement as C, ElementSize as E, Placement as P, ScenePatch as S, CanvasStore as a, CanvasElementType as b, CanvasFile as c, CanvasScene as d };
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nebutra/atelier-canvas",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Server-authoritative creative-canvas engine: scene model, non-overlap placement, write-then-broadcast consistency, tenant-scoped locking",
|
|
5
|
+
"private": false,
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"nebutra": {
|
|
9
|
+
"featureId": "atelier-canvas",
|
|
10
|
+
"category": "ai",
|
|
11
|
+
"summary": "Server-authoritative creative-canvas engine"
|
|
12
|
+
},
|
|
13
|
+
"main": "./src/index.ts",
|
|
14
|
+
"types": "./src/index.ts",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": "./src/index.ts",
|
|
17
|
+
"./store/memory": "./src/store/memory.ts",
|
|
18
|
+
"./store/prisma": "./src/store/prisma.ts",
|
|
19
|
+
"./agent": "./src/agent/index.ts"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@nebutra/logger": "0.1.0",
|
|
23
|
+
"@nebutra/tenant-store": "0.2.0"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"@nebutra/agents": "1.1.0"
|
|
27
|
+
},
|
|
28
|
+
"peerDependenciesMeta": {
|
|
29
|
+
"@nebutra/agents": {
|
|
30
|
+
"optional": true
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/node": "^22.19.15",
|
|
35
|
+
"tsup": "^8.5.1",
|
|
36
|
+
"typescript": "^5.9.3",
|
|
37
|
+
"vitest": "^4.0.18",
|
|
38
|
+
"@nebutra/agents": "1.1.0"
|
|
39
|
+
},
|
|
40
|
+
"homepage": "https://github.com/Nebutra/Nebutra-Sailor/tree/main/packages/ai/atelier-canvas#readme",
|
|
41
|
+
"repository": {
|
|
42
|
+
"type": "git",
|
|
43
|
+
"url": "git+https://github.com/Nebutra/Nebutra-Sailor.git",
|
|
44
|
+
"directory": "packages/ai/atelier-canvas"
|
|
45
|
+
},
|
|
46
|
+
"bugs": {
|
|
47
|
+
"url": "https://github.com/Nebutra/Nebutra-Sailor/issues"
|
|
48
|
+
},
|
|
49
|
+
"publishConfig": {
|
|
50
|
+
"access": "public"
|
|
51
|
+
},
|
|
52
|
+
"scripts": {
|
|
53
|
+
"build": "tsup",
|
|
54
|
+
"test": "vitest run",
|
|
55
|
+
"typecheck": "tsc --noEmit"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canvas engine: deterministic placement, tenant isolation, write-then-
|
|
3
|
+
* broadcast consistency, and per-canvas serialization.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
7
|
+
import { _resetCanvasLocks } from "../lock";
|
|
8
|
+
import { findNextPosition } from "../placement";
|
|
9
|
+
import { type GeneratedAsset, placeGeneratedAsset } from "../service";
|
|
10
|
+
import { InMemoryCanvasStore } from "../store/memory";
|
|
11
|
+
import type { CanvasElement } from "../types";
|
|
12
|
+
|
|
13
|
+
const img = (over: Partial<GeneratedAsset> = {}): GeneratedAsset => ({
|
|
14
|
+
modality: "image",
|
|
15
|
+
mimeType: "image/svg+xml",
|
|
16
|
+
url: "data:image/svg+xml;base64,AAAA",
|
|
17
|
+
width: 512,
|
|
18
|
+
height: 512,
|
|
19
|
+
...over,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
_resetCanvasLocks();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("findNextPosition", () => {
|
|
27
|
+
it("places the first element at the origin", () => {
|
|
28
|
+
expect(findNextPosition([], { width: 100, height: 100 })).toEqual({ x: 0, y: 0 });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("never overlaps an existing element (respects gap)", () => {
|
|
32
|
+
const existing: CanvasElement[] = [
|
|
33
|
+
{ id: "a", type: "image", x: 0, y: 0, width: 200, height: 200, ref: "f" },
|
|
34
|
+
];
|
|
35
|
+
const p = findNextPosition(existing, { width: 200, height: 200 });
|
|
36
|
+
const clash = p.x < 200 + 40 && p.x + 200 + 40 > 0 && p.y < 200 + 40 && p.y + 200 + 40 > 0;
|
|
37
|
+
expect(clash).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("is deterministic for identical input", () => {
|
|
41
|
+
const existing: CanvasElement[] = [
|
|
42
|
+
{ id: "a", type: "image", x: 0, y: 0, width: 300, height: 100, ref: "f" },
|
|
43
|
+
];
|
|
44
|
+
const a = findNextPosition(existing, { width: 100, height: 100 });
|
|
45
|
+
const b = findNextPosition(existing, { width: 100, height: 100 });
|
|
46
|
+
expect(a).toEqual(b);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("placeGeneratedAsset", () => {
|
|
51
|
+
it("persists before returning the patch (scene is durable)", async () => {
|
|
52
|
+
const store = new InMemoryCanvasStore();
|
|
53
|
+
const { patch } = await placeGeneratedAsset(store, "org_1", "c1", img());
|
|
54
|
+
|
|
55
|
+
const reloaded = await store.get("org_1", "c1");
|
|
56
|
+
expect(reloaded?.scene.elements).toHaveLength(1);
|
|
57
|
+
expect(reloaded?.scene.elements[0]?.id).toBe(patch.element.id);
|
|
58
|
+
expect(reloaded?.scene.files).toHaveLength(1);
|
|
59
|
+
expect(reloaded?.thumbnail).toBe(patch.thumbnail);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("isolates canvases by tenant", async () => {
|
|
63
|
+
const store = new InMemoryCanvasStore();
|
|
64
|
+
await placeGeneratedAsset(store, "org_a", "shared-id", img());
|
|
65
|
+
await placeGeneratedAsset(store, "org_b", "shared-id", img());
|
|
66
|
+
|
|
67
|
+
expect((await store.get("org_a", "shared-id"))?.scene.elements).toHaveLength(1);
|
|
68
|
+
expect(await store.list("org_a")).toHaveLength(1);
|
|
69
|
+
expect(await store.list("org_b")).toHaveLength(1);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("embeds video by URL (no file entry)", async () => {
|
|
73
|
+
const store = new InMemoryCanvasStore();
|
|
74
|
+
const { patch } = await placeGeneratedAsset(
|
|
75
|
+
store,
|
|
76
|
+
"org_1",
|
|
77
|
+
"c1",
|
|
78
|
+
img({ modality: "video", url: "https://x.test/v.mp4", mimeType: "video/mp4" }),
|
|
79
|
+
);
|
|
80
|
+
expect(patch.element.type).toBe("embeddable");
|
|
81
|
+
expect(patch.element.ref).toBe("https://x.test/v.mp4");
|
|
82
|
+
expect(patch.file).toBeUndefined();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("serializes concurrent placements on the same canvas (no stacking)", async () => {
|
|
86
|
+
const store = new InMemoryCanvasStore();
|
|
87
|
+
await Promise.all(
|
|
88
|
+
Array.from({ length: 6 }, () => placeGeneratedAsset(store, "org_1", "c1", img())),
|
|
89
|
+
);
|
|
90
|
+
const scene = (await store.get("org_1", "c1"))?.scene;
|
|
91
|
+
expect(scene?.elements).toHaveLength(6);
|
|
92
|
+
// Every element occupies a distinct position.
|
|
93
|
+
const positions = new Set(scene?.elements.map((e) => `${e.x},${e.y}`));
|
|
94
|
+
expect(positions.size).toBe(6);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The /agent subpath closes the loop end-to-end (mock provider): prompt →
|
|
3
|
+
* asset → server placement → durable scene → broadcast hook. Importing this
|
|
4
|
+
* module pulls @nebutra/agents (optional peer); the core export does not.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createAgentContext } from "@nebutra/agents";
|
|
8
|
+
import { describe, expect, it } from "vitest";
|
|
9
|
+
import { InMemoryCanvasStore } from "../../store/memory";
|
|
10
|
+
import type { ScenePatch } from "../../types";
|
|
11
|
+
import { createAtelierAgent } from "../agent";
|
|
12
|
+
import { ATELIER_SYSTEM_PROMPT } from "../prompts";
|
|
13
|
+
import { createAtelierGenerationTool } from "../tools";
|
|
14
|
+
|
|
15
|
+
const ctx = createAgentContext("org_1", "user_1", "conv_1");
|
|
16
|
+
|
|
17
|
+
describe("atelier system prompt", () => {
|
|
18
|
+
it("encodes the absorbed creative invariants", () => {
|
|
19
|
+
expect(ATELIER_SYSTEM_PROMPT).toContain("Design Strategy");
|
|
20
|
+
expect(ATELIER_SYSTEM_PROMPT).toContain("QUANTITY IS A CONTRACT");
|
|
21
|
+
expect(ATELIER_SYSTEM_PROMPT).toContain("batches of at most 10");
|
|
22
|
+
expect(ATELIER_SYSTEM_PROMPT).toContain("input_images");
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("createAtelierGenerationTool", () => {
|
|
27
|
+
it("generates, places, persists, then fires the broadcast hook", async () => {
|
|
28
|
+
const store = new InMemoryCanvasStore();
|
|
29
|
+
const broadcasts: ScenePatch[] = [];
|
|
30
|
+
const tool = createAtelierGenerationTool({
|
|
31
|
+
store,
|
|
32
|
+
onPlaced: (p) => {
|
|
33
|
+
broadcasts.push(p);
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const out = (await tool.execute(
|
|
38
|
+
{ canvasId: "c1", prompt: "a serene alpine lake at dawn" },
|
|
39
|
+
ctx,
|
|
40
|
+
)) as { ok: boolean; placed: { elementId: string; modality: string } };
|
|
41
|
+
|
|
42
|
+
expect(out.ok).toBe(true);
|
|
43
|
+
expect(out.placed.modality).toBe("image");
|
|
44
|
+
|
|
45
|
+
const canvas = await store.get("org_1", "c1");
|
|
46
|
+
expect(canvas?.scene.elements).toHaveLength(1);
|
|
47
|
+
expect(broadcasts).toHaveLength(1);
|
|
48
|
+
expect(broadcasts[0]?.element.id).toBe(out.placed.elementId);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("rejects missing required input", async () => {
|
|
52
|
+
const tool = createAtelierGenerationTool({ store: new InMemoryCanvasStore() });
|
|
53
|
+
await expect(tool.execute({ canvasId: "c1" }, ctx)).rejects.toThrow("prompt");
|
|
54
|
+
await expect(tool.execute({ prompt: "x" }, ctx)).rejects.toThrow("canvasId");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("places video as an embeddable (no file)", async () => {
|
|
58
|
+
const store = new InMemoryCanvasStore();
|
|
59
|
+
const tool = createAtelierGenerationTool({ store });
|
|
60
|
+
await tool.execute({ canvasId: "c1", prompt: "loop of falling snow", modality: "video" }, ctx);
|
|
61
|
+
const el = (await store.get("org_1", "c1"))?.scene.elements[0];
|
|
62
|
+
expect(el?.type).toBe("embeddable");
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("createAtelierAgent", () => {
|
|
67
|
+
it("produces a single-tool AgentConfig carrying the strategy prompt", () => {
|
|
68
|
+
const agent = createAtelierAgent({ store: new InMemoryCanvasStore() });
|
|
69
|
+
expect(agent.id).toBe("atelier");
|
|
70
|
+
expect(agent.tools).toHaveLength(1);
|
|
71
|
+
expect(agent.tools?.[0]?.name).toBe("atelier_generate");
|
|
72
|
+
expect(agent.instructions).toBe(ATELIER_SYSTEM_PROMPT);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The creative-canvas agent, as a single Sailor `AgentConfig`.
|
|
3
|
+
*
|
|
4
|
+
* The source product ran a two-node LangGraph swarm (planner ⇄ creator). Both
|
|
5
|
+
* roles are expressed here as one prompt with two phases plus one tool — the
|
|
6
|
+
* planner/creator split was orchestration overhead, not product value, and
|
|
7
|
+
* Sailor's `BaseAgent` tool-loop already provides the step iteration the swarm
|
|
8
|
+
* was emulating. `maxSteps` is the `@nebutra/agents` AgentConfig field (the
|
|
9
|
+
* provider maps it to `stopWhen: stepCountIs(...)` internally) — not an AI SDK
|
|
10
|
+
* option.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { AgentConfig } from "@nebutra/agents";
|
|
14
|
+
import { ATELIER_SYSTEM_PROMPT } from "./prompts";
|
|
15
|
+
import { type AtelierToolDeps, createAtelierGenerationTool } from "./tools";
|
|
16
|
+
|
|
17
|
+
export interface CreateAtelierAgentOptions extends AtelierToolDeps {
|
|
18
|
+
/** Model id / preset. Defaults to the "flagship" preset. */
|
|
19
|
+
readonly model?: string;
|
|
20
|
+
/** Max tool-loop iterations (batch generation needs headroom). */
|
|
21
|
+
readonly maxSteps?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createAtelierAgent(opts: CreateAtelierAgentOptions): AgentConfig {
|
|
25
|
+
return {
|
|
26
|
+
id: "atelier",
|
|
27
|
+
name: "Atelier",
|
|
28
|
+
description: "Plans and generates images/videos onto a creative canvas from a brief.",
|
|
29
|
+
model: opts.model ?? "flagship",
|
|
30
|
+
instructions: ATELIER_SYSTEM_PROMPT,
|
|
31
|
+
tools: [createAtelierGenerationTool(opts)],
|
|
32
|
+
maxSteps: opts.maxSteps ?? 30,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nebutra/atelier-canvas/agent — the canvas capability's agent face.
|
|
3
|
+
*
|
|
4
|
+
* Opt-in subpath: importing it pulls `@nebutra/agents` (an optional peer);
|
|
5
|
+
* the package's core export (placement / store / consistency) stays free of
|
|
6
|
+
* any agents dependency. This is a *consumer* of the agents runtime — it
|
|
7
|
+
* yields an `AgentConfig` — never a runtime parallel to it.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export { type CreateAtelierAgentOptions, createAtelierAgent } from "./agent";
|
|
11
|
+
export {
|
|
12
|
+
ATELIER_CREATOR_RULES,
|
|
13
|
+
ATELIER_PLANNER_RULES,
|
|
14
|
+
ATELIER_SYSTEM_PROMPT,
|
|
15
|
+
} from "./prompts";
|
|
16
|
+
export { type AtelierToolDeps, createAtelierGenerationTool } from "./tools";
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The creative-direction prompt strategy.
|
|
3
|
+
*
|
|
4
|
+
* This is the differentiated IP absorbed from the source product, re-expressed
|
|
5
|
+
* in Sailor's voice: a two-phase loop (plan → create), a structured "Design
|
|
6
|
+
* Strategy" the model must author before generating, and the operational
|
|
7
|
+
* invariants that make multi-asset generation reliable — exact-quantity
|
|
8
|
+
* preservation, bounded batches, reference-image parsing, and act-without-
|
|
9
|
+
* asking. These rules, not the model choice, are what make output consistent.
|
|
10
|
+
*
|
|
11
|
+
* Lives under the `/agent` subpath, not a package parallel to
|
|
12
|
+
* `@nebutra/agents`: it produces an `AgentConfig` *consumed by* the agents
|
|
13
|
+
* runtime, it is not a second runtime.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Phase 1 — decompose intent into an ordered plan. */
|
|
17
|
+
export const ATELIER_PLANNER_RULES = `\
|
|
18
|
+
You are a creative-canvas director. Reply in the SAME LANGUAGE as the user.
|
|
19
|
+
|
|
20
|
+
PLAN FIRST:
|
|
21
|
+
- For any multi-step request, write a short ordered plan (high-level steps)
|
|
22
|
+
before generating anything. For a single image with no extra steps, a plan
|
|
23
|
+
is optional.
|
|
24
|
+
- Then proceed to generate immediately. Do NOT ask the user to approve the
|
|
25
|
+
plan — acting is expected.
|
|
26
|
+
|
|
27
|
+
QUANTITY IS A CONTRACT:
|
|
28
|
+
- If the user states a count ("20 images", "make 15 frames"), carry that
|
|
29
|
+
EXACT number through the plan and the generation calls. Never silently
|
|
30
|
+
change or drop it. If no count is given, assume 1.`;
|
|
31
|
+
|
|
32
|
+
/** Phase 2 — author a design strategy, then generate. */
|
|
33
|
+
export const ATELIER_CREATOR_RULES = `\
|
|
34
|
+
BEFORE GENERATING AN IMAGE, author a brief Design Strategy in the user's
|
|
35
|
+
language covering, as bullets:
|
|
36
|
+
• Recommended resolution (and why it fits the use case)
|
|
37
|
+
• Style & mood
|
|
38
|
+
• Key visual element(s)
|
|
39
|
+
• Composition & layout
|
|
40
|
+
• Colour palette (concrete values)
|
|
41
|
+
• Typography (only if the piece carries text)
|
|
42
|
+
|
|
43
|
+
Then call the generation tool with ONE detailed, professional prompt derived
|
|
44
|
+
from that strategy. The strategy is scaffolding for the prompt — keep it tight,
|
|
45
|
+
not an essay.
|
|
46
|
+
|
|
47
|
+
REFERENCE IMAGES:
|
|
48
|
+
- The user message may carry references as XML:
|
|
49
|
+
<input_images><image file_id="..."/></input_images>
|
|
50
|
+
- When present, extract every file_id and pass them to the tool's
|
|
51
|
+
\`inputImages\` argument so edits/variations are grounded in the source.
|
|
52
|
+
|
|
53
|
+
BATCHING:
|
|
54
|
+
- Need more than 10 assets? Generate in batches of at most 10, finishing and
|
|
55
|
+
acknowledging each batch ("Batch 1 of 3 done") before starting the next.
|
|
56
|
+
|
|
57
|
+
VIDEO:
|
|
58
|
+
- For video, you may first generate the key still(s) and animate from them, or
|
|
59
|
+
generate directly from a text prompt — choose what the brief implies.`;
|
|
60
|
+
|
|
61
|
+
/** Composed system prompt for a single-agent creative-canvas operator. */
|
|
62
|
+
export const ATELIER_SYSTEM_PROMPT = `${ATELIER_PLANNER_RULES}
|
|
63
|
+
|
|
64
|
+
${ATELIER_CREATOR_RULES}`;
|