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/index.js ADDED
@@ -0,0 +1,344 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings';
4
+ import { Config } from './config.js';
5
+ import { ComfyUIClient, CLIENT_ID } from './comfyui.js';
6
+ import { ComfyUIStore } from './store.js';
7
+ import { QueueTracker } from './queue.js';
8
+ import { convertGraphToApi } from './convert.js';
9
+ import { analyzeGraph } from './analyze.js';
10
+ import { ProgressTracker } from './progress.js';
11
+ import { COMFYUI_SKILL } from './skill.js';
12
+ import { analyzeWorkflowParameters, applyWorkflowParameters } from './params.js';
13
+ import { registerComfyUITools } from './tools.js';
14
+ import { mountComfyUIRoutes } from './routes.js';
15
+ import { mountComfyUIProxy } from './proxy.js';
16
+ import { createHostHint, detectLanOrigin } from './host-hint.js';
17
+ export const name = 'dsh-comfyui';
18
+ export { Config };
19
+ /**
20
+ * Required services. `tools` is the model-facing registry the plugin writes
21
+ * into, so the fiber must wait for it: reading `ctx.tools` without declaring
22
+ * it here is what cordis rejects with `cannot get property "tools" without
23
+ * inject`. `webServer`, `settings`, and `credentials` stay OUT of this list —
24
+ * they are optional, and the plugin degrades gracefully without them (see
25
+ * apply).
26
+ */
27
+ export const inject = ['tools'];
28
+ const COMFYUI_NS = settingsNamespace('comfyui');
29
+ /** object_info is large and changes only when nodes are (re)installed. */
30
+ const OBJECT_INFO_TTL_MS = 60_000;
31
+ let objectInfoCache;
32
+ async function objectInfoCached(client) {
33
+ const now = Date.now();
34
+ if (objectInfoCache !== undefined && now - objectInfoCache.ts < OBJECT_INFO_TTL_MS)
35
+ return objectInfoCache.value;
36
+ try {
37
+ const value = await client.objectInfo();
38
+ objectInfoCache = { ts: now, value };
39
+ return value;
40
+ }
41
+ catch {
42
+ return undefined;
43
+ }
44
+ }
45
+ async function resolveApiKey(ctx, envName) {
46
+ const credentials = ctx.get('credentials');
47
+ if (credentials !== undefined) {
48
+ try {
49
+ const resolved = await credentials.resolve(envName);
50
+ if (resolved !== undefined)
51
+ return resolved.value;
52
+ }
53
+ catch {
54
+ // Fall through to the process environment below.
55
+ }
56
+ }
57
+ return process.env[envName];
58
+ }
59
+ /** Default plugin data directory under the harness home. */
60
+ function defaultDataDir() {
61
+ const base = process.env.DSH_HOME ?? join(homedir(), '.dsh');
62
+ return join(base, 'data', 'dsh-comfyui');
63
+ }
64
+ /**
65
+ * The plugin body. The loader validates the entry config against `Config`
66
+ * (defaults applied), then hands the resolved object to apply.
67
+ */
68
+ export async function apply(ctx, entryConfig) {
69
+ const resolved = {
70
+ baseUrl: entryConfig.baseUrl ?? 'http://127.0.0.1:8188',
71
+ apiKeyEnv: entryConfig.apiKeyEnv ?? 'COMFYUI_API_KEY',
72
+ connectTimeoutMs: entryConfig.connectTimeoutMs ?? 10_000,
73
+ timeoutMs: entryConfig.timeoutMs ?? 900_000,
74
+ pollIntervalMs: entryConfig.pollIntervalMs ?? 1_000,
75
+ maxMediaItems: entryConfig.maxMediaItems ?? 12,
76
+ maxMediaBytes: entryConfig.maxMediaBytes ?? 64 * 1024 * 1024,
77
+ dataDir: entryConfig.dataDir !== undefined && entryConfig.dataDir !== '' ? entryConfig.dataDir : defaultDataDir(),
78
+ maxAssets: entryConfig.maxAssets ?? 200,
79
+ mediaHost: entryConfig.mediaHost ?? '',
80
+ };
81
+ const store = new ComfyUIStore(resolved.dataDir, resolved.maxAssets);
82
+ await store.init();
83
+ const tracker = new QueueTracker({
84
+ load: () => store.loadTracked(),
85
+ save: (state) => store.saveTracked(state),
86
+ });
87
+ await tracker.init();
88
+ const progress = new ProgressTracker();
89
+ // Best-effort progress feed: listen on the server's WebSocket for `progress`
90
+ // events. The socket uses the same client id as queued prompts (CLIENT_ID),
91
+ // so progress events for prompts this plugin submits arrive here; the server
92
+ // only broadcasts them to the submitting client. Node's global WebSocket
93
+ // (undici) cannot set auth headers, so a remote server behind an
94
+ // authenticating proxy simply shows no progress.
95
+ ctx.effect(() => {
96
+ const wsUrl = resolved.baseUrl.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:').replace(/\/$/, '') + `/ws?clientId=${CLIENT_ID}`;
97
+ progress.attach(wsUrl);
98
+ return () => progress.dispose();
99
+ }, 'dsh-comfyui: progress');
100
+ const hostHint = createHostHint();
101
+ const runtime = {
102
+ getConfig: () => resolved,
103
+ getApiKey: () => resolveApiKey(ctx, resolved.apiKeyEnv),
104
+ createClient: (apiKey) => new ComfyUIClient(resolved.baseUrl, apiKey, resolved.connectTimeoutMs, resolved.maxMediaBytes),
105
+ hostHint,
106
+ proxyBase: () => {
107
+ // Explicit external media host wins (LAN/domain/reverse-proxy config);
108
+ // otherwise use the origin browsers actually reached this server with;
109
+ // then the server's own LAN origin (reachable from remote browsers);
110
+ // finally fall back to loopback. The result is always an absolute
111
+ // http(s) URL, which the chat markdown renderer requires.
112
+ const explicit = (resolved.mediaHost ?? '').trim().replace(/\/+$/, '');
113
+ if (explicit !== '')
114
+ return explicit;
115
+ const hinted = hostHint.origin();
116
+ if (hinted !== undefined)
117
+ return hinted;
118
+ const ws = ctx.get('webServer');
119
+ if (ws === undefined || ws.port === undefined)
120
+ return undefined;
121
+ const lan = detectLanOrigin(ws.port);
122
+ if (lan !== undefined)
123
+ return lan;
124
+ const host = ws.host === '0.0.0.0' ? '127.0.0.1' : ws.host ?? '127.0.0.1';
125
+ return `http://${host}:${ws.port}`;
126
+ },
127
+ settingsWritable: () => {
128
+ const settings = ctx.get('settings');
129
+ return settings?.writable === true;
130
+ },
131
+ updateConfig: async (patch) => {
132
+ const settings = ctx.get('settings');
133
+ if (settings === undefined) {
134
+ return { ok: false, error: 'settings service unavailable — edit cordis.yml instead' };
135
+ }
136
+ try {
137
+ await settings.update(COMFYUI_NS, patch);
138
+ return { ok: true };
139
+ }
140
+ catch (error) {
141
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
142
+ }
143
+ },
144
+ queue: async (workflow, meta) => {
145
+ const client = runtime.createClient(await resolveApiKey(ctx, resolved.apiKeyEnv));
146
+ let prompt = workflow;
147
+ if (meta.parameters !== undefined && meta.parameters.length > 0) {
148
+ const objectInfo = await objectInfoCached(client);
149
+ const current = await store.loadCurrentImage();
150
+ prompt = applyWorkflowParameters(prompt, meta.parameters, meta.values ?? {}, objectInfo, await store.loadMediaSizes(), current?.name);
151
+ }
152
+ const extraData = {};
153
+ if (meta.workflowId !== undefined && meta.workflowId !== null && meta.workflowName !== null) {
154
+ // ComfyUI's job metadata derives workflow_id from extra_pnginfo.workflow.id.
155
+ extraData['extra_pnginfo'] = { workflow: { id: meta.workflowId, name: meta.workflowName } };
156
+ }
157
+ const promptId = await client.queuePrompt(prompt, { extraData });
158
+ tracker.track({ promptId, ts: new Date().toISOString(), workflowName: meta.workflowName, source: meta.source });
159
+ return promptId;
160
+ },
161
+ untrack: (promptId) => tracker.untrack(promptId),
162
+ trackedRuns: () => tracker.list(),
163
+ queueProgress: (promptId) => progress.get(promptId),
164
+ listWorkflows: () => store.listWorkflows(),
165
+ getWorkflow: (id) => store.getWorkflow(id),
166
+ saveWorkflow: (input) => store.saveWorkflow(input),
167
+ deleteWorkflow: (id) => store.deleteWorkflow(id),
168
+ listMediaSizes: () => store.loadMediaSizes(),
169
+ saveMediaSize: (name, size) => store.saveMediaSize(name, size),
170
+ lookupMediaHash: (hash) => store.lookupMediaHash(hash),
171
+ saveMediaHash: (hash, name) => store.saveMediaHash(hash, name),
172
+ loadCurrentImage: () => store.loadCurrentImage(),
173
+ saveCurrentImage: (image) => store.saveCurrentImage(image),
174
+ listAssets: () => store.listAssets(),
175
+ sweep: async () => {
176
+ const client = runtime.createClient(await resolveApiKey(ctx, resolved.apiKeyEnv));
177
+ return tracker.sweep({ client, store, maxItems: resolved.maxMediaItems, proxyBase: runtime.proxyBase() });
178
+ },
179
+ listComfyWorkflows: async () => {
180
+ const client = runtime.createClient(await resolveApiKey(ctx, resolved.apiKeyEnv));
181
+ const entries = await client.listUserData('workflows');
182
+ const library = await store.listWorkflows();
183
+ return entries
184
+ .filter((entry) => entry.type === 'file' && entry.name.endsWith('.json'))
185
+ .map((entry) => {
186
+ const derived = library.filter((workflow) => workflow.comfyuiFile === entry.name);
187
+ return {
188
+ name: entry.name,
189
+ size: entry.size,
190
+ modified: entry.modified,
191
+ extracted: derived.length > 0,
192
+ derived: derived.map((workflow) => ({ libraryId: workflow.id, name: workflow.name })),
193
+ };
194
+ });
195
+ },
196
+ getComfyWorkflow: async (file) => {
197
+ const client = runtime.createClient(await resolveApiKey(ctx, resolved.apiKeyEnv));
198
+ return client.getUserDataFile(`workflows/${file}`);
199
+ },
200
+ analyzeComfyWorkflow: async (file) => {
201
+ const client = runtime.createClient(await resolveApiKey(ctx, resolved.apiKeyEnv));
202
+ const graph = await client.getUserDataFile(`workflows/${file}`);
203
+ return analyzeGraph(graph);
204
+ },
205
+ extractComfyWorkflow: async ({ file, mode }) => {
206
+ const client = runtime.createClient(await resolveApiKey(ctx, resolved.apiKeyEnv));
207
+ const [graph, objectInfo] = await Promise.all([
208
+ client.getUserDataFile(`workflows/${file}`),
209
+ client.objectInfo(),
210
+ ]);
211
+ const analysis = analyzeGraph(graph);
212
+ if (!analysis.ok)
213
+ return { ok: false, error: analysis.error };
214
+ if (analysis.components.length === 0) {
215
+ return { ok: false, error: '图里没有可执行的分量(所有节点都被绕过或悬空)' };
216
+ }
217
+ const base = file.replace(/\.json$/i, '').slice(0, 40);
218
+ const groupLabel = (component) => component.groups.length > 0 ? `(${component.groups.slice(0, 3).join('+')})` : '';
219
+ const jobs = [];
220
+ if (mode === 'all') {
221
+ jobs.push({
222
+ name: base,
223
+ description: `从 ComfyUI 图工作流 ${file} 整体提取:${analysis.components.length} 个分量合成一个运行工作流。`,
224
+ includeNodeIds: new Set(analysis.components.flatMap((component) => component.nodeIds)),
225
+ });
226
+ }
227
+ else if (mode === 'main') {
228
+ const main = analysis.components[0];
229
+ if (main === undefined) {
230
+ return { ok: false, error: '图里没有可提取的分量' };
231
+ }
232
+ jobs.push({
233
+ name: `${base} · 主流程`,
234
+ description: `从 ComfyUI 图工作流 ${file} 提取主流程(${main.size} 节点)${groupLabel(main)}。`,
235
+ includeNodeIds: new Set(main.nodeIds),
236
+ });
237
+ }
238
+ else {
239
+ for (const component of analysis.components) {
240
+ jobs.push({
241
+ name: `${base} · 分量${component.index}${groupLabel(component)}`,
242
+ description: `从 ComfyUI 图工作流 ${file} 提取第 ${component.index} 个分量(${component.size} 节点)${groupLabel(component)}。`,
243
+ includeNodeIds: new Set(component.nodeIds),
244
+ });
245
+ }
246
+ }
247
+ const saved = [];
248
+ const warnings = [];
249
+ for (const job of jobs) {
250
+ const converted = convertGraphToApi(graph, objectInfo, { includeNodeIds: job.includeNodeIds });
251
+ if (!converted.ok)
252
+ return { ok: false, error: `${job.name}:${converted.error}` };
253
+ for (const warning of converted.warnings)
254
+ warnings.push(`${job.name}:${warning}`);
255
+ const hasOutput = Object.values(converted.workflow).some((node) => {
256
+ const def = objectInfo[node.class_type];
257
+ return def?.output_node === true;
258
+ });
259
+ if (!hasOutput) {
260
+ warnings.push(`${job.name}:分量没有任何输出节点(ComfyUI 无法排队),已跳过`);
261
+ continue;
262
+ }
263
+ const parameters = analyzeWorkflowParameters(converted.workflow, objectInfo);
264
+ const result = await store.saveWorkflow({
265
+ name: job.name.slice(0, 80),
266
+ description: job.description,
267
+ workflow: converted.workflow,
268
+ parameters,
269
+ source: 'comfyui',
270
+ comfyuiFile: file,
271
+ });
272
+ if (!result.ok)
273
+ return result;
274
+ saved.push(result.workflow);
275
+ }
276
+ if (saved.length === 0) {
277
+ return { ok: false, error: '没有可提取的分量:所有分量都无输出节点或被跳过' };
278
+ }
279
+ return { ok: true, saved, analysis, warnings };
280
+ },
281
+ };
282
+ // The settings section rides the plugin fiber: a host without a settings
283
+ // service simply never registers it, and the entry config stands as composed.
284
+ let source = () => resolved;
285
+ installSettingsSection(ctx, COMFYUI_NS, Config, resolved, {
286
+ setSource: (current) => {
287
+ source = current;
288
+ },
289
+ onChange: () => {
290
+ Object.assign(resolved, source());
291
+ },
292
+ });
293
+ ctx.effect(() => {
294
+ const disposers = registerComfyUITools(ctx, runtime);
295
+ return () => {
296
+ for (const dispose of disposers)
297
+ dispose();
298
+ };
299
+ }, 'dsh-comfyui: tools');
300
+ // The companion skill rides the same optional-services pattern: headless
301
+ // hosts without a skills service simply skip it. Runtime skills register at
302
+ // rank 250, so project/user skills can override the shipped guidance.
303
+ ctx.effect(() => {
304
+ const skills = ctx.get('skills');
305
+ if (skills === undefined)
306
+ return () => { };
307
+ return skills.register({
308
+ ...COMFYUI_SKILL,
309
+ content: COMFYUI_SKILL.content,
310
+ });
311
+ }, 'dsh-comfyui: skill');
312
+ // Routes and the media proxy ride a `webServer` sub-fiber rather than a
313
+ // one-shot `ctx.get` at apply time: loader entries settle concurrently, so
314
+ // reading the service here would silently skip both mounts whenever the web
315
+ // server happens to activate after this plugin. A headless host never
316
+ // activates this fiber and keeps the tools alone.
317
+ ctx.inject(['webServer'], (webCtx) => {
318
+ webCtx.effect(() => {
319
+ const disposers = [];
320
+ const routesDisposer = mountComfyUIRoutes(webCtx, runtime);
321
+ if (routesDisposer !== undefined)
322
+ disposers.push(routesDisposer);
323
+ const proxyDisposer = mountComfyUIProxy(webCtx, runtime);
324
+ if (proxyDisposer !== undefined)
325
+ disposers.push(proxyDisposer);
326
+ // Inject a one-line self-report into the served index.html: on every
327
+ // page load the browser pings /comfyui/ping, which records the origin
328
+ // the browser actually uses, so generated media URLs match the user's
329
+ // address (LAN IP, domain, reverse proxy) without any configuration.
330
+ const webServer = webCtx.get('webServer');
331
+ if (webServer !== undefined) {
332
+ disposers.push(webServer.tapIndex((html) => {
333
+ if (html.includes('dsh-comfyui-ping'))
334
+ return html;
335
+ return html.replace('</head>', '<script>/* dsh-comfyui-ping */try{fetch("/comfyui/ping",{cache:"no-store"})}catch(e){}</script></head>');
336
+ }));
337
+ }
338
+ return () => {
339
+ for (const dispose of disposers)
340
+ dispose();
341
+ };
342
+ }, 'dsh-comfyui: routes and media proxy');
343
+ });
344
+ }
@@ -0,0 +1,63 @@
1
+ /** One exposed, adjustable parameter of a saved workflow. */
2
+ export interface WorkflowParameter {
3
+ id: string;
4
+ /** English identifier the caller passes values by, e.g. "prompt". */
5
+ name: string;
6
+ /** Short display label (localized by the UI). */
7
+ label: string;
8
+ type: 'string' | 'number' | 'boolean';
9
+ /** Node id in the API workflow the value is written back to. */
10
+ nodeId: string;
11
+ /** Input key on that node. */
12
+ inputKey: string;
13
+ /** Value used when the caller omits it; also the "as authored" value. */
14
+ default: string | number | boolean;
15
+ /** Note shown to the agent alongside the parameter. */
16
+ description?: string;
17
+ /** Number parameters (seeds): randomize on every run when true. */
18
+ random?: boolean;
19
+ /** Allowed values when the node input is a dropdown (object_info combo). */
20
+ options?: Array<string | number>;
21
+ /** Loader-node inputs (LoadImage/LoadVideo/LoadAudio): the value is a
22
+ * server-side filename; the panel offers upload via drag & drop.
23
+ * 'media' is a multi-slot media list (e.g. MiniMaxH3 media_state): one
24
+ * parameter per reference slot, merged back into the JSON array on apply. */
25
+ upload?: 'image' | 'video' | 'audio' | 'media';
26
+ /** Upload subdirectory (e.g. 'minimax_h3'); media files land there. */
27
+ subfolder?: string;
28
+ }
29
+ type Workflow = Record<string, {
30
+ class_type: string;
31
+ inputs: Record<string, unknown>;
32
+ }>;
33
+ export type { Workflow };
34
+ /**
35
+ * Whether a node input is a loader file picker (LoadImage/LoadVideo/LoadAudio
36
+ * and friends), recognized generically from object_info: an explicit
37
+ * image/video/audio upload flag, or a classic COMBO whose options are a file
38
+ * list and whose key name is loader-shaped. Returns the upload kind.
39
+ */
40
+ export declare function uploadKindOf(objectInfo: Record<string, unknown> | undefined, classType: string, inputKey: string): 'image' | 'video' | 'audio' | undefined;
41
+ /** The object_info input spec for one input name of a node class, if declared. */
42
+ export declare function inputOptions(objectInfo: Record<string, unknown> | undefined, classType: string, inputKey: string): Array<string | number> | undefined;
43
+ /** Full child info (key, options, default) for one selected DynamicCombo parent value. */
44
+ export declare function comboChildInfo(objectInfo: Record<string, unknown> | undefined, classType: string, inputKey: string, parentValue: string): {
45
+ childInputKey: string;
46
+ options: Array<string | number>;
47
+ default: string;
48
+ } | undefined;
49
+ /**
50
+ * Detect the conservative parameter set of a workflow: prompt text inputs,
51
+ * EmptyLatentImage width/height, and KSampler steps/seed. Returns them in a
52
+ * stable order (text, size, steps, seed) with defaults from current values.
53
+ */
54
+ export declare function analyzeWorkflowParameters(workflow: Workflow, objectInfo?: Record<string, unknown>): WorkflowParameter[];
55
+ /**
56
+ * Apply caller-provided values (and randomized seeds) onto a copy of the
57
+ * workflow. Unknown parameters are ignored; omitted ones fall back to the
58
+ * parameter default. The input workflow is not mutated.
59
+ */
60
+ export declare function applyWorkflowParameters(workflow: Workflow, parameters: WorkflowParameter[], values: Record<string, unknown>, objectInfo?: Record<string, unknown>, imageSizes?: Record<string, {
61
+ width: number;
62
+ height: number;
63
+ }>, defaultImage?: string): Workflow;