dsh-comfyui 0.3.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/tools.d.ts ADDED
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Model-facing tools for dsh-comfyui, registered into the host `tools`
3
+ * registry. `comfyui_run` submits a workflow and returns media results
4
+ * (synchronously or as a background job); `comfyui_object_info` exposes the
5
+ * server's node definitions; `comfyui_workflow` lists and runs saved
6
+ * workflows from the panel-managed workflow library.
7
+ */
8
+ import type { Context } from '@deepseek-ai/cordis';
9
+ import type { Config } from './config.js';
10
+ import { ComfyUIClient } from './comfyui.js';
11
+ import type { AssetRecord, StoredWorkflow } from './store.js';
12
+ import type { GraphAnalysis } from './analyze.js';
13
+ import type { RunProgress } from './progress.js';
14
+ import type { QueuedRun } from './queue.js';
15
+ import type { WorkflowParameter } from './params.js';
16
+ import type { HostHint } from './host-hint.js';
17
+ /** A workflow saved on the ComfyUI server (userdata/workflows), with extract status. */
18
+ export interface ComfyUIComfyWorkflow {
19
+ name: string;
20
+ size?: number;
21
+ modified?: number;
22
+ /** Whether at least one runnable API workflow was extracted from this graph. */
23
+ extracted: boolean;
24
+ /** Runnable API workflows extracted from this graph (运行主题). */
25
+ derived: Array<{
26
+ libraryId: string;
27
+ name: string;
28
+ }>;
29
+ }
30
+ /** Live runtime the tools, routes, and proxy share. */
31
+ export interface ComfyUIRuntime {
32
+ getConfig(): Config;
33
+ /** Resolve the API key per request (credentials store, then environment). */
34
+ getApiKey(): Promise<string | undefined>;
35
+ createClient(apiKey: string | undefined): ComfyUIClient;
36
+ /** Absolute media proxy URL base: explicit config > detected request host > loopback. */
37
+ proxyBase(): string | undefined;
38
+ /** Remembers the origin browsers use to reach this server (Host header). */
39
+ hostHint: HostHint;
40
+ /** Whether the settings service can persist config writes. */
41
+ settingsWritable(): boolean;
42
+ updateConfig(patch: Record<string, unknown>): Promise<{
43
+ ok: true;
44
+ } | {
45
+ ok: false;
46
+ error: string;
47
+ }>;
48
+ /** Queue a workflow and track it in the queue tracker. `meta.parameters`
49
+ * applies adjustable parameters (values/random seeds) before submitting. */
50
+ queue(workflow: unknown, meta: {
51
+ workflowName: string | null;
52
+ workflowId?: string | null;
53
+ source: string;
54
+ parameters?: WorkflowParameter[];
55
+ values?: Record<string, unknown>;
56
+ }): Promise<string>;
57
+ /** Stop tracking a prompt (used when a tool call fails before completion). */
58
+ untrack(promptId: string): void;
59
+ /** Every prompt this plugin queued and is still waiting on. */
60
+ trackedRuns(): QueuedRun[];
61
+ /** Live generation progress for one prompt (from the ComfyUI WebSocket). */
62
+ queueProgress(promptId: string): RunProgress | undefined;
63
+ /** Saved workflows from the library. */
64
+ listWorkflows(): Promise<StoredWorkflow[]>;
65
+ getWorkflow(id: string): Promise<StoredWorkflow | undefined>;
66
+ /** Create or update a workflow in the library. */
67
+ saveWorkflow(input: {
68
+ id?: string;
69
+ name: string;
70
+ description: string;
71
+ workflow: unknown;
72
+ parameters?: WorkflowParameter[];
73
+ tags?: string[];
74
+ source?: 'user' | 'comfyui';
75
+ comfyuiFile?: string;
76
+ }): Promise<{
77
+ ok: true;
78
+ workflow: StoredWorkflow;
79
+ } | {
80
+ ok: false;
81
+ error: string;
82
+ }>;
83
+ /** Delete a workflow from the library; false when it did not exist. */
84
+ deleteWorkflow(id: string): Promise<boolean>;
85
+ /** Pixel sizes of panel-uploaded files, keyed by file name. */
86
+ listMediaSizes(): Promise<Record<string, {
87
+ width: number;
88
+ height: number;
89
+ }>>;
90
+ /** Record the pixel size of one uploaded file. */
91
+ saveMediaSize(name: string, size: {
92
+ width: number;
93
+ height: number;
94
+ }): Promise<void>;
95
+ /** File name recorded for a content hash (dedup index), if any. */
96
+ lookupMediaHash(hash: string): Promise<string | undefined>;
97
+ /** Record a content hash → file name pair for dedup. */
98
+ saveMediaHash(hash: string, name: string): Promise<void>;
99
+ /** The load-area selection (default source image for image-to-image). */
100
+ loadCurrentImage(): Promise<{
101
+ name: string;
102
+ kind: 'image' | 'video' | 'audio';
103
+ source: 'imported' | 'generated';
104
+ } | undefined>;
105
+ /** Persist the load-area selection. */
106
+ saveCurrentImage(image: {
107
+ name: string;
108
+ kind: 'image' | 'video' | 'audio';
109
+ source: 'imported' | 'generated';
110
+ }): Promise<void>;
111
+ /** The asset index (newest first). */
112
+ listAssets(): Promise<AssetRecord[]>;
113
+ /** Move completed tracked runs into the asset index. */
114
+ sweep(): Promise<AssetRecord[]>;
115
+ /** Workflows the user saved on the ComfyUI server, with extract status. */
116
+ listComfyWorkflows(): Promise<ComfyUIComfyWorkflow[]>;
117
+ /** Read one ComfyUI-side saved workflow graph (UI format, not runnable as-is). */
118
+ getComfyWorkflow(file: string): Promise<unknown>;
119
+ /** Analyze one ComfyUI-side graph: connected components, groups, dangling nodes. */
120
+ analyzeComfyWorkflow(file: string): Promise<GraphAnalysis | {
121
+ ok: false;
122
+ error: string;
123
+ }>;
124
+ /** Extract runnable API workflows from a ComfyUI-side graph (整体/按分量/主流程). */
125
+ extractComfyWorkflow(input: {
126
+ file: string;
127
+ mode: 'all' | 'split' | 'main';
128
+ }): Promise<{
129
+ ok: true;
130
+ saved: StoredWorkflow[];
131
+ analysis: GraphAnalysis;
132
+ warnings: string[];
133
+ } | {
134
+ ok: false;
135
+ error: string;
136
+ }>;
137
+ }
138
+ /** One media item returned by comfyui_run (JSON-safe). */
139
+ export interface RunMediaItem {
140
+ filename: string;
141
+ subfolder: string;
142
+ type: string;
143
+ node: string;
144
+ index: number;
145
+ kind: 'image' | 'video' | 'audio' | 'other';
146
+ url: string;
147
+ }
148
+ /** Synchronous completion result. */
149
+ export interface RunResult {
150
+ kind: 'sync';
151
+ promptId: string;
152
+ status: 'completed' | 'interrupted';
153
+ elapsedMs: number;
154
+ media: RunMediaItem[];
155
+ summary: string;
156
+ }
157
+ /** Background mode result: collect later with job_output. */
158
+ export interface BackgroundResult {
159
+ kind: 'background';
160
+ jobId: string;
161
+ promptId: string;
162
+ label: string;
163
+ }
164
+ /** Register the plugin tools; returns disposers. */
165
+ export declare function registerComfyUITools(ctx: Context, runtime: ComfyUIRuntime): Array<() => void>;
package/lib/tools.js ADDED
@@ -0,0 +1,493 @@
1
+ import { collectMedia } from './comfyui.js';
2
+ import { TEMPLATES, findTemplate, cloneWorkflow, applyTemplateInputs } from './templates.js';
3
+ const TOOL_TIMEOUT_MS = 3_600_000;
4
+ function missing(args, name) {
5
+ return args[name] === undefined || args[name] === null;
6
+ }
7
+ function requireOneOf(args, names) {
8
+ const present = names.filter((name) => !missing(args, name));
9
+ if (present.length === 0)
10
+ return `exactly one of ${names.join(', ')} is required`;
11
+ if (present.length > 1)
12
+ return `only one of ${names.join(', ')} may be given`;
13
+ return undefined;
14
+ }
15
+ function buildWorkflow(args) {
16
+ const template = args.template;
17
+ if (typeof template === 'string') {
18
+ const found = findTemplate(template);
19
+ if (found === undefined) {
20
+ throw new Error(`comfyui_run: unknown template "${template}" — use one of ${TEMPLATES.map((t) => t.id).join(', ')}`);
21
+ }
22
+ const workflow = cloneWorkflow(found.workflow);
23
+ const inputs = args.inputs;
24
+ if (inputs !== undefined) {
25
+ if (typeof inputs !== 'object' || inputs === null) {
26
+ throw new Error('comfyui_run: inputs must be an object keyed by node id');
27
+ }
28
+ applyTemplateInputs(workflow, inputs);
29
+ }
30
+ return { workflow, label: `comfyui ${template}` };
31
+ }
32
+ const workflow = args.workflow;
33
+ if (typeof workflow !== 'object' || workflow === null) {
34
+ throw new Error('comfyui_run: workflow must be an object');
35
+ }
36
+ const inputs = args.inputs;
37
+ if (inputs !== undefined) {
38
+ if (typeof inputs !== 'object' || inputs === null) {
39
+ throw new Error('comfyui_run: inputs must be an object keyed by node id');
40
+ }
41
+ applyTemplateInputs(workflow, inputs);
42
+ }
43
+ return { workflow: workflow, label: 'comfyui custom workflow' };
44
+ }
45
+ function summarizeMedia(media) {
46
+ if (media.length === 0)
47
+ return 'no media outputs';
48
+ const images = media.filter((item) => item.kind === 'image').length;
49
+ const videos = media.filter((item) => item.kind === 'video').length;
50
+ const others = media.length - images - videos;
51
+ const parts = [];
52
+ if (images > 0)
53
+ parts.push(`${images} image(s)`);
54
+ if (videos > 0)
55
+ parts.push(`${videos} video(s)`);
56
+ if (others > 0)
57
+ parts.push(`${others} other file(s)`);
58
+ return parts.join(', ');
59
+ }
60
+ function renderRunResult(_args, value) {
61
+ const result = value;
62
+ if (result.kind === 'background') {
63
+ return [{
64
+ type: 'text',
65
+ text: `ComfyUI generation started in the background (job ${result.jobId}, prompt ${result.promptId}). Collect the result with job_output.`,
66
+ }];
67
+ }
68
+ const lines = [
69
+ `ComfyUI ${result.status} (prompt ${result.promptId}) in ${result.elapsedMs} ms — ${summarizeMedia(result.media)}`,
70
+ ];
71
+ for (const item of result.media) {
72
+ lines.push(` ${item.kind}: ${item.url}`);
73
+ }
74
+ return [{ type: 'text', text: lines.join('\n') }];
75
+ }
76
+ /**
77
+ * Wait for a queued prompt and collect its media. Untracks the prompt when
78
+ * the wait fails; an interrupted wait reports an interrupted result instead
79
+ * of throwing.
80
+ */
81
+ async function waitSync(runtime, client, promptId, config, signal, timeoutMs) {
82
+ const startedAt = Date.now();
83
+ try {
84
+ const entry = await client.waitForCompletion({
85
+ promptId,
86
+ timeoutMs: timeoutMs ?? config.timeoutMs,
87
+ pollIntervalMs: config.pollIntervalMs,
88
+ signal,
89
+ });
90
+ const items = collectMedia({ promptId, entry, maxItems: config.maxMediaItems, proxyBase: runtime.proxyBase() });
91
+ return {
92
+ kind: 'sync',
93
+ promptId,
94
+ status: 'completed',
95
+ elapsedMs: Date.now() - startedAt,
96
+ media: items,
97
+ summary: summarizeMedia(items),
98
+ };
99
+ }
100
+ catch (error) {
101
+ runtime.untrack(promptId);
102
+ if (error instanceof Error && error.name === 'ComfyUIError' && error.message.includes('interrupted')) {
103
+ return {
104
+ kind: 'sync',
105
+ promptId,
106
+ status: 'interrupted',
107
+ elapsedMs: Date.now() - startedAt,
108
+ media: [],
109
+ summary: 'interrupted before completion',
110
+ };
111
+ }
112
+ throw error;
113
+ }
114
+ }
115
+ function runDefinition(runtime, ctx) {
116
+ return {
117
+ name: 'comfyui_run',
118
+ description: [
119
+ 'Submit a workflow to the configured ComfyUI server and return the generated media (images/videos).',
120
+ 'Provide exactly one of `workflow` (ComfyUI API-format object: node id → { class_type, inputs }) or `template` (built-in: txt2img | img2img | video).',
121
+ 'Use `inputs` to override node inputs by id, e.g. {"6": {"text": "a red cat"}} for the positive prompt in the templates.',
122
+ 'Templates: txt2img — 4 checkpoint, 5 EmptyLatentImage (width/height), 6 positive text, 7 negative text, 3 KSampler (seed/steps/cfg/denoise), 9 SaveImage. img2img — 10 LoadImage (image), 11 VAEEncode, 6 text, 3 KSampler (denoise). video — Wan 2.1, needs ComfyUI-WanVideoWrapper custom nodes (10 UNETLoader, 13 WanTextEncode, 14 WanImageToVideo, 15 KSampler, 17 SaveVideo).',
123
+ 'Inspect available node types with comfyui_object_info before hand-writing a workflow.',
124
+ '`mode: sync` (default) waits and returns media URLs; `mode: async` starts a background job and returns a job id for job_output.',
125
+ ].join(' '),
126
+ parameters: {
127
+ type: 'object',
128
+ properties: {
129
+ workflow: { type: 'object', description: 'ComfyUI API-format workflow: node id → { class_type, inputs }. Alternative to `template`.' },
130
+ template: { type: 'string', enum: ['txt2img', 'img2img', 'video'], description: 'Built-in workflow template id. Alternative to `workflow`.' },
131
+ inputs: { type: 'object', description: 'Per-node input overrides keyed by node id, e.g. {"3": {"seed": 42, "steps": 30}, "6": {"text": "prompt"}}.' },
132
+ mode: { type: 'string', enum: ['sync', 'async'], default: 'sync', description: 'sync waits and returns media; async returns a background job id.' },
133
+ timeout_ms: { type: 'number', minimum: 5_000, maximum: 3_600_000, description: 'Generation wait budget in ms (default 180000). Video needs minutes.' },
134
+ },
135
+ required: [],
136
+ },
137
+ output: {
138
+ schema: { type: 'object' },
139
+ render: renderRunResult,
140
+ presentationMeta(_args, value) {
141
+ const result = value;
142
+ if (result.kind === 'background') {
143
+ return { kind: 'background', jobId: result.jobId, promptId: result.promptId, label: result.label };
144
+ }
145
+ return {
146
+ kind: 'sync',
147
+ promptId: result.promptId,
148
+ status: result.status,
149
+ elapsedMs: result.elapsedMs,
150
+ media: result.media,
151
+ summary: result.summary,
152
+ };
153
+ },
154
+ },
155
+ timeoutMs: TOOL_TIMEOUT_MS,
156
+ async execute(args, exec) {
157
+ const problem = requireOneOf(args, ['workflow', 'template']);
158
+ if (problem !== undefined)
159
+ throw new Error(`comfyui_run: ${problem}`);
160
+ const mode = args.mode === undefined ? 'sync' : args.mode;
161
+ if (mode !== 'sync' && mode !== 'async')
162
+ throw new Error(`comfyui_run: mode must be sync or async, got ${String(mode)}`);
163
+ const config = runtime.getConfig();
164
+ const apiKey = await runtime.getApiKey();
165
+ const client = runtime.createClient(apiKey);
166
+ const { workflow, label } = buildWorkflow(args);
167
+ const promptId = await runtime.queue(workflow, { workflowName: label, source: 'tool' });
168
+ const waitMs = typeof args.timeout_ms === 'number' ? args.timeout_ms : config.timeoutMs;
169
+ if (mode === 'async') {
170
+ const jobs = ctx.get('jobs');
171
+ if (jobs === undefined) {
172
+ throw new Error('comfyui_run: background jobs unavailable — load @deepseek-ai/dsh-jobs-local and @deepseek-ai/dsh-tool-jobs');
173
+ }
174
+ const jobId = jobs.start({
175
+ kind: 'comfyui',
176
+ label,
177
+ ...(exec.agent !== undefined ? { owner: exec.agent } : {}),
178
+ run: () => {
179
+ const startedAt = Date.now();
180
+ const done = (async () => {
181
+ try {
182
+ const entry = await client.waitForCompletion({
183
+ promptId,
184
+ timeoutMs: waitMs,
185
+ pollIntervalMs: config.pollIntervalMs,
186
+ signal: new AbortController().signal,
187
+ });
188
+ const items = collectMedia({ promptId, entry, maxItems: config.maxMediaItems, proxyBase: runtime.proxyBase() });
189
+ const result = {
190
+ kind: 'sync',
191
+ promptId,
192
+ status: 'completed',
193
+ elapsedMs: Date.now() - startedAt,
194
+ media: items,
195
+ summary: summarizeMedia(items),
196
+ };
197
+ return { status: 'completed', output: JSON.stringify(result) };
198
+ }
199
+ catch (error) {
200
+ runtime.untrack(promptId);
201
+ const message = error instanceof Error ? error.message : String(error);
202
+ return { status: 'failed', detail: 'comfyui', output: message };
203
+ }
204
+ })();
205
+ return {
206
+ cancel: () => { void client.interrupt().catch(() => undefined); },
207
+ done,
208
+ };
209
+ },
210
+ });
211
+ const result = { kind: 'background', jobId, promptId, label };
212
+ return result;
213
+ }
214
+ const result = await waitSync(runtime, client, promptId, config, exec.signal, waitMs);
215
+ return result;
216
+ },
217
+ };
218
+ }
219
+ function summarizeFields(fields, required) {
220
+ const out = [];
221
+ for (const [name, spec] of Object.entries(fields ?? {})) {
222
+ if (!Array.isArray(spec))
223
+ continue;
224
+ const [typeOrList, options] = spec;
225
+ const optionsRecord = typeof options === 'object' && options !== null ? options : undefined;
226
+ const entry = { name, type: 'unknown', required };
227
+ if (Array.isArray(typeOrList)) {
228
+ entry.type = 'enum';
229
+ entry.options = typeOrList.slice(0, 6).map(String);
230
+ }
231
+ else if (typeof typeOrList === 'string') {
232
+ entry.type = typeOrList;
233
+ }
234
+ if (optionsRecord !== undefined && 'default' in optionsRecord) {
235
+ entry.default = optionsRecord.default;
236
+ }
237
+ out.push(entry);
238
+ if (out.length >= 14)
239
+ break;
240
+ }
241
+ return out;
242
+ }
243
+ function objectInfoDefinition(runtime) {
244
+ return {
245
+ name: 'comfyui_object_info',
246
+ description: 'List the node definitions the configured ComfyUI server supports (class types, required and optional inputs). Use it to build valid API-format workflows for comfyui_run. Optional `filter` narrows by class-name substring, e.g. "KSampler", "VAE", "LoadImage".',
247
+ parameters: {
248
+ type: 'object',
249
+ properties: {
250
+ filter: { type: 'string', description: 'Optional substring filter on node class names.' },
251
+ },
252
+ required: [],
253
+ },
254
+ output: {
255
+ schema: { type: 'object' },
256
+ render(_args, value) {
257
+ const data = value;
258
+ const lines = [`ComfyUI nodes: ${data.total} total, showing ${data.shown}`];
259
+ for (const node of data.nodes) {
260
+ lines.push(`- ${node.class_type}${node.display_name !== undefined ? ` (${node.display_name})` : ''}${node.description !== undefined && node.description !== '' ? `: ${node.description}` : ''}`);
261
+ }
262
+ if (data.hint !== undefined)
263
+ lines.push(data.hint);
264
+ return [{ type: 'text', text: lines.join('\n') }];
265
+ },
266
+ },
267
+ timeoutMs: 60_000,
268
+ async execute(args) {
269
+ const client = runtime.createClient(await runtime.getApiKey());
270
+ const raw = await client.objectInfo();
271
+ const entries = Object.entries(raw);
272
+ const filter = typeof args.filter === 'string' ? args.filter.trim().toLowerCase() : undefined;
273
+ const filtered = filter === undefined || filter === ''
274
+ ? entries
275
+ : entries.filter(([name]) => name.toLowerCase().includes(filter));
276
+ const nodes = filtered.slice(0, 60).map(([classType, def]) => ({
277
+ class_type: classType,
278
+ display_name: def.display_name,
279
+ description: (def.description ?? '').slice(0, 200),
280
+ required: summarizeFields(def.input?.required, true),
281
+ optional: summarizeFields(def.input?.optional, false),
282
+ }));
283
+ return {
284
+ total: entries.length,
285
+ shown: nodes.length,
286
+ filter: filter ?? null,
287
+ hint: filtered.length > nodes.length ? `filter matched ${filtered.length} nodes, showing first ${nodes.length} — narrow the filter for more` : undefined,
288
+ nodes,
289
+ };
290
+ },
291
+ };
292
+ }
293
+ /** List and run saved workflows from the panel-managed library. */
294
+ function workflowDefinition(runtime, ctx) {
295
+ return {
296
+ name: 'comfyui_workflow',
297
+ description: [
298
+ 'List and run saved ComfyUI workflows from the plugin workflow library (运行主题: API-format workflows extracted from a graph or pasted directly).',
299
+ '`action: list` returns every runnable workflow with its id, name, description (what the workflow does), and input notes.',
300
+ 'It also lists workflows the user saved on the ComfyUI server (衍生主题: UI graph format canvases). A graph may hold SEVERAL independent flows; each is extracted into its own runnable workflow in the panel (整体/按分量/主流程). A graph with no extracted workflow yet cannot run — tell the user to open the ComfyUI panel and 提取 it first.',
301
+ '`action: run` runs one saved workflow by id — pass only the id plus parameter overrides; the plugin submits the saved workflow JSON itself (never copy the JSON into your reply). It waits for media by default; add `mode: "async"` to run in the background and collect the result with job_output.',
302
+ '`action: get` returns one saved workflow\'s complete API-format JSON by id for inspection/diagnostics only — it consumes many tokens and is not the run path.',
303
+ ].join(' '),
304
+ parameters: {
305
+ type: 'object',
306
+ properties: {
307
+ action: { type: 'string', enum: ['list', 'run', 'get'], description: 'list returns the workflow library; run executes one workflow by id (direct call to the saved JSON); get returns one workflow\'s full JSON for inspection.' },
308
+ id: { type: 'string', description: 'Workflow id (required for action: run and get).' },
309
+ mode: { type: 'string', enum: ['sync', 'async'], description: 'run mode (default sync); async starts a background job and returns its id for job_output. Video/audio workflows should use async — generation takes minutes and sync may time out.' },
310
+ timeout_ms: { type: 'number', minimum: 5_000, maximum: 3_600_000, description: 'Generation wait budget in ms (default 900000 = 15 min). Video needs minutes; raise this for long videos.' },
311
+ parameters: {
312
+ type: 'object',
313
+ description: 'Optional per-run values for the workflow\'s adjustable parameters (see the workflow\'s `inputs` note from action: list — e.g. {"prompt": "a red cat", "seed": 42}). Omitted parameters keep their defaults; seed-type parameters randomize when the workflow marks them 随机.',
314
+ },
315
+ },
316
+ required: ['action'],
317
+ },
318
+ output: {
319
+ schema: { type: 'object' },
320
+ render(_args, value) {
321
+ const data = value;
322
+ if (data.action === 'get') {
323
+ return [{ type: 'text', text: `ComfyUI workflow ${data.name ?? data.id}: ${JSON.stringify(data.workflow)}` }];
324
+ }
325
+ if (data.background !== undefined) {
326
+ return [{ type: 'text', text: `ComfyUI workflow started in the background (job ${data.background.jobId}, prompt ${data.background.promptId}). Collect the result with job_output.` }];
327
+ }
328
+ if (data.action === 'list') {
329
+ const lines = [`Saved ComfyUI workflows (${data.workflows?.length ?? 0}):`];
330
+ for (const workflow of data.workflows ?? []) {
331
+ lines.push(`- ${workflow.id} — ${workflow.name}${workflow.description !== '' ? `: ${workflow.description}` : ''}`);
332
+ for (const param of workflow.parameters ?? []) {
333
+ const def = typeof param.default === 'string' ? `"${param.default}"` : String(param.default);
334
+ const options = Array.isArray(param.options) && param.options.length > 0 ? `,可选: ${param.options.join(' / ')}` : '';
335
+ const upload = param.upload !== undefined
336
+ ? `,上传类型: ${param.upload}${param.upload === 'media' ? `(${param.subfolder ?? ''}/,空值=移除该参考位)` : ''}`
337
+ : '';
338
+ lines.push(` ${param.name}(${param.label}${param.random === true ? ',随机' : ''},默认 ${def}${options}${upload})`);
339
+ }
340
+ }
341
+ const comfyui = data.comfyuiWorkflows ?? [];
342
+ if (comfyui.length > 0) {
343
+ lines.push(`ComfyUI 端保存的图工作流(${comfyui.length} 个,UI 图格式,不能直接运行):`);
344
+ for (const workflow of comfyui) {
345
+ if (workflow.extracted) {
346
+ lines.push(`- ${workflow.name} — 已提取 ${workflow.derived.length} 个运行工作流:${workflow.derived.map((d) => `${d.name}(${d.libraryId})`).join('、')}`);
347
+ }
348
+ else {
349
+ lines.push(`- ${workflow.name} — 未提取:如需运行,请转告用户先在 ComfyUI 面板里“提取”它(可选择整体/按分量/主流程)`);
350
+ }
351
+ }
352
+ }
353
+ return [{ type: 'text', text: lines.join('\n') }];
354
+ }
355
+ const result = data.result;
356
+ if (result === undefined)
357
+ return [{ type: 'text', text: 'ComfyUI workflow run returned no result.' }];
358
+ const lines = [`ComfyUI workflow ${result.status} (prompt ${result.promptId}) in ${result.elapsedMs} ms — ${summarizeMedia(result.media)}`];
359
+ for (const item of result.media)
360
+ lines.push(` ${item.kind}: ${item.url}`);
361
+ return [{ type: 'text', text: lines.join('\n') }];
362
+ },
363
+ presentationMeta(_args, value) {
364
+ return value;
365
+ },
366
+ },
367
+ timeoutMs: TOOL_TIMEOUT_MS,
368
+ async execute(args, exec) {
369
+ const action = args.action;
370
+ if (action !== 'list' && action !== 'run' && action !== 'get') {
371
+ throw new Error(`comfyui_workflow: action must be list, run, or get, got ${String(action)}`);
372
+ }
373
+ if (action === 'list') {
374
+ const [workflows, comfyui] = await Promise.all([
375
+ runtime.listWorkflows(),
376
+ runtime.listComfyWorkflows().catch(() => []),
377
+ ]);
378
+ return {
379
+ action: 'list',
380
+ workflows: workflows.map(({ id, name, description, parameters, updatedAt }) => ({
381
+ id,
382
+ name,
383
+ description,
384
+ parameters: (parameters ?? []).map(({ name: pname, label, type, default: def, random, options, upload }) => {
385
+ // DSH validates tool output as lossless JSON: JSON.stringify drops
386
+ // undefined keys, so omit optional fields instead of passing undefined.
387
+ const entry = { name: pname, label, type, default: def };
388
+ if (random !== undefined)
389
+ entry.random = random;
390
+ if (options !== undefined)
391
+ entry.options = options;
392
+ if (upload !== undefined)
393
+ entry.upload = upload;
394
+ return entry;
395
+ }),
396
+ updatedAt,
397
+ })),
398
+ comfyuiWorkflows: comfyui.map(({ name, extracted, derived }) => ({ name, extracted, derived })),
399
+ };
400
+ }
401
+ const id = args.id;
402
+ if (typeof id !== 'string' || id === '') {
403
+ throw new Error('comfyui_workflow: id is required for action: run and get');
404
+ }
405
+ const saved = await runtime.getWorkflow(id);
406
+ if (saved === undefined) {
407
+ throw new Error(`comfyui_workflow: workflow "${id}" not found — run action: list first`);
408
+ }
409
+ if (action === 'get') {
410
+ return {
411
+ action: 'get',
412
+ id: saved.id,
413
+ name: saved.name,
414
+ description: saved.description,
415
+ parameters: saved.parameters ?? [],
416
+ workflow: saved.workflow,
417
+ };
418
+ }
419
+ const config = runtime.getConfig();
420
+ const client = runtime.createClient(await runtime.getApiKey());
421
+ const values = typeof args.parameters === 'object' && args.parameters !== null
422
+ ? args.parameters
423
+ : {};
424
+ const promptId = await runtime.queue(saved.workflow, {
425
+ workflowName: saved.name,
426
+ workflowId: saved.id,
427
+ source: 'workflow-tool',
428
+ parameters: saved.parameters,
429
+ values,
430
+ });
431
+ const mode = args.mode === undefined ? 'sync' : args.mode;
432
+ if (mode !== 'sync' && mode !== 'async') {
433
+ throw new Error(`comfyui_workflow: mode must be sync or async, got ${String(mode)}`);
434
+ }
435
+ const waitMs = typeof args.timeout_ms === 'number' ? args.timeout_ms : config.timeoutMs;
436
+ if (mode === 'async') {
437
+ const jobs = ctx.get('jobs');
438
+ if (jobs === undefined) {
439
+ throw new Error('comfyui_workflow: background jobs unavailable — load @deepseek-ai/dsh-jobs-local and @deepseek-ai/dsh-tool-jobs');
440
+ }
441
+ const jobId = jobs.start({
442
+ kind: 'comfyui',
443
+ label: saved.name,
444
+ ...(exec.agent !== undefined ? { owner: exec.agent } : {}),
445
+ run: () => {
446
+ const startedAt = Date.now();
447
+ const done = (async () => {
448
+ try {
449
+ const entry = await client.waitForCompletion({
450
+ promptId,
451
+ timeoutMs: waitMs,
452
+ pollIntervalMs: config.pollIntervalMs,
453
+ signal: new AbortController().signal,
454
+ });
455
+ const items = collectMedia({ promptId, entry, maxItems: config.maxMediaItems, proxyBase: runtime.proxyBase() });
456
+ const result = {
457
+ kind: 'sync',
458
+ promptId,
459
+ status: 'completed',
460
+ elapsedMs: Date.now() - startedAt,
461
+ media: items,
462
+ summary: summarizeMedia(items),
463
+ };
464
+ return { status: 'completed', output: JSON.stringify(result) };
465
+ }
466
+ catch (error) {
467
+ runtime.untrack(promptId);
468
+ const message = error instanceof Error ? error.message : String(error);
469
+ return { status: 'failed', detail: 'comfyui', output: message };
470
+ }
471
+ })();
472
+ return {
473
+ cancel: () => { void client.interrupt().catch(() => undefined); },
474
+ done,
475
+ };
476
+ },
477
+ });
478
+ return { action: 'run', id, workflowName: saved.name, background: { kind: 'background', jobId, promptId, label: saved.name } };
479
+ }
480
+ const result = await waitSync(runtime, client, promptId, config, exec.signal, waitMs);
481
+ return { action: 'run', id, workflowName: saved.name, result };
482
+ },
483
+ };
484
+ }
485
+ /** Register the plugin tools; returns disposers. */
486
+ export function registerComfyUITools(ctx, runtime) {
487
+ const tools = ctx.tools;
488
+ const disposers = [];
489
+ disposers.push(tools.register(runDefinition(runtime, ctx)));
490
+ disposers.push(tools.register(objectInfoDefinition(runtime)));
491
+ disposers.push(tools.register(workflowDefinition(runtime, ctx)));
492
+ return disposers;
493
+ }