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/nodeapi.js
ADDED
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 服务节点对外暴露的「用户节点」接口。
|
|
3
|
+
*
|
|
4
|
+
* 挂在 API 接入端口上(默认 8287),和 ComfyUI 自己的协议端点共用同一个监听:
|
|
5
|
+
* 用户节点要的模板、队列、模型,本来就是这台机器上任务池与工作流库的视图,
|
|
6
|
+
* 另开一个端口只会让「端口设错了」变成第二种故障。
|
|
7
|
+
*
|
|
8
|
+
* 路径一律以 `/node/` 开头。理由:ComfyUI 的协议占掉了 `/prompt`、`/queue`、`/history`、
|
|
9
|
+
* `/view`、`/object_info`,而 `/node/templates` 这种名字不可能和任何自定义节点撞车。
|
|
10
|
+
*
|
|
11
|
+
* 鉴权:`Authorization: Bearer <apikey>` / `X-API-Key` / `?apikey=`。服务节点没开校验时
|
|
12
|
+
* 放行但明确标注 `checked: false`——「没校验」和「校验通过」在审计里必须能分开。
|
|
13
|
+
*/
|
|
14
|
+
import { readFile } from 'node:fs/promises';
|
|
15
|
+
import { basename, join } from 'node:path';
|
|
16
|
+
import { errorMessage, pipeGuarded, readJson, sendJson } from './http.js';
|
|
17
|
+
import { ApiKeyStore } from './apikeys.js';
|
|
18
|
+
import { classifyWorkflow, visibleTo } from './tiers.js';
|
|
19
|
+
import { modelInventory } from './models.js';
|
|
20
|
+
|
|
21
|
+
const MEDIA_MAX_BYTES = 512 * 1024 * 1024;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 免凭据的路径。一处定义,两处引用(请求前的 guard 与路由内部的 authorize)。
|
|
25
|
+
*
|
|
26
|
+
* 只放两个:一个只会回 ok 的健康探针,一个只会报自己状态的 ping。
|
|
27
|
+
* 任何一个都要凭据的话,监控、反代健康检查、面板自己的连通性测试就都得先配一把 key,
|
|
28
|
+
* 而「配好 key 才能确认服务活着」是一个死循环。
|
|
29
|
+
*/
|
|
30
|
+
const OPEN_PATHS = new Set(['/health', '/node/ping']);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {{ settings:()=>any, keys:ApiKeyStore, pool:object, instances:object,
|
|
34
|
+
* library:object, runs:object, assets:object, blender:object,
|
|
35
|
+
* gateway:object, log:Function }} deps
|
|
36
|
+
*/
|
|
37
|
+
export function createNodeRoutes(deps) {
|
|
38
|
+
/** 一次请求的凭据,失败就返回 401 并结束响应。null = 已结束。 */
|
|
39
|
+
const authorize = (request, response, url) => {
|
|
40
|
+
/**
|
|
41
|
+
* 开放路径先放行。
|
|
42
|
+
*
|
|
43
|
+
* gateway 在请求进门时已经判过一次(createApiGuard),但这里是第二个入口:
|
|
44
|
+
* 路由表也可能被人单独挂(测试、别的宿主、将来的反向代理中间件)。
|
|
45
|
+
* 两处判断必须用同一份白名单,否则就是「gateway 放行、路由再拦一次」——
|
|
46
|
+
* 表现为 /node/ping 莫名其妙回 401,而日志里一个字都没有。
|
|
47
|
+
*/
|
|
48
|
+
if (OPEN_PATHS.has(url?.pathname ?? '')) {
|
|
49
|
+
return { ok: true, checked: false, key: null, tier: 'nsfw', name: null, open: true };
|
|
50
|
+
}
|
|
51
|
+
const raw = ApiKeyStore.extract(request.headers, url);
|
|
52
|
+
const result = deps.keys.verify(raw);
|
|
53
|
+
if (result.ok === true) return result;
|
|
54
|
+
sendJson(response, 401, {
|
|
55
|
+
ok: false,
|
|
56
|
+
error: result.error ?? '缺少 apikey',
|
|
57
|
+
authRequired: true,
|
|
58
|
+
});
|
|
59
|
+
return null;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** 服务节点自己的机器摘要:用户节点连上来的第一件事就是看这个。 */
|
|
63
|
+
const ping = () => {
|
|
64
|
+
const snapshot = deps.pool.snapshot();
|
|
65
|
+
const inventory = modelInventory(deps.settings(), { withSize: false });
|
|
66
|
+
return {
|
|
67
|
+
ok: true,
|
|
68
|
+
plugin: 'dsh-multimodal-node',
|
|
69
|
+
mode: deps.settings().mode,
|
|
70
|
+
gateway: { port: deps.gateway.port, listening: deps.gateway.listening },
|
|
71
|
+
queue: {
|
|
72
|
+
queued: (snapshot.tasks ?? []).filter((task) => task.state === 'queued').length,
|
|
73
|
+
running: (snapshot.tasks ?? []).filter((task) => task.state === 'running').length,
|
|
74
|
+
dynamic: snapshot.dynamic === true,
|
|
75
|
+
estimateMs: snapshot.estimate?.makespanMs ?? 0,
|
|
76
|
+
},
|
|
77
|
+
instances: (snapshot.instances ?? []).map((entry) => ({
|
|
78
|
+
id: entry.id,
|
|
79
|
+
label: entry.label,
|
|
80
|
+
healthy: entry.healthy,
|
|
81
|
+
enabled: entry.enabled,
|
|
82
|
+
maxConcurrent: entry.maxConcurrent,
|
|
83
|
+
devices: entry.devices,
|
|
84
|
+
})),
|
|
85
|
+
models: { total: inventory.total ?? 0, folders: (inventory.folders ?? []).length, error: inventory.error ?? null },
|
|
86
|
+
blender: (() => {
|
|
87
|
+
const payload = deps.blender?.statusPayload?.() ?? { instances: [] };
|
|
88
|
+
return {
|
|
89
|
+
enabled: payload.enabled === true,
|
|
90
|
+
instances: (payload.instances ?? []).map((entry) => ({
|
|
91
|
+
id: entry.id,
|
|
92
|
+
label: entry.label,
|
|
93
|
+
kind: entry.kind,
|
|
94
|
+
healthy: entry.healthy,
|
|
95
|
+
running: entry.running,
|
|
96
|
+
})),
|
|
97
|
+
};
|
|
98
|
+
})(),
|
|
99
|
+
now: new Date().toISOString(),
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/** 模板列表按 key 的 tier 过滤:SFW 的 key 连成人模板的名字都不给看。 */
|
|
104
|
+
const templates = (tier) => {
|
|
105
|
+
const library = deps.library;
|
|
106
|
+
if (library === null || library === undefined) return { ok: false, error: '工作流库还没就绪', templates: [] };
|
|
107
|
+
const rows = library.list(tier);
|
|
108
|
+
return {
|
|
109
|
+
ok: true,
|
|
110
|
+
tier,
|
|
111
|
+
templates: rows.map((row) => {
|
|
112
|
+
const full = library.get(row.id);
|
|
113
|
+
const described = full === undefined ? null : library.describe(full);
|
|
114
|
+
return {
|
|
115
|
+
id: row.id,
|
|
116
|
+
name: row.name,
|
|
117
|
+
category: row.category,
|
|
118
|
+
tier: row.tier,
|
|
119
|
+
nodeCount: row.nodeCount,
|
|
120
|
+
updatedAt: row.updatedAt,
|
|
121
|
+
needsText: described?.needsText ?? null,
|
|
122
|
+
promptBoxes: (described?.promptBoxes ?? []).map((box) => ({
|
|
123
|
+
nodeId: box.nodeId,
|
|
124
|
+
input: box.input,
|
|
125
|
+
role: box.role,
|
|
126
|
+
head: box.head ?? '',
|
|
127
|
+
layers: box.layers ?? [],
|
|
128
|
+
})),
|
|
129
|
+
images: (described?.images ?? []).length,
|
|
130
|
+
params: (described?.paramPicks ?? []).map((entry) => entry.name ?? entry),
|
|
131
|
+
};
|
|
132
|
+
}),
|
|
133
|
+
};
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const queueView = () => {
|
|
137
|
+
const snapshot = deps.pool.snapshot();
|
|
138
|
+
return {
|
|
139
|
+
ok: true,
|
|
140
|
+
dynamic: snapshot.dynamic === true,
|
|
141
|
+
counters: snapshot.counters ?? {},
|
|
142
|
+
estimateMs: snapshot.estimate?.makespanMs ?? 0,
|
|
143
|
+
tasks: (snapshot.tasks ?? []).map((task) => ({
|
|
144
|
+
id: task.id,
|
|
145
|
+
state: task.state,
|
|
146
|
+
backendId: task.backendId ?? null,
|
|
147
|
+
cost: task.cost,
|
|
148
|
+
priority: task.priority,
|
|
149
|
+
enqueuedAt: task.enqueuedAt,
|
|
150
|
+
startedAt: task.startedAt ?? null,
|
|
151
|
+
finishedAt: task.finishedAt ?? null,
|
|
152
|
+
error: task.error ?? null,
|
|
153
|
+
})),
|
|
154
|
+
};
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* 提交一次任务。
|
|
159
|
+
*
|
|
160
|
+
* 分级在这里兜底:`/prompt` 本身不审查(程序发的就是要的),但**带 key 的调用**必须
|
|
161
|
+
* 按 key 的 tier 判一次——否则给某人发一把 SFW 的 key 等于没发。
|
|
162
|
+
*/
|
|
163
|
+
const submit = (request, response, auth) => {
|
|
164
|
+
void (async () => {
|
|
165
|
+
let body;
|
|
166
|
+
try {
|
|
167
|
+
body = await readJson(request);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
sendJson(response, 400, { ok: false, error: errorMessage(error) });
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (auth.checked === true && auth.tier !== 'nsfw') {
|
|
173
|
+
const classification = classifyWorkflow({ api: body?.prompt ?? {}, name: body?.extra_data?.workflowName ?? '' });
|
|
174
|
+
if (!visibleTo(auth.tier, classification.tier)) {
|
|
175
|
+
deps.log('warn', `apikey ${auth.name} 提交了成人工作流,已按 tier=sfw 拒绝`);
|
|
176
|
+
sendJson(response, 403, {
|
|
177
|
+
ok: false,
|
|
178
|
+
error: '这个 apikey 的分级是 SFW,不能提交成人内容的工作流',
|
|
179
|
+
tier: auth.tier,
|
|
180
|
+
reason: classification.reason,
|
|
181
|
+
});
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
const reply = deps.pool.enqueue(body);
|
|
187
|
+
sendJson(response, 200, reply);
|
|
188
|
+
} catch (error) {
|
|
189
|
+
sendJson(response, 400, { ok: false, error: errorMessage(error), node_errors: {} });
|
|
190
|
+
}
|
|
191
|
+
})();
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const poolAction = (request, response, auth) => {
|
|
195
|
+
void (async () => {
|
|
196
|
+
const body = await readJson(request).catch(() => ({}));
|
|
197
|
+
const action = String(body?.action ?? '');
|
|
198
|
+
try {
|
|
199
|
+
if (action === 'dynamic') {
|
|
200
|
+
deps.pool.toggleDynamic(body.enabled === true);
|
|
201
|
+
sendJson(response, 200, { ok: true, dynamic: deps.settings().dynamic });
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (action === 'cancel') {
|
|
205
|
+
sendJson(response, 200, { ...deps.pool.cancel(String(body.id ?? '')) });
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (action === 'prioritize') {
|
|
209
|
+
sendJson(response, 200, { ...deps.pool.prioritize(String(body.id ?? ''), body.delta ?? 1) });
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (action === 'free') {
|
|
213
|
+
// 释放显存是运维动作:SFW 的 key 不许把人家的模型从卡上踢下去。
|
|
214
|
+
if (auth.checked === true && auth.tier !== 'nsfw') {
|
|
215
|
+
sendJson(response, 403, { ok: false, error: '这个 apikey 的分级是 SFW,不能释放显存' });
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
await Promise.all(
|
|
219
|
+
deps.instances.enabled().map(async (runtime) => {
|
|
220
|
+
try {
|
|
221
|
+
await deps.pool.backend(runtime).free({ unload_models: true, free_memory: true });
|
|
222
|
+
} catch {
|
|
223
|
+
// 睡着的实例没有可释放的东西
|
|
224
|
+
}
|
|
225
|
+
}),
|
|
226
|
+
);
|
|
227
|
+
sendJson(response, 200, { ok: true });
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (action === 'clearQueue') {
|
|
231
|
+
for (const task of deps.pool.ordered()) deps.pool.cancel(task.id);
|
|
232
|
+
sendJson(response, 200, { ok: true });
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
sendJson(response, 400, { ok: false, error: `未知动作 ${action}` });
|
|
236
|
+
} catch (error) {
|
|
237
|
+
sendJson(response, 400, { ok: false, error: errorMessage(error) });
|
|
238
|
+
}
|
|
239
|
+
})();
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
/** 历史:不带 id 给最近 N 条,带 id 给这一条。 */
|
|
243
|
+
const history = (url, path) => {
|
|
244
|
+
const suffix = path.slice('/node/history'.length).replace(/^\//, '');
|
|
245
|
+
const snapshot = deps.pool.snapshot();
|
|
246
|
+
if (suffix !== '') {
|
|
247
|
+
const id = decodeURIComponent(suffix);
|
|
248
|
+
const task = (snapshot.tasks ?? []).find((entry) => entry.id === id);
|
|
249
|
+
if (task === undefined) return { ok: false, error: `没有任务 ${id}` };
|
|
250
|
+
return { ok: true, task };
|
|
251
|
+
}
|
|
252
|
+
const limit = Math.min(200, Math.max(1, Number(url.searchParams.get('limit') ?? '40') || 40));
|
|
253
|
+
return {
|
|
254
|
+
ok: true,
|
|
255
|
+
finished: (snapshot.tasks ?? []).filter((task) => task.state === 'done' || task.state === 'failed' || task.state === 'cancelled').slice(0, limit),
|
|
256
|
+
};
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* 产物下载。
|
|
261
|
+
*
|
|
262
|
+
* 只接受 `prompt_id` + 文件名,由服务端自己去实例上取——用户节点不该知道、也不该能访问
|
|
263
|
+
* 8188/8189。文件名做路径段校验,防止 `../../`。
|
|
264
|
+
*/
|
|
265
|
+
const media = (url, request, response, auth) => {
|
|
266
|
+
void (async () => {
|
|
267
|
+
if (auth.checked === true && auth.tier !== 'nsfw') {
|
|
268
|
+
// 产物本身归提交者,但一个 SFW 的 key 不该能把成人图拉走。
|
|
269
|
+
// 判定用文件名 + 归属任务的工作流名,取得到就判,取不到就只按文件名。
|
|
270
|
+
const name = url.searchParams.get('file') ?? '';
|
|
271
|
+
const hit = classifyWorkflow({ name });
|
|
272
|
+
if (!visibleTo(auth.tier, hit.tier)) {
|
|
273
|
+
sendJson(response, 403, { ok: false, error: '这个 apikey 的分级是 SFW,不能取回这个产物' });
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
const promptId = url.searchParams.get('prompt_id') ?? '';
|
|
278
|
+
const filename = url.searchParams.get('file') ?? url.searchParams.get('filename') ?? '';
|
|
279
|
+
if (filename === '') {
|
|
280
|
+
sendJson(response, 400, { ok: false, error: '缺少 file 参数' });
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (basename(filename) !== filename) {
|
|
284
|
+
sendJson(response, 400, { ok: false, error: 'file 只能是文件名,不能带路径' });
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
const subfolder = url.searchParams.get('subfolder') ?? '';
|
|
288
|
+
const type = url.searchParams.get('type') ?? 'output';
|
|
289
|
+
const query = new URLSearchParams({ filename, subfolder, type }).toString();
|
|
290
|
+
const ordered = [];
|
|
291
|
+
if (promptId !== '') {
|
|
292
|
+
const task = deps.pool.tasks.get(promptId);
|
|
293
|
+
const runtime = task?.backendId ? deps.instances.get(task.backendId) : undefined;
|
|
294
|
+
if (runtime !== undefined) ordered.push(runtime);
|
|
295
|
+
}
|
|
296
|
+
for (const runtime of deps.instances.enabled()) {
|
|
297
|
+
if (!ordered.includes(runtime)) ordered.push(runtime);
|
|
298
|
+
}
|
|
299
|
+
let lastError = '没有实例持有这个文件';
|
|
300
|
+
for (const runtime of ordered) {
|
|
301
|
+
try {
|
|
302
|
+
// 先探大小,超过上限就不往下拉:一个 4 GB 的 mp4 拖进用户节点的内存不会报错,
|
|
303
|
+
// 只会让进程被系统杀掉。
|
|
304
|
+
const head = await deps.pool.backend(runtime).openView(query, 'HEAD');
|
|
305
|
+
const length = Number(head.headers.get('content-length') ?? '0');
|
|
306
|
+
if (Number.isFinite(length) && length > MEDIA_MAX_BYTES) {
|
|
307
|
+
sendJson(response, 413, { ok: false, error: `产物 ${Math.round(length / 1048576)} MB 超过单次上限 ${Math.round(MEDIA_MAX_BYTES / 1048576)} MB` });
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
const stream = await deps.pool.backend(runtime).openView(query, 'GET', request.headers.range);
|
|
311
|
+
const headers = {
|
|
312
|
+
'content-type': stream.headers.get('content-type') ?? 'application/octet-stream',
|
|
313
|
+
'cache-control': 'private, max-age=600',
|
|
314
|
+
};
|
|
315
|
+
for (const key of ['content-length', 'content-range', 'accept-ranges']) {
|
|
316
|
+
const value = stream.headers.get(key);
|
|
317
|
+
if (value !== null) headers[key] = value;
|
|
318
|
+
}
|
|
319
|
+
response.writeHead(stream.status, headers);
|
|
320
|
+
if (stream.body === null) {
|
|
321
|
+
response.end();
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
pipeGuarded(stream.body, response);
|
|
325
|
+
return;
|
|
326
|
+
} catch (error) {
|
|
327
|
+
lastError = errorMessage(error);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
sendJson(response, 404, { ok: false, error: lastError });
|
|
331
|
+
})();
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
/** 模型清单。SFW 的 key 只拿到目录名,不拿文件名——和 03 号控制台同一条规则。 */
|
|
335
|
+
const models = (auth) => {
|
|
336
|
+
const inventory = modelInventory(deps.settings(), { withSize: true });
|
|
337
|
+
if (auth.checked === false || auth.tier === 'nsfw') return { ok: inventory.ok !== false, ...inventory };
|
|
338
|
+
return {
|
|
339
|
+
ok: inventory.ok !== false,
|
|
340
|
+
...inventory,
|
|
341
|
+
folders: (inventory.folders ?? []).map((folder) => ({ ...folder, items: [] })),
|
|
342
|
+
total: 0,
|
|
343
|
+
locked: true,
|
|
344
|
+
error: '这个 apikey 的分级是 SFW,不列出模型文件名',
|
|
345
|
+
};
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Blender:查列表、提交渲染、取消、看日志。
|
|
350
|
+
*
|
|
351
|
+
* 用户节点在侧边栏的 Blender 板块里点「渲染」,走的就是这里——它不是服务节点独占的,
|
|
352
|
+
* 否则「用户节点用服务节点的算力」这件事在 Blender 上就断了。
|
|
353
|
+
*/
|
|
354
|
+
const blenderRoutes = (request, response, url, path, method, auth) => {
|
|
355
|
+
if (deps.blender === undefined) return undefined;
|
|
356
|
+
if (path === '/node/blender' && method === 'GET') {
|
|
357
|
+
sendJson(response, 200, { ok: true, ...deps.blender.statusPayload() });
|
|
358
|
+
return true;
|
|
359
|
+
}
|
|
360
|
+
if (path === '/node/blender/render' && method === 'POST') {
|
|
361
|
+
void (async () => {
|
|
362
|
+
const body = await readJson(request).catch(() => ({}));
|
|
363
|
+
if (auth.checked === true && auth.tier !== 'nsfw' && body?.scene !== undefined) {
|
|
364
|
+
const hit = classifyWorkflow({ name: String(body.scene ?? '') });
|
|
365
|
+
if (!visibleTo(auth.tier, hit.tier)) {
|
|
366
|
+
sendJson(response, 403, { ok: false, error: '这个 apikey 的分级是 SFW,不能提交这个 Blender 工程' });
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
try {
|
|
371
|
+
const instanceId = String(body.instanceId ?? '');
|
|
372
|
+
if (instanceId === '') {
|
|
373
|
+
sendJson(response, 400, { ok: false, error: '缺少 instanceId' });
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
const result = await deps.blender.submit(instanceId, body);
|
|
377
|
+
sendJson(response, 200, result);
|
|
378
|
+
} catch (error) {
|
|
379
|
+
sendJson(response, 400, { ok: false, error: errorMessage(error) });
|
|
380
|
+
}
|
|
381
|
+
})();
|
|
382
|
+
return true;
|
|
383
|
+
}
|
|
384
|
+
if (path === '/node/blender/cancel' && method === 'POST') {
|
|
385
|
+
void (async () => {
|
|
386
|
+
const body = await readJson(request).catch(() => ({}));
|
|
387
|
+
try {
|
|
388
|
+
const result = await deps.blender.cancel(String(body.instanceId ?? ''), String(body.jobId ?? ''));
|
|
389
|
+
sendJson(response, 200, result);
|
|
390
|
+
} catch (error) {
|
|
391
|
+
sendJson(response, 400, { ok: false, error: errorMessage(error) });
|
|
392
|
+
}
|
|
393
|
+
})();
|
|
394
|
+
return true;
|
|
395
|
+
}
|
|
396
|
+
if (path === '/node/blender/blends' && method === 'GET') {
|
|
397
|
+
const dir = url.searchParams.get('dir') ?? '';
|
|
398
|
+
sendJson(response, 200, { ok: true, files: deps.blender.browseBlends(dir) });
|
|
399
|
+
return true;
|
|
400
|
+
}
|
|
401
|
+
return undefined;
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* 唯一的入口。返回 true = 已经处理并写完响应,gateway 不要再往下走。
|
|
406
|
+
*
|
|
407
|
+
* 挂一个 bindLibrary:工作流库、运行索引、资产库在 index.js 里比本函数晚建好,
|
|
408
|
+
* 而闭包读的是 deps 上的字段,所以后补即可——不需要重新构造一遍路由表。
|
|
409
|
+
*/
|
|
410
|
+
const handle = function handle(request, response, url, path, method) {
|
|
411
|
+
if (!path.startsWith('/node/')) return false;
|
|
412
|
+
const auth = authorize(request, response, url);
|
|
413
|
+
if (auth === null) return true;
|
|
414
|
+
|
|
415
|
+
if (path === '/node/ping') {
|
|
416
|
+
sendJson(response, 200, ping());
|
|
417
|
+
return true;
|
|
418
|
+
}
|
|
419
|
+
if (path === '/node/templates') {
|
|
420
|
+
sendJson(response, 200, templates(auth.checked === true ? auth.tier : 'nsfw'));
|
|
421
|
+
return true;
|
|
422
|
+
}
|
|
423
|
+
if (path === '/node/models') {
|
|
424
|
+
sendJson(response, 200, models(auth));
|
|
425
|
+
return true;
|
|
426
|
+
}
|
|
427
|
+
if (path === '/node/queue') {
|
|
428
|
+
sendJson(response, 200, queueView());
|
|
429
|
+
return true;
|
|
430
|
+
}
|
|
431
|
+
if (path === '/node/history' || path.startsWith('/node/history/')) {
|
|
432
|
+
sendJson(response, 200, history(url, path));
|
|
433
|
+
return true;
|
|
434
|
+
}
|
|
435
|
+
if (path === '/node/pool' && method === 'POST') {
|
|
436
|
+
poolAction(request, response, auth);
|
|
437
|
+
return true;
|
|
438
|
+
}
|
|
439
|
+
if (path === '/node/prompt' && method === 'POST') {
|
|
440
|
+
submit(request, response, auth);
|
|
441
|
+
return true;
|
|
442
|
+
}
|
|
443
|
+
if (path === '/node/media') {
|
|
444
|
+
media(url, request, response, auth);
|
|
445
|
+
return true;
|
|
446
|
+
}
|
|
447
|
+
if (blenderRoutes(request, response, url, path, method, auth) === true) return true;
|
|
448
|
+
|
|
449
|
+
sendJson(response, 404, { ok: false, error: `没有这个用户节点接口:${path}` });
|
|
450
|
+
return true;
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
handle.bindLibrary = (libs) => {
|
|
454
|
+
if (libs.library !== undefined) deps.library = libs.library;
|
|
455
|
+
if (libs.runs !== undefined) deps.runs = libs.runs;
|
|
456
|
+
if (libs.assets !== undefined) deps.assets = libs.assets;
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
return handle;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* API 接入端口的统一闸门。
|
|
464
|
+
*
|
|
465
|
+
* 返回 { ok, checked, tier, name, error }。gateway 在每个请求进来时调一次,
|
|
466
|
+
* 把结果挂到 request.dshAuth 上供后续分支使用。
|
|
467
|
+
*
|
|
468
|
+
* 白名单是故意的:一个只会回 "ok" 的健康探针不该要凭据,否则任何一次端口探测
|
|
469
|
+
* (监控、反代健康检查、面板自己的连通性测试)都得先配一把 key。
|
|
470
|
+
*/
|
|
471
|
+
export function createApiGuard(keys) {
|
|
472
|
+
return (request, url) => {
|
|
473
|
+
const path = url?.pathname ?? '/';
|
|
474
|
+
if (OPEN_PATHS.has(path)) return { ok: true, checked: false, tier: 'nsfw', name: null, open: true };
|
|
475
|
+
// CORS 预检不带凭据,浏览器不发 Authorization。
|
|
476
|
+
if (request.method === 'OPTIONS') return { ok: true, checked: false, tier: 'nsfw', name: null, preflight: true };
|
|
477
|
+
const raw = ApiKeyStore.extract(request.headers, url);
|
|
478
|
+
const result = keys.verify(raw);
|
|
479
|
+
if (result.ok === true) return result;
|
|
480
|
+
return { ok: false, checked: true, error: result.error ?? '缺少 apikey' };
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** 从库里读一个文件的内容(模板可能要带上完整 JSON 给用户节点自己解析)。 */
|
|
485
|
+
export async function readLibraryFile(library, id) {
|
|
486
|
+
const record = library.get(id);
|
|
487
|
+
if (record === undefined) throw new Error(`没有工作流 ${id}`);
|
|
488
|
+
return record;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export { MEDIA_MAX_BYTES, OPEN_PATHS };
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 用户节点 → 服务节点的客户端。
|
|
3
|
+
*
|
|
4
|
+
* 用户在侧边栏看到的三样东西全部来自服务节点:工作流模板、当前队列、模型列表。
|
|
5
|
+
* 这三件事都只有一个入口——服务节点的 API 端口,带 apikey。本机不发任何别的东西。
|
|
6
|
+
*
|
|
7
|
+
* 三条设计约束:
|
|
8
|
+
*
|
|
9
|
+
* 1. **不代理媒体**。产物落到本机目录是靠「服务端给地址,本机去拉」,不是把整个
|
|
10
|
+
* ComfyUI 的传输层搬过来。拉取有大小上限,失败就报清楚是哪一步。
|
|
11
|
+
* 2. **错误要能定位**。连不上 / 401 / 地址没填 是三种完全不同的事,面板上必须分开说,
|
|
12
|
+
* 否则用户唯一能做的就是重启。
|
|
13
|
+
* 3. **超时短**。侧边栏是被人反复点的地方,5 秒没答案就该说「服务节点没响应」,
|
|
14
|
+
* 而不是转圈到天荒地老。
|
|
15
|
+
*/
|
|
16
|
+
import { errorMessage } from './http.js';
|
|
17
|
+
|
|
18
|
+
export class NodeClient {
|
|
19
|
+
/** @param {() => any} getSettings */
|
|
20
|
+
constructor(getSettings) {
|
|
21
|
+
this.getSettings = getSettings;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
get block() {
|
|
25
|
+
return this.getSettings()?.node ?? {};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
get configured() {
|
|
29
|
+
const block = this.block;
|
|
30
|
+
return typeof block.publicUrl === 'string' && block.publicUrl !== '';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
base() {
|
|
34
|
+
const url = String(this.block.publicUrl ?? '').trim();
|
|
35
|
+
if (url === '') throw new Error('还没有填服务节点的公网地址');
|
|
36
|
+
return url.replace(/\/+$/, '');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
timeout() {
|
|
40
|
+
const value = Number(this.block.timeoutMs);
|
|
41
|
+
return Number.isFinite(value) ? value : 15000;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
headers(extra = {}) {
|
|
45
|
+
const headers = { ...extra };
|
|
46
|
+
const key = String(this.block.apiKey ?? '').trim();
|
|
47
|
+
if (key !== '') headers.authorization = `Bearer ${key}`;
|
|
48
|
+
const label = String(this.block.label ?? '').trim();
|
|
49
|
+
if (label !== '') headers['x-node-label'] = label;
|
|
50
|
+
return headers;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async call(path, options = {}) {
|
|
54
|
+
if (!this.configured) throw new Error('还没有填服务节点的公网地址');
|
|
55
|
+
const target = `${this.base()}${path}`;
|
|
56
|
+
const method = options.method ?? 'GET';
|
|
57
|
+
let reply;
|
|
58
|
+
try {
|
|
59
|
+
reply = await fetch(target, {
|
|
60
|
+
method,
|
|
61
|
+
headers: this.headers(options.headers),
|
|
62
|
+
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
|
63
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? this.timeout()),
|
|
64
|
+
});
|
|
65
|
+
} catch (error) {
|
|
66
|
+
// fetch 把 DNS 失败、拒绝连接、超时都报成 "fetch failed",这一点必须拆开说,
|
|
67
|
+
// 否则面板上只会显示一句没用的英文。
|
|
68
|
+
const reason = errorMessage(error);
|
|
69
|
+
if (/abort|timeout/i.test(reason)) throw new Error(`服务节点 ${target} 没有响应(超时)`);
|
|
70
|
+
throw new Error(`连不上服务节点 ${target}:${reason}`);
|
|
71
|
+
}
|
|
72
|
+
const text = await reply.text();
|
|
73
|
+
if (reply.status === 401 || reply.status === 403) {
|
|
74
|
+
throw new Error(`服务节点拒绝了这个 apikey(HTTP ${reply.status}):检查设置页里填的 key`);
|
|
75
|
+
}
|
|
76
|
+
let parsed;
|
|
77
|
+
try {
|
|
78
|
+
parsed = text === '' ? {} : JSON.parse(text);
|
|
79
|
+
} catch {
|
|
80
|
+
throw new Error(`服务节点返回的不是 JSON(HTTP ${reply.status}):${text.slice(0, 160)}`);
|
|
81
|
+
}
|
|
82
|
+
if (!reply.ok) throw new Error(parsed?.error ?? `服务节点返回 HTTP ${reply.status}`);
|
|
83
|
+
return parsed;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 侧边栏顶部的连通性卡片:一次请求拿到端口、模式、队列长度、模型数。 */
|
|
87
|
+
async probe() {
|
|
88
|
+
if (!this.configured) {
|
|
89
|
+
return { ok: false, configured: false, error: '还没有填服务节点的公网地址' };
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
const data = await this.call('/node/ping');
|
|
93
|
+
return { ok: true, configured: true, ...data };
|
|
94
|
+
} catch (error) {
|
|
95
|
+
return { ok: false, configured: true, error: errorMessage(error) };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async templates() {
|
|
100
|
+
const data = await this.call('/node/templates');
|
|
101
|
+
return Array.isArray(data.templates) ? data.templates : [];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async queue() {
|
|
105
|
+
const data = await this.call('/node/queue');
|
|
106
|
+
return data;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async models() {
|
|
110
|
+
const data = await this.call('/node/models');
|
|
111
|
+
return data;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async submit(payload) {
|
|
115
|
+
return this.call('/prompt', { method: 'POST', body: payload, timeoutMs: 60000 });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async history(promptId) {
|
|
119
|
+
const suffix = promptId === undefined ? '' : `/${encodeURIComponent(promptId)}`;
|
|
120
|
+
return this.call(`/node/history${suffix}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 取消 / 插队 / 释放显存:动作名与被控对象一起递过去。 */
|
|
124
|
+
async pool(action, body = {}) {
|
|
125
|
+
return this.call('/node/pool', { method: 'POST', body: { action, ...body } });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 把产物拉到本机:只接受服务端明确列出来的文件名。 */
|
|
129
|
+
async fetchOutput(promptId, filename, saveTo) {
|
|
130
|
+
if (!this.configured) throw new Error('还没有填服务节点的公网地址');
|
|
131
|
+
const query = new URLSearchParams({ prompt_id: promptId, file: filename }).toString();
|
|
132
|
+
const target = `${this.base()}/node/media?${query}`;
|
|
133
|
+
const reply = await fetch(target, { headers: this.headers(), signal: AbortSignal.timeout(120000) });
|
|
134
|
+
if (!reply.ok) throw new Error(`拉取产物失败 HTTP ${reply.status}`);
|
|
135
|
+
const { writeFile, mkdir } = await import('node:fs/promises');
|
|
136
|
+
const { dirname, join } = await import('node:path');
|
|
137
|
+
const file = join(saveTo, filename.replace(/[\\/]/g, '_'));
|
|
138
|
+
await mkdir(dirname(file), { recursive: true });
|
|
139
|
+
const buffer = Buffer.from(await reply.arrayBuffer());
|
|
140
|
+
await writeFile(file, buffer);
|
|
141
|
+
return { file, bytes: buffer.length };
|
|
142
|
+
}
|
|
143
|
+
}
|