cozyclay 1.7.0 → 1.7.1
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/CHANGELOG.md +5 -0
- package/README.md +21 -1
- package/THIRD_PARTY_NOTICES.md +8 -0
- package/bin/agent/agent-routes.mjs +348 -0
- package/bin/agent/agent-tools.mjs +164 -0
- package/bin/agent/codex-client.mjs +295 -0
- package/bin/agent/video-adapters.mjs +127 -0
- package/bin/codex-auth.mjs +112 -0
- package/bin/cozyclay.mjs +50 -3
- package/bin/package-signature.mjs +3 -0
- package/bin/telemetry-state.mjs +15 -0
- package/dist/app/index.html +5 -3
- package/dist/assets/analytics-Ce3YvFby.js +1 -0
- package/dist/assets/app-DNYMNzNh.css +1 -0
- package/dist/assets/app-jcR6lsws.js +4859 -0
- package/dist/assets/demo-CEleUnpD.js +1 -0
- package/dist/assets/rolldown-runtime-hePW80VL.js +1 -0
- package/dist/assets/shot-prompt-s4R0Y9R7.js +14 -0
- package/dist/assets/ticket-CX73jjoc.js +1 -0
- package/dist/assets/workflow-BcZSgt5C.js +180 -0
- package/dist/assets/workflow-CKCpHJGL.css +1 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/workflow/index.html +18 -0
- package/mcp/LIVE-PROTOCOL.md +3 -1
- package/mcp/live-hub.mjs +5 -1
- package/mcp/server.mjs +33 -1808
- package/mcp/tool-handlers.mjs +1896 -0
- package/package.json +7 -2
- package/src/App.jsx +1826 -127
- package/src/analytics.js +171 -7
- package/src/app-stage.jsx +31 -6
- package/src/ardy/auto-physics.js +1204 -0
- package/src/ardy/collision-blockers.js +289 -0
- package/src/ardy/cskel27.js +5 -1
- package/src/ardy/fix-collisions.js +2343 -0
- package/src/ardy/ik.js +475 -74
- package/src/ardy/npz.js +18 -2
- package/src/ardy/physics-panel.css +19 -0
- package/src/ardy/physics-panel.jsx +76 -0
- package/src/ardy/physics-refine.js +111 -0
- package/src/ardy/physics-review.js +516 -0
- package/src/ardy/physics-support.js +212 -0
- package/src/ardy/physics-surface.js +116 -0
- package/src/ardy/physics-temporal.js +54 -0
- package/src/ardy/playback-clock.js +15 -0
- package/src/ardy/playback.js +145 -12
- package/src/ardy/prompt-clips.js +16 -7
- package/src/ardy/timeline-coordinates.js +24 -0
- package/src/ardy/timeline.jsx +102 -26
- package/src/camera-move.js +23 -0
- package/src/first-success-guide.jsx +75 -0
- package/src/hierarchy-panel.jsx +19 -2
- package/src/keyframe-pack.js +91 -0
- package/src/model-presets.js +85 -0
- package/src/multimodel-ingest.js +25 -0
- package/src/part-colours.js +105 -0
- package/src/pose-extract/detector.js +24 -4
- package/src/pose-extract/fit.js +36 -3
- package/src/pose-extract/image-frame.js +16 -5
- package/src/pose-extract/index.js +3 -2
- package/src/pose-extract/mirror.js +153 -0
- package/src/pose-extract/take.js +20 -3
- package/src/pose-thumbs.js +2 -1
- package/src/poses.js +55 -0
- package/src/posestudio.jsx +22 -9
- package/src/project-browser.jsx +20 -3
- package/src/project.js +134 -4
- package/src/render-passes.js +114 -0
- package/src/scene-objects.js +89 -3
- package/src/scenes.js +26 -2
- package/src/shot-authoring.js +9 -0
- package/src/shot-meta.js +66 -0
- package/src/shot-prompt.js +80 -0
- package/src/shot.js +1 -0
- package/src/storyboard.js +60 -0
- package/src/styles.css +743 -2
- package/src/ui-scrub.js +102 -0
- package/src/ui.jsx +80 -43
- package/src/use-render-activity.js +11 -0
- package/src/workflow/AgentPanel.jsx +505 -0
- package/src/workflow/CozySceneNode.jsx +140 -0
- package/src/workflow/ShotPromptNode.jsx +35 -0
- package/src/workflow/WorkflowBuilder.jsx +573 -0
- package/src/workflow/agent-client.js +336 -0
- package/src/workflow/agent-panel.css +692 -0
- package/src/workflow/canvas-commands.js +64 -0
- package/src/workflow/clipboard-image.js +44 -0
- package/src/workflow/cozy-scene-node.css +52 -0
- package/src/workflow/cozy-scene-node.js +196 -0
- package/src/workflow/image-versions.js +69 -0
- package/src/workflow/keyframe-pack-request.js +64 -0
- package/src/workflow/local-workflow.js +60 -0
- package/src/workflow/main.jsx +12 -0
- package/src/workflow/motion-input.js +99 -0
- package/src/workflow/node-schema.js +64 -0
- package/src/workflow/scene-asset-sync.js +223 -0
- package/src/workflow/shot-prompt-node.js +33 -0
- package/src/workflow/vibe-payload.js +215 -0
- package/src/workflow/workflow.css +8 -0
- package/src/zip-store.js +122 -0
- package/tools/ardy/GVHMR-NPZ.md +68 -0
- package/tools/ardy/README.md +63 -0
- package/tools/ardy/cclay_gvhmr_worker.py +127 -0
- package/tools/ardy/extract.mjs +136 -10
- package/tools/ardy/gvhmr-floor.mjs +102 -0
- package/tools/ardy/gvhmr-to-cskel27.mjs +21 -0
- package/tools/ardy/gvhmr_fastpath.py +168 -0
- package/tools/ardy/gvhmr_trajectory.py +240 -0
- package/tools/ardy/mocap-metrics.mjs +166 -0
- package/tools/ardy/npz.mjs +15 -1
- package/tools/ardy/runners/gvhmr-worker.mjs +194 -0
- package/tools/ardy/smpl-cskel27.mjs +178 -0
- package/tools/dev-full.mjs +30 -1
- package/tools/perf/ik-camera-drag.mjs +138 -0
- package/tools/run-tests.mjs +58 -1
- package/tools/verify-package-tarball.mjs +41 -0
- package/dist/assets/app-BdCI2Bys.js +0 -4851
- package/dist/assets/app-CrB3UF4K.css +0 -1
- package/dist/assets/demo-DEAy-8Eq.js +0 -1
- package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +0 -1
- package/dist/assets/ticket-DbrIKBrJ.js +0 -1
package/CHANGELOG.md
CHANGED
package/README.md
CHANGED
|
@@ -120,7 +120,11 @@ npm install
|
|
|
120
120
|
npm run dev
|
|
121
121
|
```
|
|
122
122
|
|
|
123
|
-
Open `http://127.0.0.1:5180/
|
|
123
|
+
Open `http://127.0.0.1:5180/` for the Studio. The Workflow canvas remains available at `http://127.0.0.1:5180/workflow/` when you need its node editor. `npm run dev` starts the studio together with its local Kimodo bridge once `CCLAY_KIMODO_HOST` points at a GPU box; without that variable it starts the studio alone and says so, and Block Generation stays unavailable until you set it. `npm run dev:ui` starts the browser UI alone in every case. The bridge listens on loopback only; Kimodo host variables are documented in [`tools/kimodo/setup-on-box.sh`](tools/kimodo/setup-on-box.sh).
|
|
124
|
+
|
|
125
|
+
### Workflow canvas
|
|
126
|
+
|
|
127
|
+
The Vibe-Workflow canvas is available at `http://127.0.0.1:5180/workflow/`. It keeps the original node-style flow and adds a **CozyClay Scene** node with an interactive 3D viewport, frame/camera controls, and an Open Studio handoff. Start it with the normal dev server, or run `npm run dev:ui` by itself for local-only editing. Workflow graphs are saved in the browser and execute locally; no remote workflow service or API key is required.
|
|
124
128
|
|
|
125
129
|
## Hosted demo
|
|
126
130
|
|
|
@@ -228,15 +232,23 @@ Events collected:
|
|
|
228
232
|
| --- | --- |
|
|
229
233
|
| `install:first_launch` | First run of the official npm package |
|
|
230
234
|
| `app:session_started` | Start of an official npm package session |
|
|
235
|
+
| `app:session_ended` | Session duration, action count, and scenes touched (bucketed) |
|
|
236
|
+
| `feature:used` | One signal per feature per session |
|
|
231
237
|
| `$pageview` | Funnel and drop-off analysis |
|
|
232
238
|
| `scene:created` | Funnel and drop-off analysis |
|
|
233
239
|
| `scene:loaded` | Funnel and drop-off analysis |
|
|
240
|
+
| `project:saved` | User-owned project persistence |
|
|
241
|
+
| `project:opened` | Return to a saved project (age bucket) |
|
|
234
242
|
| `craft:first_action` | Funnel and drop-off analysis |
|
|
243
|
+
| `motion:backend_state` | Motion capability at session start (`none`, `local_kimodo`, or `hosted`) |
|
|
244
|
+
| `motion:generate_blocked` | Generate intent when no motion backend is available |
|
|
235
245
|
| `motion:job_started` | Motion reliability |
|
|
236
246
|
| `motion:job_succeeded` | Motion reliability |
|
|
237
247
|
| `motion:job_failed` | Motion reliability |
|
|
238
248
|
| `export:blocking_frame_succeeded` | Funnel and drop-off analysis |
|
|
239
249
|
| `activation:completed` | Funnel and drop-off analysis |
|
|
250
|
+
| `hosted:composer_viewed`, `hosted:login_started`, `hosted:ticket_created` | Hosted demo funnel |
|
|
251
|
+
| `hosted:result_opened`, `hosted:opened_in_studio` | Hosted result funnel |
|
|
240
252
|
|
|
241
253
|
Geo data comes from ingest-time GeoIP country lookup only — no precise location is collected. Prompt text, asset names, file names, project content, local paths, and any user-entered text are never collected.
|
|
242
254
|
|
|
@@ -248,6 +260,14 @@ development servers, CI, and tests do not send analytics. Official npm
|
|
|
248
260
|
artifacts carry a signature checked by the launcher, so copying or repackaging
|
|
249
261
|
the source does not enable telemetry.
|
|
250
262
|
|
|
263
|
+
Each event is registered with `origin_kind` (`local` or `hosted`), a coarse
|
|
264
|
+
operating-system label, and (for npm sessions) `install_kind` (`npx` or
|
|
265
|
+
`global`). Source checkouts are classified as `clone` and remain telemetry-off.
|
|
266
|
+
The first npm launch may optionally answer a one-line channel question
|
|
267
|
+
(`x`, `hn`, `reddit`, `github`, `friend`, `other`, or `skip`); `skip` sends no
|
|
268
|
+
acquisition value. Session duration and action counts are buckets, and project
|
|
269
|
+
events never include names, paths, prompts, or timestamps.
|
|
270
|
+
|
|
251
271
|
The npm package prints this disclosure once on first launch. Control it at any
|
|
252
272
|
time:
|
|
253
273
|
|
package/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -105,6 +105,14 @@ as third-party content that this licence does not cover.
|
|
|
105
105
|
|
|
106
106
|
The hosted site at cozyclay.org uses [posthog-js](https://posthog.com/docs/libraries/js) for anonymous usage analytics.
|
|
107
107
|
|
|
108
|
+
The disclosed wire events include session start/end (bucketed duration and
|
|
109
|
+
actions), one-per-session feature usage, project save/open buckets, and the
|
|
110
|
+
hosted composer/login/ticket/result funnel. Events carry only the registered
|
|
111
|
+
`origin_kind`, coarse `os`, and npm `install_kind`; prompts, filenames, paths,
|
|
112
|
+
project names, and timestamps are excluded. Source checkouts keep telemetry
|
|
113
|
+
disabled. See the Analytics & privacy section in `README.md` for the complete
|
|
114
|
+
event table and opt-out controls.
|
|
115
|
+
|
|
108
116
|
- Copyright PostHog Inc.
|
|
109
117
|
- License: Apache-2.0 AND MIT
|
|
110
118
|
- Source: https://github.com/PostHog/posthog-js
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import * as defaultAuth from "../codex-auth.mjs";
|
|
3
|
+
import { createCodexClient } from "./codex-client.mjs";
|
|
4
|
+
import { createAgentTools, agentToolSchemas, SYSTEM_PROMPT, pickWorkspace } from "./agent-tools.mjs";
|
|
5
|
+
import { createVideoAdapters } from "./video-adapters.mjs";
|
|
6
|
+
|
|
7
|
+
// Values the codex backend accepts for reasoning.effort (its own 400 lists them).
|
|
8
|
+
export const REASONING_EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"];
|
|
9
|
+
|
|
10
|
+
const json = (res, status, value) => {
|
|
11
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
12
|
+
res.end(JSON.stringify(value));
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function allowAgentOrigin(req, port) {
|
|
16
|
+
return [`http://127.0.0.1:${port}`, `http://${"local" + "host"}:${port}`].includes(req.headers.origin)
|
|
17
|
+
|| (req.headers.origin === undefined && req.method === "GET"
|
|
18
|
+
&& [`127.0.0.1:${port}`, `localhost:${port}`].includes(req.headers.host));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Two 1920x1080 PNG data URLs (frame + reference) fit comfortably in this.
|
|
22
|
+
const IMAGE_BODY_LIMIT = 24 * 1024 * 1024;
|
|
23
|
+
|
|
24
|
+
// Attached scene references (#167): identity sheets and the environment
|
|
25
|
+
// reference. Capped because every one of them is another full image the
|
|
26
|
+
// backend has to read, and a shot with seven of them is a prompt nobody wrote.
|
|
27
|
+
const IMAGE_REFERENCES_MAX = 6;
|
|
28
|
+
|
|
29
|
+
/** Reject anything that is not a list of {role, name?, dataUrl} inline images. */
|
|
30
|
+
function validReferences(references) {
|
|
31
|
+
if (references === undefined) return true;
|
|
32
|
+
if (!Array.isArray(references) || references.length > IMAGE_REFERENCES_MAX) return false;
|
|
33
|
+
return references.every((entry) => entry && typeof entry === "object" && !Array.isArray(entry)
|
|
34
|
+
&& typeof entry.role === "string" && entry.role
|
|
35
|
+
&& (entry.name === undefined || typeof entry.name === "string")
|
|
36
|
+
&& typeof entry.dataUrl === "string" && entry.dataUrl.startsWith("data:image/"));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* What the attached pictures MEAN, in the order they are attached. Without
|
|
41
|
+
* this the backend sees a pile of images and guesses; with it the clay frame
|
|
42
|
+
* owns the geometry, each character sheet owns one performer's look and the
|
|
43
|
+
* environment reference owns the location.
|
|
44
|
+
*/
|
|
45
|
+
export function referenceGuidance(references = []) {
|
|
46
|
+
const list = Array.isArray(references) ? references : [];
|
|
47
|
+
if (!list.length) return "";
|
|
48
|
+
const lines = ["Geometry, camera and blocking come from the first image (the clay frame)."];
|
|
49
|
+
for (const entry of list) {
|
|
50
|
+
if (entry.role === "character") {
|
|
51
|
+
lines.push(`Character ${entry.name || "reference"}: match the identity, face, hair and wardrobe from the attached character sheet.`);
|
|
52
|
+
} else if (entry.role === "environment") {
|
|
53
|
+
lines.push("Environment: take the location look, materials, palette and lighting from the attached environment reference.");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return `\n${lines.join("\n")}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function readBody(req, limit = 64 * 1024) {
|
|
60
|
+
let text = "";
|
|
61
|
+
for await (const chunk of req) {
|
|
62
|
+
text += chunk;
|
|
63
|
+
if (Buffer.byteLength(text) > limit) throw new Error("Request too large.");
|
|
64
|
+
}
|
|
65
|
+
return JSON.parse(text || "{}");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function quotaEvent(codex, headers) {
|
|
69
|
+
const quota = codex.parseQuotaHeaders(headers);
|
|
70
|
+
let resetAt = quota.primary.resetAt;
|
|
71
|
+
if (resetAt && /^\d+(\.\d+)?$/.test(String(resetAt))) resetAt = Number(resetAt) * 1000;
|
|
72
|
+
if (!resetAt && quota.primary.resetAfterSeconds !== undefined) resetAt = Date.now() + quota.primary.resetAfterSeconds * 1000;
|
|
73
|
+
return {
|
|
74
|
+
type: "quota", plan: quota.planType ?? null,
|
|
75
|
+
primary: { usedPercent: quota.primary.usedPercent ?? null, windowMinutes: quota.primary.windowMinutes ?? null, resetAt: resetAt ?? null },
|
|
76
|
+
credits: { has: quota.credits.hasCredits },
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function errorInfo(error, quota) {
|
|
81
|
+
if (error?.status === 401 || error?.code === "unauthorized") return { code: "auth", message: "Authentication required. Sign in again." };
|
|
82
|
+
if (error?.status === 429) return { code: "rate_limit", message: "Rate limit exceeded.", resetAt: quota?.primary.resetAt ?? null };
|
|
83
|
+
if (error?.code === "entitlement") return { code: "entitlement", message: "This account cannot generate images." };
|
|
84
|
+
if (error?.code === "overloaded") return { code: "overloaded", message: "The model service is overloaded right now. Try again in a moment." };
|
|
85
|
+
if (error?.code === "server_error") return { code: "overloaded", message: "The model service hit an internal error. Try again in a moment." };
|
|
86
|
+
// Backend errors may echo credentials or image inputs. Never forward their bodies.
|
|
87
|
+
return { code: "upstream", message: "The model or live editor could not complete this turn." };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Use the existing client and its request queue, retaining failure headers that
|
|
91
|
+
* the client otherwise discards. A 429 belongs to the panel's paused state, not
|
|
92
|
+
* the client's unbounded retry loop (which cannot be interrupted during sleep). */
|
|
93
|
+
function defaultClient(auth, requestContext) {
|
|
94
|
+
return createCodexClient({
|
|
95
|
+
getAccessToken: auth.getAccessToken, getAccountId: auth.getAccountId, originator: "cozyclay",
|
|
96
|
+
fetch: async (url, init) => {
|
|
97
|
+
const response = await fetch(url, init);
|
|
98
|
+
requestContext.getStore()?.(response.headers);
|
|
99
|
+
if (response.ok) return response;
|
|
100
|
+
const detail = await response.text();
|
|
101
|
+
const error = Object.assign(new Error("Codex backend request failed."), { status: response.status, headers: response.headers });
|
|
102
|
+
if (url.includes("/images/") && /entitlement|plan/i.test(detail)) error.code = "entitlement";
|
|
103
|
+
throw error;
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Start the optional registry/live dependencies without making signed-out
|
|
109
|
+
* startup depend on an MCP dependency install. Failures remain visible on use. */
|
|
110
|
+
function liveToolsRuntime() {
|
|
111
|
+
return Promise.all([import("../../mcp/tool-handlers.mjs"), import("../../mcp/live-hub.mjs")]).then(async ([registry, { startLiveHub }]) => {
|
|
112
|
+
const liveHub = await startLiveHub(Number(process.env.COZYCLAY_LIVE_PORT ?? 5184));
|
|
113
|
+
registry.setLiveHub(liveHub);
|
|
114
|
+
const handlers = registry.createToolHandlers().map((tool) => ({
|
|
115
|
+
...tool,
|
|
116
|
+
handler: async (args, { workspaceHandle } = {}) => {
|
|
117
|
+
const parsed = Object.fromEntries(Object.entries(tool.inputSchema).map(([key, schema]) => [key, schema.parse(args[key])]));
|
|
118
|
+
const run = (handle) => registry.liveWorkspace.run(handle, () => tool.handler(parsed));
|
|
119
|
+
return liveHub?.connected ? liveHub.runExclusive(tool.name, workspaceHandle, run) : run(workspaceHandle);
|
|
120
|
+
},
|
|
121
|
+
}));
|
|
122
|
+
return { liveHub, handlers };
|
|
123
|
+
}).catch((error) => ({ error }));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function createAgentHandler({ auth = defaultAuth, codex, handlers, liveHub, port, retryDelayMs = 2000 } = {}) {
|
|
127
|
+
const requestContext = new AsyncLocalStorage();
|
|
128
|
+
codex ||= defaultClient(auth, requestContext);
|
|
129
|
+
const runtime = handlers !== undefined || liveHub !== undefined ? Promise.resolve({ handlers: handlers ?? [], liveHub }) : liveToolsRuntime();
|
|
130
|
+
const renderGuidance = async (environment) => {
|
|
131
|
+
try {
|
|
132
|
+
const { handlers: tools, liveHub: hub } = await runtime;
|
|
133
|
+
const tool = tools.find((entry) => entry.name === "render_prompt");
|
|
134
|
+
if (!tool || !hub?.connected) return "";
|
|
135
|
+
const workspaceHandle = pickWorkspace(hub);
|
|
136
|
+
const result = await tool.handler({ mode: "image", environment }, { workspaceHandle });
|
|
137
|
+
if (result?.isError) return "";
|
|
138
|
+
const text = typeof result === "string" ? result : (result?.content ?? []).filter((part) => part.type === "text").map((part) => part.text).join("\n");
|
|
139
|
+
return text ? `\n${text}` : "";
|
|
140
|
+
} catch { return ""; }
|
|
141
|
+
};
|
|
142
|
+
const sessions = new Map();
|
|
143
|
+
const unsubscribe = auth.onAuthChange?.(() => {
|
|
144
|
+
for (const session of sessions.values()) session.controller?.abort();
|
|
145
|
+
sessions.clear();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const handle = async (req, res, path = new URL(req.url, "http://127.0.0.1").pathname) => {
|
|
149
|
+
if (!path.startsWith("/agent/")) return false;
|
|
150
|
+
if (port !== undefined && !allowAgentOrigin(req, typeof port === "function" ? port() : port)) {
|
|
151
|
+
json(res, 403, { error: "forbidden origin" }); return true;
|
|
152
|
+
}
|
|
153
|
+
if (path === "/agent/models" && req.method === "GET") {
|
|
154
|
+
try {
|
|
155
|
+
const result = await codex.listModels();
|
|
156
|
+
const models = (Array.isArray(result) ? result : result.models).map((model) => {
|
|
157
|
+
const id = typeof model === "string" ? model : model.slug || model.id;
|
|
158
|
+
const efforts = Array.isArray(model.supported_reasoning_levels) ? model.supported_reasoning_levels.map((level) => (typeof level === "string" ? level : level.effort)).filter(Boolean) : [];
|
|
159
|
+
return { id, label: id, efforts, defaultEffort: typeof model.default_reasoning_level === "string" ? model.default_reasoning_level : efforts[0] ?? null };
|
|
160
|
+
});
|
|
161
|
+
models.sort((a, b) => Number(b.id === "gpt-6-astra") - Number(a.id === "gpt-6-astra"));
|
|
162
|
+
json(res, 200, { models });
|
|
163
|
+
} catch (error) { json(res, error.status === 401 ? 401 : 502, { error: errorInfo(error) }); }
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
if (path === "/agent/image" && req.method === "POST") {
|
|
167
|
+
let value;
|
|
168
|
+
try {
|
|
169
|
+
value = await readBody(req, IMAGE_BODY_LIMIT);
|
|
170
|
+
if (typeof value.prompt !== "string" || !value.prompt.trim() || typeof value.imageDataUrl !== "string" || !value.imageDataUrl.startsWith("data:image/") || (value.referenceDataUrl !== undefined && (typeof value.referenceDataUrl !== "string" || !value.referenceDataUrl.startsWith("data:image/"))) || !validReferences(value.references) || (value.quality !== undefined && !["auto", "low", "medium", "high"].includes(value.quality))) throw new Error("Invalid request.");
|
|
171
|
+
} catch { json(res, 400, { error: "invalid request" }); return true; }
|
|
172
|
+
if (!await auth.getAccessToken()) { json(res, 401, { error: { code: "auth", message: "Sign in with ChatGPT in the Agent panel." } }); return true; }
|
|
173
|
+
try {
|
|
174
|
+
// Same composition guidance the agent's render_from_frame appends: the
|
|
175
|
+
// node's prompt is intent only; camera, cast and set come from the scene.
|
|
176
|
+
// Scene references (#167) are appended after the frame/reference pair,
|
|
177
|
+
// and the prompt says what each attachment is for.
|
|
178
|
+
const references = Array.isArray(value.references) ? value.references : [];
|
|
179
|
+
const prompt = `${value.prompt}${await renderGuidance(value.prompt)}${referenceGuidance(references)}`;
|
|
180
|
+
const result = await codex.editImage({ ...value, prompt, extraImages: references.map((entry) => entry.dataUrl) });
|
|
181
|
+
json(res, 200, { dataUrl: `data:image/png;base64,${result.pngBase64}`, width: result.width, height: result.height });
|
|
182
|
+
} catch (error) { json(res, error.status === 401 ? 401 : 502, { error: errorInfo(error) }); }
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
if (path === "/agent/video/providers" && req.method === "GET") {
|
|
186
|
+
json(res, 200, { providers: createVideoAdapters().map((adapter) => ({ id: adapter.id, name: adapter.name, configured: adapter.configured() })) });
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
if (path === "/agent/video" && req.method === "POST") {
|
|
190
|
+
let value;
|
|
191
|
+
try {
|
|
192
|
+
value = await readBody(req, IMAGE_BODY_LIMIT);
|
|
193
|
+
if (!value || typeof value.provider !== "string" || typeof value.prompt !== "string" || !value.prompt.trim() || typeof value.imageDataUrl !== "string" || !value.imageDataUrl.startsWith("data:image/") || (value.lastFrameDataUrl !== undefined && (typeof value.lastFrameDataUrl !== "string" || !value.lastFrameDataUrl.startsWith("data:image/"))) || !Number.isFinite(Number(value.durationSeconds)) || Number(value.durationSeconds) < 1 || Number(value.durationSeconds) > 15 || typeof value.aspect !== "string" || (value.model !== undefined && typeof value.model !== "string")) throw new Error("Invalid request.");
|
|
194
|
+
} catch { json(res, 400, { error: "invalid request" }); return true; }
|
|
195
|
+
const adapter = createVideoAdapters().find((entry) => entry.id === value.provider);
|
|
196
|
+
if (!adapter || !adapter.configured()) { json(res, 409, { error: "video provider is not configured" }); return true; }
|
|
197
|
+
try {
|
|
198
|
+
const result = await adapter.generate({ ...value, durationSeconds: Number(value.durationSeconds) });
|
|
199
|
+
json(res, 200, { ...(result.mp4Base64 ? { dataUrl: `data:video/mp4;base64,${result.mp4Base64}` } : { url: result.url }), width: result.width, height: result.height, seconds: result.seconds });
|
|
200
|
+
} catch (error) { json(res, 502, { error: error?.message || "video provider failed" }); }
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
if (req.method !== "POST" || !["/agent/turn", "/agent/stop"].includes(path)) {
|
|
204
|
+
json(res, 404, { error: "not found" }); return true;
|
|
205
|
+
}
|
|
206
|
+
let value;
|
|
207
|
+
try {
|
|
208
|
+
value = await readBody(req);
|
|
209
|
+
if (!value || typeof value.sessionId !== "string" || !value.sessionId
|
|
210
|
+
|| (path === "/agent/turn" && (typeof value.text !== "string"
|
|
211
|
+
|| (value.attachFrame !== undefined && typeof value.attachFrame !== "boolean")
|
|
212
|
+
|| (value.model !== undefined && typeof value.model !== "string")
|
|
213
|
+
|| (value.effort !== undefined && !REASONING_EFFORTS.includes(value.effort))))) throw new Error("Invalid request.");
|
|
214
|
+
} catch { json(res, 400, { error: "invalid request" }); return true; }
|
|
215
|
+
if (path === "/agent/stop") {
|
|
216
|
+
sessions.get(value.sessionId)?.controller?.abort();
|
|
217
|
+
json(res, 200, { ok: true }); return true;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
res.writeHead(200, { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-cache", connection: "keep-alive" });
|
|
221
|
+
res.flushHeaders();
|
|
222
|
+
const send = (event) => { if (!res.writableEnded && !res.destroyed) res.write(`data: ${JSON.stringify(event)}\n\n`); };
|
|
223
|
+
let session = sessions.get(value.sessionId);
|
|
224
|
+
if (session?.running) {
|
|
225
|
+
send({ type: "error", code: "upstream", message: "busy" }); send({ type: "done" }); res.end(); return true;
|
|
226
|
+
}
|
|
227
|
+
session ||= { images: new Map(), history: [] };
|
|
228
|
+
sessions.set(value.sessionId, session);
|
|
229
|
+
const controller = new AbortController();
|
|
230
|
+
const { signal } = controller;
|
|
231
|
+
Object.assign(session, { running: true, controller, signal });
|
|
232
|
+
const disconnect = () => controller.abort();
|
|
233
|
+
res.once("close", disconnect);
|
|
234
|
+
let quota;
|
|
235
|
+
const observeHeaders = (headers) => {
|
|
236
|
+
const next = quotaEvent(codex, headers);
|
|
237
|
+
if (!quota) send(next);
|
|
238
|
+
quota = next;
|
|
239
|
+
};
|
|
240
|
+
let refreshed = false;
|
|
241
|
+
// A stream that fails with server_is_overloaded usually succeeds on the
|
|
242
|
+
// next attempt; retry twice before reporting it.
|
|
243
|
+
const retryOverloaded = async (operation, attempts = 2) => {
|
|
244
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
245
|
+
try { return await operation(); } catch (error) {
|
|
246
|
+
if (!["overloaded", "server_error"].includes(error.code) || attempt >= attempts || signal.aborted) throw error;
|
|
247
|
+
await new Promise((resolve) => setTimeout(resolve, retryDelayMs * (attempt + 1)));
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
const retryAuth = async (operation) => {
|
|
252
|
+
try { signal.throwIfAborted(); return await operation(); }
|
|
253
|
+
catch (error) {
|
|
254
|
+
if (error.status !== 401 || refreshed || signal.aborted) throw error;
|
|
255
|
+
refreshed = true;
|
|
256
|
+
if (!await auth.getAccessToken()) throw error;
|
|
257
|
+
signal.throwIfAborted();
|
|
258
|
+
return operation();
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
const turn = async () => {
|
|
262
|
+
if (!await auth.getAccessToken()) throw Object.assign(new Error("Authentication required."), { status: 401 });
|
|
263
|
+
const dependencies = await runtime;
|
|
264
|
+
session.codex = { editImage: (args) => retryAuth(() => codex.editImage(args)) };
|
|
265
|
+
const tools = createAgentTools({ ...dependencies, session, emit: send });
|
|
266
|
+
const executeTool = async (item, override) => {
|
|
267
|
+
signal.throwIfAborted();
|
|
268
|
+
const tool = override ?? tools.find((entry) => entry.name === item.name);
|
|
269
|
+
if (!tool) throw new Error("Unknown tool.");
|
|
270
|
+
const args = typeof item.arguments === "string" ? JSON.parse(item.arguments) : item.arguments;
|
|
271
|
+
send({ type: "tool.start", callId: item.call_id, name: item.name, label: item.name.replaceAll("_", " "), args });
|
|
272
|
+
const started = performance.now();
|
|
273
|
+
try {
|
|
274
|
+
if (dependencies.error) throw dependencies.error;
|
|
275
|
+
const result = await tool.handler(args);
|
|
276
|
+
signal.throwIfAborted();
|
|
277
|
+
send({ type: "tool.done", callId: item.call_id, ok: true, elapsedMs: Math.round(performance.now() - started), result });
|
|
278
|
+
return result;
|
|
279
|
+
} catch (error) {
|
|
280
|
+
if (process.env.COZYCLAY_AGENT_DEBUG) console.error("[agent] tool", item.name, "failed:", error?.message);
|
|
281
|
+
send({ type: "tool.done", callId: item.call_id, ok: false, elapsedMs: Math.round(performance.now() - started), error: errorInfo(error).message });
|
|
282
|
+
throw error;
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
let text = value.text;
|
|
286
|
+
if (value.attachFrame) {
|
|
287
|
+
const captured = await executeTool({ call_id: "attached-frame", name: "capture_blocking_frame", arguments: {} }, tools.internal.capture);
|
|
288
|
+
text += `\nAttached frame imageId: ${captured.imageId}`;
|
|
289
|
+
}
|
|
290
|
+
const history = [...session.history, { role: "user", content: [{ type: "input_text", text }] }];
|
|
291
|
+
// runAgentTurn closes over streamResponses and hides its headers. Drive
|
|
292
|
+
// that same serial loop here so quotas are observable, and retry only
|
|
293
|
+
// the failed request rather than replaying already-executed scene tools.
|
|
294
|
+
while (true) {
|
|
295
|
+
const output = await retryOverloaded(() => retryAuth(async () => {
|
|
296
|
+
const stream = codex.streamResponses({ input: history, tools: agentToolSchemas(tools), instructions: SYSTEM_PROMPT, model: value.model, effort: value.effort, signal });
|
|
297
|
+
const headers = stream.headers.then(observeHeaders, () => {});
|
|
298
|
+
const items = [];
|
|
299
|
+
try {
|
|
300
|
+
for await (const event of stream) {
|
|
301
|
+
signal.throwIfAborted();
|
|
302
|
+
if (event.type === "response.output_text.delta") send({ type: "text.delta", text: event.delta });
|
|
303
|
+
if (event.type === "response.output_item.done") items.push(event.item);
|
|
304
|
+
if (event.type === "error" || event.type === "response.failed") {
|
|
305
|
+
if (process.env.COZYCLAY_AGENT_DEBUG) console.error("[agent] model event:", JSON.stringify(event).slice(0, 600));
|
|
306
|
+
const code = event.error?.code ?? event.response?.error?.code;
|
|
307
|
+
throw Object.assign(new Error("Model response failed."), code === "server_is_overloaded" ? { code: "overloaded" } : code === "server_error" ? { code: "server_error" } : {});
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
await headers;
|
|
311
|
+
return items;
|
|
312
|
+
} finally { await headers; }
|
|
313
|
+
}));
|
|
314
|
+
for (const item of output) {
|
|
315
|
+
history.push(item); // Preserve reasoning items verbatim.
|
|
316
|
+
if (item.type === "function_call") {
|
|
317
|
+
const result = await executeTool(item);
|
|
318
|
+
history.push({ type: "function_call_output", call_id: item.call_id, output: JSON.stringify(result) });
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (!output.some((item) => item.type === "function_call")) break;
|
|
322
|
+
}
|
|
323
|
+
session.history = history;
|
|
324
|
+
};
|
|
325
|
+
try { await requestContext.run(observeHeaders, turn); }
|
|
326
|
+
catch (error) {
|
|
327
|
+
if (!signal.aborted) {
|
|
328
|
+
if (error.headers) observeHeaders(error.headers);
|
|
329
|
+
if (process.env.COZYCLAY_AGENT_DEBUG) console.error("[agent] turn failed:", error?.status, error?.message, String(error?.body ?? "").slice(0, 300));
|
|
330
|
+
send({ type: "error", ...errorInfo(error, quota) });
|
|
331
|
+
}
|
|
332
|
+
} finally {
|
|
333
|
+
session.running = false; send({ type: "done" }); res.end(); res.off("close", disconnect);
|
|
334
|
+
}
|
|
335
|
+
return true;
|
|
336
|
+
};
|
|
337
|
+
handle.close = async () => {
|
|
338
|
+
unsubscribe?.();
|
|
339
|
+
for (const session of sessions.values()) session.controller?.abort();
|
|
340
|
+
sessions.clear();
|
|
341
|
+
const { liveHub: hub } = await runtime;
|
|
342
|
+
if (hub?.server) {
|
|
343
|
+
for (const socket of hub.server.clients) socket.terminate();
|
|
344
|
+
await new Promise((resolve) => hub.server.close(resolve));
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
return handle;
|
|
348
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
const objectSchema = (properties = {}, required = []) => ({ type: "object", properties, required, additionalProperties: false });
|
|
4
|
+
export const SYSTEM_PROMPT = "You are CozyClay's workflow agent. The user is looking at the Workflow canvas, and you work by building and running nodes on that canvas, so every step is visible and editable. Always call describe_workflow first to read the current graph. To render the scene in a new look: reuse the existing Scene node, add an Image node with model image-generation whose data.prompt is the user's intent, connect the Scene node to it with connect_workflow_nodes (sourceHandle render, targetHandle input), and when the user attached or mentioned a reference image, add one with add_reference_node and connect it to the same Image node's input handle; then call run_workflow. Finish with one or two sentences naming the nodes you created. Never describe results you did not run. Keep responses concise and practical.";
|
|
5
|
+
|
|
6
|
+
/** The Workflow page embeds the Studio as a live preview, so the hub usually
|
|
7
|
+
* sees at least two editors. Prefer the tab the user is authoring in: any
|
|
8
|
+
* workspace whose hello meta does not say embed:true. Fall back to the hub's
|
|
9
|
+
* own single-workspace rule (which throws when the choice is ambiguous). */
|
|
10
|
+
export function pickWorkspace(liveHub, requiredCommands = ["capture_framing_png", "import_asset"], kind = "scene") {
|
|
11
|
+
const details = typeof liveHub.workspaceHandleDetails === "function" ? liveHub.workspaceHandleDetails() : [];
|
|
12
|
+
if (kind === "workflow") {
|
|
13
|
+
const workflow = details.filter((entry) => entry.meta?.kind === "workflow").map((entry) => entry.handle);
|
|
14
|
+
if (workflow.length) return workflow[workflow.length - 1];
|
|
15
|
+
throw new Error("No workflow canvas workspace is connected.");
|
|
16
|
+
}
|
|
17
|
+
// An editor that does not advertise its commands predates the agent work;
|
|
18
|
+
// it cannot answer capture_framing_png, so it is never a candidate.
|
|
19
|
+
const supports = (entry) => Array.isArray(entry.meta?.commands) && requiredCommands.every((name) => entry.meta.commands.includes(name));
|
|
20
|
+
const authoring = details.filter((entry) => entry.meta?.embed !== true && supports(entry)).map((entry) => entry.handle);
|
|
21
|
+
if (authoring.length === 1) return authoring[0];
|
|
22
|
+
if (authoring.length > 1) return authoring[authoring.length - 1];
|
|
23
|
+
// On the Workflow page the embedded Studio IS the scene the user is looking
|
|
24
|
+
// at; use it when no standalone editor tab is open.
|
|
25
|
+
const embedded = details.filter((entry) => entry.meta?.embed === true && supports(entry)).map((entry) => entry.handle);
|
|
26
|
+
if (embedded.length) return embedded[embedded.length - 1];
|
|
27
|
+
// The hub's own rule would hand back whatever single workspace exists —
|
|
28
|
+
// on the Workflow page that is the canvas, which cannot capture a frame.
|
|
29
|
+
if (details.some((entry) => entry.meta?.kind === "workflow")) throw new Error("No scene editor is connected yet.");
|
|
30
|
+
return liveHub.resolveWorkspace("agent turn");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** What the model gets back from a canvas command: ids, types, models and
|
|
34
|
+
* small fields only. Data URLs become a size note and the echoed graph is
|
|
35
|
+
* dropped — the model can call describe_workflow when it needs the graph. */
|
|
36
|
+
export function summariseCanvasResult(result) {
|
|
37
|
+
const strip = (value) => {
|
|
38
|
+
if (typeof value === "string") return value.startsWith("data:") ? `[image ${Math.round(value.length * 3 / 4 / 1024)} KB]` : value;
|
|
39
|
+
if (Array.isArray(value)) return value.map(strip);
|
|
40
|
+
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, strip(item)]));
|
|
41
|
+
return value;
|
|
42
|
+
};
|
|
43
|
+
if (!result || typeof result !== "object") return result;
|
|
44
|
+
const { graph, ...rest } = result;
|
|
45
|
+
return strip(rest);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createAgentTools({ liveHub, handlers = [], session, emit }) {
|
|
49
|
+
const registry = new Map(handlers.map((tool) => [tool.name, tool]));
|
|
50
|
+
const workspace = (kind = "scene") => {
|
|
51
|
+
const key = kind === "workflow" ? "workflowHandle" : "workspaceHandle";
|
|
52
|
+
// The panel session outlives page reloads, so a cached handle may name an
|
|
53
|
+
// editor that is gone or the wrong kind of workspace; re-pick when it does.
|
|
54
|
+
const details = typeof liveHub?.workspaceHandleDetails === "function" ? liveHub.workspaceHandleDetails() : [];
|
|
55
|
+
const current = details.find((entry) => entry.handle === session[key]);
|
|
56
|
+
const fits = current && ((current.meta?.kind === "workflow") === (kind === "workflow"));
|
|
57
|
+
if (liveHub?.resolveWorkspace && !fits) session[key] = pickWorkspace(liveHub, kind === "workflow" ? [] : undefined, kind);
|
|
58
|
+
return session[key];
|
|
59
|
+
};
|
|
60
|
+
// The embedded Studio says hello a moment after the canvas; give it up to
|
|
61
|
+
// ten seconds before declaring that no scene editor exists.
|
|
62
|
+
const warmingUp = (error) => /scene editor|renderer is not ready/i.test(error?.message ?? "");
|
|
63
|
+
const untilReady = async (attempt) => {
|
|
64
|
+
const deadline = Date.now() + 10_000;
|
|
65
|
+
for (;;) {
|
|
66
|
+
try { return await attempt(); } catch (error) {
|
|
67
|
+
if (!warmingUp(error) || Date.now() >= deadline) throw error;
|
|
68
|
+
session.signal?.throwIfAborted?.();
|
|
69
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
const live = (name, args = {}, kind = "scene") => {
|
|
74
|
+
if (!liveHub) throw new Error("Live editor is not connected.");
|
|
75
|
+
return liveHub.command(name, args, workspace(kind));
|
|
76
|
+
};
|
|
77
|
+
const registered = async (name, args = {}) => {
|
|
78
|
+
const tool = registry.get(name);
|
|
79
|
+
if (!tool) throw new Error(`Tool ${name} is unavailable.`);
|
|
80
|
+
const result = await tool.handler(args, { workspaceHandle: workspace("scene") });
|
|
81
|
+
if (result?.isError) throw new Error("The live editor could not describe the scene.");
|
|
82
|
+
return result;
|
|
83
|
+
};
|
|
84
|
+
const capture = {
|
|
85
|
+
name: "capture_blocking_frame", description: "Capture the current blocking frame before rendering.", parameters: objectSchema(),
|
|
86
|
+
handler: async () => {
|
|
87
|
+
const result = await untilReady(() => live("capture_framing_png"));
|
|
88
|
+
session.signal.throwIfAborted();
|
|
89
|
+
const imageId = randomUUID();
|
|
90
|
+
session.images.set(imageId, result.dataUrl);
|
|
91
|
+
session.latestCaptureId = imageId;
|
|
92
|
+
return { imageId, width: result.width, height: result.height };
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
const render = {
|
|
96
|
+
name: "render_from_frame", description: "Render an edited image from a captured frame.",
|
|
97
|
+
parameters: objectSchema({ prompt: { type: "string" }, imageId: { type: "string" }, quality: { type: "string", enum: ["auto", "low", "medium", "high"] }, addAsNode: { type: "boolean" } }, ["prompt"]),
|
|
98
|
+
handler: async ({ prompt, imageId, quality, addAsNode = false }) => {
|
|
99
|
+
if (typeof prompt !== "string" || !prompt.trim()) throw new Error("A render prompt is required.");
|
|
100
|
+
if (quality !== undefined && !["auto", "low", "medium", "high"].includes(quality)) throw new Error("Invalid image quality.");
|
|
101
|
+
const source = session.images.get(imageId || session.latestCaptureId);
|
|
102
|
+
if (!source) throw new Error("Capture a frame before rendering.");
|
|
103
|
+
const guidance = registry.has("render_prompt") ? await registered("render_prompt", { mode: "image", environment: prompt }) : null;
|
|
104
|
+
const suffix = typeof guidance === "string" ? guidance : (guidance?.content ?? []).filter((part) => part.type === "text").map((part) => part.text).join("\n");
|
|
105
|
+
const fullPrompt = `${prompt}${suffix ? `\n${suffix}` : ""}`;
|
|
106
|
+
let result;
|
|
107
|
+
try {
|
|
108
|
+
session.signal.throwIfAborted();
|
|
109
|
+
result = await session.codex.editImage({ prompt: fullPrompt, imageDataUrl: source, quality, signal: session.signal });
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (/entitlement|plan/i.test(error.message)) error.code = "entitlement";
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
session.signal.throwIfAborted();
|
|
115
|
+
const newId = randomUUID();
|
|
116
|
+
const dataUrl = `data:image/png;base64,${result.pngBase64}`;
|
|
117
|
+
session.images.set(newId, dataUrl);
|
|
118
|
+
emit({ type: "image", imageId: newId, dataUrl, width: result.width, height: result.height, prompt: fullPrompt });
|
|
119
|
+
if (addAsNode) await live("add_node", { type: "image", model: "image-passthrough", data: { image_url: dataUrl, outputs: [{ value: dataUrl }] } }, "workflow");
|
|
120
|
+
return { imageId: newId, width: result.width, height: result.height, ...(addAsNode ? { addedAsNode: true } : {}) };
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
const place = {
|
|
124
|
+
name: "place_image_in_scene", description: "Place a rendered image in the scene.",
|
|
125
|
+
parameters: objectSchema({ imageId: { type: "string" }, placeAs: { type: "string", enum: ["cutout", "backdrop"] } }, ["imageId"]),
|
|
126
|
+
handler: async ({ imageId, placeAs = "cutout" }) => {
|
|
127
|
+
const dataUrl = session.images.get(imageId);
|
|
128
|
+
if (!dataUrl) throw new Error("Unknown image.");
|
|
129
|
+
if (!["cutout", "backdrop"].includes(placeAs)) throw new Error("Invalid image placement.");
|
|
130
|
+
return live("import_asset", { name: `${imageId}.png`, mimeType: "image/png", dataUrl, placeAs });
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
const reference = {
|
|
134
|
+
name: "add_reference_node", description: "Add an attached or captured image to the canvas as a reference upload node.",
|
|
135
|
+
parameters: objectSchema({ imageId: { type: "string" } }),
|
|
136
|
+
handler: async ({ imageId } = {}) => {
|
|
137
|
+
const dataUrl = session.images.get(imageId ?? session.latestCaptureId);
|
|
138
|
+
if (!dataUrl) throw new Error("No reference image is available. Capture or attach one first.");
|
|
139
|
+
return summariseCanvasResult(await live("add_node", { type: "upload", data: { image_url: dataUrl, fileName: "reference.png", mimeType: "image/png", outputs: [{ value: dataUrl }] } }, "workflow"));
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
const workflow = [
|
|
143
|
+
["describe_workflow", "Describe the current workflow canvas.", "get_graph", objectSchema()],
|
|
144
|
+
["add_workflow_node", "Add a node to the workflow canvas.", "add_node", objectSchema({ type: { type: "string" }, model: { type: "string" }, data: { type: "object" }, position: { type: "object" } }, ["type"])],
|
|
145
|
+
["update_workflow_node", "Update a workflow node.", "update_node", objectSchema({ id: { type: "string" }, data: { type: "object" } }, ["id", "data"])],
|
|
146
|
+
["remove_workflow_node", "Remove a workflow node.", "remove_node", objectSchema({ id: { type: "string" } }, ["id"])],
|
|
147
|
+
["connect_workflow_nodes", "Connect two workflow nodes.", "connect", objectSchema({ source: { type: "string" }, target: { type: "string" }, sourceHandle: { type: "string" }, targetHandle: { type: "string" } }, ["source", "target"])],
|
|
148
|
+
["disconnect_workflow_nodes", "Disconnect workflow nodes.", "disconnect", objectSchema({ edgeId: { type: "string" } }, ["edgeId"])],
|
|
149
|
+
["run_workflow", "Run the workflow locally.", "run_workflow", objectSchema()],
|
|
150
|
+
["set_workflow_node_output", "Set a workflow node output.", "set_node_output", objectSchema({ id: { type: "string" }, value: {} }, ["id", "value"])],
|
|
151
|
+
["focus_workflow_node", "Focus a workflow node.", "focus_node", objectSchema({ id: { type: "string" } }, ["id"])],
|
|
152
|
+
].map(([name, description, command, parameters]) => ({ name, description, parameters, handler: async (args) => summariseCanvasResult(await live(command, args, "workflow")) }));
|
|
153
|
+
const direct = ["describe_scene", "describe_shot"].map((name) => ({
|
|
154
|
+
name, description: registry.get(name)?.description || name,
|
|
155
|
+
parameters: objectSchema(), handler: () => registered(name),
|
|
156
|
+
}));
|
|
157
|
+
const tools = [reference, ...workflow, ...direct];
|
|
158
|
+
// The sidecar captures the frame itself when the user attaches it; the
|
|
159
|
+
// model never sees this tool, it builds an Image node instead.
|
|
160
|
+
tools.internal = { capture };
|
|
161
|
+
return tools;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export const agentToolSchemas = (tools) => tools.map(({ name, description, parameters }) => ({ type: "function", name, description, parameters }));
|