dsh-comfyui 0.5.2 → 0.5.4

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/README.en.md CHANGED
@@ -20,10 +20,10 @@
20
20
  >
21
21
  > | dsh-comfyui | Paired DeepSeek Harness | Notes |
22
22
  > | --- | --- | --- |
23
- > | **0.4.0 (latest, `latest` tag)** | **>= 0.1.2** | From 0.4.0 this plugin uses the new settings service API |
23
+ > | **0.5.x (latest, `latest` tag)** | **>= 0.1.2** (incl. 0.1.5 / 0.1.6 / 0.1.7 prereleases) | From 0.5.3 the settings page works with dsh 0.1.7's settings model (saves apply live, no restart) |
24
24
  > | **0.3.x (beta line, `beta` tag)** | **0.1.1** | On older dsh versions stay on the 0.3.x line |
25
25
  >
26
- > Install: `dsh plugin --profile web add dsh-comfyui` (latest 0.4.0) / `dsh plugin --profile web add dsh-comfyui@beta` (0.3.x for older hosts).
26
+ > Install: `dsh plugin --profile web add dsh-comfyui` (latest 0.5.x) / `dsh plugin --profile web add dsh-comfyui@beta` (0.3.x for older hosts).
27
27
 
28
28
  ## Features
29
29
 
package/README.md CHANGED
@@ -20,10 +20,10 @@
20
20
  >
21
21
  > | dsh-comfyui | 配对的 DeepSeek Harness | 说明 |
22
22
  > | --- | --- | --- |
23
- > | **0.4.0(最新,`latest` tag)** | **≥ 0.1.2** | 0.4.0 起使用新版 settings 服务 API |
23
+ > | **0.5.x(最新,`latest` tag)** | **≥ 0.1.2**(含 0.1.5 / 0.1.6 / 0.1.7 预发布版) | 0.5.3 起兼容 dsh 0.1.7 的设置机制(设置页保存直接生效,无需重启) |
24
24
  > | **0.3.x(beta 线,`beta` tag)** | **0.1.1** | 老版本 dsh 请留在 0.3.x 线 |
25
25
  >
26
- > 安装:`dsh plugin --profile web add dsh-comfyui`(装最新 0.4.0)/ `dsh plugin --profile web add dsh-comfyui@beta`(老宿主装 0.3.x)。
26
+ > 安装:`dsh plugin --profile web add dsh-comfyui`(装最新 0.5.x)/ `dsh plugin --profile web add dsh-comfyui@beta`(老宿主装 0.3.x)。
27
27
 
28
28
  ## 功能
29
29
 
package/lib/analyze.js CHANGED
@@ -5,7 +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
+ import { isVirtualNodeType, normalizeLinks, resolveVirtualLinks } from './graph.js';
9
9
  function isObject(value) {
10
10
  return typeof value === 'object' && value !== null && !Array.isArray(value);
11
11
  }
@@ -28,11 +28,15 @@ export function analyzeGraph(graph) {
28
28
  return { ok: false, error: '无法解析图文件(缺少 nodes/links)' };
29
29
  }
30
30
  const nodes = graph.nodes;
31
- const links = normalizeLinks(graph.links);
31
+ // Virtual nodes (Set/Get wireless links, rgthree mode relays) are rewired
32
+ // out first, exactly as conversion does, so components follow the real data
33
+ // flow and the virtual nodes themselves never show up as components or as
34
+ // dangling nodes (Issue #8).
35
+ const links = resolveVirtualLinks(graph.nodes, normalizeLinks(graph.links));
32
36
  const groups = Array.isArray(graph.groups) ? graph.groups : [];
33
- const active = nodes.filter((node) => node.mode !== 4);
37
+ const active = nodes.filter((node) => node.mode !== 4 && !isVirtualNodeType(node.type));
34
38
  const activeById = new Map(active.map((node) => [node.id, node]));
35
- const bypassedCount = nodes.length - active.length;
39
+ const bypassedCount = nodes.filter((node) => node.mode === 4).length;
36
40
  // Dangling nodes: active but touching no link at all.
37
41
  const linkedIds = new Set();
