dsh-multimodal-node 0.0.0-stage → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +504 -2
- package/client.js +2817 -0
- package/cordis.patch.yml +4 -0
- package/icon.svg +9 -0
- package/index.js +960 -0
- package/lib/apikeys.js +250 -0
- package/lib/assets.js +234 -0
- package/lib/auth.js +315 -0
- package/lib/blender.js +644 -0
- package/lib/chain.js +161 -0
- package/lib/comfy.js +204 -0
- package/lib/config.js +377 -0
- package/lib/gateway.js +974 -0
- package/lib/http.js +119 -0
- package/lib/imagesize.js +92 -0
- package/lib/instances.js +554 -0
- package/lib/library.js +1209 -0
- package/lib/models.js +322 -0
- package/lib/nodeapi.js +491 -0
- package/lib/nodeclient.js +143 -0
- package/lib/pages.js +5788 -0
- package/lib/pool.js +591 -0
- package/lib/promptassist.js +1077 -0
- package/lib/promptlibrary.js +92 -0
- package/lib/remote.js +3348 -0
- package/lib/runindex.js +126 -0
- package/lib/seed.js +118 -0
- package/lib/tagdata.json +1 -0
- package/lib/tiers.js +283 -0
- package/lib/tools.js +230 -0
- package/lib/viewport.js +134 -0
- package/lib/workflows.js +472 -0
- package/lib/ws.js +106 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +62 -4
package/lib/chain.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Work chains: an ordered list of steps, run one after another.
|
|
3
|
+
*
|
|
4
|
+
* What a chain is NOT: a graph. Each step is an existing workflow plus the settings a
|
|
5
|
+
* person would have typed into the console (layers, negatives, params, a run count). The
|
|
6
|
+
* value of chaining is that step N+1 can consume step N's image, which is the thing the
|
|
7
|
+
* console cannot do — you would have to download the output, re-upload it, and pick the
|
|
8
|
+
* next workflow by hand.
|
|
9
|
+
*
|
|
10
|
+
* Storage is per account, in `chains.json`, same shape as prompts.json:
|
|
11
|
+
* { byOwner: { <user>: [ {id, name, steps[], at} ] } }
|
|
12
|
+
*
|
|
13
|
+
* A step is validated, not trusted: it must name a workflow the account can see, and the
|
|
14
|
+
* count is clamped. An unvalidated step would only fail at run time, after the earlier
|
|
15
|
+
* steps had already burned GPU time.
|
|
16
|
+
*/
|
|
17
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { randomBytes } from 'node:crypto';
|
|
20
|
+
|
|
21
|
+
const MAX_CHAINS = 100;
|
|
22
|
+
export const MAX_STEPS = 12;
|
|
23
|
+
|
|
24
|
+
export class ChainLibrary {
|
|
25
|
+
constructor(dataDir) {
|
|
26
|
+
this.file = join(dataDir, 'chains.json');
|
|
27
|
+
this.byOwner = {};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async init() {
|
|
31
|
+
await mkdir(join(this.file, '..'), { recursive: true });
|
|
32
|
+
try {
|
|
33
|
+
const raw = await readFile(this.file, 'utf8');
|
|
34
|
+
const parsed = JSON.parse(raw);
|
|
35
|
+
this.byOwner = parsed !== null && typeof parsed.byOwner === 'object' && parsed.byOwner !== null ? parsed.byOwner : {};
|
|
36
|
+
} catch {
|
|
37
|
+
this.byOwner = {};
|
|
38
|
+
await this.save();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async save() {
|
|
43
|
+
const temp = `${this.file}.tmp`;
|
|
44
|
+
await writeFile(temp, `${JSON.stringify({ byOwner: this.byOwner }, null, 2)}\n`, 'utf8');
|
|
45
|
+
await rename(temp, this.file);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
list(owner) {
|
|
49
|
+
const rows = Array.isArray(this.byOwner[owner]) ? this.byOwner[owner] : [];
|
|
50
|
+
return rows.slice().sort((left, right) => (right.at ?? 0) - (left.at ?? 0));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
get(owner, id) {
|
|
54
|
+
return this.list(owner).find((row) => row.id === id);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Normalise one step.
|
|
59
|
+
*
|
|
60
|
+
* `feedPrevious` is the whole point of a chain: when true, the previous step's first
|
|
61
|
+
* output image is pushed into this step's image input. It defaults to false because
|
|
62
|
+
* feeding a text-to-image step an image it does not have an input for is a silent
|
|
63
|
+
* no-op, and defaults should not be surprising.
|
|
64
|
+
*/
|
|
65
|
+
static normalizeStep(input, index) {
|
|
66
|
+
const workflowId = String(input?.workflowId ?? '');
|
|
67
|
+
const repeat = Math.min(8, Math.max(1, Math.floor(Number(input?.repeat ?? 1)) || 1));
|
|
68
|
+
const layers = Array.isArray(input?.layers) ? input.layers.map((value) => String(value ?? '')) : [];
|
|
69
|
+
const params = Array.isArray(input?.params)
|
|
70
|
+
? input.params
|
|
71
|
+
.filter((row) => row !== null && typeof row === 'object' && typeof row.nodeId === 'string' && typeof row.input === 'string')
|
|
72
|
+
.map((row) => ({ nodeId: row.nodeId, input: row.input, value: row.value }))
|
|
73
|
+
: [];
|
|
74
|
+
return {
|
|
75
|
+
index,
|
|
76
|
+
workflowId,
|
|
77
|
+
// A step with no name is legal at build time (the id is what runs); the label
|
|
78
|
+
// is resolved from the library at display time so renames show up.
|
|
79
|
+
label: typeof input?.label === 'string' ? input.label.slice(0, 60) : '',
|
|
80
|
+
layers,
|
|
81
|
+
negative: typeof input?.negative === 'string' ? input.negative : '',
|
|
82
|
+
params,
|
|
83
|
+
repeat,
|
|
84
|
+
/**
|
|
85
|
+
* Seed for this step: random by default, fixed when the step names a value.
|
|
86
|
+
*
|
|
87
|
+
* Kept per step rather than per chain because a chain mixes intents — the base
|
|
88
|
+
* image wants variety, a refiner pass wants to stay on the picture it was given.
|
|
89
|
+
*/
|
|
90
|
+
seed: input?.seed === undefined || input?.seed === null
|
|
91
|
+
? { mode: 'random' }
|
|
92
|
+
: {
|
|
93
|
+
mode: input?.seed?.mode === 'fixed' ? 'fixed' : 'random',
|
|
94
|
+
...(input?.seed?.value === undefined ? {} : { value: input.seed.value }),
|
|
95
|
+
},
|
|
96
|
+
feedPrevious: input?.feedPrevious === true,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Save (or update by name) one chain. */
|
|
101
|
+
async add(owner, input) {
|
|
102
|
+
const name = String(input?.name ?? '').trim().slice(0, 60);
|
|
103
|
+
if (name === '') throw new Error('工作链需要一个名字');
|
|
104
|
+
const rawSteps = Array.isArray(input?.steps) ? input.steps : [];
|
|
105
|
+
if (rawSteps.length === 0) throw new Error('工作链至少要有一个环节');
|
|
106
|
+
if (rawSteps.length > MAX_STEPS) throw new Error(`最多 ${MAX_STEPS} 个环节`);
|
|
107
|
+
const steps = rawSteps.map((step, index) => ChainLibrary.normalizeStep(step, index));
|
|
108
|
+
if (steps.some((step) => step.workflowId === '')) throw new Error('每个环节都要选一个工作流');
|
|
109
|
+
|
|
110
|
+
const rows = Array.isArray(this.byOwner[owner]) ? this.byOwner[owner] : [];
|
|
111
|
+
const existing = rows.find((row) => row.name === name);
|
|
112
|
+
const record = {
|
|
113
|
+
id: existing?.id ?? `c-${randomBytes(4).toString('hex')}`,
|
|
114
|
+
name,
|
|
115
|
+
steps,
|
|
116
|
+
at: Date.now(),
|
|
117
|
+
};
|
|
118
|
+
if (existing === undefined) {
|
|
119
|
+
rows.unshift(record);
|
|
120
|
+
this.byOwner[owner] = rows.slice(0, MAX_CHAINS);
|
|
121
|
+
} else {
|
|
122
|
+
rows[rows.indexOf(existing)] = record;
|
|
123
|
+
this.byOwner[owner] = rows;
|
|
124
|
+
}
|
|
125
|
+
await this.save();
|
|
126
|
+
return record;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async remove(owner, id) {
|
|
130
|
+
const rows = Array.isArray(this.byOwner[owner]) ? this.byOwner[owner] : [];
|
|
131
|
+
const next = rows.filter((row) => row.id !== id);
|
|
132
|
+
if (next.length === rows.length) throw new Error('没有这条工作链');
|
|
133
|
+
this.byOwner[owner] = next;
|
|
134
|
+
await this.save();
|
|
135
|
+
return { ok: true };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Which image a step should be fed from the previous step's outputs.
|
|
141
|
+
*
|
|
142
|
+
* Picks the first image by extension, preferring `output` over `temp`: a preview written
|
|
143
|
+
* to temp is often a lower-quality or intermediate render, and chaining off it produces a
|
|
144
|
+
* worse second step than chaining off the saved output.
|
|
145
|
+
*/
|
|
146
|
+
export function firstImageOutput(outputs) {
|
|
147
|
+
const rows = [];
|
|
148
|
+
for (const output of Object.values(outputs ?? {})) {
|
|
149
|
+
for (const list of Object.values(output ?? {})) {
|
|
150
|
+
if (!Array.isArray(list)) continue;
|
|
151
|
+
for (const item of list) {
|
|
152
|
+
if (item === null || typeof item !== 'object') continue;
|
|
153
|
+
if (typeof item.filename !== 'string' || item.filename === '') continue;
|
|
154
|
+
if (!/\.(png|jpe?g|webp|bmp)$/i.test(item.filename)) continue;
|
|
155
|
+
rows.push({ filename: item.filename, type: String(item.type ?? 'output') });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const saved = rows.find((row) => row.type === 'output');
|
|
160
|
+
return saved ?? rows[0] ?? null;
|
|
161
|
+
}
|
package/lib/comfy.js
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal ComfyUI HTTP client used by the gateway.
|
|
3
|
+
*
|
|
4
|
+
* One instance of this class = one ComfyUI backend (one GPU, one port). Every
|
|
5
|
+
* method is a plain fetch: the gateway is a façade, so it must stay free of
|
|
6
|
+
* dependencies and must never buffer media it can stream.
|
|
7
|
+
*/
|
|
8
|
+
import { Readable } from 'node:stream';
|
|
9
|
+
|
|
10
|
+
/** Strip a trailing slash so `${base()}/queue` never doubles it. */
|
|
11
|
+
function trimBase(url) {
|
|
12
|
+
return String(url).trim().replace(/\/+$/, '');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class BackendError extends Error {
|
|
16
|
+
constructor(message, status) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = 'BackendError';
|
|
19
|
+
this.status = status;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class Backend {
|
|
24
|
+
/** @param {{id:string,label?:string,baseUrl:string,apiKey?:string}} instance */
|
|
25
|
+
constructor(instance) {
|
|
26
|
+
this.instance = instance;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
get id() {
|
|
30
|
+
return this.instance.id;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
base() {
|
|
34
|
+
return trimBase(this.instance.baseUrl);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
headers(extra) {
|
|
38
|
+
const head = { ...(extra ?? {}) };
|
|
39
|
+
const key = this.instance.apiKey ?? '';
|
|
40
|
+
if (key !== '') head.authorization = `Bearer ${key}`;
|
|
41
|
+
return head;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async request(path, options = {}) {
|
|
45
|
+
const url = this.base() + path;
|
|
46
|
+
const timeout = options.timeout ?? 15000;
|
|
47
|
+
const init = {
|
|
48
|
+
method: options.method ?? 'GET',
|
|
49
|
+
headers: this.headers(options.headers),
|
|
50
|
+
signal: options.signal ?? AbortSignal.timeout(timeout),
|
|
51
|
+
};
|
|
52
|
+
if (options.body !== undefined) {
|
|
53
|
+
init.body = options.body;
|
|
54
|
+
if (typeof options.body === 'string' && init.headers['content-type'] === undefined) {
|
|
55
|
+
init.headers['content-type'] = 'application/json';
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
let response;
|
|
59
|
+
try {
|
|
60
|
+
response = await fetch(url, init);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
throw new BackendError(`${this.id} ${path}: ${error instanceof Error ? error.message : String(error)}`, 0);
|
|
63
|
+
}
|
|
64
|
+
if (!response.ok) {
|
|
65
|
+
const text = await response.text().catch(() => '');
|
|
66
|
+
/**
|
|
67
|
+
* Keep the WHOLE rejection body.
|
|
68
|
+
*
|
|
69
|
+
* This used to be `.slice(0, 200)`, and that made every upstream failure
|
|
70
|
+
* undiagnosable: a ComfyUI validation error names the exact node and input that
|
|
71
|
+
* was rejected, and 200 characters cut the string off just before
|
|
72
|
+
* `node_errors` — the one field that says WHAT was wrong. Two real failures
|
|
73
|
+
* ("精修 失败") were investigated blind because of it, and the stored row in
|
|
74
|
+
* runs.json ended up as invalid JSON that could not even be parsed back.
|
|
75
|
+
*
|
|
76
|
+
* The cap now only guards against a pathological megabyte-sized body; normal
|
|
77
|
+
* validation errors are a few hundred bytes and come through intact.
|
|
78
|
+
*/
|
|
79
|
+
throw new BackendError(`${this.id} ${path} -> HTTP ${response.status}${text === '' ? '' : ` ${text.slice(0, 8000)}`}`, response.status);
|
|
80
|
+
}
|
|
81
|
+
return response;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async json(path, options) {
|
|
85
|
+
const response = await this.request(path, options);
|
|
86
|
+
return response.json();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
systemStats() {
|
|
90
|
+
return this.json('/system_stats', { timeout: 4000 });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
objectInfo() {
|
|
94
|
+
return this.json('/object_info', { timeout: 20000 });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
queue() {
|
|
98
|
+
return this.json('/queue', { timeout: 6000 });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** `/history?max_items=` — the map of prompt id to completed entry. */
|
|
102
|
+
history(maxItems = 64) {
|
|
103
|
+
return this.json(`/history?max_items=${encodeURIComponent(String(maxItems))}`, { timeout: 10000 });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** `{}` while the prompt is still queued or running. */
|
|
107
|
+
historyOf(promptId) {
|
|
108
|
+
return this.json(`/history/${encodeURIComponent(promptId)}`, { timeout: 10000 });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
prompt(body) {
|
|
112
|
+
return this.json('/prompt', {
|
|
113
|
+
method: 'POST',
|
|
114
|
+
body: JSON.stringify(body),
|
|
115
|
+
timeout: 30000,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
interrupt(promptId) {
|
|
120
|
+
const body = promptId === undefined ? {} : { prompt_id: promptId };
|
|
121
|
+
return this.request('/interrupt', {
|
|
122
|
+
method: 'POST',
|
|
123
|
+
body: JSON.stringify(body),
|
|
124
|
+
timeout: 8000,
|
|
125
|
+
}).then(() => undefined);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
free(body) {
|
|
129
|
+
return this.request('/free', {
|
|
130
|
+
method: 'POST',
|
|
131
|
+
body: JSON.stringify(body ?? { unload_models: true, free_memory: true }),
|
|
132
|
+
timeout: 15000,
|
|
133
|
+
}).then(() => undefined);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async deleteQueueItems(promptIds) {
|
|
137
|
+
if (promptIds.length === 0) return;
|
|
138
|
+
await this.request('/queue', {
|
|
139
|
+
method: 'POST',
|
|
140
|
+
body: JSON.stringify({ delete: promptIds }),
|
|
141
|
+
timeout: 8000,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async clearQueue() {
|
|
146
|
+
await this.request('/queue', { method: 'POST', body: JSON.stringify({ clear: true }), timeout: 8000 });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Raw stream for `/view`, forwarding Range so media players can seek. */
|
|
150
|
+
async openView(query, method = 'GET', range) {
|
|
151
|
+
const headers = this.headers();
|
|
152
|
+
if (range !== undefined && range !== null && range !== '') headers.range = range;
|
|
153
|
+
const response = await fetch(`${this.base()}/view?${query}`, {
|
|
154
|
+
method,
|
|
155
|
+
headers,
|
|
156
|
+
signal: AbortSignal.timeout(60000),
|
|
157
|
+
});
|
|
158
|
+
if (!response.ok) {
|
|
159
|
+
await response.body?.cancel().catch(() => {});
|
|
160
|
+
throw new BackendError(`${this.id} /view -> HTTP ${response.status}`, response.status);
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
status: response.status,
|
|
164
|
+
headers: response.headers,
|
|
165
|
+
body: response.body === null ? null : Readable.fromWeb(response.body),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** One backend, one call, with the health result normalised for the panel. */
|
|
171
|
+
export async function probe(instance, timeout = 4000) {
|
|
172
|
+
const backend = new Backend(instance);
|
|
173
|
+
const started = Date.now();
|
|
174
|
+
try {
|
|
175
|
+
const stats = await backend.systemStats();
|
|
176
|
+
const devices = Array.isArray(stats?.devices) ? stats.devices : [];
|
|
177
|
+
return {
|
|
178
|
+
id: instance.id,
|
|
179
|
+
ok: true,
|
|
180
|
+
latencyMs: Date.now() - started,
|
|
181
|
+
version: stats?.system?.comfyui_version ?? null,
|
|
182
|
+
python: stats?.system?.python_version ?? null,
|
|
183
|
+
argv: Array.isArray(stats?.system?.argv) ? stats.system.argv : [],
|
|
184
|
+
devices: devices.map((device) => ({
|
|
185
|
+
name: device.name ?? null,
|
|
186
|
+
type: device.type ?? null,
|
|
187
|
+
vramTotal: device.vram_total ?? null,
|
|
188
|
+
vramFree: device.vram_free ?? null,
|
|
189
|
+
})),
|
|
190
|
+
error: null,
|
|
191
|
+
at: Date.now(),
|
|
192
|
+
};
|
|
193
|
+
} catch (error) {
|
|
194
|
+
return {
|
|
195
|
+
id: instance.id,
|
|
196
|
+
ok: false,
|
|
197
|
+
latencyMs: Date.now() - started,
|
|
198
|
+
version: null,
|
|
199
|
+
devices: [],
|
|
200
|
+
error: error instanceof Error ? error.message : String(error),
|
|
201
|
+
at: Date.now(),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
}
|