dsh-multimodal-node 0.0.0-stage → 0.2.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/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 +600 -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 +60 -4
package/index.js
ADDED
|
@@ -0,0 +1,960 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-multimodal-node —— Host 半。
|
|
3
|
+
*
|
|
4
|
+
* 一个插件,两种身份,共享同一套底座:
|
|
5
|
+
*
|
|
6
|
+
* service(服务节点)
|
|
7
|
+
* · API 接入端口(默认 8287)—— ComfyUI 自己的协议 + `/node/*` 用户节点接口。
|
|
8
|
+
* 别的机器、脚本、别的 DSH 都往这里发 JSON。
|
|
9
|
+
* · 网页端口(默认 8286)—— 带登录的控制台,人用。
|
|
10
|
+
* · ComfyUI 板块 —— 一个实例 = 一张卡 = 一个端口,可托管本机进程。
|
|
11
|
+
* · Blender 板块 —— bridge(HTTP 端点)或 spawn(本机 blender.exe 渲染)。
|
|
12
|
+
*
|
|
13
|
+
* user(用户节点)
|
|
14
|
+
* · 不开任何监听。它的侧边栏是一个客户端:拉服务节点的模板、队列、模型,
|
|
15
|
+
* 产物落到本机目录。
|
|
16
|
+
* · 本机仍然跑工作流库、运行索引、资产库——因为「本机产物文件夹」要能浏览,
|
|
17
|
+
* 而这个插件换到服务节点模式时不能表现为「数据没了」。
|
|
18
|
+
*
|
|
19
|
+
* 模式切换的边界:换模式 = 换**监听行为与侧边栏**,不换数据目录。
|
|
20
|
+
* 一个人在一台机器上先当服务节点、后来只当客户端,他的工作流库和产物索引必须还在。
|
|
21
|
+
*/
|
|
22
|
+
import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
23
|
+
import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
24
|
+
import { SettingsStore, resolveDataDir, resolveOutputDir, resolveBlenderOutputDir, MODES } from './lib/config.js';
|
|
25
|
+
import { InstanceManager } from './lib/instances.js';
|
|
26
|
+
import { TaskPool } from './lib/pool.js';
|
|
27
|
+
import { GatewayServer } from './lib/gateway.js';
|
|
28
|
+
import { RemoteServer } from './lib/remote.js';
|
|
29
|
+
import { AuthStore } from './lib/auth.js';
|
|
30
|
+
import { ApiKeyStore } from './lib/apikeys.js';
|
|
31
|
+
import { WorkflowLibrary } from './lib/library.js';
|
|
32
|
+
import { RunIndex } from './lib/runindex.js';
|
|
33
|
+
import { AssetLibrary } from './lib/assets.js';
|
|
34
|
+
import { PromptLibrary } from './lib/promptlibrary.js';
|
|
35
|
+
import { ChainLibrary } from './lib/chain.js';
|
|
36
|
+
import { BlenderManager, findBlenderExecutable, parseFrames } from './lib/blender.js';
|
|
37
|
+
import { NodeClient } from './lib/nodeclient.js';
|
|
38
|
+
import { createNodeRoutes, createApiGuard } from './lib/nodeapi.js';
|
|
39
|
+
import { modelInventory, inspectModelDir, localModelRootEntries, modelFolders, modelsInFolder } from './lib/models.js';
|
|
40
|
+
import { errorMessage, readJson, sameOrigin, sendJson } from './lib/http.js';
|
|
41
|
+
import { multimodalDefinition } from './lib/tools.js';
|
|
42
|
+
import { listKnowledgeBases } from './lib/promptassist.js';
|
|
43
|
+
|
|
44
|
+
export const name = 'dsh-multimodal-node';
|
|
45
|
+
|
|
46
|
+
const PRODUCT_EXT = /\.(png|jpg|jpeg|webp|gif|mp4|mov|webm|mkv|avi|wav|mp3|flac|glb|gltf|blend|exr|tif|tiff)$/i;
|
|
47
|
+
|
|
48
|
+
export async function apply(ctx, entryConfig) {
|
|
49
|
+
const dataDir = resolveDataDir(entryConfig);
|
|
50
|
+
const settings = new SettingsStore(dataDir);
|
|
51
|
+
await settings.init();
|
|
52
|
+
|
|
53
|
+
const log = (level, message) => {
|
|
54
|
+
const line = `[multimodal-node] ${message}`;
|
|
55
|
+
if (level === 'error') console.error(line);
|
|
56
|
+
else if (level === 'warn') console.warn(line);
|
|
57
|
+
else console.log(line);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
let saving = Promise.resolve();
|
|
61
|
+
const onChange = () => {
|
|
62
|
+
// 串行化写盘:面板上两次相邻点击不能在同一个临时文件上交错 rename。
|
|
63
|
+
saving = saving
|
|
64
|
+
.then(() => settings.save())
|
|
65
|
+
.catch((error) => log('error', `settings save failed: ${errorMessage(error)}`));
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const instances = new InstanceManager({
|
|
69
|
+
getInstances: () => settings.get().instances,
|
|
70
|
+
onChange,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const pool = new TaskPool({
|
|
74
|
+
instances,
|
|
75
|
+
settings: () => settings.get(),
|
|
76
|
+
onChange,
|
|
77
|
+
log,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const keys = new ApiKeyStore(settings);
|
|
81
|
+
|
|
82
|
+
const blender = new BlenderManager({
|
|
83
|
+
getConfig: () => settings.get().blender ?? {},
|
|
84
|
+
onChange,
|
|
85
|
+
log,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const nodeClient = new NodeClient(() => settings.get());
|
|
89
|
+
|
|
90
|
+
/** 用户节点接口:挂在 API 接入端口上,gateway 先问它、再走 ComfyUI 协议。 */
|
|
91
|
+
const nodeRoutes = createNodeRoutes({
|
|
92
|
+
settings: () => settings.get(),
|
|
93
|
+
keys,
|
|
94
|
+
pool,
|
|
95
|
+
instances,
|
|
96
|
+
library: null, // 下面建好库之后再补:闭包里读的是这个变量
|
|
97
|
+
runs: null,
|
|
98
|
+
assets: null,
|
|
99
|
+
blender,
|
|
100
|
+
get gateway() {
|
|
101
|
+
return gateway;
|
|
102
|
+
},
|
|
103
|
+
log,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const apiGuard = createApiGuard(keys);
|
|
107
|
+
|
|
108
|
+
const gateway = new GatewayServer({
|
|
109
|
+
settings: () => settings.get(),
|
|
110
|
+
instances,
|
|
111
|
+
pool,
|
|
112
|
+
log,
|
|
113
|
+
nodeRoutes,
|
|
114
|
+
apiGuard,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const auth = new AuthStore(settings);
|
|
118
|
+
const library = new WorkflowLibrary(dataDir);
|
|
119
|
+
await library.init();
|
|
120
|
+
// 控制台按账号显示自己跑过的东西;那必须跨进程重启留着,所以落盘而不是只放在池子里。
|
|
121
|
+
const runs = new RunIndex(dataDir);
|
|
122
|
+
await runs.init();
|
|
123
|
+
// 控制台浏览的是图片,一行一个文件、带像素尺寸。与运行索引是两张表:
|
|
124
|
+
// 一次运行出好几个文件,而人点的是文件。
|
|
125
|
+
const assets = new AssetLibrary(dataDir);
|
|
126
|
+
await assets.init();
|
|
127
|
+
// 收藏的提示词:可复用文本,按账号,独立于任何工作流。
|
|
128
|
+
const prompts = new PromptLibrary(dataDir);
|
|
129
|
+
await prompts.init();
|
|
130
|
+
// 工作链:保存下来的一串步骤,后面的步骤吃前面的产物。
|
|
131
|
+
const chains = new ChainLibrary(dataDir);
|
|
132
|
+
await chains.init();
|
|
133
|
+
|
|
134
|
+
// 用户节点接口拿到的是这几个已经建好的单例。
|
|
135
|
+
nodeRoutes.bindLibrary?.({ library, runs, assets });
|
|
136
|
+
|
|
137
|
+
// 两条一次性迁移:让 03 号插件写下的旧数据(没有分级、没有归属)直接可用。
|
|
138
|
+
// 都是幂等的,只碰还缺字段的行,所以在已迁移目录上重启是空操作。
|
|
139
|
+
ctx.effect(() => {
|
|
140
|
+
void (async () => {
|
|
141
|
+
try {
|
|
142
|
+
const tiered = await auth.migrateTiers();
|
|
143
|
+
if (tiered > 0) log('info', `migrated ${tiered} account(s) to a content tier`);
|
|
144
|
+
const owner = auth.accounts()[0]?.name;
|
|
145
|
+
if (typeof owner === 'string' && owner !== '') {
|
|
146
|
+
const claimed = await library.claimOwnerless(owner);
|
|
147
|
+
if (claimed > 0) log('info', `assigned ${claimed} legacy workflow(s) to ${owner}`);
|
|
148
|
+
}
|
|
149
|
+
} catch (error) {
|
|
150
|
+
log('error', `migration failed: ${errorMessage(error)}`);
|
|
151
|
+
}
|
|
152
|
+
})();
|
|
153
|
+
return () => {};
|
|
154
|
+
}, 'dsh-multimodal-node: migrations');
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* 提示词助手那次模型调用打到哪。
|
|
158
|
+
*
|
|
159
|
+
* 懒读:默认模型可能在设置里被换掉,而插件不会重启;在 apply() 时钉死会一直用
|
|
160
|
+
* 一个用户已经换掉的路由。没有这个服务的宿主也照常跑——助手退化到词典检索。
|
|
161
|
+
*/
|
|
162
|
+
const llmRoute = () => {
|
|
163
|
+
const model = ctx.get('agentDefaultModel');
|
|
164
|
+
if (model === undefined || typeof model.currentSelection !== 'function') return undefined;
|
|
165
|
+
try {
|
|
166
|
+
const selection = model.currentSelection();
|
|
167
|
+
if (typeof selection?.provider !== 'string' || typeof selection?.model !== 'string') return undefined;
|
|
168
|
+
return { provider: selection.provider, model: selection.model };
|
|
169
|
+
} catch {
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const remote = new RemoteServer({
|
|
175
|
+
settings: () => settings.get(),
|
|
176
|
+
store: settings,
|
|
177
|
+
auth,
|
|
178
|
+
instances,
|
|
179
|
+
pool,
|
|
180
|
+
gateway,
|
|
181
|
+
library,
|
|
182
|
+
runs,
|
|
183
|
+
assets,
|
|
184
|
+
prompts,
|
|
185
|
+
chains,
|
|
186
|
+
log,
|
|
187
|
+
// 两个都是 getter:启动之后才选的路由必须是被用的那条,而没挂 `llm` 的宿主不能崩。
|
|
188
|
+
get llm() {
|
|
189
|
+
return ctx.get('llm');
|
|
190
|
+
},
|
|
191
|
+
get llmRoute() {
|
|
192
|
+
return llmRoute();
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// --- 生命周期 ---------------------------------------------------------
|
|
197
|
+
ctx.effect(() => {
|
|
198
|
+
pool.start();
|
|
199
|
+
return () => pool.dispose();
|
|
200
|
+
}, 'dsh-multimodal-node: scheduler');
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* 最后一道防线:进程级 exit 钩子。
|
|
204
|
+
*
|
|
205
|
+
* `ctx.effect` 的清理只在插件按顺序卸载时跑。宿主被强杀、窗口关闭应用退出、
|
|
206
|
+
* 关闭链里前面某一步抛错——这三种情况它都不跑,而那正是 ComfyUI 进程树存活、
|
|
207
|
+
* 显存被占住的场景。
|
|
208
|
+
*
|
|
209
|
+
* `process.on('exit')` 不能 await,所以杀是同步的(spawnSync)。只注册一次、
|
|
210
|
+
* 从不注销,`disposed` 让正常路径与强制路径互斥,一个 pid 不会被杀两次。
|
|
211
|
+
*/
|
|
212
|
+
let disposed = false;
|
|
213
|
+
const killOnExit = () => {
|
|
214
|
+
if (disposed) return;
|
|
215
|
+
disposed = true;
|
|
216
|
+
try {
|
|
217
|
+
instances.dispose();
|
|
218
|
+
blender.dispose();
|
|
219
|
+
} catch {
|
|
220
|
+
// exit 钩子里不能抛:抛了后面的钩子就不跑了。
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
process.once('exit', killOnExit);
|
|
224
|
+
|
|
225
|
+
/** 按模式起停监听。模式切换、端口变更都走这里。 */
|
|
226
|
+
const applyListeners = async () => {
|
|
227
|
+
const value = settings.get();
|
|
228
|
+
const mode = value.mode === 'user' ? 'user' : 'service';
|
|
229
|
+
if (mode === 'user') {
|
|
230
|
+
// 用户节点不开门。它要是还监听着上一个人留下的端口,那就是一台
|
|
231
|
+
// 「自称客户端却在给别人发任务」的机器。
|
|
232
|
+
await gateway.stop().catch(() => undefined);
|
|
233
|
+
await remote.stop().catch(() => undefined);
|
|
234
|
+
return { ok: true, mode, gateway: { ok: true, skipped: 'user-mode' }, remote: { ok: true, skipped: 'user-mode' } };
|
|
235
|
+
}
|
|
236
|
+
const gatewayResult = await gateway.start().catch((error) => ({ ok: false, error: errorMessage(error) }));
|
|
237
|
+
const remoteResult =
|
|
238
|
+
value.remote?.enabled === true
|
|
239
|
+
? await remote.start().catch((error) => ({ ok: false, error: errorMessage(error) }))
|
|
240
|
+
: await remote.stop().then(() => ({ ok: true, skipped: 'disabled' }));
|
|
241
|
+
return { ok: gatewayResult.ok !== false && remoteResult.ok !== false, mode, gateway: gatewayResult, remote: remoteResult };
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
ctx.effect(() => {
|
|
245
|
+
void (async () => {
|
|
246
|
+
// 先绑端口,让启动期间打到任一端口的人拿到队列而不是 ECONNREFUSED,
|
|
247
|
+
// 然后再热一次健康表。
|
|
248
|
+
const started = await applyListeners();
|
|
249
|
+
if (started.gateway?.ok === false && started.gateway.skipped === undefined) {
|
|
250
|
+
log('error', `API 接入端口没起来:${started.gateway.error}`);
|
|
251
|
+
}
|
|
252
|
+
if (started.remote?.ok === false && started.remote.skipped === undefined) {
|
|
253
|
+
log('error', `网页端口没起来:${started.remote.error}`);
|
|
254
|
+
}
|
|
255
|
+
await auth.ensureSecret().catch((error) => log('error', `pairing secret: ${errorMessage(error)}`));
|
|
256
|
+
await instances.probeAll(true).catch(() => undefined);
|
|
257
|
+
await blender.probeAll(true).catch(() => undefined);
|
|
258
|
+
for (const runtime of instances.list()) {
|
|
259
|
+
if (runtime.instance.autoStart && !runtime.health.ok) void instances.start(runtime.id);
|
|
260
|
+
}
|
|
261
|
+
})();
|
|
262
|
+
return () => {
|
|
263
|
+
/**
|
|
264
|
+
* 关闭监听必须是**可等待的**,不能 `void` 掉就不管。
|
|
265
|
+
*
|
|
266
|
+
* 宿主热重载插件时的顺序是「先调清理、再重新 apply」。清理里如果只是
|
|
267
|
+
* `void gateway.stop()`,函数立刻返回,而两件事还没做完:
|
|
268
|
+
* - 端口的 listen 还没关(server.close 是异步的)
|
|
269
|
+
* - 于是紧接着的 apply 再去 listen 同一个端口 → EADDRINUSE
|
|
270
|
+
* 表现是「重载插件后端口没了,日志里一行 address already in use」。
|
|
271
|
+
*
|
|
272
|
+
* 清理函数本身不能改成 async(宿主不 await 它),所以这里同步地把
|
|
273
|
+
* server 从内核解绑:先 unref + close,再把「等待关闭」挂到返回的
|
|
274
|
+
* promise 上供测试与调试使用。
|
|
275
|
+
*/
|
|
276
|
+
const closing = Promise.allSettled([gateway.stop(), remote.stop()]);
|
|
277
|
+
// 不 await(清理函数不能 async),但失败要记下来——否则「端口没关掉」
|
|
278
|
+
// 会表现为下一次启动的 EADDRINUSE,而不是这里的一条日志。
|
|
279
|
+
void closing.then((results) => {
|
|
280
|
+
for (const result of results) {
|
|
281
|
+
if (result.status === 'rejected') log('warn', `关闭监听失败:${errorMessage(result.reason)}`);
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
/**
|
|
285
|
+
* 退出顺序很重要:监听先关,别让新任务进一个 worker 马上要死的队列;
|
|
286
|
+
* 然后同步杀掉实例进程(异步 taskkill 会随宿主一起死,等于什么都没杀)。
|
|
287
|
+
*
|
|
288
|
+
* 摘要是要记日志的,不能吞:机器回来显存仍被占时,「哪个实例我们没停掉」
|
|
289
|
+
* 是第一个问题,而这里是唯一能回答它的地方。
|
|
290
|
+
*/
|
|
291
|
+
if (disposed) return;
|
|
292
|
+
disposed = true;
|
|
293
|
+
const summary = instances.dispose();
|
|
294
|
+
if (summary.stopped.length > 0) log('info', `shutdown: stopped ${summary.stopped.join(', ')}`);
|
|
295
|
+
if (summary.kept.length > 0) log('info', `shutdown: left running (stopOnExit=false): ${summary.kept.join(', ')}`);
|
|
296
|
+
const blenderStopped = blender.dispose();
|
|
297
|
+
if (blenderStopped.length > 0) log('info', `shutdown: blender stopped ${blenderStopped.join(', ')}`);
|
|
298
|
+
};
|
|
299
|
+
}, 'dsh-multimodal-node: listeners');
|
|
300
|
+
|
|
301
|
+
// 每 1.5 秒推一次状态:一个定时器,负载很轻。
|
|
302
|
+
ctx.effect(() => {
|
|
303
|
+
const timer = setInterval(() => {
|
|
304
|
+
void gateway.pushStatus();
|
|
305
|
+
}, 1500);
|
|
306
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
307
|
+
return () => clearInterval(timer);
|
|
308
|
+
}, 'dsh-multimodal-node: push');
|
|
309
|
+
|
|
310
|
+
// --- 面板与工具共用的运行时 -------------------------------------------
|
|
311
|
+
const runtime = {
|
|
312
|
+
settings,
|
|
313
|
+
instances,
|
|
314
|
+
pool,
|
|
315
|
+
gateway,
|
|
316
|
+
remote,
|
|
317
|
+
auth,
|
|
318
|
+
keys,
|
|
319
|
+
blender,
|
|
320
|
+
nodeClient,
|
|
321
|
+
library,
|
|
322
|
+
runs,
|
|
323
|
+
assets,
|
|
324
|
+
dataDir,
|
|
325
|
+
get mode() {
|
|
326
|
+
return settings.get().mode === 'user' ? 'user' : 'service';
|
|
327
|
+
},
|
|
328
|
+
async restartListeners() {
|
|
329
|
+
const result = await applyListeners();
|
|
330
|
+
await instances.probeAll(true).catch(() => undefined);
|
|
331
|
+
await blender.probeAll(true).catch(() => undefined);
|
|
332
|
+
return result;
|
|
333
|
+
},
|
|
334
|
+
async applySettings(patch) {
|
|
335
|
+
/**
|
|
336
|
+
* 变更前后的指纹只包含「决定监听怎么绑」的字段。多带一个字段进去,
|
|
337
|
+
* 改一次产物目录就会触发一次整个监听重启——那是会断掉正在下载的产物的。
|
|
338
|
+
*/
|
|
339
|
+
const fingerprint = (value) =>
|
|
340
|
+
JSON.stringify([
|
|
341
|
+
value.mode,
|
|
342
|
+
value.listen?.enabled,
|
|
343
|
+
value.listen?.host,
|
|
344
|
+
value.listen?.port,
|
|
345
|
+
value.remote?.enabled,
|
|
346
|
+
value.remote?.host,
|
|
347
|
+
value.remote?.port,
|
|
348
|
+
]);
|
|
349
|
+
const before = fingerprint(settings.get());
|
|
350
|
+
await settings.patch(patch ?? {});
|
|
351
|
+
instances.sync();
|
|
352
|
+
blender.sync();
|
|
353
|
+
await instances.probeAll(true).catch(() => undefined);
|
|
354
|
+
if (fingerprint(settings.get()) !== before) return runtime.restartListeners();
|
|
355
|
+
await pool.pump();
|
|
356
|
+
return { ok: true };
|
|
357
|
+
},
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
/** 面板状态:两套 UI 要的数据一次给全,前端不做拼接。 */
|
|
361
|
+
const statusOf = async () => {
|
|
362
|
+
const value = settings.get();
|
|
363
|
+
const mode = value.mode === 'user' ? 'user' : 'service';
|
|
364
|
+
const snapshot = pool.snapshot();
|
|
365
|
+
const outputDir = resolveOutputDir(value);
|
|
366
|
+
const serviceNode = mode === 'user' ? await nodeClient.probe() : null;
|
|
367
|
+
const gatewayStatus = gateway.statusPayload();
|
|
368
|
+
return {
|
|
369
|
+
ok: true,
|
|
370
|
+
mode,
|
|
371
|
+
dataDir,
|
|
372
|
+
gateway: {
|
|
373
|
+
listening: gatewayStatus.listening,
|
|
374
|
+
host: gatewayStatus.host,
|
|
375
|
+
port: gatewayStatus.port,
|
|
376
|
+
lastError: gatewayStatus.lastError,
|
|
377
|
+
},
|
|
378
|
+
remote: {
|
|
379
|
+
enabled: value.remote?.enabled === true,
|
|
380
|
+
listening: remote.listening,
|
|
381
|
+
port: remote.port,
|
|
382
|
+
host: remote.host,
|
|
383
|
+
lastError: remote.lastError,
|
|
384
|
+
accounts: auth.accounts().map((entry) => ({
|
|
385
|
+
name: entry.name,
|
|
386
|
+
role: entry.role,
|
|
387
|
+
tier: entry.tier === 'nsfw' ? 'nsfw' : 'sfw',
|
|
388
|
+
createdAt: entry.createdAt ?? null,
|
|
389
|
+
})),
|
|
390
|
+
sessions: auth.sessions().map((entry) => ({
|
|
391
|
+
user: entry.user,
|
|
392
|
+
tier: entry.tier === 'nsfw' ? 'nsfw' : 'sfw',
|
|
393
|
+
ip: entry.ip,
|
|
394
|
+
createdAt: entry.createdAt,
|
|
395
|
+
seenAt: entry.seenAt,
|
|
396
|
+
})),
|
|
397
|
+
assistant: value.remote?.assistant !== false,
|
|
398
|
+
requireCode: value.remote?.requireCode !== false,
|
|
399
|
+
bridges: [...remote.bridges.entries()].map(([, lease]) => ({ instanceId: lease.instanceId, user: lease.user })),
|
|
400
|
+
},
|
|
401
|
+
apiKeys: keys.list(),
|
|
402
|
+
apiAuth: { requireKey: keys.requireKey() },
|
|
403
|
+
node: {
|
|
404
|
+
configured: nodeClient.configured,
|
|
405
|
+
publicUrl: value.node?.publicUrl ?? '',
|
|
406
|
+
apiKeySet: String(value.node?.apiKey ?? '') !== '',
|
|
407
|
+
outputDir,
|
|
408
|
+
label: value.node?.label ?? '',
|
|
409
|
+
timeoutMs: value.node?.timeoutMs ?? 15000,
|
|
410
|
+
serviceNode,
|
|
411
|
+
},
|
|
412
|
+
queue: {
|
|
413
|
+
dynamic: snapshot.dynamic === true,
|
|
414
|
+
counters: snapshot.counters ?? {},
|
|
415
|
+
estimateMs: snapshot.estimate?.makespanMs ?? 0,
|
|
416
|
+
tasks: snapshot.tasks ?? [],
|
|
417
|
+
},
|
|
418
|
+
instances: gatewayStatus.instances ?? [],
|
|
419
|
+
blender: blender.statusPayload(),
|
|
420
|
+
models: (() => {
|
|
421
|
+
const inventory = modelInventory(value, { withSize: false });
|
|
422
|
+
return {
|
|
423
|
+
ok: inventory.ok !== false,
|
|
424
|
+
total: inventory.total ?? 0,
|
|
425
|
+
folders: (inventory.folders ?? []).map((folder) => ({ name: folder.name, label: folder.label, count: folder.items.length })),
|
|
426
|
+
roots: inventory.roots ?? [],
|
|
427
|
+
// 每个扫描根是从哪来的:面板要能解释,不然「这个目录为什么会出现」只能靠猜
|
|
428
|
+
rootEntries: inventory.rootEntries ?? [],
|
|
429
|
+
extraRoots: (value.modelsScan?.roots ?? []),
|
|
430
|
+
error: inventory.error ?? null,
|
|
431
|
+
};
|
|
432
|
+
})(),
|
|
433
|
+
counts: {
|
|
434
|
+
workflows: library.listAll().length,
|
|
435
|
+
runs: [...runs.byOwner.values()].reduce((sum, rows) => sum + rows.length, 0),
|
|
436
|
+
prompts: Object.values(prompts.byOwner ?? {}).reduce((sum, rows) => sum + (Array.isArray(rows) ? rows.length : 0), 0),
|
|
437
|
+
chains: chains.list('nsfw').length,
|
|
438
|
+
},
|
|
439
|
+
settings: {
|
|
440
|
+
listen: value.listen,
|
|
441
|
+
dynamic: value.dynamic,
|
|
442
|
+
probeMs: value.probeMs,
|
|
443
|
+
historyItems: value.historyItems,
|
|
444
|
+
stallWarnSec: value.stallWarnSec,
|
|
445
|
+
promptHeads: value.promptHeads,
|
|
446
|
+
promptKb: value.promptKb,
|
|
447
|
+
remote: {
|
|
448
|
+
enabled: value.remote?.enabled === true,
|
|
449
|
+
port: value.remote?.port,
|
|
450
|
+
host: value.remote?.host,
|
|
451
|
+
assistant: value.remote?.assistant !== false,
|
|
452
|
+
requireCode: value.remote?.requireCode !== false,
|
|
453
|
+
},
|
|
454
|
+
blender: {
|
|
455
|
+
enabled: value.blender?.enabled !== false,
|
|
456
|
+
executable: value.blender?.executable ?? '',
|
|
457
|
+
resolvedExecutable: findBlenderExecutable(String(value.blender?.executable ?? '')),
|
|
458
|
+
outputDir: resolveBlenderOutputDir(value),
|
|
459
|
+
},
|
|
460
|
+
modelsScan: {
|
|
461
|
+
roots: value.modelsScan?.roots ?? [],
|
|
462
|
+
maxDepth: value.modelsScan?.maxDepth ?? 4,
|
|
463
|
+
},
|
|
464
|
+
},
|
|
465
|
+
};
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
const webServer = ctx.get('webServer');
|
|
469
|
+
if (webServer !== undefined) {
|
|
470
|
+
/**
|
|
471
|
+
* 挂一条同源路由。
|
|
472
|
+
*
|
|
473
|
+
* 返回式清理很重要:`register` 的返回值是一个注销函数。原来的写法把它丢掉了,
|
|
474
|
+
* 于是插件卸载时路由留在表里——下一次热重载再挂一遍,同一个 path 出现两个处理者,
|
|
475
|
+
* 请求被处理两次(表现为「点一次按钮,动作执行两遍」)。
|
|
476
|
+
*/
|
|
477
|
+
const mount = (path, handler) =>
|
|
478
|
+
ctx.effect(() => {
|
|
479
|
+
const dispose = webServer.register({ kind: 'exact', path, handler });
|
|
480
|
+
return typeof dispose === 'function' ? dispose : () => {};
|
|
481
|
+
}, `dsh-multimodal-node: route ${path}`);
|
|
482
|
+
|
|
483
|
+
const readBody = async (request) => {
|
|
484
|
+
try {
|
|
485
|
+
return await readJson(request);
|
|
486
|
+
} catch {
|
|
487
|
+
return {};
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
mount('/multimodal/status', (request, response) => {
|
|
492
|
+
if (request.method !== 'GET') {
|
|
493
|
+
sendJson(response, 405, { error: 'method not allowed' });
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
void (async () => {
|
|
497
|
+
try {
|
|
498
|
+
sendJson(response, 200, await statusOf());
|
|
499
|
+
} catch (error) {
|
|
500
|
+
sendJson(response, 500, { ok: false, error: errorMessage(error) });
|
|
501
|
+
}
|
|
502
|
+
})();
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
/** 配对码只在这里给:从桌面读出来念给人听,永不上线传输。 */
|
|
506
|
+
mount('/multimodal/pairing', (request, response) => {
|
|
507
|
+
if (request.method !== 'GET' || sameOrigin(request) === false) {
|
|
508
|
+
sendJson(response, 403, { error: 'same-origin GET required' });
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
void (async () => {
|
|
512
|
+
try {
|
|
513
|
+
const code = await auth.currentCode();
|
|
514
|
+
sendJson(response, 200, { ok: true, ...code });
|
|
515
|
+
} catch (error) {
|
|
516
|
+
sendJson(response, 500, { ok: false, error: errorMessage(error) });
|
|
517
|
+
}
|
|
518
|
+
})();
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
/** 设置页唯一写入点:模式、端口、用户节点、Blender、表头、知识库全走这里。 */
|
|
522
|
+
mount('/multimodal/settings', (request, response) => {
|
|
523
|
+
if (request.method !== 'POST' || sameOrigin(request) === false) {
|
|
524
|
+
sendJson(response, 403, { error: 'same-origin POST required' });
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
void (async () => {
|
|
528
|
+
const body = await readBody(request);
|
|
529
|
+
try {
|
|
530
|
+
const result = await runtime.applySettings(body ?? {});
|
|
531
|
+
sendJson(response, 200, { ...result, status: await statusOf() });
|
|
532
|
+
} catch (error) {
|
|
533
|
+
sendJson(response, 400, { ok: false, error: errorMessage(error) });
|
|
534
|
+
}
|
|
535
|
+
})();
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
/** 网页账号:给网页端口用。 */
|
|
539
|
+
mount('/multimodal/accounts', (request, response) => {
|
|
540
|
+
if (request.method !== 'POST' || sameOrigin(request) === false) {
|
|
541
|
+
sendJson(response, 403, { error: 'same-origin POST required' });
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
void (async () => {
|
|
545
|
+
const body = await readBody(request);
|
|
546
|
+
try {
|
|
547
|
+
const action = String(body?.action ?? 'add');
|
|
548
|
+
const accountName = String(body?.name ?? '');
|
|
549
|
+
let result;
|
|
550
|
+
if (action === 'add') {
|
|
551
|
+
result = await auth.addAccount(
|
|
552
|
+
accountName,
|
|
553
|
+
String(body?.password ?? ''),
|
|
554
|
+
body?.role === 'admin' ? 'admin' : 'viewer',
|
|
555
|
+
body?.tier === 'nsfw' ? 'nsfw' : 'sfw',
|
|
556
|
+
);
|
|
557
|
+
} else if (action === 'tier') result = await auth.setTier(accountName, body?.tier === 'nsfw' ? 'nsfw' : 'sfw');
|
|
558
|
+
else if (action === 'requireCode') result = await auth.setRequireCode(body?.requireCode !== false);
|
|
559
|
+
else if (action === 'remove') {
|
|
560
|
+
result = await auth.removeAccount(accountName);
|
|
561
|
+
// 他们的工作流与运行本来是私有的;留着这些行等于留着一批没人能读、
|
|
562
|
+
// 却仍占着索引的数据,而重建同名账号会继承它们。
|
|
563
|
+
await library.dropOwner(accountName);
|
|
564
|
+
await runs.dropOwner(accountName);
|
|
565
|
+
await assets.dropOwner(accountName);
|
|
566
|
+
} else if (action === 'password') result = await auth.setPassword(accountName, String(body?.password ?? ''));
|
|
567
|
+
else if (action === 'revoke') result = await auth.revokeAll();
|
|
568
|
+
else result = { ok: false, error: `unknown action ${action}` };
|
|
569
|
+
sendJson(response, 200, { ...result, status: await statusOf() });
|
|
570
|
+
} catch (error) {
|
|
571
|
+
sendJson(response, 400, { ok: false, error: errorMessage(error) });
|
|
572
|
+
}
|
|
573
|
+
})();
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
/** API 接入凭据:给程序用。创建时返回一次明文 key,之后只剩前缀。 */
|
|
577
|
+
mount('/multimodal/apikeys', (request, response) => {
|
|
578
|
+
if (request.method !== 'POST' || sameOrigin(request) === false) {
|
|
579
|
+
sendJson(response, 403, { error: 'same-origin POST required' });
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
void (async () => {
|
|
583
|
+
const body = await readBody(request);
|
|
584
|
+
try {
|
|
585
|
+
const action = String(body?.action ?? 'list');
|
|
586
|
+
const name = String(body?.name ?? '');
|
|
587
|
+
let result;
|
|
588
|
+
if (action === 'create') result = await keys.create({ name, tier: body?.tier, note: body?.note });
|
|
589
|
+
else if (action === 'update') result = await keys.update(name, { tier: body?.tier, enabled: body?.enabled, note: body?.note });
|
|
590
|
+
else if (action === 'remove') result = await keys.remove(name);
|
|
591
|
+
else if (action === 'rotate') result = await keys.rotate(name);
|
|
592
|
+
else if (action === 'requireKey') result = await keys.setRequireKey(body?.requireKey === true);
|
|
593
|
+
else if (action === 'list') result = { ok: true, apiKeys: keys.list(), requireKey: keys.requireKey() };
|
|
594
|
+
else result = { ok: false, error: `unknown action ${action}` };
|
|
595
|
+
sendJson(response, 200, { ...result, status: await statusOf() });
|
|
596
|
+
} catch (error) {
|
|
597
|
+
sendJson(response, 400, { ok: false, error: errorMessage(error) });
|
|
598
|
+
}
|
|
599
|
+
})();
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* 模型扫描:ComfyUI 板块内的模型列表。
|
|
604
|
+
*
|
|
605
|
+
* 三个动作各管一件事,不合并:
|
|
606
|
+
* list —— 按实例(或不带实例 = 全部)读目录与文件
|
|
607
|
+
* inspect —— 填了一个地址,先告诉他这个目录到底能不能扫、像不像 models 根
|
|
608
|
+
* roots —— 增删「额外扫描目录」
|
|
609
|
+
*
|
|
610
|
+
* inspect 单独存在是因为「填错一层目录」是最常见的错误:用户填
|
|
611
|
+
* `E:\ComfyUI-aki-v3.2` 而不是 `E:\ComfyUI-aki-v3.2\ComfyUI\models`,
|
|
612
|
+
* 然后困惑为什么一个模型都没有。探测能直接指出下一层。
|
|
613
|
+
*/
|
|
614
|
+
mount('/multimodal/models', (request, response) => {
|
|
615
|
+
if (request.method !== 'POST' || sameOrigin(request) === false) {
|
|
616
|
+
sendJson(response, 403, { error: 'same-origin POST required' });
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
void (async () => {
|
|
620
|
+
const body = await readBody(request);
|
|
621
|
+
try {
|
|
622
|
+
const action = String(body?.action ?? 'list');
|
|
623
|
+
const value = settings.get();
|
|
624
|
+
if (action === 'inspect') {
|
|
625
|
+
sendJson(response, 200, { ok: true, ...inspectModelDir(body?.dir) });
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
if (action === 'setInstanceRoot') {
|
|
629
|
+
// 直接改某个实例的模型文件夹地址(实例行里那一栏)
|
|
630
|
+
const id = String(body?.id ?? '');
|
|
631
|
+
const dir = String(body?.dir ?? '').trim();
|
|
632
|
+
if (dir !== '' && !isAbsolute(dir)) throw new Error('模型文件夹地址必须是绝对路径');
|
|
633
|
+
const next = (value.instances ?? []).map((entry) =>
|
|
634
|
+
entry.id === id ? { ...entry, modelsRoot: dir } : entry,
|
|
635
|
+
);
|
|
636
|
+
const result = await runtime.applySettings({ instances: next });
|
|
637
|
+
sendJson(response, 200, { ...result, status: await statusOf() });
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
if (action === 'setRoots') {
|
|
641
|
+
const roots = Array.isArray(body?.roots) ? body.roots : [];
|
|
642
|
+
const result = await runtime.applySettings({ modelsScan: { roots } });
|
|
643
|
+
sendJson(response, 200, { ...result, status: await statusOf() });
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
if (action === 'addRoot' || action === 'removeRoot') {
|
|
647
|
+
const dir = String(body?.dir ?? '').trim();
|
|
648
|
+
const current = Array.isArray(value.modelsScan?.roots) ? value.modelsScan.roots : [];
|
|
649
|
+
const roots = action === 'addRoot'
|
|
650
|
+
? [...current, dir]
|
|
651
|
+
: current.filter((row) => row !== dir);
|
|
652
|
+
const result = await runtime.applySettings({ modelsScan: { roots } });
|
|
653
|
+
sendJson(response, 200, { ...result, status: await statusOf() });
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
// list
|
|
657
|
+
const instanceId = body?.instanceId === undefined || body.instanceId === null || body.instanceId === ''
|
|
658
|
+
? undefined
|
|
659
|
+
: String(body.instanceId);
|
|
660
|
+
const inventory = modelInventory(value, { withSize: body?.withSize === true, instanceId });
|
|
661
|
+
if (inventory.ok === false) {
|
|
662
|
+
sendJson(response, 200, { ok: false, ...inventory });
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
// 面板要「目录 + 文件」全量;这里不裁剪,裁剪交给前端的分页/搜索
|
|
666
|
+
sendJson(response, 200, {
|
|
667
|
+
...inventory,
|
|
668
|
+
folders: inventory.folders.map((folder) => ({
|
|
669
|
+
...folder,
|
|
670
|
+
items: body?.withSize === true ? folder.items : folder.items,
|
|
671
|
+
})),
|
|
672
|
+
});
|
|
673
|
+
} catch (error) {
|
|
674
|
+
sendJson(response, 400, { ok: false, error: errorMessage(error) });
|
|
675
|
+
}
|
|
676
|
+
})();
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
/** ComfyUI 板块:实例的增删改、启停、探测、释放显存。 */
|
|
680
|
+
mount('/multimodal/instances', (request, response) => {
|
|
681
|
+
if (request.method !== 'POST' || sameOrigin(request) === false) {
|
|
682
|
+
sendJson(response, 403, { error: 'same-origin POST required' });
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
void (async () => {
|
|
686
|
+
const body = await readBody(request);
|
|
687
|
+
try {
|
|
688
|
+
const action = String(body?.action ?? '');
|
|
689
|
+
const id = String(body?.id ?? '');
|
|
690
|
+
let result;
|
|
691
|
+
if (action === 'start' || action === 'stop' || action === 'restart') {
|
|
692
|
+
result = await instances[action](id);
|
|
693
|
+
} else if (action === 'apply') {
|
|
694
|
+
result = await runtime.applySettings({ instances: body.instances });
|
|
695
|
+
} else if (action === 'probe') {
|
|
696
|
+
await instances.probeAll(true);
|
|
697
|
+
result = { ok: true };
|
|
698
|
+
} else if (action === 'unbridge') {
|
|
699
|
+
for (const [token, lease] of remote.bridges) {
|
|
700
|
+
if (lease.instanceId === id) remote.releaseBridge(token);
|
|
701
|
+
}
|
|
702
|
+
result = { ok: true };
|
|
703
|
+
} else {
|
|
704
|
+
result = { ok: false, error: `unknown action ${action}` };
|
|
705
|
+
}
|
|
706
|
+
sendJson(response, 200, { ...result, status: await statusOf() });
|
|
707
|
+
} catch (error) {
|
|
708
|
+
sendJson(response, 400, { ok: false, error: errorMessage(error) });
|
|
709
|
+
}
|
|
710
|
+
})();
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
/** 队列板块:排序策略、取消、插队、释放显存、清空。 */
|
|
714
|
+
mount('/multimodal/pool', (request, response) => {
|
|
715
|
+
if (request.method !== 'POST' || sameOrigin(request) === false) {
|
|
716
|
+
sendJson(response, 403, { error: 'same-origin POST required' });
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
void (async () => {
|
|
720
|
+
const body = await readBody(request);
|
|
721
|
+
try {
|
|
722
|
+
const action = String(body?.action ?? '');
|
|
723
|
+
let result;
|
|
724
|
+
if (action === 'dynamic') {
|
|
725
|
+
pool.toggleDynamic(body.enabled === true);
|
|
726
|
+
result = { ok: true, dynamic: settings.get().dynamic };
|
|
727
|
+
} else if (action === 'cancel') {
|
|
728
|
+
result = pool.cancel(String(body.id ?? ''));
|
|
729
|
+
} else if (action === 'prioritize') {
|
|
730
|
+
result = pool.prioritize(String(body.id ?? ''), body.delta ?? 1);
|
|
731
|
+
} else if (action === 'free') {
|
|
732
|
+
await Promise.all(
|
|
733
|
+
instances.enabled().map(async (item) => {
|
|
734
|
+
try {
|
|
735
|
+
await pool.backend(item).free({ unload_models: true, free_memory: true });
|
|
736
|
+
} catch {
|
|
737
|
+
// 停着的实例没有可释放的东西
|
|
738
|
+
}
|
|
739
|
+
}),
|
|
740
|
+
);
|
|
741
|
+
result = { ok: true };
|
|
742
|
+
} else if (action === 'clearQueue') {
|
|
743
|
+
for (const task of pool.ordered()) pool.cancel(task.id);
|
|
744
|
+
result = { ok: true };
|
|
745
|
+
} else {
|
|
746
|
+
result = { ok: false, error: `unknown action ${action}` };
|
|
747
|
+
}
|
|
748
|
+
sendJson(response, 200, { ...result, status: await statusOf() });
|
|
749
|
+
} catch (error) {
|
|
750
|
+
sendJson(response, 400, { ok: false, error: errorMessage(error) });
|
|
751
|
+
}
|
|
752
|
+
})();
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
/** Blender 板块:实例管理 + 提交渲染 + 取消 + 浏览工程文件。 */
|
|
756
|
+
mount('/multimodal/blender', (request, response) => {
|
|
757
|
+
if (request.method !== 'POST' || sameOrigin(request) === false) {
|
|
758
|
+
sendJson(response, 403, { error: 'same-origin POST required' });
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
void (async () => {
|
|
762
|
+
const body = await readBody(request);
|
|
763
|
+
try {
|
|
764
|
+
const action = String(body?.action ?? '');
|
|
765
|
+
const id = String(body?.id ?? body?.instanceId ?? '');
|
|
766
|
+
let result;
|
|
767
|
+
if (action === 'apply') {
|
|
768
|
+
result = await runtime.applySettings({ blender: body.blender ?? {} });
|
|
769
|
+
} else if (action === 'probe') {
|
|
770
|
+
await blender.probeAll(true);
|
|
771
|
+
result = { ok: true };
|
|
772
|
+
} else if (action === 'render') {
|
|
773
|
+
result = await blender.submit(id, body);
|
|
774
|
+
} else if (action === 'cancel') {
|
|
775
|
+
result = await blender.cancel(id, String(body?.jobId ?? ''));
|
|
776
|
+
} else if (action === 'blends') {
|
|
777
|
+
result = { ok: true, files: blender.browseBlends(String(body?.dir ?? '')) };
|
|
778
|
+
} else if (action === 'setExecutable') {
|
|
779
|
+
result = await runtime.applySettings({ blender: { executable: String(body?.executable ?? '') } });
|
|
780
|
+
} else {
|
|
781
|
+
result = { ok: false, error: `unknown action ${action}` };
|
|
782
|
+
}
|
|
783
|
+
sendJson(response, 200, { ...result, status: await statusOf() });
|
|
784
|
+
} catch (error) {
|
|
785
|
+
sendJson(response, 400, { ok: false, error: errorMessage(error) });
|
|
786
|
+
}
|
|
787
|
+
})();
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* 用户节点的动作:探测、模板、队列、模型、提交、拉产物。
|
|
792
|
+
*
|
|
793
|
+
* 全部由 Host 代发,浏览器不直连服务节点:否则 apikey 就落进页面里了,
|
|
794
|
+
* 而页面会被截图、会被 devtools 读、会进浏览器缓存。
|
|
795
|
+
*/
|
|
796
|
+
mount('/multimodal/node', (request, response) => {
|
|
797
|
+
if (request.method !== 'POST' || sameOrigin(request) === false) {
|
|
798
|
+
sendJson(response, 403, { error: 'same-origin POST required' });
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
void (async () => {
|
|
802
|
+
const body = await readBody(request);
|
|
803
|
+
try {
|
|
804
|
+
const action = String(body?.action ?? 'probe');
|
|
805
|
+
let result;
|
|
806
|
+
if (action === 'probe') result = await nodeClient.probe();
|
|
807
|
+
else if (action === 'templates') result = { ok: true, templates: await nodeClient.templates() };
|
|
808
|
+
else if (action === 'queue') result = await nodeClient.queue();
|
|
809
|
+
else if (action === 'models') result = await nodeClient.models();
|
|
810
|
+
else if (action === 'submit') result = await nodeClient.submit(body.payload ?? {});
|
|
811
|
+
else if (action === 'history') result = await nodeClient.history(body.promptId === undefined ? undefined : String(body.promptId));
|
|
812
|
+
else if (action === 'pool') result = await nodeClient.pool(String(body.poolAction ?? 'status'), body);
|
|
813
|
+
else if (action === 'blender') result = await nodeClient.call(`/node/blender${body.suffix === undefined ? '' : String(body.suffix)}`);
|
|
814
|
+
else if (action === 'blenderRender') {
|
|
815
|
+
result = await nodeClient.call('/node/blender/render', { method: 'POST', body: body.payload ?? {} });
|
|
816
|
+
} else if (action === 'fetch') {
|
|
817
|
+
const dir = resolveOutputDir(settings.get());
|
|
818
|
+
const saved = await nodeClient.fetchOutput(String(body.promptId ?? ''), String(body.file ?? ''), dir);
|
|
819
|
+
result = { ok: true, ...saved };
|
|
820
|
+
} else result = { ok: false, error: `unknown action ${action}` };
|
|
821
|
+
sendJson(response, 200, result);
|
|
822
|
+
} catch (error) {
|
|
823
|
+
sendJson(response, 400, { ok: false, error: errorMessage(error) });
|
|
824
|
+
}
|
|
825
|
+
})();
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
/** 本机产物文件夹:列出、设路径、看总大小。 */
|
|
829
|
+
mount('/multimodal/output', (request, response) => {
|
|
830
|
+
if (request.method !== 'POST' || sameOrigin(request) === false) {
|
|
831
|
+
sendJson(response, 403, { error: 'same-origin POST required' });
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
void (async () => {
|
|
835
|
+
const body = await readBody(request);
|
|
836
|
+
try {
|
|
837
|
+
const action = String(body?.action ?? 'list');
|
|
838
|
+
if (action === 'set') {
|
|
839
|
+
const target = String(body?.dir ?? '').trim();
|
|
840
|
+
if (target !== '' && !isAbsolute(target)) throw new Error('产物目录必须是绝对路径');
|
|
841
|
+
const result = await runtime.applySettings({ node: { outputDir: target } });
|
|
842
|
+
sendJson(response, 200, { ...result, status: await statusOf() });
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
if (action === 'mkdir') {
|
|
846
|
+
const dir = resolveOutputDir(settings.get());
|
|
847
|
+
await mkdir(dir, { recursive: true });
|
|
848
|
+
sendJson(response, 200, { ok: true, dir, status: await statusOf() });
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
const dir = resolveOutputDir(settings.get());
|
|
852
|
+
await mkdir(dir, { recursive: true });
|
|
853
|
+
const rows = [];
|
|
854
|
+
let total = 0;
|
|
855
|
+
let entries = [];
|
|
856
|
+
try {
|
|
857
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
858
|
+
} catch {
|
|
859
|
+
entries = [];
|
|
860
|
+
}
|
|
861
|
+
for (const entry of entries) {
|
|
862
|
+
if (!entry.isFile()) continue;
|
|
863
|
+
if (!PRODUCT_EXT.test(entry.name)) continue;
|
|
864
|
+
const info = await stat(join(dir, entry.name)).catch(() => null);
|
|
865
|
+
if (info === null) continue;
|
|
866
|
+
total += info.size;
|
|
867
|
+
rows.push({ name: entry.name, path: join(dir, entry.name), bytes: info.size, modifiedAt: info.mtimeMs });
|
|
868
|
+
}
|
|
869
|
+
rows.sort((left, right) => right.modifiedAt - left.modifiedAt);
|
|
870
|
+
sendJson(response, 200, { ok: true, dir, total, files: rows.slice(0, 200) });
|
|
871
|
+
} catch (error) {
|
|
872
|
+
sendJson(response, 400, { ok: false, error: errorMessage(error) });
|
|
873
|
+
}
|
|
874
|
+
})();
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
/** 提示词助手能真正读到的知识库。 */
|
|
878
|
+
mount('/multimodal/knowledge', (request, response) => {
|
|
879
|
+
if (request.method !== 'GET') {
|
|
880
|
+
sendJson(response, 405, { error: 'method not allowed' });
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
void (async () => {
|
|
884
|
+
try {
|
|
885
|
+
const root = settings.get().promptKb?.root ?? '';
|
|
886
|
+
const bases = await listKnowledgeBases(root);
|
|
887
|
+
sendJson(response, 200, {
|
|
888
|
+
ok: true,
|
|
889
|
+
selected: settings.get().promptKb?.kbName ?? '',
|
|
890
|
+
bases: bases.map((entry) => ({
|
|
891
|
+
name: entry.name,
|
|
892
|
+
documents: entry.documents,
|
|
893
|
+
description: entry.description.slice(0, 120),
|
|
894
|
+
})),
|
|
895
|
+
});
|
|
896
|
+
} catch (error) {
|
|
897
|
+
sendJson(response, 500, { ok: false, error: errorMessage(error) });
|
|
898
|
+
}
|
|
899
|
+
})();
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
mount('/multimodal/restart', (request, response) => {
|
|
903
|
+
if (request.method !== 'POST' || sameOrigin(request) === false) {
|
|
904
|
+
sendJson(response, 403, { error: 'same-origin POST required' });
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
void (async () => {
|
|
908
|
+
const result = await runtime.restartListeners();
|
|
909
|
+
sendJson(response, 200, { ...result, status: await statusOf() });
|
|
910
|
+
})();
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
/** 读一个产物文件给页面预览(只在本机产物目录内)。 */
|
|
914
|
+
mount('/multimodal/product', (request, response) => {
|
|
915
|
+
void (async () => {
|
|
916
|
+
const url = new URL(request.url ?? '/', 'http://localhost');
|
|
917
|
+
const name = url.searchParams.get('name') ?? '';
|
|
918
|
+
const dir = resolveOutputDir(settings.get());
|
|
919
|
+
if (name === '' || basename(name) !== name) {
|
|
920
|
+
sendJson(response, 400, { ok: false, error: 'name 只能是文件名' });
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
const file = join(dir, name);
|
|
924
|
+
try {
|
|
925
|
+
const buffer = await readFile(file);
|
|
926
|
+
const lower = name.toLowerCase();
|
|
927
|
+
const type = /\.(png)$/.test(lower)
|
|
928
|
+
? 'image/png'
|
|
929
|
+
: /\.(jpe?g)$/.test(lower)
|
|
930
|
+
? 'image/jpeg'
|
|
931
|
+
: /\.(webp)$/.test(lower)
|
|
932
|
+
? 'image/webp'
|
|
933
|
+
: /\.(gif)$/.test(lower)
|
|
934
|
+
? 'image/gif'
|
|
935
|
+
: /\.(mp4)$/.test(lower)
|
|
936
|
+
? 'video/mp4'
|
|
937
|
+
: /\.(webm)$/.test(lower)
|
|
938
|
+
? 'video/webm'
|
|
939
|
+
: 'application/octet-stream';
|
|
940
|
+
response.writeHead(200, {
|
|
941
|
+
'content-type': type,
|
|
942
|
+
'content-length': String(buffer.length),
|
|
943
|
+
'cache-control': 'private, max-age=60',
|
|
944
|
+
});
|
|
945
|
+
response.end(buffer);
|
|
946
|
+
} catch (error) {
|
|
947
|
+
sendJson(response, 404, { ok: false, error: errorMessage(error) });
|
|
948
|
+
}
|
|
949
|
+
})();
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// --- 智能体工具 -------------------------------------------------------
|
|
954
|
+
const tools = ctx.get('tools');
|
|
955
|
+
if (tools !== undefined) {
|
|
956
|
+
ctx.effect(() => tools.register(multimodalDefinition(runtime, { statusOf, parseFrames })), 'dsh-multimodal-node: tool');
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
export { MODES, defaultDataDir as dataDir } from './lib/config.js';
|