dsh-comfyui-canvas 0.1.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/LICENSE +21 -0
- package/README.md +184 -0
- package/README.zh.md +210 -0
- package/comfyui-bridge/ComfyUI-DSH-Canvas/__init__.py +174 -0
- package/comfyui-bridge/ComfyUI-DSH-Canvas/entry/bridge.js +414 -0
- package/cordis.patch.yml +9 -0
- package/docs/NEXT-VERSION-PLAN.md +199 -0
- package/docs/PLAN-v0.1.2.md +307 -0
- package/docs/architecture-flow.html +210 -0
- package/docs/architecture.html +252 -0
- package/docs/comfyui-nav-icon.svg +5 -0
- package/docs/screenshots/01-startup-prompt.png +0 -0
- package/docs/screenshots/02-canvas-ready.png +0 -0
- package/docs/screenshots/03-workflow-output.png +0 -0
- package/lib/client.js +810 -0
- package/lib/index.js +726 -0
- package/package.json +57 -0
- package/screenshots.json +5 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DSH-ComfyUI-Canvas — host-side agent tools.
|
|
3
|
+
*
|
|
4
|
+
* Registers the canvas tools the agent uses to operate the live ComfyUI
|
|
5
|
+
* canvas through ComfyUI-DSH-Canvas bridge (a custom_node injected into
|
|
6
|
+
* ComfyUI). Reads go straight to the bridge's /dsh-bridge/workflow store;
|
|
7
|
+
* writes POST a command, then poll the frontend's execution result.
|
|
8
|
+
*
|
|
9
|
+
* M1: read_workflow + add_node / connect / set_param / remove_node.
|
|
10
|
+
* M2: load_workflow (rewrite loop) + run + debug (validate + highlight).
|
|
11
|
+
* M3: settings-driven configuration — the base URL is read from the
|
|
12
|
+
* `dsh-comfyui-canvas` settings namespace (registration optional so an
|
|
13
|
+
* environment without the settings service still starts with defaults).
|
|
14
|
+
*/
|
|
15
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
16
|
+
import z from '@deepseek-ai/schemastery'
|
|
17
|
+
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
18
|
+
|
|
19
|
+
export const name = 'dsh-comfyui-canvas'
|
|
20
|
+
export const inject = ['tools', 'settings', 'shell']
|
|
21
|
+
|
|
22
|
+
const NAMESPACE = settingsNamespace('dsh-comfyui-canvas')
|
|
23
|
+
const DEFAULT_BASE = 'http://127.0.0.1:8188'
|
|
24
|
+
const ENV_BASE = (process.env.COMFYUI_URL || '').replace(/\/+$/, '')
|
|
25
|
+
|
|
26
|
+
const ConfigSchema = z.object({
|
|
27
|
+
/** ComfyUI HTTP server base URL. */
|
|
28
|
+
baseUrl: z.string().default(DEFAULT_BASE),
|
|
29
|
+
/** Optional port shortcut; when non-empty it rewrites the base URL's port. */
|
|
30
|
+
port: z.number(),
|
|
31
|
+
/** loopback | lan | cloud-selfhosted | saas */
|
|
32
|
+
networkMode: z.string().default('loopback'),
|
|
33
|
+
/** Launch command used by the canvas Start button / agent auto-start. */
|
|
34
|
+
launchCommand: z.string().default(''),
|
|
35
|
+
/**
|
|
36
|
+
* Absolute path to the ComfyUI install directory. Used by comfyui_upgrade
|
|
37
|
+
* (git pull core + custom nodes) and by the launch button's working dir.
|
|
38
|
+
* Empty falls back to the launch command's own directory / env COMfyUI_DIR.
|
|
39
|
+
*/
|
|
40
|
+
comfyuiDir: z.string().default(''),
|
|
41
|
+
/** Split-rail width in px. */
|
|
42
|
+
railWidth: z.number().default(360),
|
|
43
|
+
/**
|
|
44
|
+
* Optional shared secret for the ComfyUI bridge. When set, every agent
|
|
45
|
+
* request carries `Authorization: Bearer <token>`, and the bridge only
|
|
46
|
+
* answers requests that present the same token (the bridge reads it from
|
|
47
|
+
* its OWN `DSH_BRIDGE_TOKEN` env var). Empty keeps the bridge open, the
|
|
48
|
+
* same trust model as ComfyUI's own /prompt. Keep both sides identical.
|
|
49
|
+
*/
|
|
50
|
+
bridgeToken: z.string().default(''),
|
|
51
|
+
/**
|
|
52
|
+
* One-shot launch request: the client start button sets this true, the host
|
|
53
|
+
* watcher picks it up, starts ComfyUI via launchCommand, then clears it.
|
|
54
|
+
*/
|
|
55
|
+
launchRequested: z.boolean().default(false),
|
|
56
|
+
/**
|
|
57
|
+
* Last launch failure message (host writes it when the launch command exits
|
|
58
|
+
* before ComfyUI comes up). The client start card shows it instead of
|
|
59
|
+
* hanging on "正在启动…" forever. Cleared on the next successful launch.
|
|
60
|
+
*/
|
|
61
|
+
launchError: z.string().default(''),
|
|
62
|
+
/**
|
|
63
|
+
* Per-session record of which conversation view is active in the browser
|
|
64
|
+
* right now, keyed by session id: 'canvas' = the ComfyUI split-screen tab is
|
|
65
|
+
* selected for that session, 'chat' = the plain Chat tab is. Written by the
|
|
66
|
+
* client view component on mount/unmount for ITS OWN session only, so one
|
|
67
|
+
* session's canvas state never leaks into another. comfyui_config reads the
|
|
68
|
+
* current session's value so the agent knows whether to focus on canvas work.
|
|
69
|
+
*/
|
|
70
|
+
activeViewBySession: z.dict(z.union([z.const('canvas'), z.const('chat')])).default({}),
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
function renderJSON(_args, value) {
|
|
74
|
+
return [{ type: 'text', text: JSON.stringify(value, null, 2) }]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Guess a media type from a file extension (lowercased, no dot). Unknown → null. */
|
|
78
|
+
const MEDIA_TYPE_BY_EXT = {
|
|
79
|
+
// image
|
|
80
|
+
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif',
|
|
81
|
+
// audio
|
|
82
|
+
wav: 'audio/wav', mp3: 'audio/mpeg', ogg: 'audio/ogg', flac: 'audio/flac', m4a: 'audio/mp4', aac: 'audio/aac',
|
|
83
|
+
// video
|
|
84
|
+
mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime', mkv: 'video/x-matroska', avi: 'video/x-msvideo',
|
|
85
|
+
// 3D
|
|
86
|
+
glb: 'model/gltf-binary', gltf: 'model/gltf+json', obj: 'model/obj', fbx: 'model/fbx', stl: 'model/stl',
|
|
87
|
+
}
|
|
88
|
+
function mediaTypeOf(ext) {
|
|
89
|
+
return MEDIA_TYPE_BY_EXT[ext] ?? null
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Extra headers for bridge requests; carries the optional shared token. */
|
|
93
|
+
function bridgeHeaders(token) {
|
|
94
|
+
return token
|
|
95
|
+
? { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }
|
|
96
|
+
: { 'Content-Type': 'application/json' }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** POST a write command to the bridge and poll for the frontend result. */
|
|
100
|
+
async function sendCommand(baseUrl, token, cmd, payload, signal, { pollMs = 400, timeoutMs = 8000 } = {}) {
|
|
101
|
+
const started = Date.now()
|
|
102
|
+
const res = await fetch(`${baseUrl}/dsh-bridge/command`, {
|
|
103
|
+
method: 'POST',
|
|
104
|
+
headers: bridgeHeaders(token),
|
|
105
|
+
body: JSON.stringify({ cmd, payload }),
|
|
106
|
+
signal,
|
|
107
|
+
})
|
|
108
|
+
if (!res.ok) throw new Error(`bridge command HTTP ${res.status}`)
|
|
109
|
+
const { accepted, id, error } = await res.json()
|
|
110
|
+
if (!accepted) throw new Error(error || 'bridge command not accepted')
|
|
111
|
+
|
|
112
|
+
while (Date.now() - started < timeoutMs) {
|
|
113
|
+
const r = await fetch(`${baseUrl}/dsh-bridge/result/${id}`, {
|
|
114
|
+
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
|
115
|
+
signal,
|
|
116
|
+
})
|
|
117
|
+
const item = await r.json().catch(() => null)
|
|
118
|
+
if (item && item.error !== 'not_found') {
|
|
119
|
+
if (item.ok === false) throw new Error(item.error || 'command failed')
|
|
120
|
+
return item.result
|
|
121
|
+
}
|
|
122
|
+
await new Promise((done) => setTimeout(done, pollMs))
|
|
123
|
+
}
|
|
124
|
+
throw new Error('bridge command timed out — is the ComfyUI page loaded with the bridge?')
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function apply(ctx) {
|
|
128
|
+
let resolveBase = () => ENV_BASE || DEFAULT_BASE
|
|
129
|
+
// Function-level handle to the settings scope (used by comfyui_config's
|
|
130
|
+
// activeView read); undefined when the settings service is unavailable.
|
|
131
|
+
let settingsScope = undefined
|
|
132
|
+
|
|
133
|
+
// M3: prefer the settings namespace when the settings service is present.
|
|
134
|
+
// Registration is optional so missing settings never blocks startup.
|
|
135
|
+
const settings = ctx.settings ?? ctx.get?.('settings')
|
|
136
|
+
if (settings && typeof settings.register === 'function') {
|
|
137
|
+
try {
|
|
138
|
+
const scope = settings.register(NAMESPACE, ConfigSchema, { applies: 'live' })
|
|
139
|
+
settingsScope = scope
|
|
140
|
+
resolveBase = () => {
|
|
141
|
+
const value = scope.get()
|
|
142
|
+
const base = (value?.baseUrl || DEFAULT_BASE).replace(/\/+$/, '')
|
|
143
|
+
if (value?.port != null && value.port !== '' && /^\d+$/.test(String(value.port))) {
|
|
144
|
+
return base.replace(/:\d+$/, '') + ':' + String(value.port)
|
|
145
|
+
}
|
|
146
|
+
return base
|
|
147
|
+
}
|
|
148
|
+
// Launch watcher: the client Start button sets launchRequested=true; we
|
|
149
|
+
// start ComfyUI via launchCommand (background), then clear the flag.
|
|
150
|
+
let launching = false
|
|
151
|
+
scope.watch(() => {
|
|
152
|
+
const value = scope.get()
|
|
153
|
+
if (!value?.launchRequested || launching) return
|
|
154
|
+
launching = true
|
|
155
|
+
void (async () => {
|
|
156
|
+
const fail = (message) => {
|
|
157
|
+
console.error(`[dsh-comfyui-canvas] ${message}`)
|
|
158
|
+
try { scope.update({ launchError: message }) } catch { /* best effort */ }
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
const cmd = (value?.launchCommand || '').trim()
|
|
162
|
+
if (!cmd) {
|
|
163
|
+
fail('launch requested but launchCommand is empty')
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
// Clear any stale error from a previous attempt before launching.
|
|
167
|
+
try { scope.update({ launchError: '' }) } catch { /* best effort */ }
|
|
168
|
+
const workdir = (value?.comfyuiDir || '').trim() || undefined
|
|
169
|
+
const shell = ctx.get?.('shell')
|
|
170
|
+
if (shell && typeof shell.start === 'function') {
|
|
171
|
+
// ComfyUI lives outside the session workspace (E: drive) and
|
|
172
|
+
// writes output/temp as a long-lived server, so the launch must
|
|
173
|
+
// run unconfined — the default workspace-write sandbox would
|
|
174
|
+
// deny the bat and kill ComfyUI before it ever binds 8188.
|
|
175
|
+
const proc = shell.start(shell.resolve({
|
|
176
|
+
command: cmd,
|
|
177
|
+
...(workdir ? { workdir } : {}),
|
|
178
|
+
sandboxPolicy: { mode: 'danger-full-access', workspaceRoot: process.cwd() },
|
|
179
|
+
}))
|
|
180
|
+
console.log(`[dsh-comfyui-canvas] launching ComfyUI: ${cmd}`)
|
|
181
|
+
// A healthy launch keeps python alive well past this grace
|
|
182
|
+
// window; an immediate exit (bad command / sandbox denial /
|
|
183
|
+
// crash) is a launch failure the client must see.
|
|
184
|
+
let grace = setTimeout(() => { grace = null }, 30000)
|
|
185
|
+
const settle = (detail) => {
|
|
186
|
+
if (!grace) return // still running after grace = launched OK
|
|
187
|
+
clearTimeout(grace)
|
|
188
|
+
grace = null
|
|
189
|
+
fail(`ComfyUI launch process exited (${detail}) — check the launch command / install dir`)
|
|
190
|
+
}
|
|
191
|
+
proc.done.then(
|
|
192
|
+
(outcome) => settle(`exit ${outcome?.exitCode ?? outcome?.code ?? '?'}`),
|
|
193
|
+
(err) => settle(String(err?.message ?? err)),
|
|
194
|
+
)
|
|
195
|
+
} else {
|
|
196
|
+
fail('no shell service available to launch ComfyUI')
|
|
197
|
+
}
|
|
198
|
+
} finally {
|
|
199
|
+
launching = false
|
|
200
|
+
try { scope.update({ launchRequested: false }) } catch { /* best effort */ }
|
|
201
|
+
}
|
|
202
|
+
})()
|
|
203
|
+
})
|
|
204
|
+
} catch {
|
|
205
|
+
// Duplicate registration or unavailable provider — keep defaults.
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const baseUrl = () => resolveBase()
|
|
210
|
+
const bridgeToken = () => String(settingsScope?.get?.()?.bridgeToken || '')
|
|
211
|
+
|
|
212
|
+
ctx.tools.register(defineTool({
|
|
213
|
+
name: 'comfyui_read_workflow',
|
|
214
|
+
description: 'Read the current ComfyUI canvas state: node list summary plus the full workflow JSON. ' +
|
|
215
|
+
'Use before editing the canvas so you know which node ids exist and how they are connected.',
|
|
216
|
+
parameters: {},
|
|
217
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: renderJSON },
|
|
218
|
+
async execute(_args, exec) {
|
|
219
|
+
// Ask the live frontend to re-report the graph right now, so an open but
|
|
220
|
+
// idle page does not serve a stale cache. Best-effort: if no frontend is
|
|
221
|
+
// loaded (or the bridge is missing), this times out and is ignored.
|
|
222
|
+
await sendCommand(baseUrl(), bridgeToken(), 'refresh_report', {}, exec.signal, { timeoutMs: 2500 })
|
|
223
|
+
.catch(() => null)
|
|
224
|
+
const res = await fetch(`${baseUrl()}/dsh-bridge/workflow`, {
|
|
225
|
+
headers: bridgeToken() ? { Authorization: `Bearer ${bridgeToken()}` } : undefined,
|
|
226
|
+
signal: exec.signal,
|
|
227
|
+
})
|
|
228
|
+
if (!res.ok) throw new Error(`bridge HTTP ${res.status}`)
|
|
229
|
+
const data = await res.json()
|
|
230
|
+
if (!data.updated_at) {
|
|
231
|
+
return {
|
|
232
|
+
ready: false,
|
|
233
|
+
error: 'No ComfyUI frontend has reported canvas state yet — open the ComfyUI canvas page (the bridge reports on load and on every change).',
|
|
234
|
+
nodes: [],
|
|
235
|
+
workflow: null,
|
|
236
|
+
updated_at: null,
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return { ready: true, ...data }
|
|
240
|
+
},
|
|
241
|
+
}))
|
|
242
|
+
|
|
243
|
+
ctx.tools.register(defineTool({
|
|
244
|
+
name: 'comfyui_add_node',
|
|
245
|
+
description: 'Add a node to the live ComfyUI canvas. `class` is the ComfyUI node class (e.g. "KSampler", "VAEDecode"). ' +
|
|
246
|
+
'`pos` is an optional [x, y] canvas position. The node appears on the canvas immediately.',
|
|
247
|
+
parameters: {
|
|
248
|
+
class: { type: 'string', required: true, description: 'ComfyUI node class name' },
|
|
249
|
+
pos: { type: 'array', description: 'Optional [x, y] canvas position' },
|
|
250
|
+
},
|
|
251
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: renderJSON },
|
|
252
|
+
async execute(args, exec) {
|
|
253
|
+
return sendCommand(baseUrl(), bridgeToken(), 'add_node', { class: args.class, pos: args.pos }, exec.signal)
|
|
254
|
+
},
|
|
255
|
+
}))
|
|
256
|
+
|
|
257
|
+
ctx.tools.register(defineTool({
|
|
258
|
+
name: 'comfyui_connect',
|
|
259
|
+
description: 'Connect two nodes on the live ComfyUI canvas: `srcId:srcSlot` to `dstId:dstSlot`. ' +
|
|
260
|
+
'Slots are output/input indexes; use comfyui_read_workflow to see exact ids.',
|
|
261
|
+
parameters: {
|
|
262
|
+
srcId: { type: 'number', required: true, description: 'Source node id' },
|
|
263
|
+
srcSlot: { type: 'number', required: true, description: 'Source output slot index' },
|
|
264
|
+
dstId: { type: 'number', required: true, description: 'Destination node id' },
|
|
265
|
+
dstSlot: { type: 'number', required: true, description: 'Destination input slot index' },
|
|
266
|
+
},
|
|
267
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: renderJSON },
|
|
268
|
+
async execute(args, exec) {
|
|
269
|
+
return sendCommand(baseUrl(), bridgeToken(), 'connect', {
|
|
270
|
+
srcId: args.srcId, srcSlot: args.srcSlot, dstId: args.dstId, dstSlot: args.dstSlot,
|
|
271
|
+
}, exec.signal)
|
|
272
|
+
},
|
|
273
|
+
}))
|
|
274
|
+
|
|
275
|
+
ctx.tools.register(defineTool({
|
|
276
|
+
name: 'comfyui_set_param',
|
|
277
|
+
description: 'Set a widget value on a node in the live ComfyUI canvas (e.g. a prompt text, seed, or steps).',
|
|
278
|
+
parameters: {
|
|
279
|
+
nodeId: { type: 'number', required: true, description: 'Node id' },
|
|
280
|
+
key: { type: 'string', required: true, description: 'Widget name, e.g. "text", "seed", "steps"' },
|
|
281
|
+
value: { type: 'json', description: 'New widget value (string, number, or boolean)' },
|
|
282
|
+
},
|
|
283
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: renderJSON },
|
|
284
|
+
async execute(args, exec) {
|
|
285
|
+
return sendCommand(baseUrl(), bridgeToken(), 'set_param', { nodeId: args.nodeId, key: args.key, value: args.value }, exec.signal)
|
|
286
|
+
},
|
|
287
|
+
}))
|
|
288
|
+
|
|
289
|
+
ctx.tools.register(defineTool({
|
|
290
|
+
name: 'comfyui_remove_node',
|
|
291
|
+
description: 'Remove a node (and its connections) from the live ComfyUI canvas.',
|
|
292
|
+
parameters: {
|
|
293
|
+
nodeId: { type: 'number', required: true, description: 'Node id to remove' },
|
|
294
|
+
},
|
|
295
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: renderJSON },
|
|
296
|
+
async execute(args, exec) {
|
|
297
|
+
return sendCommand(baseUrl(), bridgeToken(), 'remove_node', { nodeId: args.nodeId }, exec.signal)
|
|
298
|
+
},
|
|
299
|
+
}))
|
|
300
|
+
|
|
301
|
+
// M2: rewrite loop — swap the whole canvas for a workflow JSON.
|
|
302
|
+
ctx.tools.register(defineTool({
|
|
303
|
+
name: 'comfyui_load_workflow',
|
|
304
|
+
description: 'Replace the entire live ComfyUI canvas with a workflow JSON (UI format, as returned by ' +
|
|
305
|
+
'comfyui_read_workflow). Use to apply a full rewrite: read → modify node list/links → load back.',
|
|
306
|
+
parameters: {
|
|
307
|
+
workflow: { type: 'json', required: true, description: 'Workflow JSON (UI format with nodes/links). May also be the object from comfyui_read_workflow.workflow.' },
|
|
308
|
+
},
|
|
309
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: renderJSON },
|
|
310
|
+
async execute(args, exec) {
|
|
311
|
+
const data = args.workflow
|
|
312
|
+
// A full comfyui_read_workflow result carries `nodes`/`prompt`/`updated_at`
|
|
313
|
+
// alongside the actual UI workflow under `workflow`. When the caller passes
|
|
314
|
+
// that whole object (the documented usage), unwrap to its `.workflow`
|
|
315
|
+
// instead of feeding the read envelope to the bridge.
|
|
316
|
+
const isReadResult = data && typeof data === 'object' && !Array.isArray(data)
|
|
317
|
+
&& Array.isArray(data.nodes)
|
|
318
|
+
&& data.workflow && typeof data.workflow === 'object'
|
|
319
|
+
const wf = isReadResult ? data.workflow : data
|
|
320
|
+
return sendCommand(baseUrl(), bridgeToken(), 'load_workflow', { workflow: wf }, exec.signal, { timeoutMs: 15000 })
|
|
321
|
+
},
|
|
322
|
+
}))
|
|
323
|
+
|
|
324
|
+
// M2: run the current canvas graph and return the prompt id.
|
|
325
|
+
// `overrides` temporarily sets widget values (e.g. seed/prompt) for this run
|
|
326
|
+
// only — the canvas is restored afterwards, so the same graph can be swept.
|
|
327
|
+
ctx.tools.register(defineTool({
|
|
328
|
+
name: 'comfyui_run',
|
|
329
|
+
description: 'Run the current ComfyUI canvas graph (queuePrompt) and return the prompt id. ' +
|
|
330
|
+
'Optionally pass `overrides` ([{nodeId, key, value}]) to run with temporary widget values ' +
|
|
331
|
+
'(e.g. seed/prompt/strength) — the canvas is restored afterwards, leaving it untouched. ' +
|
|
332
|
+
'After running, poll results with comfyui_get_outputs(promptId).',
|
|
333
|
+
parameters: {
|
|
334
|
+
overrides: { type: 'array', description: 'Optional [{nodeId, key, value}] temporary widget values for this run only.' },
|
|
335
|
+
},
|
|
336
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: renderJSON },
|
|
337
|
+
async execute(args, exec) {
|
|
338
|
+
return sendCommand(baseUrl(), bridgeToken(), 'run', { overrides: args.overrides }, exec.signal, { timeoutMs: 20000 })
|
|
339
|
+
},
|
|
340
|
+
}))
|
|
341
|
+
|
|
342
|
+
// M4: batch run — sweep the current graph over a parameter matrix.
|
|
343
|
+
// Each run in `runs` is an override set; every run is queued as its own
|
|
344
|
+
// prompt and returns a prompt_id. ComfyUI executes the queue serially.
|
|
345
|
+
ctx.tools.register(defineTool({
|
|
346
|
+
name: 'comfyui_batch_run',
|
|
347
|
+
description: 'Batch-run the current ComfyUI graph over a parameter matrix. ' +
|
|
348
|
+
'`runs` is a list of override sets, e.g. [{overrides: [{nodeId, key, value}]}], one per run. ' +
|
|
349
|
+
'Each run is queued as its own prompt (ComfyUI executes serially) and returns its prompt_id. ' +
|
|
350
|
+
'The canvas is restored after every run. Collect results with comfyui_get_outputs per id.',
|
|
351
|
+
parameters: {
|
|
352
|
+
runs: {
|
|
353
|
+
type: 'array', required: true,
|
|
354
|
+
description: 'Override sets, one per run. Each is [{nodeId, key, value}] temporary widget values.',
|
|
355
|
+
},
|
|
356
|
+
},
|
|
357
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: renderJSON },
|
|
358
|
+
async execute(args, exec) {
|
|
359
|
+
return sendCommand(baseUrl(), bridgeToken(), 'batch_run', { runs: args.runs }, exec.signal, { timeoutMs: 30000 })
|
|
360
|
+
},
|
|
361
|
+
}))
|
|
362
|
+
|
|
363
|
+
// M2: fetch the output images of a completed run. Reads ComfyUI's own
|
|
364
|
+
// /history/{promptId} (native endpoint, same trust model as /prompt) and
|
|
365
|
+
// lists every output node's images (filename / subfolder / type / view URL).
|
|
366
|
+
// With downloadDir, the images are also saved locally. `ready:false` means
|
|
367
|
+
// the id is not in history yet — the run may still be executing.
|
|
368
|
+
ctx.tools.register(defineTool({
|
|
369
|
+
name: 'comfyui_get_outputs',
|
|
370
|
+
description: 'Fetch the output images produced by a completed ComfyUI run (comfyui_run returns prompt_id). ' +
|
|
371
|
+
'Reads ComfyUI /history/{promptId}, lists every output node with its images (filename/type/subfolder/URL), ' +
|
|
372
|
+
'and optionally downloads them into a local directory. Returns ready:false while the run is still executing.',
|
|
373
|
+
parameters: {
|
|
374
|
+
promptId: { type: 'string', required: true, description: 'The prompt_id returned by comfyui_run.' },
|
|
375
|
+
downloadDir: { type: 'string', description: 'Optional absolute local directory to save the images into. Omit to return URLs only.' },
|
|
376
|
+
},
|
|
377
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: renderJSON },
|
|
378
|
+
async execute(args, exec) {
|
|
379
|
+
const base = baseUrl()
|
|
380
|
+
const pid = encodeURIComponent(args.promptId)
|
|
381
|
+
const res = await fetch(`${base}/history/${pid}`, { signal: exec.signal })
|
|
382
|
+
if (!res.ok) throw new Error(`history HTTP ${res.status}`)
|
|
383
|
+
const history = await res.json()
|
|
384
|
+
const item = history?.[args.promptId]
|
|
385
|
+
if (!item) {
|
|
386
|
+
return { promptId: args.promptId, ready: false, error: 'not in history yet — run may still be executing, or the id is wrong' }
|
|
387
|
+
}
|
|
388
|
+
const outputs = item.outputs ?? {}
|
|
389
|
+
const files = []
|
|
390
|
+
for (const [nodeId, out] of Object.entries(outputs)) {
|
|
391
|
+
const images = Array.isArray(out?.images) ? out.images : []
|
|
392
|
+
for (const img of images) {
|
|
393
|
+
const filename = img?.filename
|
|
394
|
+
if (!filename) continue
|
|
395
|
+
const subfolder = img?.subfolder ?? ''
|
|
396
|
+
const type = img?.type ?? 'output'
|
|
397
|
+
const qs = `filename=${encodeURIComponent(filename)}` +
|
|
398
|
+
(subfolder ? `&subfolder=${encodeURIComponent(subfolder)}` : '') +
|
|
399
|
+
`&type=${encodeURIComponent(type)}`
|
|
400
|
+
files.push({ nodeId, filename, subfolder, type, url: `${base}/view?${qs}` })
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
let downloaded = []
|
|
404
|
+
if (args.downloadDir && files.length > 0) {
|
|
405
|
+
const fs = await import('node:fs')
|
|
406
|
+
const path = await import('node:path')
|
|
407
|
+
fs.mkdirSync(args.downloadDir, { recursive: true })
|
|
408
|
+
for (const f of files) {
|
|
409
|
+
const resp = await fetch(f.url, { signal: exec.signal })
|
|
410
|
+
if (!resp.ok) throw new Error(`download ${f.filename} HTTP ${resp.status}`)
|
|
411
|
+
const buf = Buffer.from(await resp.arrayBuffer())
|
|
412
|
+
const local = path.join(args.downloadDir, f.filename)
|
|
413
|
+
fs.writeFileSync(local, buf)
|
|
414
|
+
downloaded.push({ ...f, local })
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return {
|
|
418
|
+
promptId: args.promptId,
|
|
419
|
+
ready: true,
|
|
420
|
+
status: item.status?.status_str ?? null,
|
|
421
|
+
completed: item.status?.completed ?? false,
|
|
422
|
+
nodeCount: Object.keys(outputs).length,
|
|
423
|
+
outputs: files,
|
|
424
|
+
downloaded,
|
|
425
|
+
}
|
|
426
|
+
},
|
|
427
|
+
}))
|
|
428
|
+
|
|
429
|
+
// M2: debug — validate the graph and flash offending nodes.
|
|
430
|
+
// Uses the bridge's validate command (local structural checks only), which
|
|
431
|
+
// never submits /prompt, so debug has no side effect on a valid graph.
|
|
432
|
+
// Note: validate flags nodes with unconnected REQUIRED inputs; type-mismatch
|
|
433
|
+
// links cannot survive — LiteGraph's connect() rejects them at connect time.
|
|
434
|
+
// Dynamic-slot nodes (switch/index, numbered inputs like image15) report as
|
|
435
|
+
// `warnings` instead of errors, so debug does not false-flag expected slots.
|
|
436
|
+
ctx.tools.register(defineTool({
|
|
437
|
+
name: 'comfyui_debug',
|
|
438
|
+
description: 'Validate the current ComfyUI canvas. Checks the live graph for structural problems ' +
|
|
439
|
+
'(unconnected required inputs) without running it, flashes the offending nodes, ' +
|
|
440
|
+
'and returns the error map. Dynamic-slot nodes (switch/index selectors with numbered inputs ' +
|
|
441
|
+
'such as image15) are reported as warnings, not errors.',
|
|
442
|
+
parameters: {},
|
|
443
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: renderJSON },
|
|
444
|
+
async execute(_args, exec) {
|
|
445
|
+
return sendCommand(baseUrl(), bridgeToken(), 'validate', {}, exec.signal, { timeoutMs: 8000 })
|
|
446
|
+
.then(async (res) => {
|
|
447
|
+
const errors = res?.nodeErrors ?? {}
|
|
448
|
+
const warnings = res?.warnings ?? {}
|
|
449
|
+
const ids = res?.offendingIds ?? Object.keys(errors)
|
|
450
|
+
if (ids.length > 0) {
|
|
451
|
+
await sendCommand(baseUrl(), bridgeToken(), 'highlight', { ids }, exec.signal, { timeoutMs: 5000 }).catch(() => null)
|
|
452
|
+
}
|
|
453
|
+
return { nodeErrors: errors, warnings, highlighted: ids }
|
|
454
|
+
})
|
|
455
|
+
},
|
|
456
|
+
}))
|
|
457
|
+
|
|
458
|
+
// M3: expose the active configuration to the agent (read-only convenience).
|
|
459
|
+
// activeView is session-isolated: the client writes per-session values keyed
|
|
460
|
+
// by session id, and this tool reads the value for the CURRENT session.
|
|
461
|
+
// 'canvas' = the user has the ComfyUI split canvas tab selected for this
|
|
462
|
+
// session right now; 'chat' = plain chat tab. The session id comes from the
|
|
463
|
+
// stable tool-run contract (exec.agent.session), falling back to the
|
|
464
|
+
// inherited initiator; when neither is available the caller is told
|
|
465
|
+
// activeViewKnown=false instead of silently assuming chat.
|
|
466
|
+
ctx.tools.register(defineTool({
|
|
467
|
+
name: 'comfyui_config',
|
|
468
|
+
description: 'Show the active ComfyUI connection configuration the canvas plugin is using, ' +
|
|
469
|
+
'plus `activeView` for the CURRENT session ("canvas" when the ComfyUI split canvas tab is ' +
|
|
470
|
+
'selected in the browser for this session, "chat" when the plain chat tab is). ' +
|
|
471
|
+
'Read this before canvas work to confirm the user is on the canvas in THIS session.',
|
|
472
|
+
parameters: {},
|
|
473
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: renderJSON },
|
|
474
|
+
async execute(_args, exec) {
|
|
475
|
+
const value = settingsScope?.get?.()
|
|
476
|
+
let currentSessionId
|
|
477
|
+
let sessionSource = 'none'
|
|
478
|
+
try {
|
|
479
|
+
currentSessionId = exec?.agent?.session?.id
|
|
480
|
+
if (currentSessionId) sessionSource = 'exec.agent'
|
|
481
|
+
} catch { /* exec contract unavailable */ }
|
|
482
|
+
if (!currentSessionId) {
|
|
483
|
+
try {
|
|
484
|
+
currentSessionId = ctx.get?.('agents')?.currentInitiator?.()?.session?.id
|
|
485
|
+
if (currentSessionId) sessionSource = 'currentInitiator'
|
|
486
|
+
} catch { /* agents service unavailable */ }
|
|
487
|
+
}
|
|
488
|
+
let activeView = 'chat'
|
|
489
|
+
let activeViewKnown = false
|
|
490
|
+
if (currentSessionId && value?.activeViewBySession?.[currentSessionId] === 'canvas') {
|
|
491
|
+
activeView = 'canvas'
|
|
492
|
+
activeViewKnown = true
|
|
493
|
+
} else if (currentSessionId && value?.activeViewBySession?.[currentSessionId] === 'chat') {
|
|
494
|
+
activeView = 'chat'
|
|
495
|
+
activeViewKnown = true
|
|
496
|
+
}
|
|
497
|
+
return {
|
|
498
|
+
baseUrl: baseUrl(),
|
|
499
|
+
activeView,
|
|
500
|
+
activeViewKnown,
|
|
501
|
+
sessionSource,
|
|
502
|
+
bridgeTokenSet: Boolean(value?.bridgeToken),
|
|
503
|
+
comfyuiDir: value?.comfyuiDir || '',
|
|
504
|
+
launchCommand: value?.launchCommand || '',
|
|
505
|
+
}
|
|
506
|
+
},
|
|
507
|
+
}))
|
|
508
|
+
|
|
509
|
+
// M4: one-click upgrade — git pull the ComfyUI core, then every git-backed
|
|
510
|
+
// custom node under custom_nodes/. Platform-neutral via the shell service.
|
|
511
|
+
ctx.tools.register(defineTool({
|
|
512
|
+
name: 'comfyui_upgrade',
|
|
513
|
+
description: 'Upgrade the local ComfyUI install: git pull the core, then git pull every git-backed ' +
|
|
514
|
+
'custom node under custom_nodes/. Returns per-repo results. Requires the ComfyUI install directory ' +
|
|
515
|
+
'(set it in Settings → ComfyUI 画布 → ComfyUI 安装目录, or env COMFYUI_DIR).',
|
|
516
|
+
parameters: {
|
|
517
|
+
scope: { type: 'string', enum: ['core', 'nodes', 'all'], description: 'What to upgrade: core only, nodes only, or all (default all).' },
|
|
518
|
+
},
|
|
519
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: renderJSON },
|
|
520
|
+
async execute(args) {
|
|
521
|
+
const shell = ctx.get?.('shell')
|
|
522
|
+
if (!shell || typeof shell.run !== 'function') {
|
|
523
|
+
throw new Error('shell service unavailable — cannot run upgrade commands')
|
|
524
|
+
}
|
|
525
|
+
const value = settingsScope?.get?.()
|
|
526
|
+
const dir = (value?.comfyuiDir || process.env.COMFYUI_DIR || '').trim()
|
|
527
|
+
if (!dir) {
|
|
528
|
+
throw new Error('ComfyUI install directory not set. Set comfyuiDir in Settings → ComfyUI 画布, or env COMFYUI_DIR.')
|
|
529
|
+
}
|
|
530
|
+
const fs = await import('node:fs')
|
|
531
|
+
const path = await import('node:path')
|
|
532
|
+
const run = async (label, command, workdir) => {
|
|
533
|
+
try {
|
|
534
|
+
const spec = shell.resolve({ command, workdir, timeoutMs: 120000 })
|
|
535
|
+
const result = await shell.run(spec)
|
|
536
|
+
const out = (result.stdout?.text || result.stdout || '').toString().trim()
|
|
537
|
+
const err = (result.stderr?.text || result.stderr || '').toString().trim()
|
|
538
|
+
return { label, exitCode: result.exitCode, ok: result.exitCode === 0, out, err }
|
|
539
|
+
} catch (e) {
|
|
540
|
+
return { label, ok: false, error: e instanceof Error ? e.message : String(e) }
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
const results = []
|
|
544
|
+
const scopeArg = args?.scope ?? 'all'
|
|
545
|
+
// core: the ComfyUI root is itself a git checkout
|
|
546
|
+
if (scopeArg !== 'nodes' && fs.existsSync(path.join(dir, '.git'))) {
|
|
547
|
+
results.push(await run('core', 'git pull', dir))
|
|
548
|
+
}
|
|
549
|
+
// nodes: every git-backed custom node
|
|
550
|
+
if (scopeArg !== 'core') {
|
|
551
|
+
const nodesDir = path.join(dir, 'custom_nodes')
|
|
552
|
+
if (fs.existsSync(nodesDir)) {
|
|
553
|
+
const entries = fs.readdirSync(nodesDir, { withFileTypes: true })
|
|
554
|
+
.filter(d => d.isDirectory())
|
|
555
|
+
.map(d => d.name)
|
|
556
|
+
.sort()
|
|
557
|
+
for (const name of entries) {
|
|
558
|
+
const nodeDir = path.join(nodesDir, name)
|
|
559
|
+
if (fs.existsSync(path.join(nodeDir, '.git'))) {
|
|
560
|
+
results.push(await run(`node:${name}`, 'git pull', nodeDir))
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
return { upgraded: results.length, results }
|
|
566
|
+
},
|
|
567
|
+
}))
|
|
568
|
+
|
|
569
|
+
// v0.1.2: upload ANY local file (image/audio/video/3D) into ComfyUI's input/
|
|
570
|
+
// and optionally point a Load node at it. Uses ComfyUI's NATIVE /upload/image
|
|
571
|
+
// (the input/ directory is ComfyUI's single upload entry regardless of the
|
|
572
|
+
// endpoint name — audio/video/3D land in the same place, consumable by their
|
|
573
|
+
// native Load nodes). Not the bridge: the source file is on the agent's own
|
|
574
|
+
// machine, so the host reads and uploads it. mediaType defaults to a
|
|
575
|
+
// per-extension guess; nodeId + widgetKey let the caller point the matching
|
|
576
|
+
// Load node (LoadImage→image, LoadAudio→audio, LoadVideo→video, 3D→mesh…).
|
|
577
|
+
ctx.tools.register(defineTool({
|
|
578
|
+
name: 'comfyui_attach_file',
|
|
579
|
+
description: 'Upload a local file (image/audio/video/3D) into ComfyUI\'s input/ directory and optionally ' +
|
|
580
|
+
'point a Load node at it. Uses ComfyUI\'s native upload endpoint (not the bridge); the source file is read ' +
|
|
581
|
+
'from the agent\'s own machine. mediaType is inferred from the extension when omitted. After upload, pass ' +
|
|
582
|
+
'nodeId (and widgetKey for non-image loads) to update the Load node so it takes effect immediately.',
|
|
583
|
+
parameters: {
|
|
584
|
+
path: { type: 'string', required: true, description: 'Absolute local path to the file.' },
|
|
585
|
+
mediaType: { type: 'string', description: 'Optional explicit media type (e.g. video/mp4, audio/wav, model/gltf-binary); defaults to a per-extension guess.' },
|
|
586
|
+
nodeId: { type: 'number', description: 'Optional Load node id to point at the uploaded file.' },
|
|
587
|
+
widgetKey: { type: 'string', description: 'Optional widget name on the Load node (default "image"; use audio/video/mesh for other Loads).' },
|
|
588
|
+
},
|
|
589
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: renderJSON },
|
|
590
|
+
async execute(args, exec) {
|
|
591
|
+
const fs = await import('node:fs')
|
|
592
|
+
const path = await import('node:path')
|
|
593
|
+
const filePath = args.path
|
|
594
|
+
if (!fs.existsSync(filePath)) throw new Error(`file not found: ${filePath}`)
|
|
595
|
+
const buf = fs.readFileSync(filePath)
|
|
596
|
+
const filename = path.basename(filePath)
|
|
597
|
+
const ext = path.extname(filePath).toLowerCase().replace('.', '')
|
|
598
|
+
const mediaType = args.mediaType || mediaTypeOf(ext) || 'application/octet-stream'
|
|
599
|
+
// ComfyUI's /upload/image is the input/ upload entry; it accepts any
|
|
600
|
+
// file type there. overwrite=false dedups a clashing name and returns the
|
|
601
|
+
// actually-stored name.
|
|
602
|
+
const form = new FormData()
|
|
603
|
+
form.append('image', new Blob([buf]), filename)
|
|
604
|
+
form.append('type', 'input')
|
|
605
|
+
form.append('overwrite', 'false')
|
|
606
|
+
const res = await fetch(`${baseUrl()}/upload/image`, {
|
|
607
|
+
method: 'POST',
|
|
608
|
+
body: form,
|
|
609
|
+
signal: exec.signal,
|
|
610
|
+
})
|
|
611
|
+
if (!res.ok) throw new Error(`upload HTTP ${res.status}`)
|
|
612
|
+
const data = await res.json()
|
|
613
|
+
const stored = (data.subfolder ? `${data.subfolder}/${data.name}` : data.name)
|
|
614
|
+
const out = { filename: stored, name: data.name, subfolder: data.subfolder ?? '', type: data.type ?? 'input', mediaType }
|
|
615
|
+
if (args.nodeId != null) {
|
|
616
|
+
const key = args.widgetKey || 'image'
|
|
617
|
+
const r = await sendCommand(baseUrl(), bridgeToken(), 'set_param', { nodeId: args.nodeId, key, value: stored }, exec.signal)
|
|
618
|
+
out.nodeId = args.nodeId
|
|
619
|
+
out.updated = r
|
|
620
|
+
}
|
|
621
|
+
return out
|
|
622
|
+
},
|
|
623
|
+
}))
|
|
624
|
+
|
|
625
|
+
// v0.1.1: upload a local image into ComfyUI's input/ and optionally point a
|
|
626
|
+
// LoadImage node at it. Uses ComfyUI's NATIVE /upload/image (not the bridge):
|
|
627
|
+
// the source file is on the agent's own machine, so the host reads and
|
|
628
|
+
// uploads it — same trust model as get_outputs' /history + /view. The bridge
|
|
629
|
+
// token does not apply (native endpoint, not /dsh-bridge/*).
|
|
630
|
+
ctx.tools.register(defineTool({
|
|
631
|
+
name: 'comfyui_attach_image',
|
|
632
|
+
description: 'Upload a local image into ComfyUI\'s input/ directory and optionally point a LoadImage ' +
|
|
633
|
+
'node at it. Uses ComfyUI\'s native /upload/image (not the bridge), so the source file is read from ' +
|
|
634
|
+
'the agent\'s own machine. After upload, pass nodeId to update a LoadImage node\'s image widget to ' +
|
|
635
|
+
'the new filename so it takes effect immediately.',
|
|
636
|
+
parameters: {
|
|
637
|
+
image: { type: 'string', required: true, description: 'Absolute local path to the image file (png/jpeg/webp/gif).' },
|
|
638
|
+
nodeId: { type: 'number', description: 'Optional LoadImage node id to point at the uploaded file.' },
|
|
639
|
+
},
|
|
640
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: renderJSON },
|
|
641
|
+
async execute(args, exec) {
|
|
642
|
+
const fs = await import('node:fs')
|
|
643
|
+
const path = await import('node:path')
|
|
644
|
+
const filePath = args.image
|
|
645
|
+
if (!fs.existsSync(filePath)) throw new Error(`image not found: ${filePath}`)
|
|
646
|
+
const buf = fs.readFileSync(filePath)
|
|
647
|
+
const filename = path.basename(filePath)
|
|
648
|
+
// Node 22: global FormData + Blob are available; fetch sends a real
|
|
649
|
+
// multipart body when handed a FormData. overwrite=false lets ComfyUI
|
|
650
|
+
// dedup a clashing name and return the actually-stored name.
|
|
651
|
+
const form = new FormData()
|
|
652
|
+
form.append('image', new Blob([buf]), filename)
|
|
653
|
+
form.append('type', 'input')
|
|
654
|
+
form.append('overwrite', 'false')
|
|
655
|
+
const res = await fetch(`${baseUrl()}/upload/image`, {
|
|
656
|
+
method: 'POST',
|
|
657
|
+
body: form,
|
|
658
|
+
signal: exec.signal,
|
|
659
|
+
})
|
|
660
|
+
if (!res.ok) throw new Error(`upload HTTP ${res.status}`)
|
|
661
|
+
const data = await res.json()
|
|
662
|
+
// ComfyUI returns { name, subfolder, type }; LoadImage's image widget
|
|
663
|
+
// expects "subfolder/name" when a subfolder is present, else "name".
|
|
664
|
+
const stored = (data.subfolder ? `${data.subfolder}/${data.name}` : data.name)
|
|
665
|
+
const out = { filename: stored, name: data.name, subfolder: data.subfolder ?? '', type: data.type ?? 'input' }
|
|
666
|
+
if (args.nodeId != null) {
|
|
667
|
+
const r = await sendCommand(baseUrl(), bridgeToken(), 'set_param', { nodeId: args.nodeId, key: 'image', value: stored }, exec.signal)
|
|
668
|
+
out.nodeId = args.nodeId
|
|
669
|
+
out.updated = r
|
|
670
|
+
}
|
|
671
|
+
return out
|
|
672
|
+
},
|
|
673
|
+
}))
|
|
674
|
+
|
|
675
|
+
// v0.1.1: inject conversation text into the canvas. With nodeId: write a
|
|
676
|
+
// widget directly. With newClass: create a source node, set its widget, and
|
|
677
|
+
// optionally connect it to a target input — a one-step "conversation text →
|
|
678
|
+
// canvas node". The bridge's inject_text branch composes add_node + set_param
|
|
679
|
+
// + connect; set_param alone already covers "write an existing widget", so
|
|
680
|
+
// inject_text is the convenience for "create a new source and wire it".
|
|
681
|
+
ctx.tools.register(defineTool({
|
|
682
|
+
name: 'comfyui_inject_text',
|
|
683
|
+
description: 'Inject text into the live ComfyUI canvas. With nodeId: writes the text to that ' +
|
|
684
|
+
'node\'s widget (default key "text"). With newClass: creates a new node of that class, sets its ' +
|
|
685
|
+
'widget, and (if targetId given) connects it to the target input — a text source you can wire. ' +
|
|
686
|
+
'A convenience wrapper over add_node + set_param + connect for one-step conversation text → canvas node.',
|
|
687
|
+
parameters: {
|
|
688
|
+
text: { type: 'string', required: true, description: 'The text to inject.' },
|
|
689
|
+
nodeId: { type: 'number', description: 'Target node id to write the widget directly (mode 1).' },
|
|
690
|
+
widgetKey: { type: 'string', description: 'Widget name to write (default "text").' },
|
|
691
|
+
newClass: { type: 'string', description: 'Create a new node of this class as the text source, e.g. "CLIPTextEncode" (mode 2).' },
|
|
692
|
+
targetId: { type: 'number', description: 'Connect the new node\'s output to this target node input.' },
|
|
693
|
+
targetSlot: { type: 'number', description: 'Target input slot index for the connection.' },
|
|
694
|
+
sourceSlot: { type: 'number', description: 'Source output slot index, default 0.' },
|
|
695
|
+
},
|
|
696
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: renderJSON },
|
|
697
|
+
async execute(args, exec) {
|
|
698
|
+
return sendCommand(baseUrl(), bridgeToken(), 'inject_text', {
|
|
699
|
+
text: args.text,
|
|
700
|
+
nodeId: args.nodeId,
|
|
701
|
+
widgetKey: args.widgetKey ?? 'text',
|
|
702
|
+
newClass: args.newClass,
|
|
703
|
+
targetId: args.targetId,
|
|
704
|
+
targetSlot: args.targetSlot,
|
|
705
|
+
sourceSlot: args.sourceSlot ?? 0,
|
|
706
|
+
}, exec.signal, { timeoutMs: 10000 })
|
|
707
|
+
},
|
|
708
|
+
}))
|
|
709
|
+
|
|
710
|
+
// v0.1.1: export the current canvas as API-format workflow JSON — the format
|
|
711
|
+
// ComfyUI's /prompt and comfy-cli run_workflow consume. Bridges the live
|
|
712
|
+
// canvas to headless/MCP batch runs: build a graph on the canvas, export it,
|
|
713
|
+
// then run it at scale via comfy-cli run_workflow.
|
|
714
|
+
ctx.tools.register(defineTool({
|
|
715
|
+
name: 'comfyui_export_api',
|
|
716
|
+
description: 'Export the current ComfyUI canvas as an API-format workflow JSON (the format ComfyUI\'s ' +
|
|
717
|
+
'/prompt and comfy-cli run_workflow consume). Use to bridge the live canvas to headless/MCP batch ' +
|
|
718
|
+
'runs: build a graph on the canvas, export it, then run it at scale. Run comfyui_debug first if the ' +
|
|
719
|
+
'canvas has unconnected required inputs (graphToPrompt will reject them).',
|
|
720
|
+
parameters: {},
|
|
721
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: renderJSON },
|
|
722
|
+
async execute(_args, exec) {
|
|
723
|
+
return sendCommand(baseUrl(), bridgeToken(), 'export_api', {}, exec.signal, { timeoutMs: 10000 })
|
|
724
|
+
},
|
|
725
|
+
}))
|
|
726
|
+
}
|