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/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
+ }