dsh-comfyui 0.5.0 → 0.5.2

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/analyze.d.ts CHANGED
@@ -20,7 +20,9 @@ export interface GraphGroupLike {
20
20
  }
21
21
  export interface GraphLike {
22
22
  nodes: GraphNodeLike[];
23
- links: Array<[number, number, number, number, number, string] | number[]>;
23
+ /** Saved rows are positional or object entries depending on the frontend
24
+ * version — always consumed through {@link normalizeLinks}. */
25
+ links: unknown[];
24
26
  groups?: GraphGroupLike[];
25
27
  }
26
28
  export interface IsolatedNode {
package/lib/analyze.js CHANGED
@@ -5,6 +5,7 @@
5
5
  * graph links (with bypassed and dangling nodes excluded). The analysis feeds
6
6
  * the extract (拆分) choices in the panel and the agent-facing skill.
7
7
  */
8
+ import { normalizeLinks } from './graph.js';
8
9
  function isObject(value) {
9
10
  return typeof value === 'object' && value !== null && !Array.isArray(value);
10
11
  }
@@ -27,7 +28,7 @@ export function analyzeGraph(graph) {
27
28
  return { ok: false, error: '无法解析图文件(缺少 nodes/links)' };
28
29
  }
29
30
  const nodes = graph.nodes;
30
- const links = graph.links;
31
+ const links = normalizeLinks(graph.links);
31
32
  const groups = Array.isArray(graph.groups) ? graph.groups : [];
32
33
  const active = nodes.filter((node) => node.mode !== 4);
33
34
  const activeById = new Map(active.map((node) => [node.id, node]));
@@ -43,14 +44,36 @@ export function analyzeGraph(graph) {
43
44
  const isolated = active
44
45
  .filter((node) => !linkedIds.has(node.id))
45
46
  .map((node) => ({ id: node.id, type: node.type }));
47
+ // Bypassed nodes are pass-throughs at conversion time (resolveOrigin in
48
+ // convert.ts: the output follows the FIRST wired input). Mirror that rule
49
+ // here, or a chain `120 → [406 bypassed] → 119` splits into two components
50
+ // and "main flow" extraction ships 119 referencing a node it left out
51
+ // (Issue #8).
52
+ const linkById = new Map(links.map((link) => [link[0], link]));
53
+ const nodeById = new Map(nodes.map((node) => [node.id, node]));
54
+ const passThroughOrigin = (originId) => {
55
+ let current = originId;
56
+ const visited = new Set();
57
+ while (nodeById.get(current)?.mode === 4) {
58
+ if (visited.has(current))
59
+ return undefined;
60
+ visited.add(current);
61
+ const inputLink = (nodeById.get(current)?.inputs ?? []).find((entry) => entry.link !== null)?.link;
62
+ const upstream = inputLink === undefined || inputLink === null ? undefined : linkById.get(inputLink);
63
+ if (upstream === undefined)
64
+ return undefined;
65
+ current = upstream[1];
66
+ }
67
+ return current;
68
+ };
46
69
  // Connected components over linked active nodes.
47
70
  const adjacency = new Map();
48
71
  for (const node of active)
49
72
  adjacency.set(node.id, new Set());
50
73
  for (const link of links) {
51
- const a = link[1];
74
+ const a = passThroughOrigin(link[1]);
52
75
  const b = link[3];
53
- if (typeof a !== 'number' || typeof b !== 'number')
76
+ if (a === undefined)
54
77
  continue;
55
78
  if (!adjacency.has(a) || !adjacency.has(b))
56
79
  continue;
package/lib/comfyui.d.ts CHANGED
@@ -109,7 +109,10 @@ export declare class ComfyUIClient {
109
109
  private readonly connectTimeoutMs;
110
110
  private readonly maxMediaBytes;
111
111
  constructor(baseUrl: string, apiKey: string | undefined, connectTimeoutMs: number, maxMediaBytes: number);
112
+ private base;
112
113
  private endpoint;
114
+ /** fetch() one route, retrying a 404 under /api until the prefix is known. */
115
+ private fetchRoute;
113
116
  private request;
114
117
  /** Best-effort GET of an arbitrary JSON endpoint on the ComfyUI server.
115
118
  *
package/lib/comfyui.js CHANGED
@@ -62,6 +62,14 @@ export function guessContentType(filename) {
62
62
  }
63
63
  /** The per-process client id ComfyUI uses to correlate queued prompts. */
64
64
  export const CLIENT_ID = randomUUID();
65
+ /**
66
+ * Route prefix learned per base URL: '' when bare routes answer, '/api' when
67
+ * only the /api-prefixed mirror does. ComfyUI serves every route under both,
68
+ * but API-only reverse proxies (comfy-api-proxy, Issue #6) forward just
69
+ * `/api/*`, so the bare /system_stats probe 404s. Learned on the first
70
+ * request and kept for the process — clients are created per call.
71
+ */
72
+ const routePrefix = new Map();
65
73
  /** HTTP client over the ComfyUI REST API. */
66
74
  export class ComfyUIClient {
67
75
  baseUrl;
@@ -74,8 +82,34 @@ export class ComfyUIClient {
74
82
  this.connectTimeoutMs = connectTimeoutMs;
75
83
  this.maxMediaBytes = maxMediaBytes;
76
84
  }
85
+ base() {
86
+ return this.baseUrl.replace(/\/+$/, '');
87
+ }
77
88
  endpoint(path) {
78
- return `${this.baseUrl.replace(/\/+$/, '')}${path}`;
89
+ if (path.startsWith('/api/'))
90
+ return `${this.base()}${path}`;
91
+ return `${this.base()}${routePrefix.get(this.base()) ?? ''}${path}`;
92
+ }
93
+ /** fetch() one route, retrying a 404 under /api until the prefix is known. */
94
+ async fetchRoute(path, init) {
95
+ const base = this.base();
96
+ const response = await fetch(this.endpoint(path), init);
97
+ if (path.startsWith('/api/') || routePrefix.has(base))
98
+ return response;
99
+ if (response.ok) {
100
+ routePrefix.set(base, '');
101
+ return response;
102
+ }
103
+ if (response.status !== 404)
104
+ return response;
105
+ const retry = await fetch(`${base}/api${path}`, init);
106
+ if (!retry.ok) {
107
+ await retry.body?.cancel();
108
+ return response;
109
+ }
110
+ routePrefix.set(base, '/api');
111
+ await response.body?.cancel();
112
+ return retry;
79
113
  }
80
114
  async request(path, init = {}, timeoutMs) {
81
115
  const controller = new AbortController();
@@ -84,7 +118,7 @@ export class ComfyUIClient {
84
118
  const headers = { ...init.headers };
85
119
  if (this.apiKey !== undefined)
86
120
  headers['Authorization'] = `Bearer ${this.apiKey}`;
87
- const response = await fetch(this.endpoint(path), { ...init, headers, signal: controller.signal });
121
+ const response = await this.fetchRoute(path, { ...init, headers, signal: controller.signal });
88
122
  if (!response.ok) {
89
123
  const body = await response.text().catch(() => '');
90
124
  throw new ComfyUIError(`ComfyUI ${path} failed: HTTP ${response.status}${body !== '' ? ` — ${body.slice(0, 300)}` : ''}`, response.status);
@@ -284,7 +318,28 @@ export class ComfyUIClient {
284
318
  }
285
319
  /** List one user-data subdirectory (e.g. 'workflows') on the ComfyUI server. */
286
320
  async listUserData(subdir) {
287
- const data = await this.request(`/v2/userdata?path=${encodeURIComponent(subdir)}`);
321
+ let data;
322
+ try {
323
+ data = await this.request(`/v2/userdata?path=${encodeURIComponent(subdir)}`);
324
+ }
325
+ catch (error) {
326
+ // Some builds (e.g. the 0.35 desktop app, Issue #5) have no /v2/userdata;
327
+ // the v1 listing takes `dir` and returns paths relative to it.
328
+ if (!(error instanceof ComfyUIError) || error.status !== 404)
329
+ throw error;
330
+ const legacy = await this.request(`/userdata?dir=${encodeURIComponent(subdir)}&recurse=true&split=false&full_info=true`);
331
+ if (!Array.isArray(legacy))
332
+ return [];
333
+ return legacy.map((item) => {
334
+ const rel = (typeof item === 'string' ? item : item.path).replace(/\\/g, '/');
335
+ const entry = { name: rel, path: `${subdir}/${rel}`, type: 'file' };
336
+ if (typeof item !== 'string' && item.size !== undefined)
337
+ entry.size = item.size;
338
+ if (typeof item !== 'string' && item.modified !== undefined)
339
+ entry.modified = item.modified;
340
+ return entry;
341
+ });
342
+ }
288
343
  if (!Array.isArray(data))
289
344
  return [];
290
345
  if (typeof data[0] === 'string') {
@@ -319,7 +374,7 @@ export class ComfyUIClient {
319
374
  const headers = {};
320
375
  if (this.apiKey !== undefined)
321
376
  headers['Authorization'] = `Bearer ${this.apiKey}`;
322
- const response = await fetch(this.endpoint(`/view?${params.toString()}`), { headers, signal: controller.signal });
377
+ const response = await this.fetchRoute(`/view?${params.toString()}`, { headers, signal: controller.signal });
323
378
  if (!response.ok) {
324
379
  throw new ComfyUIError(`ComfyUI /view failed: HTTP ${response.status}`);
325
380
  }
@@ -352,7 +407,7 @@ export class ComfyUIClient {
352
407
  headers['Authorization'] = `Bearer ${this.apiKey}`;
353
408
  if (rangeHeader !== undefined)
354
409
  headers['Range'] = rangeHeader;
355
- const response = await fetch(this.endpoint(`/view?${params.toString()}`), { headers, method, signal: controller.signal });
410
+ const response = await this.fetchRoute(`/view?${params.toString()}`, { headers, method, signal: controller.signal });
356
411
  if (response.status !== 206 && response.status !== 416 && !response.ok) {
357
412
  throw new ComfyUIError(`ComfyUI /view failed: HTTP ${response.status}`);
358
413
  }
package/lib/convert.d.ts CHANGED
@@ -1,12 +1,3 @@
1
- /**
2
- * Convert a ComfyUI UI-graph workflow (the format the ComfyUI frontend saves
3
- * to the server via /api/userdata) into the API format accepted by POST
4
- * /prompt. Modeled on the frontend's convertToApiFormat: input links become
5
- * [nodeId, slot] references, widgets_values are zipped onto input names in
6
- * object_info order (control_after_generate is the extra seed widget), and
7
- * Reroute / bypassed (mode 4) nodes are skipped with their links rewired.
8
- * Nodes the conversion cannot represent fail loudly with the offending type.
9
- */
10
1
  export interface ApiWorkflow {
11
2
  [nodeId: string]: {
12
3
  class_type: string;
@@ -21,6 +12,18 @@ export type ConvertResult = {
21
12
  ok: false;
22
13
  error: string;
23
14
  };
15
+ /**
16
+ * Flatten DynamicCombo values that 0.2.0–0.5.1 extraction wrapped as
17
+ * `{ key, inputs: { sub: value } }` back into the flat API shape
18
+ * (`master: key`, `master.sub: value`, recursively). Library entries saved by
19
+ * those versions still carry the wrapped shape; normalizing at queue time
20
+ * heals them without asking users to re-extract (Issue #10). Returns the same
21
+ * object when nothing needed flattening.
22
+ */
23
+ export declare function flattenDynamicCombos<W extends Record<string, {
24
+ class_type: string;
25
+ inputs: Record<string, unknown>;
26
+ }>>(workflow: W): W;
24
27
  /**
25
28
  * Convert a UI-graph workflow (or one extracted component of it) to API
26
29
  * format using the live node definitions.
package/lib/convert.js CHANGED
@@ -1,3 +1,13 @@
1
+ /**
2
+ * Convert a ComfyUI UI-graph workflow (the format the ComfyUI frontend saves
3
+ * to the server via /api/userdata) into the API format accepted by POST
4
+ * /prompt. Modeled on the frontend's convertToApiFormat: input links become
5
+ * [nodeId, slot] references, widgets_values are zipped onto input names in
6
+ * object_info order (control_after_generate is the extra seed widget), and
7
+ * Reroute / bypassed (mode 4) nodes are skipped with their links rewired.
8
+ * Nodes the conversion cannot represent fail loudly with the offending type.
9
+ */
10
+ import { normalizeLinks } from './graph.js';
1
11
  /** Node types that exist only in the UI and carry no data flow. */
2
12
  const UI_ONLY = new Set(['Note', 'StickyNote', 'Reroute', 'Fast Groups Bypasser (rgthree)']);
3
13
  function isObject(value) {
@@ -20,6 +30,51 @@ function inputSpec(objectInfo, classType, name) {
20
30
  }
21
31
  return undefined;
22
32
  }
33
+ /** Option keys a COMFY_DYNAMICCOMBO_V3 spec declares, or undefined when unreadable. */
34
+ function dynamicComboKeys(spec) {
35
+ const options = isObject(spec[1]) ? spec[1].options : undefined;
36
+ if (!Array.isArray(options))
37
+ return undefined;
38
+ const keys = options
39
+ .map((option) => (isObject(option) ? option.key : undefined))
40
+ .filter((key) => typeof key === 'string');
41
+ return keys.length > 0 ? keys : undefined;
42
+ }
43
+ /**
44
+ * Flatten DynamicCombo values that 0.2.0–0.5.1 extraction wrapped as
45
+ * `{ key, inputs: { sub: value } }` back into the flat API shape
46
+ * (`master: key`, `master.sub: value`, recursively). Library entries saved by
47
+ * those versions still carry the wrapped shape; normalizing at queue time
48
+ * heals them without asking users to re-extract (Issue #10). Returns the same
49
+ * object when nothing needed flattening.
50
+ */
51
+ export function flattenDynamicCombos(workflow) {
52
+ const isWrapped = (value) => isObject(value) && typeof value.key === 'string' && isObject(value.inputs)
53
+ && Object.keys(value).every((k) => k === 'key' || k === 'inputs');
54
+ const flatten = (prefix, value, into) => {
55
+ if (!isWrapped(value)) {
56
+ into[prefix] = value;
57
+ return;
58
+ }
59
+ into[prefix] = value.key;
60
+ for (const [sub, subValue] of Object.entries(value.inputs))
61
+ flatten(`${prefix}.${sub}`, subValue, into);
62
+ };
63
+ let changed = false;
64
+ const out = {};
65
+ for (const [id, node] of Object.entries(workflow)) {
66
+ if (!Object.values(node.inputs).some(isWrapped)) {
67
+ out[id] = node;
68
+ continue;
69
+ }
70
+ changed = true;
71
+ const inputs = {};
72
+ for (const [name, value] of Object.entries(node.inputs))
73
+ flatten(name, value, inputs);
74
+ out[id] = { ...node, inputs };
75
+ }
76
+ return changed ? out : workflow;
77
+ }
23
78
  /**
24
79
  * Derive the ordered widget-input names for one node. The graph's own input
25
80
  * array is the source of truth — widgets_values is stored in UI widget order,
@@ -195,13 +250,11 @@ export function convertGraphToApi(graph, objectInfo, options) {
195
250
  };
196
251
  });
197
252
  const links = new Map();
198
- for (const raw of rawLinks) {
199
- if (!Array.isArray(raw) || raw.length < 6)
200
- continue;
201
- const link = raw;
202
- if (typeof link[0] === 'number')
203
- links.set(link[0], link);
204
- }
253
+ // normalizeLinks accepts both the legacy positional rows and the v0.4
254
+ // frontend's object entries; anything unreadable is skipped, exactly like
255
+ // the old array-only guard did.
256
+ for (const link of normalizeLinks(rawLinks))
257
+ links.set(link[0], link);
205
258
  const nodesById = new Map(nodes.map((node) => [node.id, node]));
206
259
  const included = options?.includeNodeIds;
207
260
  const candidates = included !== undefined ? nodes.filter((node) => included.has(node.id)) : nodes;
@@ -281,27 +334,48 @@ export function convertGraphToApi(graph, objectInfo, options) {
281
334
  inputs[name] = value;
282
335
  }
283
336
  }
284
- // Collapse DynamicCombo V3 flat keys back into the API object shape:
285
- // { key, inputs: { subWidget: value } } — the master combo's value is the
286
- // selected option key, and its `master.sub` siblings become the sub-inputs.
337
+ // DynamicCombo V3 stays FLAT in the API prompt: the master input holds the
338
+ // selected option key as a string and its sub-widgets sit beside it as
339
+ // `master.sub` keys. The server rebuilds the nested shape itself
340
+ // (DynamicCombo._expand_schema_for_dynamic matches the plain value against
341
+ // option keys); wrapping it as { key, inputs } never matches, the input is
342
+ // dropped, and the node dies at execute() — Issue #10 (SaveVideo 'codec').
343
+ // Validate the selected key here so a bad value fails at extraction rather
344
+ // than after a full generation.
287
345
  for (const [name, value] of Object.entries(inputs)) {
288
346
  const spec = inputSpec(objectInfo, node.type, name);
289
347
  if (!Array.isArray(spec) || spec[0] !== 'COMFY_DYNAMICCOMBO_V3')
290
348
  continue;
291
- const sub = {};
292
- for (const [other, otherValue] of Object.entries(inputs)) {
293
- if (other.startsWith(`${name}.`))
294
- sub[other.slice(name.length + 1)] = otherValue;
349
+ const keys = dynamicComboKeys(spec);
350
+ if (keys === undefined || Array.isArray(value))
351
+ continue;
352
+ if (typeof value !== 'string' || !keys.includes(value)) {
353
+ return {
354
+ ok: false,
355
+ error: `节点 ${node.type}(id ${node.id})的输入 ${name} 取值 ${JSON.stringify(value)} 不是可选项之一(${keys.join(' / ')})——请在 ComfyUI 画布上重新选择后再提取`,
356
+ };
295
357
  }
296
- inputs[name] = { key: value, inputs: sub };
297
- for (const subName of Object.keys(sub))
298
- delete inputs[`${name}.${subName}`];
299
358
  }
300
359
  workflow[String(node.id)] = { class_type: node.type, inputs };
301
360
  }
302
361
  if (Object.keys(workflow).length === 0) {
303
362
  return { ok: false, error: '转换结果为空(图中没有可执行的节点)' };
304
363
  }
364
+ // Reference integrity: every link must point at a node of this same output.
365
+ // A component that leaves out an upstream node would otherwise extract as
366
+ // ok and only fail at run time (Issue #8).
367
+ for (const [id, node] of Object.entries(workflow)) {
368
+ for (const [name, value] of Object.entries(node.inputs)) {
369
+ if (!Array.isArray(value) || value.length !== 2 || typeof value[0] !== 'string' || typeof value[1] !== 'number')
370
+ continue;
371
+ if (workflow[value[0]] === undefined) {
372
+ return {
373
+ ok: false,
374
+ error: `节点 ${node.class_type}(id ${id})的输入 ${name} 引用了不在本次提取范围内的节点 ${value[0]}——请改用「整体提取」,或把这部分画布整理成独立的流程`,
375
+ };
376
+ }
377
+ }
378
+ }
305
379
  // Fail loudly on nodes whose required inputs are missing — the source graph
306
380
  // itself is broken (inputs never wired), and the server would only echo a
307
381
  // confusing per-node error at run time. Growable/lazy v3 inputs (template
package/lib/graph.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * ComfyUI UI-graph link normalization. The frontend changed how saved graphs
3
+ * serialize the `links` array: rows used to be positional
4
+ * (`[id, originId, originSlot, targetId, targetSlot, type]`), while the v0.4
5
+ * frontend writes object entries
6
+ * (`{ id, origin_id, origin_slot, target_id, target_slot, type }`). Analysis
7
+ * and conversion both want one canonical shape, so both go through
8
+ * {@link normalizeLinks} before touching a graph.
9
+ */
10
+ /** Canonical link row: [linkId, originId, originSlot, targetId, targetSlot, type]. */
11
+ export type GraphLink = [number, number, number, number, number, string];
12
+ /** Normalize one link entry; undefined when the entry is not a readable link. */
13
+ export declare function normalizeLink(raw: unknown): GraphLink | undefined;
14
+ /** Normalize a whole `links` array, skipping entries that are not readable. */
15
+ export declare function normalizeLinks(raw: unknown): GraphLink[];
package/lib/graph.js ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * ComfyUI UI-graph link normalization. The frontend changed how saved graphs
3
+ * serialize the `links` array: rows used to be positional
4
+ * (`[id, originId, originSlot, targetId, targetSlot, type]`), while the v0.4
5
+ * frontend writes object entries
6
+ * (`{ id, origin_id, origin_slot, target_id, target_slot, type }`). Analysis
7
+ * and conversion both want one canonical shape, so both go through
8
+ * {@link normalizeLinks} before touching a graph.
9
+ */
10
+ function isObject(value) {
11
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
12
+ }
13
+ /** Normalize one link entry; undefined when the entry is not a readable link. */
14
+ export function normalizeLink(raw) {
15
+ if (Array.isArray(raw)) {
16
+ if (raw.length < 6)
17
+ return undefined;
18
+ const [id, origin, originSlot, target, targetSlot, type] = raw;
19
+ if (typeof id !== 'number' || typeof origin !== 'number' ||
20
+ typeof originSlot !== 'number' || typeof target !== 'number' ||
21
+ typeof targetSlot !== 'number') {
22
+ return undefined;
23
+ }
24
+ return [id, origin, originSlot, target, targetSlot, typeof type === 'string' ? type : ''];
25
+ }
26
+ if (isObject(raw)) {
27
+ const { id, origin_id: origin, origin_slot: originSlot, target_id: target, target_slot: targetSlot, type } = raw;
28
+ if (typeof id !== 'number' || typeof origin !== 'number' ||
29
+ typeof originSlot !== 'number' || typeof target !== 'number' ||
30
+ typeof targetSlot !== 'number') {
31
+ return undefined;
32
+ }
33
+ return [id, origin, originSlot, target, targetSlot, typeof type === 'string' ? type : ''];
34
+ }
35
+ return undefined;
36
+ }
37
+ /** Normalize a whole `links` array, skipping entries that are not readable. */
38
+ export function normalizeLinks(raw) {
39
+ if (!Array.isArray(raw))
40
+ return [];
41
+ const links = [];
42
+ for (const entry of raw) {
43
+ const link = normalizeLink(entry);
44
+ if (link !== undefined)
45
+ links.push(link);
46
+ }
47
+ return links;
48
+ }
package/lib/index.js CHANGED
@@ -4,7 +4,7 @@ import { Config } from './config.js';
4
4
  import { ComfyUIClient, CLIENT_ID } from './comfyui.js';
5
5
  import { ComfyUIStore } from './store.js';
6
6
  import { QueueTracker } from './queue.js';
7
- import { convertGraphToApi } from './convert.js';
7
+ import { convertGraphToApi, flattenDynamicCombos } from './convert.js';
8
8
  import { analyzeGraph } from './analyze.js';
9
9
  import { ProgressTracker } from './progress.js';
10
10
  import { COMFYUI_SKILL } from './skill.js';
@@ -164,7 +164,8 @@ export async function apply(ctx, entryConfig) {
164
164
  },
165
165
  queue: async (workflow, meta) => {
166
166
  const client = runtime.createClient(await resolveApiKey(ctx, resolved.apiKeyEnv));
167
- let prompt = workflow;
167
+ // Heals library entries saved with the pre-0.5.2 wrapped DynamicCombo shape (Issue #10).
168
+ let prompt = flattenDynamicCombos(workflow);
168
169
  if (meta.parameters !== undefined && meta.parameters.length > 0) {
169
170
  const objectInfo = await objectInfoCached(client);
170
171
  const slots = await store.loadSlots();
@@ -217,7 +218,9 @@ export async function apply(ctx, entryConfig) {
217
218
  const entries = await client.listUserData('workflows');
218
219
  const library = await store.listWorkflows();
219
220
  return entries
220
- .filter((entry) => entry.type === 'file' && entry.name.endsWith('.json'))
221
+ // Dot entries are ComfyUI bookkeeping (e.g. `.index.json`), not graphs.
222
+ .filter((entry) => entry.type === 'file' && entry.name.endsWith('.json')
223
+ && entry.name.split(/[\\/]/).every((segment) => !segment.startsWith('.')))
221
224
  .map((entry) => {
222
225
  const derived = library.filter((workflow) => workflow.comfyuiFile === entry.name);
223
226
  return {
@@ -319,7 +322,12 @@ export async function apply(ctx, entryConfig) {
319
322
  // service simply never registers it, and the entry config stands as composed.
320
323
  let source = () => resolved;
321
324
  ctx.inject(['settings'], (settingsCtx) => {
322
- settingsCtx.settings.installSection(ctx, COMFYUI_NS, Config, resolved, {
325
+ const settings = settingsCtx.settings;
326
+ if (typeof settings.installSection !== 'function') {
327
+ ctx.logger.warn('comfyui: settings service lacks installSection — settings page stays read-only, entry config stands');
328
+ return;
329
+ }
330
+ settings.installSection(ctx, COMFYUI_NS, Config, resolved, {
323
331
  setSource: (current) => {
324
332
  source = current;
325
333
  },
package/lib/routes.js CHANGED
@@ -250,7 +250,14 @@ export function mountComfyUIRoutes(ctx, runtime) {
250
250
  });
251
251
  }
252
252
  catch (error) {
253
- sendJson(response, 200, { ok: false, error: errorMessage(error), latencyMs: Date.now() - startedAt });
253
+ // Name the probed address: a save that never persisted leaves the
254
+ // default 127.0.0.1:8188 in force, and a bare "fetch failed" reads
255
+ // like a network problem with the address the user typed (Issue #11).
256
+ sendJson(response, 200, {
257
+ ok: false,
258
+ error: `${errorMessage(error)}(探测地址 ${runtime.getConfig().baseUrl})`,
259
+ latencyMs: Date.now() - startedAt,
260
+ });
254
261
  }
255
262
  }),
256
263
  }));
@@ -1428,7 +1435,12 @@ export function mountComfyUIRoutes(ctx, runtime) {
1428
1435
  const client = runtime.createClient(await runtime.getApiKey());
1429
1436
  const entry = await client.getHistory(promptId);
1430
1437
  if (entry === undefined) {
1431
- sendJson(response, 200, { ok: true, status: 'unknown' });
1438
+ // No history entry: still waiting in ComfyUI's queue, or evicted.
1439
+ // Tell the two apart so the background card keeps polling a long
1440
+ // queue instead of timing it out as "gone".
1441
+ const queue = await client.getQueue();
1442
+ const waiting = [...queue.queue_running, ...queue.queue_pending].some((item) => item.prompt_id === promptId);
1443
+ sendJson(response, 200, { ok: true, status: waiting ? 'queued' : 'unknown' });
1432
1444
  return;
1433
1445
  }
1434
1446
  const statusStr = entry.status?.status_str;
package/lib/tools.js CHANGED
@@ -263,7 +263,7 @@ function summarizeFields(fields, required) {
263
263
  else if (typeof typeOrList === 'string') {
264
264
  entry.type = typeOrList;
265
265
  }
266
- if (optionsRecord !== undefined && 'default' in optionsRecord) {
266
+ if (optionsRecord !== undefined && optionsRecord.default !== undefined) {
267
267
  entry.default = optionsRecord.default;
268
268
  }
269
269
  out.push(entry);
@@ -307,16 +307,23 @@ function objectInfoDefinition(runtime) {
307
307
  : entries.filter(([name]) => name.toLowerCase().includes(filter));
308
308
  const nodes = filtered.slice(0, 60).map(([classType, def]) => ({
309
309
  class_type: classType,
310
- display_name: def.display_name,
310
+ // Omitted rather than assigned undefined: a key holding undefined makes the
311
+ // whole result fail DSH's lossless-JSON validation. The render type already
312
+ // declares display_name optional.
313
+ ...(def.display_name !== undefined ? { display_name: def.display_name } : {}),
311
314
  description: (def.description ?? '').slice(0, 200),
312
315
  required: summarizeFields(def.input?.required, true),
313
316
  optional: summarizeFields(def.input?.optional, false),
314
317
  }));
318
+ const hint = filtered.length > nodes.length
319
+ ? `filter matched ${filtered.length} nodes, showing first ${nodes.length} — narrow the filter for more`
320
+ : undefined;
315
321
  return {
316
322
  total: entries.length,
317
323
  shown: nodes.length,
318
324
  filter: filter ?? null,
319
- hint: filtered.length > nodes.length ? `filter matched ${filtered.length} nodes, showing first ${nodes.length} — narrow the filter for more` : undefined,
325
+ // Omitted rather than assigned undefined, same reason as display_name.
326
+ ...(hint !== undefined ? { hint } : {}),
320
327
  nodes,
321
328
  };
322
329
  },
@@ -409,12 +416,13 @@ function workflowDefinition(runtime, ctx) {
409
416
  lines.push(` 技能包${skill.required ? '(运行前必读)' : ''}: ${skill.summary}${extra} — 运行前先 action: skill { id: "${workflow.id}" }`);
410
417
  }
411
418
  for (const param of workflow.parameters ?? []) {
412
- const def = typeof param.default === 'string' ? `"${param.default}"` : String(param.default);
419
+ const label = param.label ?? param.name;
420
+ const def = param.default === undefined ? '' : `,默认 ${typeof param.default === 'string' ? `"${param.default}"` : String(param.default)}`;
413
421
  const options = Array.isArray(param.options) && param.options.length > 0 ? `,可选: ${param.options.join(' / ')}` : '';
414
422
  const upload = param.upload !== undefined
415
423
  ? `,上传类型: ${param.upload}${param.upload === 'media' ? `(${param.subfolder ?? ''}/,空值=移除该参考位)` : ''}`
416
424
  : '';
417
- lines.push(` ${param.name}(${param.label}${param.random === true ? ',随机' : ''},默认 ${def}${options}${upload})`);
425
+ lines.push(` ${param.name}(${label}${param.random === true ? ',随机' : ''}${def}${options}${upload})`);
418
426
  }
419
427
  }
420
428
  const loadArea = data.loadArea;
@@ -506,7 +514,13 @@ function workflowDefinition(runtime, ctx) {
506
514
  parameters: (parameters ?? []).map(({ name: pname, label, type, default: def, random, numberKind, options, upload }) => {
507
515
  // DSH validates tool output as lossless JSON: JSON.stringify drops
508
516
  // undefined keys, so omit optional fields instead of passing undefined.
509
- const entry = { name: pname, label, type, default: def };
517
+ const entry = { name: pname };
518
+ if (label !== undefined)
519
+ entry.label = label;
520
+ if (type !== undefined)
521
+ entry.type = type;
522
+ if (def !== undefined)
523
+ entry.default = def;
510
524
  if (random !== undefined)
511
525
  entry.random = random;
512
526
  // Tells the model whether decimals are accepted; absent means the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-comfyui",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Let the DeepSeek Harness agent smartly drive a local or remote ComfyUI to generate anything, with workflow and asset management panels, per-workflow skill packs, a companion skill and a same-origin media proxy. / 让 DeepSeek Harness 的 Agent 智能驱动本地或远程 ComfyUI 生成任何内容。附带工作流、资产管理面板与技能包管理挂载。配套 skill 与同源媒体代理。",
5
5
  "repository": {
6
6
  "type": "git",