38
42
  for (const link of links) {
package/lib/config.d.ts CHANGED
@@ -1,86 +1,93 @@
1
1
  /**
2
2
  * dsh-comfyui host configuration. The same schema drives the Loader entry
3
- * config (cordis.yml patch) and the `comfyui:` settings section the browser
4
- * settings page writes, so one shape covers both doors into the same values.
3
+ * config (cordis.yml patch) and the settings page, so one shape covers both
4
+ * doors into the same values.
5
+ *
6
+ * Fields the plugin reads at use time are `.volatile()`: since dsh 0.1.7 the
7
+ * settings service only edits volatile fields (anything else fails with
8
+ * "Plugin entry has no volatile fields", Issue #11), and the Loader commits a
9
+ * volatile-only change into the running references without restarting the
10
+ * plugin. `dataDir` / `maxAssets` / `outputDir` stay ordinary: the store is
11
+ * built from them at startup, so a change there restarts the plugin.
5
12
  */
6
13
  import z from '@deepseek-ai/schemastery';
7
- export declare const Config: z<Schemastery.ObjectS<{
14
+ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
8
15
  /** ComfyUI HTTP server base URL. */
9
- baseUrl: z<string, string>;
16
+ baseUrl: z<string, string, "volatile-defined">;
10
17
  /** Environment-variable name of the optional API key (credentials ref). */
11
- apiKeyEnv: z<string, string>;
18
+ apiKeyEnv: z<string, string, "volatile-defined">;
12
19
  /** Per-request connect/read timeout for the ComfyUI HTTP client. */
13
- connectTimeoutMs: z<number, number>;
20
+ connectTimeoutMs: z<number, number, "volatile-defined">;
14
21
  /** How long a synchronous generation (or background job) waits for workflow completion. */
15
- timeoutMs: z<number, number>;
22
+ timeoutMs: z<number, number, "volatile-defined">;
16
23
  /** History polling interval while waiting for completion. */
17
- pollIntervalMs: z<number, number>;
24
+ pollIntervalMs: z<number, number, "volatile-defined">;
18
25
  /** Max media items returned per completed workflow. */
19
- maxMediaItems: z<number, number>;
26
+ maxMediaItems: z<number, number, "volatile-defined">;
20
27
  /** Max bytes the media proxy streams for one file. */
21
- maxMediaBytes: z<number, number>;
28
+ maxMediaBytes: z<number, number, "volatile-defined">;
22
29
  /** Directory for plugin data (workflow library, asset index); empty means DSH_HOME/data/dsh-comfyui. */
23
- dataDir: z<string, string>;
30
+ dataDir: z<string, string, "defined">;
24
31
  /** Max asset records kept in the asset index. */
25
- maxAssets: z<number, number>;
32
+ maxAssets: z<number, number, "defined">;
26
33
  /** Directory holding the per-workflow skill packs. Empty = `<dataDir>/skills`.
27
34
  * Separate from `dataDir` on purpose: packs are documents a user may want on
28
35
  * another drive, in a synced folder, or under version control, while the
29
36
  * JSON state files belong with the rest of the plugin data. Must be absolute;
30
37
  * a relative value is ignored. */
31
- skillsDir: z<string, string>;
38
+ skillsDir: z<string, string, "volatile-defined">;
32
39
  /** External base URL for generated media (e.g. http://192.168.1.5:3080). Empty = auto-detect the browser's request host, then http://127.0.0.1:<webServerPort>. */
33
- mediaHost: z<string, string>;
40
+ mediaHost: z<string, string, "volatile-defined">;
34
41
  /** ComfyUI's output directory on this machine, used to delete asset files
35
42
  * from the panel. Empty = infer it from the file paths ComfyUI reports;
36
43
  * deletion falls back to removing the index record when neither works
37
44
  * (e.g. a ComfyUI running on another host). */
38
- outputDir: z<string, string>;
45
+ outputDir: z<string, string, "defined">;
39
46
  /** ComfyUI install root(s) on this machine (absolute paths). Multiple
40
47
  * entries are allowed because ComfyUI folders can be mapped/mounted (extra
41
48
  * models dirs, several installs, portable copies). The agent reads them to
42
49
  * locate ComfyUI files directly (workflows, models, the TTS-Audio-Suite
43
50
  * voice library) without guessing or asking the user. */
44
- comfyuiDirs: z<string[], string[]>;
45
- }>, Schemastery.ObjectT<{
51
+ comfyuiDirs: z<NoInfer<string[]>, NoInfer<string[]>, "volatile-defined">;
52
+ }>>, Schemastery.ObjectT<NoInfer<{
46
53
  /** ComfyUI HTTP server base URL. */
47
- baseUrl: z<string, string>;
54
+ baseUrl: z<string, string, "volatile-defined">;
48
55
  /** Environment-variable name of the optional API key (credentials ref). */
49
- apiKeyEnv: z<string, string>;
56
+ apiKeyEnv: z<string, string, "volatile-defined">;
50
57
  /** Per-request connect/read timeout for the ComfyUI HTTP client. */
51
- connectTimeoutMs: z<number, number>;
58
+ connectTimeoutMs: z<number, number, "volatile-defined">;
52
59
  /** How long a synchronous generation (or background job) waits for workflow completion. */
53
- timeoutMs: z<number, number>;
60
+ timeoutMs: z<number, number, "volatile-defined">;
54
61
  /** History polling interval while waiting for completion. */
55
- pollIntervalMs: z<number, number>;
62
+ pollIntervalMs: z<number, number, "volatile-defined">;
56
63
  /** Max media items returned per completed workflow. */
57
- maxMediaItems: z<number, number>;
64
+ maxMediaItems: z<number, number, "volatile-defined">;
58
65
  /** Max bytes the media proxy streams for one file. */
59
- maxMediaBytes: z<number, number>;
66
+ maxMediaBytes: z<number, number, "volatile-defined">;
60
67
  /** Directory for plugin data (workflow library, asset index); empty means DSH_HOME/data/dsh-comfyui. */
61
- dataDir: z<string, string>;
68
+ dataDir: z<string, string, "defined">;
62
69
  /** Max asset records kept in the asset index. */
63
- maxAssets: z<number, number>;
70
+ maxAssets: z<number, number, "defined">;
64
71
  /** Directory holding the per-workflow skill packs. Empty = `<dataDir>/skills`.
65
72
  * Separate from `dataDir` on purpose: packs are documents a user may want on
66
73
  * another drive, in a synced folder, or under version control, while the
67
74
  * JSON state files belong with the rest of the plugin data. Must be absolute;
68
75
  * a relative value is ignored. */
69
- skillsDir: z<string, string>;
76
+ skillsDir: z<string, string, "volatile-defined">;
70
77
  /** External base URL for generated media (e.g. http://192.168.1.5:3080). Empty = auto-detect the browser's request host, then http://127.0.0.1:<webServerPort>. */
71
- mediaHost: z<string, string>;
78
+ mediaHost: z<string, string, "volatile-defined">;
72
79
  /** ComfyUI's output directory on this machine, used to delete asset files
73
80
  * from the panel. Empty = infer it from the file paths ComfyUI reports;
74
81
  * deletion falls back to removing the index record when neither works
75
82
  * (e.g. a ComfyUI running on another host). */
76
- outputDir: z<string, string>;
83
+ outputDir: z<string, string, "defined">;
77
84
  /** ComfyUI install root(s) on this machine (absolute paths). Multiple
78
85
  * entries are allowed because ComfyUI folders can be mapped/mounted (extra
79
86
  * models dirs, several installs, portable copies). The agent reads them to
80
87
  * locate ComfyUI files directly (workflows, models, the TTS-Audio-Suite
81
88
  * voice library) without guessing or asking the user. */
82
- comfyuiDirs: z<string[], string[]>;
83
- }>>;
89
+ comfyuiDirs: z<NoInfer<string[]>, NoInfer<string[]>, "volatile-defined">;
90
+ }>>, "plain">;
84
91
  export type Config = {
85
92
  /** ComfyUI HTTP server base URL. */
86
93
  baseUrl: string;
@@ -109,3 +116,12 @@ export type Config = {
109
116
  /** ComfyUI install root(s) on this machine; the agent uses them to locate files directly. */
110
117
  comfyuiDirs: string[];
111
118
  };
119
+ /**
120
+ * Resolve the Loader's parsed entry config into the plain object the plugin
121
+ * reads. Call again after `loader/volatile-update`: the references are the
122
+ * same objects, now holding the new values.
123
+ * @param raw - parsed entry config; fields may be volatile references.
124
+ * @param defaultDataDir - data directory used when `dataDir` is empty.
125
+ * @returns a fully populated plain config.
126
+ */
127
+ export declare function resolveConfig(raw: Partial<Record<keyof Config, unknown>>, defaultDataDir: string): Config;
package/lib/config.js CHANGED
@@ -1,24 +1,31 @@
1
1
  /**
2
2
  * dsh-comfyui host configuration. The same schema drives the Loader entry
3
- * config (cordis.yml patch) and the `comfyui:` settings section the browser
4
- * settings page writes, so one shape covers both doors into the same values.
3
+ * config (cordis.yml patch) and the settings page, so one shape covers both
4
+ * doors into the same values.
5
+ *
6
+ * Fields the plugin reads at use time are `.volatile()`: since dsh 0.1.7 the
7
+ * settings service only edits volatile fields (anything else fails with
8
+ * "Plugin entry has no volatile fields", Issue #11), and the Loader commits a
9
+ * volatile-only change into the running references without restarting the
10
+ * plugin. `dataDir` / `maxAssets` / `outputDir` stay ordinary: the store is
11
+ * built from them at startup, so a change there restarts the plugin.
5
12
  */
6
13
  import z from '@deepseek-ai/schemastery';
7
14
  export const Config = z.object({
8
15
  /** ComfyUI HTTP server base URL. */
9
- baseUrl: z.string().default('http://127.0.0.1:8188'),
16
+ baseUrl: z.string().default('http://127.0.0.1:8188').volatile(),
10
17
  /** Environment-variable name of the optional API key (credentials ref). */
11
- apiKeyEnv: z.string().default('COMFYUI_API_KEY'),
18
+ apiKeyEnv: z.string().default('COMFYUI_API_KEY').volatile(),
12
19
  /** Per-request connect/read timeout for the ComfyUI HTTP client. */
13
- connectTimeoutMs: z.number().min(1_000).max(60_000).default(10_000),
20
+ connectTimeoutMs: z.number().min(1_000).max(60_000).default(10_000).volatile(),
14
21
  /** How long a synchronous generation (or background job) waits for workflow completion. */
15
- timeoutMs: z.number().min(5_000).max(3_600_000).default(900_000),
22
+ timeoutMs: z.number().min(5_000).max(3_600_000).default(900_000).volatile(),
16
23
  /** History polling interval while waiting for completion. */
17
- pollIntervalMs: z.number().min(200).max(10_000).default(1_000),
24
+ pollIntervalMs: z.number().min(200).max(10_000).default(1_000).volatile(),
18
25
  /** Max media items returned per completed workflow. */
19
- maxMediaItems: z.number().min(1).max(50).default(12),
26
+ maxMediaItems: z.number().min(1).max(50).default(12).volatile(),
20
27
  /** Max bytes the media proxy streams for one file. */
21
- maxMediaBytes: z.number().min(64 * 1024).max(512 * 1024 * 1024).default(64 * 1024 * 1024),
28
+ maxMediaBytes: z.number().min(64 * 1024).max(512 * 1024 * 1024).default(64 * 1024 * 1024).volatile(),
22
29
  /** Directory for plugin data (workflow library, asset index); empty means DSH_HOME/data/dsh-comfyui. */
23
30
  dataDir: z.string().default(''),
24
31
  /** Max asset records kept in the asset index. */
@@ -28,9 +35,9 @@ export const Config = z.object({
28
35
  * another drive, in a synced folder, or under version control, while the
29
36
  * JSON state files belong with the rest of the plugin data. Must be absolute;
30
37
  * a relative value is ignored. */
31
- skillsDir: z.string().default(''),
38
+ skillsDir: z.string().default('').volatile(),
32
39
  /** External base URL for generated media (e.g. http://192.168.1.5:3080). Empty = auto-detect the browser's request host, then http://127.0.0.1:<webServerPort>. */
33
- mediaHost: z.string().default(''),
40
+ mediaHost: z.string().default('').volatile(),
34
41
  /** ComfyUI's output directory on this machine, used to delete asset files
35
42
  * from the panel. Empty = infer it from the file paths ComfyUI reports;
36
43
  * deletion falls back to removing the index record when neither works
@@ -41,5 +48,45 @@ export const Config = z.object({
41
48
  * models dirs, several installs, portable copies). The agent reads them to
42
49
  * locate ComfyUI files directly (workflows, models, the TTS-Audio-Suite
43
50
  * voice library) without guessing or asking the user. */
44
- comfyuiDirs: z.array(z.string()).default([]),
51
+ comfyuiDirs: z.array(z.string()).default([]).volatile(),
45
52
  });
53
+ /** The volatile-reference protocol shared across cosmokit copies (Symbol.for). */
54
+ const VOLATILE_WRITE = Symbol.for('cosmokit.volatile.write');
55
+ /** Read a config value that may be a volatile reference (dsh 0.1.7+) or a plain value (older hosts). */
56
+ function current(value) {
57
+ if (typeof value === 'object' && value !== null && VOLATILE_WRITE in value) {
58
+ return value.get();
59
+ }
60
+ return value;
61
+ }
62
+ /**
63
+ * Resolve the Loader's parsed entry config into the plain object the plugin
64
+ * reads. Call again after `loader/volatile-update`: the references are the
65
+ * same objects, now holding the new values.
66
+ * @param raw - parsed entry config; fields may be volatile references.
67
+ * @param defaultDataDir - data directory used when `dataDir` is empty.
68
+ * @returns a fully populated plain config.
69
+ */
70
+ export function resolveConfig(raw, defaultDataDir) {
71
+ const str = (value, fallback) => (typeof value === 'string' ? value : fallback);
72
+ const num = (value, fallback) => (typeof value === 'number' ? value : fallback);
73
+ const dataDir = str(current(raw.dataDir), '');
74
+ const dirs = current(raw.comfyuiDirs);
75
+ return {
76
+ baseUrl: str(current(raw.baseUrl), 'http://127.0.0.1:8188'),
77
+ apiKeyEnv: str(current(raw.apiKeyEnv), 'COMFYUI_API_KEY'),
78
+ connectTimeoutMs: num(current(raw.connectTimeoutMs), 10_000),
79
+ timeoutMs: num(current(raw.timeoutMs), 900_000),
80
+ pollIntervalMs: num(current(raw.pollIntervalMs), 1_000),
81
+ maxMediaItems: num(current(raw.maxMediaItems), 12),
82
+ maxMediaBytes: num(current(raw.maxMediaBytes), 64 * 1024 * 1024),
83
+ dataDir: dataDir !== '' ? dataDir : defaultDataDir,
84
+ maxAssets: num(current(raw.maxAssets), 200),
85
+ skillsDir: str(current(raw.skillsDir), ''),
86
+ mediaHost: str(current(raw.mediaHost), ''),
87
+ outputDir: str(current(raw.outputDir), ''),
88
+ comfyuiDirs: Array.isArray(dirs)
89
+ ? dirs.filter((dir) => typeof dir === 'string' && dir.trim() !== '')
90
+ : [],
91
+ };
92
+ }
package/lib/convert.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * Reroute / bypassed (mode 4) nodes are skipped with their links rewired.
8
8
  * Nodes the conversion cannot represent fail loudly with the offending type.
9
9
  */
10
- import { normalizeLinks } from './graph.js';
10
+ import { isVirtualNodeType, normalizeLinks, resolveVirtualLinks } from './graph.js';
11
11
  /** Node types that exist only in the UI and carry no data flow. */
12
12
  const UI_ONLY = new Set(['Note', 'StickyNote', 'Reroute', 'Fast Groups Bypasser (rgthree)']);
13
13
  function isObject(value) {
@@ -253,7 +253,9 @@ export function convertGraphToApi(graph, objectInfo, options) {
253
253
  // normalizeLinks accepts both the legacy positional rows and the v0.4
254
254
  // frontend's object entries; anything unreadable is skipped, exactly like
255
255
  // the old array-only guard did.
256
- for (const link of normalizeLinks(rawLinks))
256
+ // Set/Get wireless links and rgthree mode relays are rewired out before
257
+ // anything resolves a link (Issue #8), shared with analyzeGraph.
258
+ for (const link of resolveVirtualLinks(rawNodes, normalizeLinks(rawLinks)))
257
259
  links.set(link[0], link);
258
260
  const nodesById = new Map(nodes.map((node) => [node.id, node]));
259
261
  const included = options?.includeNodeIds;
@@ -272,7 +274,7 @@ export function convertGraphToApi(graph, objectInfo, options) {
272
274
  const brokenInputs = new Map();
273
275
  const dropReasons = new Map();
274
276
  for (const node of candidates) {
275
- if (node.type === '' || UI_ONLY.has(node.type))
277
+ if (node.type === '' || UI_ONLY.has(node.type) || isVirtualNodeType(node.type))
276
278
  continue;
277
279
  if (node.mode === 4)
278
280
  continue;
package/lib/graph.d.ts CHANGED
@@ -13,3 +13,32 @@ export type GraphLink = [number, number, number, number, number, string];
13
13
  export declare function normalizeLink(raw: unknown): GraphLink | undefined;
14
14
  /** Normalize a whole `links` array, skipping entries that are not readable. */
15
15
  export declare function normalizeLinks(raw: unknown): GraphLink[];
16
+ /**
17
+ * Frontend-only nodes whose links are rewired or dropped before analysis and
18
+ * conversion (Issue #8). None of them exists on the server.
19
+ *
20
+ * - KJNodes `SetNode` / `GetNode`: "wireless" links paired by name. A GetNode's
21
+ * output is the value wired into the SetNode of the same name (and a
22
+ * SetNode's own passthrough output is its input).
23
+ * - rgthree `Mute / Bypass Relay` / `Repeater`: their `OPT_CONNECTION` links
24
+ * only propagate mute/bypass mode between nodes and carry no data. The mode
25
+ * they set is already saved on each target node, so the links are dropped.
26
+ */
27
+ export declare const SET_NODE = "SetNode";
28
+ export declare const GET_NODE = "GetNode";
29
+ /** Whether a node type is one of the virtual nodes {@link resolveVirtualLinks} removes. */
30
+ export declare function isVirtualNodeType(type: string): boolean;
31
+ /**
32
+ * Rewire the virtual nodes out of a graph's links, keeping link ids stable so
33
+ * node `inputs[].link` references stay valid.
34
+ *
35
+ * A GetNode resolves to the SetNode of the same name with the greatest `order`
36
+ * below its own (the scope rule the frontend applies), falling back to any
37
+ * SetNode of that name. A GetNode without a matching, wired SetNode (e.g. an
38
+ * empty optional slot) resolves to nothing, so its consumer stays unconnected.
39
+ * @param rawNodes - the graph's `nodes` array as saved.
40
+ * @param links - normalized links.
41
+ * @returns links with virtual-node hops replaced by their real origins, and
42
+ * every link touching a virtual node otherwise removed.
43
+ */
44
+ export declare function resolveVirtualLinks(rawNodes: unknown, links: GraphLink[]): GraphLink[];
package/lib/graph.js CHANGED
@@ -46,3 +46,102 @@ export function normalizeLinks(raw) {
46
46
  }
47
47
  return links;
48
48
  }
49
+ /**
50
+ * Frontend-only nodes whose links are rewired or dropped before analysis and
51
+ * conversion (Issue #8). None of them exists on the server.
52
+ *
53
+ * - KJNodes `SetNode` / `GetNode`: "wireless" links paired by name. A GetNode's
54
+ * output is the value wired into the SetNode of the same name (and a
55
+ * SetNode's own passthrough output is its input).
56
+ * - rgthree `Mute / Bypass Relay` / `Repeater`: their `OPT_CONNECTION` links
57
+ * only propagate mute/bypass mode between nodes and carry no data. The mode
58
+ * they set is already saved on each target node, so the links are dropped.
59
+ */
60
+ export const SET_NODE = 'SetNode';
61
+ export const GET_NODE = 'GetNode';
62
+ const MODE_LINK_NODES = new Set(['Mute / Bypass Relay (rgthree)', 'Mute / Bypass Repeater (rgthree)']);
63
+ /** Whether a node type is one of the virtual nodes {@link resolveVirtualLinks} removes. */
64
+ export function isVirtualNodeType(type) {
65
+ return type === SET_NODE || type === GET_NODE || MODE_LINK_NODES.has(type);
66
+ }
67
+ function virtualNodeFacts(raw) {
68
+ if (!isObject(raw))
69
+ return undefined;
70
+ const id = typeof raw.id === 'number' ? raw.id : Number(raw.id);
71
+ if (!Number.isFinite(id))
72
+ return undefined;
73
+ const values = Array.isArray(raw.widgets_values) ? raw.widgets_values : [];
74
+ const name = typeof values[0] === 'string' && values[0].trim() !== '' ? values[0] : undefined;
75
+ const inputs = Array.isArray(raw.inputs) ? raw.inputs : [];
76
+ const wired = inputs.find((entry) => isObject(entry) && typeof entry.link === 'number');
77
+ return {
78
+ id,
79
+ type: typeof raw.type === 'string' ? raw.type : '',
80
+ order: typeof raw.order === 'number' ? raw.order : Number.MAX_SAFE_INTEGER,
81
+ name,
82
+ firstInputLink: wired?.link,
83
+ };
84
+ }
85
+ /**
86
+ * Rewire the virtual nodes out of a graph's links, keeping link ids stable so
87
+ * node `inputs[].link` references stay valid.
88
+ *
89
+ * A GetNode resolves to the SetNode of the same name with the greatest `order`
90
+ * below its own (the scope rule the frontend applies), falling back to any
91
+ * SetNode of that name. A GetNode without a matching, wired SetNode (e.g. an
92
+ * empty optional slot) resolves to nothing, so its consumer stays unconnected.
93
+ * @param rawNodes - the graph's `nodes` array as saved.
94
+ * @param links - normalized links.
95
+ * @returns links with virtual-node hops replaced by their real origins, and
96
+ * every link touching a virtual node otherwise removed.
97
+ */
98
+ export function resolveVirtualLinks(rawNodes, links) {
99
+ const facts = new Map();
100
+ for (const raw of Array.isArray(rawNodes) ? rawNodes : []) {
101
+ const node = virtualNodeFacts(raw);
102
+ if (node !== undefined && isVirtualNodeType(node.type))
103
+ facts.set(node.id, node);
104
+ }
105
+ if (facts.size === 0)
106
+ return links;
107
+ const linkById = new Map(links.map((link) => [link[0], link]));
108
+ const setters = new Map();
109
+ for (const node of facts.values()) {
110
+ if (node.type !== SET_NODE || node.name === undefined)
111
+ continue;
112
+ const list = setters.get(node.name) ?? [];
113
+ list.push(node);
114
+ setters.set(node.name, list);
115
+ }
116
+ const setterFor = (getter) => {
117
+ const candidates = getter.name === undefined ? [] : setters.get(getter.name) ?? [];
118
+ const before = candidates.filter((node) => node.order < getter.order);
119
+ const pool = before.length > 0 ? before : candidates;
120
+ return pool.reduce((best, node) => (best === undefined || node.order > best.order ? node : best), undefined);
121
+ };
122
+ /** The real [originId, originSlot] behind an origin, or undefined when it resolves to nothing. */
123
+ const realOrigin = (originId, originSlot, seen) => {
124
+ const node = facts.get(originId);
125
+ if (node === undefined)
126
+ return [originId, originSlot];
127
+ if (seen.has(originId))
128
+ return undefined;
129
+ seen.add(originId);
130
+ if (MODE_LINK_NODES.has(node.type))
131
+ return undefined;
132
+ const source = node.type === GET_NODE ? setterFor(node) : node;
133
+ const upstream = source?.firstInputLink === undefined ? undefined : linkById.get(source.firstInputLink);
134
+ return upstream === undefined ? undefined : realOrigin(upstream[1], upstream[2], seen);
135
+ };
136
+ const out = [];
137
+ for (const link of links) {
138
+ // Links INTO a virtual node only feed the resolution above.
139
+ if (facts.has(link[3]))
140
+ continue;
141
+ const origin = realOrigin(link[1], link[2], new Set());
142
+ if (origin === undefined)
143
+ continue;
144
+ out.push(origin[0] === link[1] && origin[1] === link[2] ? link : [link[0], origin[0], origin[1], link[3], link[4], link[5]]);
145
+ }
146
+ return out;
147
+ }
package/lib/index.d.ts CHANGED
@@ -21,4 +21,4 @@ export declare const inject: string[];
21
21
  * The plugin body. The loader validates the entry config against `Config`
22
22
  * (defaults applied), then hands the resolved object to apply.
23
23
  */
24
- export declare function apply(ctx: Context, entryConfig: Partial<ConfigType>): Promise<void>;
24
+ export declare function apply(ctx: Context, entryConfig: Partial<Record<keyof ConfigType, unknown>>): Promise<void>;
package/lib/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { homedir } from 'node:os';
2
2
  import { isAbsolute, join } from 'node:path';
3
- import { Config } from './config.js';
3
+ import { Config, resolveConfig } 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';
@@ -66,23 +66,10 @@ function defaultDataDir() {
66
66
  * (defaults applied), then hands the resolved object to apply.
67
67
  */
68
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
- skillsDir: entryConfig.skillsDir ?? '',
80
- mediaHost: entryConfig.mediaHost ?? '',
81
- outputDir: entryConfig.outputDir ?? '',
82
- comfyuiDirs: Array.isArray(entryConfig.comfyuiDirs)
83
- ? entryConfig.comfyuiDirs.filter((dir) => typeof dir === 'string' && dir.trim() !== '')
84
- : [],
85
- };
69
+ // Volatile fields arrive as references on dsh 0.1.7+ (plain values on older
70
+ // hosts); `resolved` is the one plain object every consumer reads, refreshed
71
+ // in place when the Loader commits a settings-page change.
72
+ const resolved = resolveConfig(entryConfig, defaultDataDir());
86
73
  const store = new ComfyUIStore(resolved.dataDir, resolved.maxAssets);
87
74
  await store.init();
88
75
  // Per-workflow skill packs default to `<dataDir>/skills`; `skillsDir` moves
@@ -113,11 +100,27 @@ export async function apply(ctx, entryConfig) {
113
100
  // only broadcasts them to the submitting client. Node's global WebSocket
114
101
  // (undici) cannot set auth headers, so a remote server behind an
115
102
  // authenticating proxy simply shows no progress.
103
+ const progressUrl = () => resolved.baseUrl.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:').replace(/\/$/, '') + `/ws?clientId=${CLIENT_ID}`;
104
+ let attachedUrl = progressUrl();
116
105
  ctx.effect(() => {
117
- const wsUrl = resolved.baseUrl.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:').replace(/\/$/, '') + `/ws?clientId=${CLIENT_ID}`;
118
- progress.attach(wsUrl);
106
+ progress.attach(attachedUrl);
119
107
  return () => progress.dispose();
120
108
  }, 'dsh-comfyui: progress');
109
+ /** Pick up a new config in place; a new baseUrl re-points the progress socket. */
110
+ const refreshConfig = (next) => {
111
+ Object.assign(resolved, next);
112
+ const url = progressUrl();
113
+ if (url !== attachedUrl) {
114
+ attachedUrl = url;
115
+ progress.dispose();
116
+ progress.attach(url);
117
+ }
118
+ };
119
+ // dsh 0.1.7+: a settings-page save commits volatile values into the same
120
+ // references without restarting the plugin, then notifies this fiber.
121
+ ctx.on('loader/volatile-update', (() => {
122
+ refreshConfig(resolveConfig(entryConfig, defaultDataDir()));
123
+ }));
121
124
  const hostHint = createHostHint();
122
125
  const runtime = {
123
126
  getConfig: () => resolved,
@@ -323,18 +326,25 @@ export async function apply(ctx, entryConfig) {
323
326
  let source = () => resolved;
324
327
  ctx.inject(['settings'], (settingsCtx) => {
325
328
  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');
329
+ if (typeof settings.installSection === 'function') {
330
+ // dsh 0.1.2–0.1.5: the section registry keeps its own copy of the values.
331
+ settings.installSection(ctx, COMFYUI_NS, Config, resolved, {
332
+ setSource: (current) => {
333
+ source = current;
334
+ },
335
+ onChange: () => {
336
+ refreshConfig(resolveConfig(source(), defaultDataDir()));
337
+ },
338
+ });
328
339
  return;
329
340
  }
330
- settings.installSection(ctx, COMFYUI_NS, Config, resolved, {
331
- setSource: (current) => {
332
- source = current;
333
- },
334
- onChange: () => {
335
- Object.assign(resolved, source());
336
- },
337
- });
341
+ // dsh 0.1.7+: forms are projected from the volatile Config fields and a
342
+ // save arrives through loader/volatile-update above. This plugin ships its
343
+ // own settings page, so opt out of any auto-generated one.
344
+ if (typeof settings.configure === 'function') {
345
+ const configure = settings.configure.bind(settings);
346
+ settingsCtx.effect(() => configure({ auto: false }, ctx.fiber), 'dsh-comfyui: settings page policy');
347
+ }
338
348
  });
339
349
  ctx.effect(() => {
340
350
  const disposers = registerComfyUITools(ctx, runtime);
package/lib/skill.js CHANGED
@@ -66,6 +66,9 @@ TTS-Audio-Suite(\`{comfyuiDir}/custom_nodes/tts_audio_suite\`)的"🎭 Chara
66
66
  - **\`control_after_generate\` 占位**:名为 \`seed\`/\`noise_seed\` 的 INT(或 object_info 显式声明 \`control_after_generate\`)前面端会渲染生成后控制下拉,其值占据 widgets_values 一个槽位但无 API 输入——提取时消费该值但不写入工作流,否则后续 widget 全部错位(如 TTS-Audio-Suite 的 seed 后跟 "fixed")。
67
67
  - **对象型 widgets_values**(如 VHS_VideoCombine)按名取值,跳过内部状态(\`videopreview\` 等带 \`hidden\` 的对象)。
68
68
  - **Reroute 与 bypass(mode 4)节点直通**:输出跟随其第一条有连线的输入。
69
+ - **虚拟节点先改线再转换**(分析与转换共用同一规则):KJNodes \`SetNode\`/\`GetNode\` 按名称配对——\`GetNode\` 的输出接到同名 \`SetNode\` 的上游(同名多个时取 \`order\` 小于它的最大者),找不到对应 \`SetNode\` 的消费端保持未连接;rgthree \`Mute / Bypass Relay\`/\`Repeater\` 的连线只传播静音/绕过状态、不传数据,直接丢弃(状态已保存在各节点的 \`mode\` 里)。
70
+ - **DynamicCombo V3 保持扁平**:主输入的值是所选 option key 字符串,子控件以 \`主名.子名\` 平铺(如 \`codec: "auto"\`),不要包成 \`{ key, inputs }\`——服务端会自己重组,包起来反而匹配不上,节点在 execute 时报缺参数。
71
+ - **子图(subgraph)实例不支持**:遇到请让用户在 ComfyUI 里把子图转换回普通节点后再提取。
69
72
  - **未注册的 UI-only 节点**:若是 \`Primitive*\` 内联其第一个 widget 值;有输出被使用且非 Primitive → 报错。
70
73
  - **输出槽位越界**(保存图里 SaveImage 声称 2 个输出但服务端只有 1 个)→ 断开该引用并给警告。
71
74
  - **必需输入缺失**(required 里没有、且非 lazy/template 类型)→ 明确报错(源图本身断线),不产出"能转但跑不起来"的工作流。\`COMFY_AUTOGROW_V3\`/带 \`template\`/带 \`lazy\` 的输入豁免。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-comfyui",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
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",
@@ -49,12 +49,12 @@
49
49
  "node": ">=22.19"
50
50
  },
51
51
  "dependencies": {
52
- "@deepseek-ai/schemastery": "^3.18.0",
52
+ "@deepseek-ai/schemastery": "^3.18.4",
53
53
  "fflate": "^0.8.3"
54
54
  },
55
55
  "peerDependencies": {
56
56
  "@deepseek-ai/cordis": "^4.0.1",
57
- "@deepseek-ai/dsh-settings": "^0.1.2-alpha.4"
57
+ "@deepseek-ai/dsh-settings": "^0.1.2-alpha.4 || ^0.1.5-alpha.1 || ^0.1.6-alpha.1 || ^0.1.7-alpha.1"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@deepseek-ai/cordis": "^4.0.1",