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/client.js
ADDED
|
@@ -0,0 +1,2817 @@
|
|
|
1
|
+
/* dsh-multimodal-node —— Client 半。
|
|
2
|
+
*
|
|
3
|
+
* 三处 UI,一个插件:
|
|
4
|
+
*
|
|
5
|
+
* 1. 设置页(settings.section)—— 工具模式开关。选「用户节点」只出现公网地址 + apikey;
|
|
6
|
+
* 选「服务节点」出现网页端口 + API 接入端口 + 网页账号管理 + apikey 管理。
|
|
7
|
+
* 账户和 apikey 各自带 SFW / NSFW 分级:账户给网页用,apikey 给 API 接入端口用。
|
|
8
|
+
* 2. 侧边栏 · 用户节点视图 —— 连服务节点:工作流模板、当前队列、模型列表、本机产物目录。
|
|
9
|
+
* 3. 侧边栏 · 服务节点视图 —— ComfyUI 板块(实际端口托管)、Blender 板块(实际端口/进程托管)、
|
|
10
|
+
* 队列板块、模型列表板块。
|
|
11
|
+
*
|
|
12
|
+
* 两套视图不是同一个页面的开关,而是两套不同的信息结构,所以分成两个组件而不是一堆 if。
|
|
13
|
+
* 所有请求都打本插件自己的同源路由;页面永远不直连服务节点,否则 apikey 会落到浏览器里。
|
|
14
|
+
*/
|
|
15
|
+
window.__ModuleLoader__.load({
|
|
16
|
+
id: 'dsh-multimodal-node',
|
|
17
|
+
factory: (require) => {
|
|
18
|
+
const React = require('react');
|
|
19
|
+
const h = React.createElement;
|
|
20
|
+
const { useCallback, useEffect, useMemo, useRef, useState } = React;
|
|
21
|
+
|
|
22
|
+
const NS = 'multimodal-node';
|
|
23
|
+
const PAGE_KEY = 'multimodal-node';
|
|
24
|
+
const BASE = '/multimodal';
|
|
25
|
+
const API_PORT_KEY = 'listen';
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------- 文案
|
|
28
|
+
const zh = {
|
|
29
|
+
plugin: '多模态节点',
|
|
30
|
+
tab: '多模态',
|
|
31
|
+
settingsSection: '多模态节点',
|
|
32
|
+
|
|
33
|
+
// 设置页 · 通用
|
|
34
|
+
modeTitle: '工作模式',
|
|
35
|
+
modeHint: '一个插件两种身份。服务节点开门并派发任务;用户节点不开门,只连别人的服务节点。切换后立刻生效,数据目录不换——工作流库、产物索引都还在。',
|
|
36
|
+
modeService: '服务节点',
|
|
37
|
+
modeServiceHint: '这台机器有显卡:开 API 接入端口给程序用,开网页端口给人用,托管 ComfyUI 与 Blender 的实际端口。',
|
|
38
|
+
modeUser: '用户节点',
|
|
39
|
+
modeUserHint: '这台机器只是客户端:填服务节点的公网地址与 apikey,在侧边栏拿模板、看队列、看模型,产物落本机目录。',
|
|
40
|
+
currentMode: '当前模式',
|
|
41
|
+
switchMode: '切换到这个模式',
|
|
42
|
+
save: '保存',
|
|
43
|
+
saving: '保存中…',
|
|
44
|
+
saved: '已保存',
|
|
45
|
+
failed: '失败',
|
|
46
|
+
apply: '应用',
|
|
47
|
+
reload: '刷新',
|
|
48
|
+
restartListeners: '重启端口',
|
|
49
|
+
restarted: '端口已重启',
|
|
50
|
+
restartNeeded: '端口已改,点「重启端口」生效',
|
|
51
|
+
|
|
52
|
+
// 设置页 · 服务节点
|
|
53
|
+
secPorts: '端口',
|
|
54
|
+
apiPort: 'API 接入端口',
|
|
55
|
+
apiPortHint: '其他机器、脚本、别的 DSH 把 JSON POST 到这里。ComfyUI 的协议与用户节点接口共用这一个端口。',
|
|
56
|
+
webPort: '网页端口',
|
|
57
|
+
webPortHint: '浏览器打开这个端口,用网页账号登录。',
|
|
58
|
+
portRange: '端口 1–65535',
|
|
59
|
+
portSame: '两个端口不能相同',
|
|
60
|
+
webEnabled: '开启网页端口',
|
|
61
|
+
apiEnabled: '开启 API 接入端口',
|
|
62
|
+
lanHint: '外网访问请在前面加反向代理;这里只负责监听。',
|
|
63
|
+
|
|
64
|
+
secAccounts: '网页账号',
|
|
65
|
+
secAccountsHint: '给网页端口用:用户名 + 密码 + 6 位配对码。每个账号可设分级。',
|
|
66
|
+
accountName: '账号名',
|
|
67
|
+
accountPassword: '密码(至少 8 位)',
|
|
68
|
+
accountAdmin: '管理员(可桥接实例、切换成人显示)',
|
|
69
|
+
addAccount: '添加账号',
|
|
70
|
+
removeAccount: '删除',
|
|
71
|
+
noAccounts: '还没有网页账号,先加一个再让别人登录。',
|
|
72
|
+
role: '角色',
|
|
73
|
+
roleAdmin: '管理员',
|
|
74
|
+
roleViewer: '只读',
|
|
75
|
+
sessions: '在线会话',
|
|
76
|
+
noSessions: '当前无人登录',
|
|
77
|
+
revokeAll: '踢掉全部会话',
|
|
78
|
+
pairing: '配对码',
|
|
79
|
+
pairingHint: '60 秒一换,念给对方即可;只在这个页面显示。',
|
|
80
|
+
requireCode: '动态配对码',
|
|
81
|
+
requireCodeHint: '关闭后只凭账号密码登录。',
|
|
82
|
+
|
|
83
|
+
secApiKeys: 'API Key',
|
|
84
|
+
secApiKeysHint: '给 API 接入端口用:程序带一个 Bearer 串即可,不走密码、不走配对码。明文只在创建时显示一次。',
|
|
85
|
+
requireKey: '要求 apikey',
|
|
86
|
+
requireKeyHint: '关闭时 API 端口不校验凭据(家庭内网)。打开后只认下面这些 key。',
|
|
87
|
+
keyName: '名称',
|
|
88
|
+
keyNote: '备注(给谁用的)',
|
|
89
|
+
createKey: '生成 apikey',
|
|
90
|
+
rotateKey: '换一把',
|
|
91
|
+
removeKey: '删除',
|
|
92
|
+
keyOnce: '这个 key 只显示这一次,现在复制走:',
|
|
93
|
+
keyOnceHint: '关掉提示就再也拿不到了;忘了就「换一把」。',
|
|
94
|
+
copy: '复制',
|
|
95
|
+
copied: '已复制',
|
|
96
|
+
noKeys: '还没有 apikey。要给别人发凭据时先在这里生成一把。',
|
|
97
|
+
keyPrefix: '前缀',
|
|
98
|
+
keyLastUsed: '最近使用',
|
|
99
|
+
neverUsed: '从未使用',
|
|
100
|
+
keyEnabled: '启用',
|
|
101
|
+
|
|
102
|
+
// 设置页 · 用户节点
|
|
103
|
+
secServiceNode: '服务节点',
|
|
104
|
+
secServiceNodeHint: '填服务节点提供的公网地址与 apikey。别填反代后面的内部地址——用户的机器只能看见公网那个。',
|
|
105
|
+
publicUrl: '公网端口地址',
|
|
106
|
+
publicUrlHint: '例:https://node.example.com:8287 或 http://1.2.3.4:8287,不带尾斜杠。',
|
|
107
|
+
apiKey: 'API Key',
|
|
108
|
+
apiKeyHint: '服务节点在它的设置页里生成,形如 mmn_ 开头。',
|
|
109
|
+
nodeLabel: '本机名称',
|
|
110
|
+
nodeLabelHint: '连上去时带上,方便服务端审计里认出是谁(可空)。',
|
|
111
|
+
timeoutMs: '超时(毫秒)',
|
|
112
|
+
testConnection: '测试连接',
|
|
113
|
+
testing: '连接中…',
|
|
114
|
+
connected: '已连通',
|
|
115
|
+
notConfigured: '还没填地址',
|
|
116
|
+
|
|
117
|
+
// 设置页 · 产物目录
|
|
118
|
+
secOutput: '本机产物文件夹',
|
|
119
|
+
outputDir: '产物目录',
|
|
120
|
+
outputDirHint: '用户节点从服务节点拉回来的图/视频落到这里。留空用默认位置。',
|
|
121
|
+
openOutput: '创建并打开',
|
|
122
|
+
outputBytes: '总计',
|
|
123
|
+
noFiles: '目录里还没有产物',
|
|
124
|
+
blenderOutput: 'Blender 产物子目录',
|
|
125
|
+
|
|
126
|
+
// 设置页 · Blender
|
|
127
|
+
secBlender: 'Blender 板块',
|
|
128
|
+
secBlenderHint: '一个实例 = 一个可提交渲染的地方:bridge 走对方机器的 HTTP 端点,spawn 跑本机 blender.exe。',
|
|
129
|
+
blenderEnabled: '启用 Blender 板块',
|
|
130
|
+
blenderExe: 'blender.exe 路径',
|
|
131
|
+
blenderExeHint: '留空自动在常见安装位置与 PATH 里找。找到的路径会显示在下面。',
|
|
132
|
+
blenderResolved: '解析到的可执行文件',
|
|
133
|
+
blenderNotFound: '没找到 blender.exe',
|
|
134
|
+
addBlender: '+ 加一个 Blender 实例',
|
|
135
|
+
blenderKind: '托管方式',
|
|
136
|
+
blenderBridge: 'bridge:对方机器上的 HTTP 端点',
|
|
137
|
+
blenderSpawn: 'spawn:本机 blender.exe 命令行渲染',
|
|
138
|
+
|
|
139
|
+
// 侧边栏 · 通用
|
|
140
|
+
subtitleService: '统一入口,派发到各显卡实例',
|
|
141
|
+
subtitleUser: '连服务节点,取模板与队列',
|
|
142
|
+
queue: '队列',
|
|
143
|
+
queued: '排队',
|
|
144
|
+
running: '执行中',
|
|
145
|
+
done: '完成',
|
|
146
|
+
failedState: '失败',
|
|
147
|
+
cancelled: '已取消',
|
|
148
|
+
estimate: '剩余',
|
|
149
|
+
empty: '队列是空的',
|
|
150
|
+
cancel: '取消',
|
|
151
|
+
bump: '插队',
|
|
152
|
+
free: '释放显存',
|
|
153
|
+
clearQueue: '清空排队',
|
|
154
|
+
cost: '代价',
|
|
155
|
+
priority: '优先级',
|
|
156
|
+
unassigned: '未分配',
|
|
157
|
+
cost2: '代价',
|
|
158
|
+
|
|
159
|
+
// 侧边栏 · 服务节点
|
|
160
|
+
// 模型扫描(ComfyUI 板块内)
|
|
161
|
+
modelScan: '模型文件夹',
|
|
162
|
+
modelScanHint: '扫描本地模型文件夹,按目录分组列文件。地址可改。',
|
|
163
|
+
modelsRootPlaceholder: 'E:\\ComfyUI-aki-v3.2\\ComfyUI\\models',
|
|
164
|
+
probeDir: '探测目录',
|
|
165
|
+
saveDir: '保存地址',
|
|
166
|
+
scanNow: '扫描',
|
|
167
|
+
scanning: '扫描中…',
|
|
168
|
+
probeOk: '可读',
|
|
169
|
+
probeFailed: '探测失败',
|
|
170
|
+
scanIdle: '点了「扫描」才会读这里的目录与文件。',
|
|
171
|
+
scanEmpty: '这个目录能读,但没有找到权重文件(.safetensors / .ckpt / .pt / .pth / .bin / .gguf / .onnx / .sft / .pkl),也没有子目录。',
|
|
172
|
+
modelsRootUnset: '模型文件夹地址:未设置',
|
|
173
|
+
modelsRootUnsetHint: '不填就用启动脚本推断的 <bat 目录>/ComfyUI/models;推断不出来就是空的。',
|
|
174
|
+
extraRoots: '额外扫描目录',
|
|
175
|
+
extraRootsHint: '不在任何 ComfyUI 安装里的模型:另一块盘、NAS 映射盘、整理到一半的下载目录。它们不属于某张卡。',
|
|
176
|
+
noExtraRoots: '还没有额外目录。',
|
|
177
|
+
addRoot: '加一个目录',
|
|
178
|
+
removeRoot: '移除',
|
|
179
|
+
collapse: '收起',
|
|
180
|
+
expand: '展开',
|
|
181
|
+
|
|
182
|
+
boardComfy: 'ComfyUI 板块',
|
|
183
|
+
boardComfyHint: '一个实例 = 一张显卡 = 一个 ComfyUI 端口。绑好后由 API 端口统一派发。',
|
|
184
|
+
boardBlender: 'Blender 板块',
|
|
185
|
+
boardBlenderHint: '实际端口管理:bridge 看 HTTP 端点,spawn 看本机进程。',
|
|
186
|
+
boardQueue: '队列板块',
|
|
187
|
+
boardModels: '模型列表板块',
|
|
188
|
+
boardModelsHint: '从本机磁盘读:目录 + 文件,不依赖实例醒着。',
|
|
189
|
+
instances: '实例',
|
|
190
|
+
addInstance: '+ 绑定实例',
|
|
191
|
+
newInstance: '实例地址(如 http://192.168.3.253:8190)',
|
|
192
|
+
label: '显示名',
|
|
193
|
+
baseUrl: '地址',
|
|
194
|
+
weight: '权重',
|
|
195
|
+
maxConcurrent: '并发上限',
|
|
196
|
+
enable: '参与派发',
|
|
197
|
+
start: '启动',
|
|
198
|
+
stop: '停止',
|
|
199
|
+
restart: '重启',
|
|
200
|
+
remove: '解绑',
|
|
201
|
+
probe: '刷新状态',
|
|
202
|
+
healthy: '在线',
|
|
203
|
+
unhealthy: '离线',
|
|
204
|
+
starting: '启动中…',
|
|
205
|
+
stopping: '停止中…',
|
|
206
|
+
processOwned: '进程由本插件托管',
|
|
207
|
+
processForeign: '进程非托管',
|
|
208
|
+
vram: '显存',
|
|
209
|
+
modelsRoot: '模型目录',
|
|
210
|
+
modelsRootHint: '本机 ComfyUI 的 models 绝对路径;留空则按启动脚本推断。',
|
|
211
|
+
controlType: '托管方式',
|
|
212
|
+
controlNone: '不托管(只能派发)',
|
|
213
|
+
controlScript: '本机托管 bat',
|
|
214
|
+
batPath: 'bat 文件',
|
|
215
|
+
batPlaceholder: 'E:\\ComfyUI\\run_gpu0.bat',
|
|
216
|
+
cwd: '工作目录(可空)',
|
|
217
|
+
autoStart: '插件启动时自动拉起',
|
|
218
|
+
stopOnExit: '关闭 DSH 时一并停止',
|
|
219
|
+
noInstances: '还没有绑定实例。把本机 ComfyUI 的端口填进来。',
|
|
220
|
+
changed: '有未保存的修改',
|
|
221
|
+
applyFirst: '先保存实例修改',
|
|
222
|
+
|
|
223
|
+
// 侧边栏 · 模型
|
|
224
|
+
modelRoots: '模型根目录',
|
|
225
|
+
noModels: '读不到模型目录',
|
|
226
|
+
folders: '目录',
|
|
227
|
+
files: '文件',
|
|
228
|
+
searchModels: '搜文件名…',
|
|
229
|
+
|
|
230
|
+
// 侧边栏 · 用户节点
|
|
231
|
+
serviceStatus: '服务节点状态',
|
|
232
|
+
connectFirst: '先在设置页填服务节点的公网地址与 apikey,这里才有东西可看。',
|
|
233
|
+
goSettings: '去设置',
|
|
234
|
+
templates: '工作流模板',
|
|
235
|
+
templatesHint: '服务节点上的工作流库,按你的 apikey 分级过滤。',
|
|
236
|
+
noTemplates: '服务节点上没有可用模板',
|
|
237
|
+
needText: '要提示词',
|
|
238
|
+
noText: '不需提示词',
|
|
239
|
+
openTemplate: '用这个',
|
|
240
|
+
pulled: '已拉到本机',
|
|
241
|
+
detail: '详情',
|
|
242
|
+
close: '关闭',
|
|
243
|
+
nodeQueue: '当前队列',
|
|
244
|
+
nodeModels: '模型列表',
|
|
245
|
+
modelsLocked: '这个 apikey 的分级是 SFW,不列出模型文件名',
|
|
246
|
+
localOutput: '本机产物文件夹',
|
|
247
|
+
localOutputHint: '从服务节点拉下来的产物落在这个目录。',
|
|
248
|
+
refresh: '刷新',
|
|
249
|
+
retry: '重试',
|
|
250
|
+
loading: '读取中…',
|
|
251
|
+
|
|
252
|
+
// 侧边栏 · Blender
|
|
253
|
+
blendFile: '工程文件',
|
|
254
|
+
blendHint: 'fill 绝对路径,或用下面的浏览按钮在某个目录里找 .blend',
|
|
255
|
+
blendDir: '浏览目录',
|
|
256
|
+
browse: '浏览',
|
|
257
|
+
frames: '帧范围',
|
|
258
|
+
framesHint: '1 / 1-20 / 1,5,9 / 1-20:2',
|
|
259
|
+
formatOut: '输出格式',
|
|
260
|
+
engine: '渲染引擎',
|
|
261
|
+
render: '提交渲染',
|
|
262
|
+
renderJobs: '渲染任务',
|
|
263
|
+
noBlend: '还没选工程文件',
|
|
264
|
+
executable: '可执行文件',
|
|
265
|
+
kindBridge: 'bridge',
|
|
266
|
+
kindSpawn: 'spawn',
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const en = {
|
|
270
|
+
plugin: 'Multimodal Node',
|
|
271
|
+
tab: 'Multimodal',
|
|
272
|
+
settingsSection: 'Multimodal Node',
|
|
273
|
+
|
|
274
|
+
modeTitle: 'Work mode',
|
|
275
|
+
modeHint: 'One plugin, two identities. A service node opens ports and dispatches work; a user node opens nothing and only talks to a service node. Switching applies at once and keeps the data directory.',
|
|
276
|
+
modeService: 'Service node',
|
|
277
|
+
modeServiceHint: 'This machine has GPUs: an API port for programs, a web port for people, and it hosts the real ComfyUI and Blender ports.',
|
|
278
|
+
modeUser: 'User node',
|
|
279
|
+
modeUserHint: 'This machine is only a client: give the service node URL and an apikey, then browse templates, the queue and models from the sidebar, saving products locally.',
|
|
280
|
+
currentMode: 'Current mode',
|
|
281
|
+
switchMode: 'Switch to this mode',
|
|
282
|
+
save: 'Save',
|
|
283
|
+
saving: 'Saving…',
|
|
284
|
+
saved: 'Saved',
|
|
285
|
+
failed: 'Failed',
|
|
286
|
+
apply: 'Apply',
|
|
287
|
+
reload: 'Refresh',
|
|
288
|
+
restartListeners: 'Restart ports',
|
|
289
|
+
restarted: 'Ports restarted',
|
|
290
|
+
restartNeeded: 'Ports changed — press Restart ports',
|
|
291
|
+
|
|
292
|
+
secPorts: 'Ports',
|
|
293
|
+
apiPort: 'API port',
|
|
294
|
+
apiPortHint: 'Other machines, scripts and other DSHs POST JSON here. ComfyUI protocol and the user-node API share this one port.',
|
|
295
|
+
webPort: 'Web port',
|
|
296
|
+
webPortHint: 'Open this in a browser and sign in with a web account.',
|
|
297
|
+
portRange: 'Port 1–65535',
|
|
298
|
+
portSame: 'The two ports must differ',
|
|
299
|
+
webEnabled: 'Enable web port',
|
|
300
|
+
apiEnabled: 'Enable API port',
|
|
301
|
+
lanHint: 'Put a reverse proxy in front for public access; this only listens.',
|
|
302
|
+
|
|
303
|
+
secAccounts: 'Web accounts',
|
|
304
|
+
secAccountsHint: 'For the web port: name + password + a 6-digit pairing code. Each account carries a content tier.',
|
|
305
|
+
accountName: 'Account name',
|
|
306
|
+
accountPassword: 'Password (8+ chars)',
|
|
307
|
+
accountAdmin: 'Admin (may lease instances, toggle adult view)',
|
|
308
|
+
addAccount: 'Add account',
|
|
309
|
+
removeAccount: 'Remove',
|
|
310
|
+
noAccounts: 'No web account yet — add one before letting anyone in.',
|
|
311
|
+
role: 'Role',
|
|
312
|
+
roleAdmin: 'admin',
|
|
313
|
+
roleViewer: 'viewer',
|
|
314
|
+
sessions: 'Sessions',
|
|
315
|
+
noSessions: 'Nobody signed in',
|
|
316
|
+
revokeAll: 'Revoke all sessions',
|
|
317
|
+
pairing: 'Pairing code',
|
|
318
|
+
pairingHint: 'Rotates every 60 s; read it out loud. Shown only here.',
|
|
319
|
+
requireCode: 'Dynamic pairing code',
|
|
320
|
+
requireCodeHint: 'Off means password only.',
|
|
321
|
+
|
|
322
|
+
secApiKeys: 'API keys',
|
|
323
|
+
secApiKeysHint: 'For the API port: a program sends one Bearer string, no password, no pairing code. The plaintext is shown once, on creation.',
|
|
324
|
+
requireKey: 'Require apikey',
|
|
325
|
+
requireKeyHint: 'Off means the API port checks no credential (home LAN). On means only the keys below work.',
|
|
326
|
+
keyName: 'Name',
|
|
327
|
+
keyNote: 'Note (who it is for)',
|
|
328
|
+
createKey: 'Create apikey',
|
|
329
|
+
rotateKey: 'Rotate',
|
|
330
|
+
removeKey: 'Remove',
|
|
331
|
+
keyOnce: 'This key is shown once — copy it now:',
|
|
332
|
+
keyOnceHint: 'Dismiss this and it is gone; use Rotate if you lose it.',
|
|
333
|
+
copy: 'Copy',
|
|
334
|
+
copied: 'Copied',
|
|
335
|
+
noKeys: 'No apikey yet. Create one before handing credentials to anyone.',
|
|
336
|
+
keyPrefix: 'Prefix',
|
|
337
|
+
keyLastUsed: 'Last used',
|
|
338
|
+
neverUsed: 'never',
|
|
339
|
+
keyEnabled: 'Enabled',
|
|
340
|
+
|
|
341
|
+
secServiceNode: 'Service node',
|
|
342
|
+
secServiceNodeHint: 'The public URL and apikey the service node gave you. Do not use an internal address — other machines cannot see it.',
|
|
343
|
+
publicUrl: 'Public port address',
|
|
344
|
+
publicUrlHint: 'e.g. https://node.example.com:8287 or http://1.2.3.4:8287, no trailing slash.',
|
|
345
|
+
apiKey: 'API key',
|
|
346
|
+
apiKeyHint: 'Created on the service node, starts with mmn_.',
|
|
347
|
+
nodeLabel: 'This machine name',
|
|
348
|
+
nodeLabelHint: 'Sent along so the server can tell who is calling (optional).',
|
|
349
|
+
timeoutMs: 'Timeout (ms)',
|
|
350
|
+
testConnection: 'Test connection',
|
|
351
|
+
testing: 'Connecting…',
|
|
352
|
+
connected: 'Connected',
|
|
353
|
+
notConfigured: 'No address yet',
|
|
354
|
+
|
|
355
|
+
secOutput: 'Local product folder',
|
|
356
|
+
outputDir: 'Product folder',
|
|
357
|
+
outputDirHint: 'Images and videos pulled from the service node land here. Blank uses the default.',
|
|
358
|
+
openOutput: 'Create and open',
|
|
359
|
+
outputBytes: 'total',
|
|
360
|
+
noFiles: 'No products in the folder yet',
|
|
361
|
+
blenderOutput: 'Blender products subfolder',
|
|
362
|
+
|
|
363
|
+
secBlender: 'Blender board',
|
|
364
|
+
secBlenderHint: 'One instance = one place you can submit a render: bridge talks to an HTTP endpoint, spawn runs a local blender.exe.',
|
|
365
|
+
blenderEnabled: 'Enable the Blender board',
|
|
366
|
+
blenderExe: 'blender.exe path',
|
|
367
|
+
blenderExeHint: 'Blank searches the usual install locations and PATH. The resolved path is shown below.',
|
|
368
|
+
blenderResolved: 'Resolved executable',
|
|
369
|
+
blenderNotFound: 'blender.exe not found',
|
|
370
|
+
addBlender: '+ Add a Blender instance',
|
|
371
|
+
blenderKind: 'Hosting',
|
|
372
|
+
blenderBridge: 'bridge: an HTTP endpoint on another machine',
|
|
373
|
+
blenderSpawn: 'spawn: local blender.exe command line',
|
|
374
|
+
|
|
375
|
+
subtitleService: 'One entry, dispatched across GPU instances',
|
|
376
|
+
subtitleUser: 'Templates and queue from a service node',
|
|
377
|
+
queue: 'Queue',
|
|
378
|
+
queued: 'queued',
|
|
379
|
+
running: 'running',
|
|
380
|
+
done: 'done',
|
|
381
|
+
failedState: 'failed',
|
|
382
|
+
cancelled: 'cancelled',
|
|
383
|
+
estimate: 'remaining',
|
|
384
|
+
empty: 'Queue is empty',
|
|
385
|
+
cancel: 'Cancel',
|
|
386
|
+
bump: 'Bump',
|
|
387
|
+
free: 'Free VRAM',
|
|
388
|
+
clearQueue: 'Clear queue',
|
|
389
|
+
cost: 'cost',
|
|
390
|
+
priority: 'priority',
|
|
391
|
+
unassigned: 'unassigned',
|
|
392
|
+
cost2: 'cost',
|
|
393
|
+
|
|
394
|
+
// Model scanning (inside the ComfyUI board)
|
|
395
|
+
modelScan: 'Model folder',
|
|
396
|
+
modelScanHint: 'Scan a local model folder; group the files by sub-directory. The address is editable.',
|
|
397
|
+
modelsRootPlaceholder: 'E:\\ComfyUI-aki-v3.2\\ComfyUI\\models',
|
|
398
|
+
probeDir: 'Probe folder',
|
|
399
|
+
saveDir: 'Save address',
|
|
400
|
+
scanNow: 'Scan',
|
|
401
|
+
scanning: 'Scanning…',
|
|
402
|
+
probeOk: 'Readable',
|
|
403
|
+
probeFailed: 'Probe failed',
|
|
404
|
+
scanIdle: 'Press Scan to read the folders and files here.',
|
|
405
|
+
scanEmpty: 'The folder is readable but holds no weights (.safetensors / .ckpt / .pt / .pth / .bin / .gguf / .onnx / .sft / .pkl) and no sub-directories.',
|
|
406
|
+
modelsRootUnset: 'Model folder: not set',
|
|
407
|
+
modelsRootUnsetHint: 'Blank infers <bat dir>/ComfyUI/models from the launcher; when that cannot be inferred the list stays empty.',
|
|
408
|
+
extraRoots: 'Extra scan folders',
|
|
409
|
+
extraRootsHint: 'Models that live outside any ComfyUI install: another drive, a mapped NAS share, a half-sorted download folder. They belong to no GPU.',
|
|
410
|
+
noExtraRoots: 'No extra folder yet.',
|
|
411
|
+
addRoot: 'Add a folder',
|
|
412
|
+
removeRoot: 'Remove',
|
|
413
|
+
collapse: 'Collapse',
|
|
414
|
+
expand: 'Expand',
|
|
415
|
+
|
|
416
|
+
boardComfy: 'ComfyUI board',
|
|
417
|
+
boardComfyHint: 'One instance = one GPU = one ComfyUI port. Bind it and the API port dispatches to it.',
|
|
418
|
+
boardBlender: 'Blender board',
|
|
419
|
+
boardBlenderHint: 'Real port management: bridge watches an HTTP endpoint, spawn watches a local process.',
|
|
420
|
+
boardQueue: 'Queue board',
|
|
421
|
+
boardModels: 'Model list board',
|
|
422
|
+
boardModelsHint: 'Read from local disk: folders and files, no need for an instance to be awake.',
|
|
423
|
+
instances: 'Instances',
|
|
424
|
+
addInstance: '+ Bind instance',
|
|
425
|
+
newInstance: 'Instance URL (e.g. http://192.168.3.253:8190)',
|
|
426
|
+
label: 'Label',
|
|
427
|
+
baseUrl: 'URL',
|
|
428
|
+
weight: 'Weight',
|
|
429
|
+
maxConcurrent: 'Max concurrent',
|
|
430
|
+
enable: 'Dispatch',
|
|
431
|
+
start: 'Start',
|
|
432
|
+
stop: 'Stop',
|
|
433
|
+
restart: 'Restart',
|
|
434
|
+
remove: 'Unbind',
|
|
435
|
+
probe: 'Refresh status',
|
|
436
|
+
healthy: 'online',
|
|
437
|
+
unhealthy: 'offline',
|
|
438
|
+
starting: 'starting…',
|
|
439
|
+
stopping: 'stopping…',
|
|
440
|
+
processOwned: 'process owned by this plugin',
|
|
441
|
+
processForeign: 'process not owned',
|
|
442
|
+
vram: 'VRAM',
|
|
443
|
+
modelsRoot: 'Model folder',
|
|
444
|
+
modelsRootHint: 'Absolute path of this machine\'s ComfyUI models; blank infers it from the launcher.',
|
|
445
|
+
controlType: 'Hosting',
|
|
446
|
+
controlNone: 'Not owned (dispatch only)',
|
|
447
|
+
controlScript: 'Host-owned bat file',
|
|
448
|
+
batPath: 'Bat file',
|
|
449
|
+
batPlaceholder: 'E:\\ComfyUI\\run_gpu0.bat',
|
|
450
|
+
cwd: 'Working dir (optional)',
|
|
451
|
+
autoStart: 'Start when the plugin loads',
|
|
452
|
+
stopOnExit: 'Stop when DSH exits',
|
|
453
|
+
noInstances: 'No instance bound yet. Put this machine\'s ComfyUI port in.',
|
|
454
|
+
changed: 'Unsaved changes',
|
|
455
|
+
applyFirst: 'Save instance changes first',
|
|
456
|
+
|
|
457
|
+
modelRoots: 'Model roots',
|
|
458
|
+
noModels: 'No readable model folder',
|
|
459
|
+
folders: 'folders',
|
|
460
|
+
files: 'files',
|
|
461
|
+
searchModels: 'Search file names…',
|
|
462
|
+
|
|
463
|
+
serviceStatus: 'Service node',
|
|
464
|
+
connectFirst: 'Fill the service node URL and apikey in Settings first; then this page has something to show.',
|
|
465
|
+
goSettings: 'Open settings',
|
|
466
|
+
templates: 'Workflow templates',
|
|
467
|
+
templatesHint: 'The service node\'s workflow library, filtered by your apikey tier.',
|
|
468
|
+
noTemplates: 'No template available on the service node',
|
|
469
|
+
needText: 'needs prompt',
|
|
470
|
+
noText: 'no prompt',
|
|
471
|
+
openTemplate: 'Use this',
|
|
472
|
+
pulled: 'Pulled locally',
|
|
473
|
+
detail: 'Detail',
|
|
474
|
+
close: 'Close',
|
|
475
|
+
nodeQueue: 'Current queue',
|
|
476
|
+
nodeModels: 'Model list',
|
|
477
|
+
modelsLocked: 'This apikey is SFW, so file names are withheld',
|
|
478
|
+
localOutput: 'Local product folder',
|
|
479
|
+
localOutputHint: 'Products pulled from the service node land here.',
|
|
480
|
+
refresh: 'Refresh',
|
|
481
|
+
retry: 'Retry',
|
|
482
|
+
loading: 'Loading…',
|
|
483
|
+
|
|
484
|
+
blendFile: 'Blend file',
|
|
485
|
+
blendHint: 'Fill an absolute path, or browse a folder for .blend files below',
|
|
486
|
+
blendDir: 'Browse folder',
|
|
487
|
+
browse: 'Browse',
|
|
488
|
+
frames: 'Frames',
|
|
489
|
+
framesHint: '1 / 1-20 / 1,5,9 / 1-20:2',
|
|
490
|
+
formatOut: 'Format',
|
|
491
|
+
engine: 'Engine',
|
|
492
|
+
render: 'Submit render',
|
|
493
|
+
renderJobs: 'Render jobs',
|
|
494
|
+
noBlend: 'No blend file chosen',
|
|
495
|
+
executable: 'Executable',
|
|
496
|
+
kindBridge: 'bridge',
|
|
497
|
+
kindSpawn: 'spawn',
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
// ---------------------------------------------------------------- 样式
|
|
501
|
+
const CSS = `
|
|
502
|
+
.mmn-page { display:flex; flex-direction:column; gap:14px; padding:18px 20px 40px; max-width:1180px; }
|
|
503
|
+
.mmn-head { display:flex; align-items:baseline; gap:10px; flex-wrap:wrap; }
|
|
504
|
+
.mmn-title { font-size:16px; font-weight:650; color:var(--dsw-alias-label-primary); }
|
|
505
|
+
.mmn-sub { font-size:12px; color:var(--dsw-alias-label-secondary); }
|
|
506
|
+
.mmn-mode-badge { font-size:11px; padding:2px 8px; border-radius:999px; border:1px solid var(--dsw-alias-border-secondary); color:var(--dsw-alias-label-secondary); }
|
|
507
|
+
.mmn-mode-badge[data-mode="service"] { color:var(--dsw-alias-brand-primary); border-color:var(--dsw-alias-brand-primary); }
|
|
508
|
+
.mmn-card { border:1px solid var(--dsw-alias-border-secondary); border-radius:10px; padding:12px 14px; background:var(--dsw-alias-bg-elevated); display:flex; flex-direction:column; gap:10px; }
|
|
509
|
+
.mmn-card-head { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
|
|
510
|
+
.mmn-section-title { font-size:13px; font-weight:600; color:var(--dsw-alias-label-primary); }
|
|
511
|
+
.mmn-group-title { font-size:14px; font-weight:650; color:var(--dsw-alias-label-primary); }
|
|
512
|
+
.mmn-meta { font-size:11px; color:var(--dsw-alias-label-secondary); }
|
|
513
|
+
.mmn-mono { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:11px; }
|
|
514
|
+
.mmn-grow { flex:1; }
|
|
515
|
+
.mmn-row { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
|
|
516
|
+
.mmn-col { display:flex; flex-direction:column; gap:6px; }
|
|
517
|
+
.mmn-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(280px,1fr)); gap:10px; }
|
|
518
|
+
.mmn-grid--wide { grid-template-columns:repeat(auto-fill,minmax(340px,1fr)); }
|
|
519
|
+
.mmn-input { border:1px solid var(--dsw-alias-border-secondary); border-radius:7px; padding:5px 8px; font-size:12px; background:var(--dsw-alias-bg-base); color:var(--dsw-alias-label-primary); min-width:120px; }
|
|
520
|
+
.mmn-input--wide { flex:1; min-width:240px; }
|
|
521
|
+
.mmn-input--num { width:78px; min-width:78px; }
|
|
522
|
+
.mmn-input--tiny { width:56px; min-width:56px; }
|
|
523
|
+
.mmn-textarea { border:1px solid var(--dsw-alias-border-secondary); border-radius:7px; padding:6px 8px; font-size:12px; background:var(--dsw-alias-bg-base); color:var(--dsw-alias-label-primary); width:100%; min-height:52px; resize:vertical; font-family:inherit; }
|
|
524
|
+
.mmn-select { border:1px solid var(--dsw-alias-border-secondary); border-radius:7px; padding:5px 8px; font-size:12px; background:var(--dsw-alias-bg-base); color:var(--dsw-alias-label-primary); }
|
|
525
|
+
.mmn-btn { border:1px solid var(--dsw-alias-border-secondary); background:var(--dsw-alias-bg-base); color:var(--dsw-alias-label-primary); border-radius:7px; padding:4px 10px; font-size:12px; cursor:pointer; }
|
|
526
|
+
.mmn-btn:hover { border-color:var(--dsw-alias-brand-primary); }
|
|
527
|
+
.mmn-btn:disabled { opacity:.45; cursor:default; }
|
|
528
|
+
.mmn-btn--primary { background:var(--dsw-alias-brand-primary); border-color:var(--dsw-alias-brand-primary); color:#fff; }
|
|
529
|
+
.mmn-btn--danger { color:var(--dsw-alias-state-error-primary); }
|
|
530
|
+
.mmn-btn--ghost { border-color:transparent; padding:2px 6px; }
|
|
531
|
+
.mmn-error { color:var(--dsw-alias-state-error-primary); font-size:12px; }
|
|
532
|
+
.mmn-notice { color:var(--dsw-alias-state-warn-primary); font-size:12px; }
|
|
533
|
+
.mmn-ok { color:var(--dsw-alias-state-success-primary); font-size:12px; }
|
|
534
|
+
.mmn-pill { font-size:10px; padding:1px 7px; border-radius:999px; border:1px solid var(--dsw-alias-border-secondary); color:var(--dsw-alias-label-secondary); }
|
|
535
|
+
.mmn-pill[data-state="running"] { color:var(--dsw-alias-brand-primary); border-color:var(--dsw-alias-brand-primary); }
|
|
536
|
+
.mmn-pill[data-state="done"] { color:var(--dsw-alias-state-success-primary); border-color:var(--dsw-alias-state-success-primary); }
|
|
537
|
+
.mmn-pill[data-state="failed"] { color:var(--dsw-alias-state-error-primary); border-color:var(--dsw-alias-state-error-primary); }
|
|
538
|
+
.mmn-pill[data-state="queued"] { color:var(--dsw-alias-state-warn-primary); border-color:var(--dsw-alias-state-warn-primary); }
|
|
539
|
+
.mmn-pill[data-tier="nsfw"] { color:var(--dsw-alias-state-error-primary); border-color:var(--dsw-alias-state-error-primary); }
|
|
540
|
+
.mmn-kv { display:flex; gap:10px; flex-wrap:wrap; font-size:11px; color:var(--dsw-alias-label-secondary); }
|
|
541
|
+
.mmn-task { display:flex; align-items:center; gap:8px; padding:4px 0; border-bottom:1px dashed var(--dsw-alias-border-secondary); }
|
|
542
|
+
.mmn-task:last-child { border-bottom:none; }
|
|
543
|
+
.mmn-task-id { font-family:ui-monospace,monospace; font-size:11px; color:var(--dsw-alias-label-secondary); }
|
|
544
|
+
.mmn-tabs { display:flex; gap:2px; border-bottom:1px solid var(--dsw-alias-border-secondary); }
|
|
545
|
+
.mmn-tab { border:none; background:none; padding:6px 12px; font-size:12px; color:var(--dsw-alias-label-secondary); cursor:pointer; border-bottom:2px solid transparent; }
|
|
546
|
+
.mmn-tab[data-active="true"] { color:var(--dsw-alias-brand-primary); border-bottom-color:var(--dsw-alias-brand-primary); }
|
|
547
|
+
.mmn-toggle { position:relative; width:34px; height:18px; border-radius:999px; border:1px solid var(--dsw-alias-border-secondary); background:var(--dsw-alias-bg-base); cursor:pointer; transition:background .15s; flex:none; }
|
|
548
|
+
.mmn-toggle[data-on="true"] { background:var(--dsw-alias-brand-primary); border-color:var(--dsw-alias-brand-primary); }
|
|
549
|
+
.mmn-toggle::after { content:''; position:absolute; top:2px; left:2px; width:13px; height:13px; border-radius:50%; background:#fff; transition:left .15s; }
|
|
550
|
+
.mmn-toggle[data-on="true"]::after { left:17px; }
|
|
551
|
+
.mmn-field { display:flex; flex-direction:column; gap:3px; flex:1; min-width:180px; }
|
|
552
|
+
.mmn-field-label { font-size:11px; color:var(--dsw-alias-label-secondary); }
|
|
553
|
+
.mmn-hint { font-size:11px; color:var(--dsw-alias-label-secondary); line-height:1.5; }
|
|
554
|
+
.mmn-mode-cards { display:grid; grid-template-columns:1fr 1fr; gap:10px; }
|
|
555
|
+
.mmn-mode-card { border:1px solid var(--dsw-alias-border-secondary); border-radius:10px; padding:12px; cursor:pointer; display:flex; flex-direction:column; gap:6px; background:var(--dsw-alias-bg-base); }
|
|
556
|
+
.mmn-mode-card[data-active="true"] { border-color:var(--dsw-alias-brand-primary); box-shadow:0 0 0 1px var(--dsw-alias-brand-primary) inset; }
|
|
557
|
+
.mmn-mode-name { font-size:13px; font-weight:650; color:var(--dsw-alias-label-primary); }
|
|
558
|
+
.mmn-key-once { border:1px solid var(--dsw-alias-state-warn-primary); background:var(--dsw-alias-bg-base); border-radius:8px; padding:8px 10px; display:flex; flex-direction:column; gap:6px; }
|
|
559
|
+
.mmn-key-value { font-family:ui-monospace,monospace; font-size:12px; word-break:break-all; color:var(--dsw-alias-label-primary); }
|
|
560
|
+
.mmn-thumb { width:64px; height:64px; object-fit:cover; border-radius:6px; border:1px solid var(--dsw-alias-border-secondary); background:var(--dsw-alias-bg-base); }
|
|
561
|
+
.mmn-file-row { display:flex; align-items:center; gap:8px; padding:4px 0; border-bottom:1px dashed var(--dsw-alias-border-secondary); }
|
|
562
|
+
.mmn-scroll { max-height:340px; overflow:auto; }
|
|
563
|
+
.mmn-note { font-size:11px; color:var(--dsw-alias-label-secondary); border-left:2px solid var(--dsw-alias-border-secondary); padding-left:8px; line-height:1.6; }
|
|
564
|
+
`;
|
|
565
|
+
|
|
566
|
+
// ---------------------------------------------------------------- 网络
|
|
567
|
+
const getJson = async (path) => {
|
|
568
|
+
const reply = await fetch(path, { headers: { accept: 'application/json' } });
|
|
569
|
+
const text = await reply.text();
|
|
570
|
+
try {
|
|
571
|
+
return JSON.parse(text);
|
|
572
|
+
} catch {
|
|
573
|
+
throw new Error(`${path} 返回的不是 JSON:${text.slice(0, 120)}`);
|
|
574
|
+
}
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
const postJson = async (path, body) => {
|
|
578
|
+
const reply = await fetch(path, {
|
|
579
|
+
method: 'POST',
|
|
580
|
+
headers: { 'content-type': 'application/json' },
|
|
581
|
+
body: JSON.stringify(body ?? {}),
|
|
582
|
+
});
|
|
583
|
+
const text = await reply.text();
|
|
584
|
+
let parsed;
|
|
585
|
+
try {
|
|
586
|
+
parsed = text === '' ? {} : JSON.parse(text);
|
|
587
|
+
} catch {
|
|
588
|
+
throw new Error(`${path} 返回的不是 JSON:${text.slice(0, 120)}`);
|
|
589
|
+
}
|
|
590
|
+
if (parsed.ok === false && parsed.error !== undefined) throw new Error(parsed.error);
|
|
591
|
+
return parsed;
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
/** 本地化:拿不到 locale 服务就退回中文,页面不会变成一堆 key。 */
|
|
595
|
+
function makeT(ctx) {
|
|
596
|
+
const locale = typeof ctx.get === 'function' ? ctx.get('locale') : undefined;
|
|
597
|
+
if (locale === undefined || locale === null) return (key) => zh[key] ?? key;
|
|
598
|
+
try {
|
|
599
|
+
ctx.effect(() => locale.register(NS, { zh, en }), 'dsh-multimodal-node: locale');
|
|
600
|
+
} catch {
|
|
601
|
+
// 拒绝迟到的注册就保留中文兜底
|
|
602
|
+
}
|
|
603
|
+
let bound;
|
|
604
|
+
try {
|
|
605
|
+
bound = locale.bind(NS);
|
|
606
|
+
} catch {
|
|
607
|
+
bound = undefined;
|
|
608
|
+
}
|
|
609
|
+
return (key) => {
|
|
610
|
+
if (bound !== undefined) {
|
|
611
|
+
const text = bound(key);
|
|
612
|
+
if (typeof text === 'string' && text !== key) return text;
|
|
613
|
+
}
|
|
614
|
+
return zh[key] ?? key;
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// ---------------------------------------------------------------- 小组件
|
|
619
|
+
function Pill({ state, t }) {
|
|
620
|
+
const label = state === 'running' ? t('running') : state === 'queued' ? t('queued') : state === 'done' ? t('done') : state === 'failed' ? t('failedState') : state === 'cancelled' ? t('cancelled') : state;
|
|
621
|
+
return h('span', { className: 'mmn-pill', 'data-state': state }, label);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function Toggle({ on, onChange, label, disabled }) {
|
|
625
|
+
return h(
|
|
626
|
+
'div',
|
|
627
|
+
{ className: 'mmn-row' },
|
|
628
|
+
h('button', {
|
|
629
|
+
type: 'button',
|
|
630
|
+
className: 'mmn-toggle',
|
|
631
|
+
'data-on': on ? 'true' : 'false',
|
|
632
|
+
disabled: disabled === true,
|
|
633
|
+
'aria-label': label ?? '',
|
|
634
|
+
onClick: () => onChange(!on),
|
|
635
|
+
}),
|
|
636
|
+
label === undefined ? null : h('span', { className: 'mmn-meta' }, label),
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function Field({ label, hint, children }) {
|
|
641
|
+
return h(
|
|
642
|
+
'div',
|
|
643
|
+
{ className: 'mmn-field' },
|
|
644
|
+
h('span', { className: 'mmn-field-label' }, label),
|
|
645
|
+
children,
|
|
646
|
+
hint === undefined ? null : h('span', { className: 'mmn-hint' }, hint),
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function TierPicker({ value, onChange, disabled }) {
|
|
651
|
+
return h(
|
|
652
|
+
'select',
|
|
653
|
+
{
|
|
654
|
+
className: 'mmn-select',
|
|
655
|
+
value: value === 'nsfw' ? 'nsfw' : 'sfw',
|
|
656
|
+
disabled: disabled === true,
|
|
657
|
+
onChange: (event) => onChange(event.target.value),
|
|
658
|
+
},
|
|
659
|
+
h('option', { value: 'sfw' }, 'SFW'),
|
|
660
|
+
h('option', { value: 'nsfw' }, 'NSFW'),
|
|
661
|
+
);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// ---------------------------------------------------------------- 设置页
|
|
665
|
+
/**
|
|
666
|
+
* 工作模式的两张卡:选中的那张有描边,另一张是「切过去」。
|
|
667
|
+
*
|
|
668
|
+
* 不用 radio:这不是一个表单字段,这是把整个插件换一种身份,所以两张卡各自带
|
|
669
|
+
* 一句「这个模式到底做什么」,让选择变成一件被解释过的事。
|
|
670
|
+
*/
|
|
671
|
+
function ModeCards({ mode, busy, t, onPick }) {
|
|
672
|
+
const card = (id, name, hint) =>
|
|
673
|
+
h(
|
|
674
|
+
'div',
|
|
675
|
+
{
|
|
676
|
+
className: 'mmn-mode-card',
|
|
677
|
+
'data-active': mode === id ? 'true' : 'false',
|
|
678
|
+
role: 'button',
|
|
679
|
+
tabIndex: 0,
|
|
680
|
+
onClick: () => {
|
|
681
|
+
if (busy || mode === id) return;
|
|
682
|
+
onPick(id);
|
|
683
|
+
},
|
|
684
|
+
onKeyDown: (event) => {
|
|
685
|
+
if (event.key !== 'Enter' && event.key !== ' ') return;
|
|
686
|
+
event.preventDefault();
|
|
687
|
+
if (busy || mode === id) return;
|
|
688
|
+
onPick(id);
|
|
689
|
+
},
|
|
690
|
+
},
|
|
691
|
+
h('span', { className: 'mmn-mode-name' }, name),
|
|
692
|
+
h('span', { className: 'mmn-hint' }, hint),
|
|
693
|
+
mode === id ? h('span', { className: 'mmn-ok' }, `· ${t('currentMode')}`) : h('span', { className: 'mmn-meta' }, t('switchMode')),
|
|
694
|
+
);
|
|
695
|
+
return h('div', { className: 'mmn-mode-cards' }, card('service', t('modeService'), t('modeServiceHint')), card('user', t('modeUser'), t('modeUserHint')));
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function AccountsSection({ status, t, busy, act }) {
|
|
699
|
+
const [name, setName] = useState('');
|
|
700
|
+
const [password, setPassword] = useState('');
|
|
701
|
+
const [role, setRole] = useState('admin');
|
|
702
|
+
const [tier, setTier] = useState('nsfw');
|
|
703
|
+
const accounts = status.remote?.accounts ?? [];
|
|
704
|
+
const sessions = status.remote?.sessions ?? [];
|
|
705
|
+
|
|
706
|
+
const add = async () => {
|
|
707
|
+
await act('/multimodal/accounts', { action: 'add', name, password, role, tier });
|
|
708
|
+
setName('');
|
|
709
|
+
setPassword('');
|
|
710
|
+
};
|
|
711
|
+
|
|
712
|
+
return h(
|
|
713
|
+
'div',
|
|
714
|
+
{ className: 'mmn-card' },
|
|
715
|
+
h(
|
|
716
|
+
'div',
|
|
717
|
+
{ className: 'mmn-card-head' },
|
|
718
|
+
h('span', { className: 'mmn-section-title' }, t('secAccounts')),
|
|
719
|
+
h('span', { className: 'mmn-meta' }, t('secAccountsHint')),
|
|
720
|
+
),
|
|
721
|
+
accounts.length === 0
|
|
722
|
+
? h('div', { className: 'mmn-meta' }, t('noAccounts'))
|
|
723
|
+
: h(
|
|
724
|
+
'div',
|
|
725
|
+
{ className: 'mmn-col' },
|
|
726
|
+
accounts.map((account) =>
|
|
727
|
+
h(
|
|
728
|
+
'div',
|
|
729
|
+
{ className: 'mmn-task', key: account.name },
|
|
730
|
+
h('span', { className: 'mmn-mono' }, account.name),
|
|
731
|
+
h('span', { className: 'mmn-pill' }, account.role === 'admin' ? t('roleAdmin') : t('roleViewer')),
|
|
732
|
+
h('span', { className: 'mmn-pill', 'data-tier': account.tier }, account.tier === 'nsfw' ? 'NSFW' : 'SFW'),
|
|
733
|
+
h('span', { className: 'mmn-grow' }),
|
|
734
|
+
h(TierPicker, {
|
|
735
|
+
value: account.tier,
|
|
736
|
+
disabled: busy,
|
|
737
|
+
onChange: (next) => void act('/multimodal/accounts', { action: 'tier', name: account.name, tier: next }),
|
|
738
|
+
}),
|
|
739
|
+
h(
|
|
740
|
+
'button',
|
|
741
|
+
{
|
|
742
|
+
type: 'button',
|
|
743
|
+
className: 'mmn-btn mmn-btn--ghost mmn-btn--danger',
|
|
744
|
+
disabled: busy,
|
|
745
|
+
onClick: () => void act('/multimodal/accounts', { action: 'remove', name: account.name }),
|
|
746
|
+
},
|
|
747
|
+
t('removeAccount'),
|
|
748
|
+
),
|
|
749
|
+
),
|
|
750
|
+
),
|
|
751
|
+
),
|
|
752
|
+
h(
|
|
753
|
+
'div',
|
|
754
|
+
{ className: 'mmn-row' },
|
|
755
|
+
h('input', { className: 'mmn-input', placeholder: t('accountName'), value: name, onChange: (event) => setName(event.target.value) }),
|
|
756
|
+
h('input', {
|
|
757
|
+
className: 'mmn-input',
|
|
758
|
+
type: 'password',
|
|
759
|
+
placeholder: t('accountPassword'),
|
|
760
|
+
value: password,
|
|
761
|
+
onChange: (event) => setPassword(event.target.value),
|
|
762
|
+
}),
|
|
763
|
+
h(
|
|
764
|
+
'select',
|
|
765
|
+
{ className: 'mmn-select', value: role, onChange: (event) => setRole(event.target.value) },
|
|
766
|
+
h('option', { value: 'admin' }, t('roleAdmin')),
|
|
767
|
+
h('option', { value: 'viewer' }, t('roleViewer')),
|
|
768
|
+
),
|
|
769
|
+
h(TierPicker, { value: tier, onChange: setTier }),
|
|
770
|
+
h(
|
|
771
|
+
'button',
|
|
772
|
+
{ type: 'button', className: 'mmn-btn', disabled: busy || name.trim() === '' || password.length < 8, onClick: () => void add() },
|
|
773
|
+
t('addAccount'),
|
|
774
|
+
),
|
|
775
|
+
),
|
|
776
|
+
h(
|
|
777
|
+
'div',
|
|
778
|
+
{ className: 'mmn-row' },
|
|
779
|
+
h(Toggle, {
|
|
780
|
+
on: status.remote?.requireCode !== false,
|
|
781
|
+
disabled: busy,
|
|
782
|
+
label: t('requireCode'),
|
|
783
|
+
onChange: (next) => void act('/multimodal/accounts', { action: 'requireCode', requireCode: next }),
|
|
784
|
+
}),
|
|
785
|
+
h('span', { className: 'mmn-hint' }, t('requireCodeHint')),
|
|
786
|
+
),
|
|
787
|
+
h(
|
|
788
|
+
'div',
|
|
789
|
+
{ className: 'mmn-card-head' },
|
|
790
|
+
h('span', { className: 'mmn-section-title' }, t('sessions')),
|
|
791
|
+
h('span', { className: 'mmn-meta' }, sessions.length === 0 ? t('noSessions') : `${sessions.length}`),
|
|
792
|
+
h('span', { className: 'mmn-grow' }),
|
|
793
|
+
h(
|
|
794
|
+
'button',
|
|
795
|
+
{
|
|
796
|
+
type: 'button',
|
|
797
|
+
className: 'mmn-btn mmn-btn--danger',
|
|
798
|
+
disabled: busy || sessions.length === 0,
|
|
799
|
+
onClick: () => void act('/multimodal/accounts', { action: 'revoke' }),
|
|
800
|
+
},
|
|
801
|
+
t('revokeAll'),
|
|
802
|
+
),
|
|
803
|
+
),
|
|
804
|
+
sessions.length === 0
|
|
805
|
+
? null
|
|
806
|
+
: h(
|
|
807
|
+
'div',
|
|
808
|
+
{ className: 'mmn-col' },
|
|
809
|
+
sessions.map((entry) =>
|
|
810
|
+
h(
|
|
811
|
+
'div',
|
|
812
|
+
{ className: 'mmn-task', key: `${entry.user}-${entry.createdAt}` },
|
|
813
|
+
h('span', { className: 'mmn-mono' }, entry.user),
|
|
814
|
+
h('span', { className: 'mmn-pill', 'data-tier': entry.tier }, entry.tier === 'nsfw' ? 'NSFW' : 'SFW'),
|
|
815
|
+
h('span', { className: 'mmn-meta' }, entry.ip ?? ''),
|
|
816
|
+
h('span', { className: 'mmn-grow' }),
|
|
817
|
+
h('span', { className: 'mmn-meta' }, entry.seenAt ? new Date(entry.seenAt).toLocaleTimeString() : ''),
|
|
818
|
+
),
|
|
819
|
+
),
|
|
820
|
+
),
|
|
821
|
+
);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function ApiKeysSection({ status, t, busy, act, onKeyShown }) {
|
|
825
|
+
const [name, setName] = useState('');
|
|
826
|
+
const [note, setNote] = useState('');
|
|
827
|
+
const [tier, setTier] = useState('nsfw');
|
|
828
|
+
const keys = status.apiKeys ?? [];
|
|
829
|
+
|
|
830
|
+
const create = async () => {
|
|
831
|
+
const result = await act('/multimodal/apikeys', { action: 'create', name, tier, note });
|
|
832
|
+
if (result !== undefined && typeof result.key === 'string') onKeyShown({ name, key: result.key });
|
|
833
|
+
setName('');
|
|
834
|
+
setNote('');
|
|
835
|
+
};
|
|
836
|
+
|
|
837
|
+
return h(
|
|
838
|
+
'div',
|
|
839
|
+
{ className: 'mmn-card' },
|
|
840
|
+
h(
|
|
841
|
+
'div',
|
|
842
|
+
{ className: 'mmn-card-head' },
|
|
843
|
+
h('span', { className: 'mmn-section-title' }, t('secApiKeys')),
|
|
844
|
+
h('span', { className: 'mmn-meta' }, t('secApiKeysHint')),
|
|
845
|
+
),
|
|
846
|
+
h(
|
|
847
|
+
'div',
|
|
848
|
+
{ className: 'mmn-row' },
|
|
849
|
+
h(Toggle, {
|
|
850
|
+
on: status.apiAuth?.requireKey === true,
|
|
851
|
+
disabled: busy,
|
|
852
|
+
label: t('requireKey'),
|
|
853
|
+
onChange: (next) => void act('/multimodal/apikeys', { action: 'requireKey', requireKey: next }),
|
|
854
|
+
}),
|
|
855
|
+
h('span', { className: 'mmn-hint' }, t('requireKeyHint')),
|
|
856
|
+
),
|
|
857
|
+
keys.length === 0
|
|
858
|
+
? h('div', { className: 'mmn-meta' }, t('noKeys'))
|
|
859
|
+
: h(
|
|
860
|
+
'div',
|
|
861
|
+
{ className: 'mmn-col' },
|
|
862
|
+
keys.map((entry) =>
|
|
863
|
+
h(
|
|
864
|
+
'div',
|
|
865
|
+
{ className: 'mmn-task', key: entry.id ?? entry.name },
|
|
866
|
+
h('span', { className: 'mmn-mono' }, entry.name),
|
|
867
|
+
h('span', { className: 'mmn-mono mmn-meta' }, `${entry.prefix ?? ''}…`),
|
|
868
|
+
h('span', { className: 'mmn-pill', 'data-tier': entry.tier }, entry.tier === 'nsfw' ? 'NSFW' : 'SFW'),
|
|
869
|
+
entry.enabled === false ? h('span', { className: 'mmn-pill', 'data-state': 'cancelled' }, 'off') : null,
|
|
870
|
+
h('span', { className: 'mmn-meta' }, `${t('keyLastUsed')} ${entry.lastUsedAt === null || entry.lastUsedAt === undefined ? t('neverUsed') : new Date(entry.lastUsedAt).toLocaleString()}`),
|
|
871
|
+
entry.note ? h('span', { className: 'mmn-meta' }, entry.note) : null,
|
|
872
|
+
h('span', { className: 'mmn-grow' }),
|
|
873
|
+
h(TierPicker, {
|
|
874
|
+
value: entry.tier,
|
|
875
|
+
disabled: busy,
|
|
876
|
+
onChange: (next) => void act('/multimodal/apikeys', { action: 'update', name: entry.name, tier: next }),
|
|
877
|
+
}),
|
|
878
|
+
h(
|
|
879
|
+
'button',
|
|
880
|
+
{
|
|
881
|
+
type: 'button',
|
|
882
|
+
className: 'mmn-btn mmn-btn--ghost',
|
|
883
|
+
disabled: busy,
|
|
884
|
+
onClick: () => void act('/multimodal/apikeys', { action: 'update', name: entry.name, enabled: entry.enabled === false }),
|
|
885
|
+
},
|
|
886
|
+
entry.enabled === false ? t('keyEnabled') : 'off',
|
|
887
|
+
),
|
|
888
|
+
h(
|
|
889
|
+
'button',
|
|
890
|
+
{
|
|
891
|
+
type: 'button',
|
|
892
|
+
className: 'mmn-btn mmn-btn--ghost',
|
|
893
|
+
disabled: busy,
|
|
894
|
+
onClick: async () => {
|
|
895
|
+
const result = await act('/multimodal/apikeys', { action: 'rotate', name: entry.name });
|
|
896
|
+
if (result !== undefined && typeof result.key === 'string') onKeyShown({ name: entry.name, key: result.key });
|
|
897
|
+
},
|
|
898
|
+
},
|
|
899
|
+
t('rotateKey'),
|
|
900
|
+
),
|
|
901
|
+
h(
|
|
902
|
+
'button',
|
|
903
|
+
{
|
|
904
|
+
type: 'button',
|
|
905
|
+
className: 'mmn-btn mmn-btn--ghost mmn-btn--danger',
|
|
906
|
+
disabled: busy,
|
|
907
|
+
onClick: () => void act('/multimodal/apikeys', { action: 'remove', name: entry.name }),
|
|
908
|
+
},
|
|
909
|
+
t('removeKey'),
|
|
910
|
+
),
|
|
911
|
+
),
|
|
912
|
+
),
|
|
913
|
+
),
|
|
914
|
+
h(
|
|
915
|
+
'div',
|
|
916
|
+
{ className: 'mmn-row' },
|
|
917
|
+
h('input', { className: 'mmn-input', placeholder: t('keyName'), value: name, onChange: (event) => setName(event.target.value) }),
|
|
918
|
+
h('input', { className: 'mmn-input mmn-input--wide', placeholder: t('keyNote'), value: note, onChange: (event) => setNote(event.target.value) }),
|
|
919
|
+
h(TierPicker, { value: tier, onChange: setTier }),
|
|
920
|
+
h('button', { type: 'button', className: 'mmn-btn', disabled: busy || name.trim() === '', onClick: () => void create() }, t('createKey')),
|
|
921
|
+
),
|
|
922
|
+
);
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
function BlenderSettingsSection({ status, t, busy, act }) {
|
|
926
|
+
const blender = status.blender ?? { instances: [] };
|
|
927
|
+
const instances = blender.instances ?? [];
|
|
928
|
+
const [exe, setExe] = useState(status.settings?.blender?.executable ?? '');
|
|
929
|
+
|
|
930
|
+
useEffect(() => {
|
|
931
|
+
setExe(status.settings?.blender?.executable ?? '');
|
|
932
|
+
}, [status.settings?.blender?.executable]);
|
|
933
|
+
|
|
934
|
+
const patch = (id, changes) => {
|
|
935
|
+
const next = instances.map((entry) =>
|
|
936
|
+
entry.id === id
|
|
937
|
+
? {
|
|
938
|
+
...entry,
|
|
939
|
+
control: { ...(entry.controlDetail ?? entry.control ?? {}) },
|
|
940
|
+
...changes,
|
|
941
|
+
}
|
|
942
|
+
: entry,
|
|
943
|
+
);
|
|
944
|
+
return act('/multimodal/blender', { action: 'apply', blender: { instances: next } });
|
|
945
|
+
};
|
|
946
|
+
|
|
947
|
+
return h(
|
|
948
|
+
'div',
|
|
949
|
+
{ className: 'mmn-card' },
|
|
950
|
+
h(
|
|
951
|
+
'div',
|
|
952
|
+
{ className: 'mmn-card-head' },
|
|
953
|
+
h('span', { className: 'mmn-section-title' }, t('secBlender')),
|
|
954
|
+
h('span', { className: 'mmn-meta' }, t('secBlenderHint')),
|
|
955
|
+
),
|
|
956
|
+
h(
|
|
957
|
+
'div',
|
|
958
|
+
{ className: 'mmn-row' },
|
|
959
|
+
h(Toggle, {
|
|
960
|
+
on: blender.enabled === true,
|
|
961
|
+
disabled: busy,
|
|
962
|
+
label: t('blenderEnabled'),
|
|
963
|
+
onChange: (next) => void act('/multimodal/blender', { action: 'apply', blender: { enabled: next } }),
|
|
964
|
+
}),
|
|
965
|
+
),
|
|
966
|
+
h(
|
|
967
|
+
'div',
|
|
968
|
+
{ className: 'mmn-row' },
|
|
969
|
+
h(Field, {
|
|
970
|
+
label: t('blenderExe'),
|
|
971
|
+
hint: blender.executable ? `${t('blenderResolved')}:${blender.executable}` : t('blenderNotFound'),
|
|
972
|
+
children: h('input', { className: 'mmn-input mmn-input--wide', value: exe, placeholder: 'C:\\\\Program Files\\\\Blender Foundation\\\\Blender 4.2\\\\blender.exe', onChange: (event) => setExe(event.target.value) }),
|
|
973
|
+
}),
|
|
974
|
+
h('button', { type: 'button', className: 'mmn-btn', disabled: busy, onClick: () => void act('/multimodal/blender', { action: 'setExecutable', executable: exe }) }, t('save')),
|
|
975
|
+
),
|
|
976
|
+
instances.length === 0
|
|
977
|
+
? h('div', { className: 'mmn-meta' }, t('noInstances'))
|
|
978
|
+
: h(
|
|
979
|
+
'div',
|
|
980
|
+
{ className: 'mmn-col' },
|
|
981
|
+
instances.map((entry) =>
|
|
982
|
+
h(
|
|
983
|
+
'div',
|
|
984
|
+
{ className: 'mmn-task', key: entry.id },
|
|
985
|
+
h('span', { className: 'mmn-mono' }, entry.id),
|
|
986
|
+
h('span', { className: 'mmn-pill', 'data-state': entry.healthy ? 'done' : 'failed' }, entry.healthy ? t('healthy') : t('unhealthy')),
|
|
987
|
+
h('span', { className: 'mmn-pill' }, entry.kind === 'spawn' ? t('kindSpawn') : t('kindBridge')),
|
|
988
|
+
h('span', { className: 'mmn-meta' }, entry.kind === 'spawn' ? entry.executable || '—' : entry.baseUrl),
|
|
989
|
+
entry.error ? h('span', { className: 'mmn-error' }, entry.error) : null,
|
|
990
|
+
h('span', { className: 'mmn-grow' }),
|
|
991
|
+
h(
|
|
992
|
+
'select',
|
|
993
|
+
{
|
|
994
|
+
className: 'mmn-select',
|
|
995
|
+
value: entry.kind,
|
|
996
|
+
disabled: busy,
|
|
997
|
+
onChange: (event) => void patch(entry.id, { kind: event.target.value }),
|
|
998
|
+
},
|
|
999
|
+
h('option', { value: 'bridge' }, t('blenderBridge')),
|
|
1000
|
+
h('option', { value: 'spawn' }, t('blenderSpawn')),
|
|
1001
|
+
),
|
|
1002
|
+
h(Toggle, { on: entry.enabled !== false, disabled: busy, onChange: (next) => void patch(entry.id, { enabled: next }) }),
|
|
1003
|
+
),
|
|
1004
|
+
),
|
|
1005
|
+
),
|
|
1006
|
+
);
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/**
|
|
1010
|
+
* 设置页主体。
|
|
1011
|
+
*
|
|
1012
|
+
* 它自己拉 /multimodal/status,不走 props:设置页与侧边栏是两个 slot,
|
|
1013
|
+
* 谁先挂载不确定,让各自读同一个接口比把状态往上层提更简单,也更不容易挂。
|
|
1014
|
+
*/
|
|
1015
|
+
function SettingsSection({ t: tFromSlot }) {
|
|
1016
|
+
const t = tFromSlot ?? ((key) => zh[key] ?? key);
|
|
1017
|
+
const [status, setStatus] = useState(undefined);
|
|
1018
|
+
const [error, setError] = useState('');
|
|
1019
|
+
const [busy, setBusy] = useState(false);
|
|
1020
|
+
const [notice, setNotice] = useState('');
|
|
1021
|
+
const [onceKey, setOnceKey] = useState(undefined);
|
|
1022
|
+
const [copied, setCopied] = useState(false);
|
|
1023
|
+
|
|
1024
|
+
const [webPort, setWebPort] = useState('');
|
|
1025
|
+
const [apiPort, setApiPort] = useState('');
|
|
1026
|
+
const [publicUrl, setPublicUrl] = useState('');
|
|
1027
|
+
const [apiKey, setApiKey] = useState('');
|
|
1028
|
+
const [nodeLabel, setNodeLabel] = useState('');
|
|
1029
|
+
const [outputDir, setOutputDir] = useState('');
|
|
1030
|
+
const [probe, setProbe] = useState(undefined);
|
|
1031
|
+
const [products, setProducts] = useState(undefined);
|
|
1032
|
+
|
|
1033
|
+
const load = useCallback(async () => {
|
|
1034
|
+
try {
|
|
1035
|
+
const data = await getJson(`${BASE}/status`);
|
|
1036
|
+
setStatus(data);
|
|
1037
|
+
setApiPort(String(data.gateway?.port ?? ''));
|
|
1038
|
+
setWebPort(String(data.remote?.port ?? ''));
|
|
1039
|
+
setPublicUrl(data.node?.publicUrl ?? '');
|
|
1040
|
+
setNodeLabel(data.node?.label ?? '');
|
|
1041
|
+
setOutputDir(data.node?.outputDir ?? '');
|
|
1042
|
+
setError('');
|
|
1043
|
+
} catch (cause) {
|
|
1044
|
+
setError(cause instanceof Error ? cause.message : String(cause));
|
|
1045
|
+
}
|
|
1046
|
+
}, []);
|
|
1047
|
+
|
|
1048
|
+
useEffect(() => {
|
|
1049
|
+
void load();
|
|
1050
|
+
}, [load]);
|
|
1051
|
+
|
|
1052
|
+
const act = useCallback(
|
|
1053
|
+
async (path, body) => {
|
|
1054
|
+
setBusy(true);
|
|
1055
|
+
setNotice('');
|
|
1056
|
+
try {
|
|
1057
|
+
const result = await postJson(path, body);
|
|
1058
|
+
if (result.status !== undefined) {
|
|
1059
|
+
setStatus(result.status);
|
|
1060
|
+
setApiPort(String(result.status.gateway?.port ?? ''));
|
|
1061
|
+
setWebPort(String(result.status.remote?.port ?? ''));
|
|
1062
|
+
} else {
|
|
1063
|
+
await load();
|
|
1064
|
+
}
|
|
1065
|
+
setNotice(t('saved'));
|
|
1066
|
+
return result;
|
|
1067
|
+
} catch (cause) {
|
|
1068
|
+
setNotice('');
|
|
1069
|
+
setError(cause instanceof Error ? cause.message : String(cause));
|
|
1070
|
+
return undefined;
|
|
1071
|
+
} finally {
|
|
1072
|
+
setBusy(false);
|
|
1073
|
+
}
|
|
1074
|
+
},
|
|
1075
|
+
[load, t],
|
|
1076
|
+
);
|
|
1077
|
+
|
|
1078
|
+
const patchSettings = useCallback(
|
|
1079
|
+
async (patch) => {
|
|
1080
|
+
const result = await act(`${BASE}/settings`, patch);
|
|
1081
|
+
return result;
|
|
1082
|
+
},
|
|
1083
|
+
[act],
|
|
1084
|
+
);
|
|
1085
|
+
|
|
1086
|
+
const setMode = useCallback(
|
|
1087
|
+
async (mode) => {
|
|
1088
|
+
await patchSettings({ mode });
|
|
1089
|
+
await load();
|
|
1090
|
+
},
|
|
1091
|
+
[patchSettings, load],
|
|
1092
|
+
);
|
|
1093
|
+
|
|
1094
|
+
const savePorts = async () => {
|
|
1095
|
+
const api = Number(apiPort);
|
|
1096
|
+
const web = Number(webPort);
|
|
1097
|
+
if (!Number.isFinite(api) || api < 1 || api > 65535) {
|
|
1098
|
+
setError(t('portRange'));
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1101
|
+
if (!Number.isFinite(web) || web < 1 || web > 65535) {
|
|
1102
|
+
setError(t('portRange'));
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
if (api === web) {
|
|
1106
|
+
setError(t('portSame'));
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
1109
|
+
setError('');
|
|
1110
|
+
await patchSettings({ listen: { port: Math.floor(api) }, remote: { port: Math.floor(web) } });
|
|
1111
|
+
await load();
|
|
1112
|
+
};
|
|
1113
|
+
|
|
1114
|
+
const testConnection = async () => {
|
|
1115
|
+
setBusy(true);
|
|
1116
|
+
setProbe(undefined);
|
|
1117
|
+
try {
|
|
1118
|
+
await patchSettings({ node: { publicUrl, apiKey, label: nodeLabel } });
|
|
1119
|
+
const result = await postJson(`${BASE}/node`, { action: 'probe' });
|
|
1120
|
+
setProbe(result);
|
|
1121
|
+
} catch (cause) {
|
|
1122
|
+
setProbe({ ok: false, error: cause instanceof Error ? cause.message : String(cause) });
|
|
1123
|
+
} finally {
|
|
1124
|
+
setBusy(false);
|
|
1125
|
+
}
|
|
1126
|
+
};
|
|
1127
|
+
|
|
1128
|
+
const loadProducts = async () => {
|
|
1129
|
+
try {
|
|
1130
|
+
const data = await postJson(`${BASE}/output`, { action: 'list' });
|
|
1131
|
+
setProducts(data);
|
|
1132
|
+
} catch (cause) {
|
|
1133
|
+
setError(cause instanceof Error ? cause.message : String(cause));
|
|
1134
|
+
}
|
|
1135
|
+
};
|
|
1136
|
+
|
|
1137
|
+
if (status === undefined) {
|
|
1138
|
+
return h(
|
|
1139
|
+
'div',
|
|
1140
|
+
{ className: 'mmn-page' },
|
|
1141
|
+
error === '' ? h('p', { className: 'mmn-meta' }, t('loading')) : h('p', { className: 'mmn-error' }, error),
|
|
1142
|
+
);
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
const mode = status.mode === 'user' ? 'user' : 'service';
|
|
1146
|
+
|
|
1147
|
+
return h(
|
|
1148
|
+
'div',
|
|
1149
|
+
{ className: 'mmn-page' },
|
|
1150
|
+
h(
|
|
1151
|
+
'div',
|
|
1152
|
+
{ className: 'mmn-head' },
|
|
1153
|
+
h('span', { className: 'mmn-group-title' }, t('plugin')),
|
|
1154
|
+
h('span', { className: 'mmn-mode-badge', 'data-mode': mode }, mode === 'service' ? t('modeService') : t('modeUser')),
|
|
1155
|
+
h('span', { className: 'mmn-grow' }),
|
|
1156
|
+
h('button', { type: 'button', className: 'mmn-btn', disabled: busy, onClick: () => void load() }, t('reload')),
|
|
1157
|
+
h('button', { type: 'button', className: 'mmn-btn', disabled: busy, onClick: async () => { await act(`${BASE}/restart`, {}); setNotice(t('restarted')); } }, t('restartListeners')),
|
|
1158
|
+
),
|
|
1159
|
+
|
|
1160
|
+
error === '' ? null : h('div', { className: 'mmn-error' }, error),
|
|
1161
|
+
notice === '' ? null : h('div', { className: 'mmn-ok' }, notice),
|
|
1162
|
+
|
|
1163
|
+
h(
|
|
1164
|
+
'div',
|
|
1165
|
+
{ className: 'mmn-card' },
|
|
1166
|
+
h(
|
|
1167
|
+
'div',
|
|
1168
|
+
{ className: 'mmn-card-head' },
|
|
1169
|
+
h('span', { className: 'mmn-section-title' }, t('modeTitle')),
|
|
1170
|
+
h('span', { className: 'mmn-meta' }, t('modeHint')),
|
|
1171
|
+
),
|
|
1172
|
+
h(ModeCards, { mode, busy, t, onPick: (next) => void setMode(next) }),
|
|
1173
|
+
),
|
|
1174
|
+
|
|
1175
|
+
mode === 'service'
|
|
1176
|
+
? h(
|
|
1177
|
+
React.Fragment,
|
|
1178
|
+
null,
|
|
1179
|
+
h(
|
|
1180
|
+
'div',
|
|
1181
|
+
{ className: 'mmn-card' },
|
|
1182
|
+
h(
|
|
1183
|
+
'div',
|
|
1184
|
+
{ className: 'mmn-card-head' },
|
|
1185
|
+
h('span', { className: 'mmn-section-title' }, t('secPorts')),
|
|
1186
|
+
h('span', { className: 'mmn-meta' }, t('lanHint')),
|
|
1187
|
+
),
|
|
1188
|
+
h(
|
|
1189
|
+
'div',
|
|
1190
|
+
{ className: 'mmn-row' },
|
|
1191
|
+
h(Field, {
|
|
1192
|
+
label: t('apiPort'),
|
|
1193
|
+
hint: t('apiPortHint'),
|
|
1194
|
+
children: h('input', {
|
|
1195
|
+
className: 'mmn-input mmn-input--num',
|
|
1196
|
+
value: apiPort,
|
|
1197
|
+
onChange: (event) => setApiPort(event.target.value.replace(/[^0-9]/g, '')),
|
|
1198
|
+
}),
|
|
1199
|
+
}),
|
|
1200
|
+
h(Field, {
|
|
1201
|
+
label: t('webPort'),
|
|
1202
|
+
hint: t('webPortHint'),
|
|
1203
|
+
children: h('input', {
|
|
1204
|
+
className: 'mmn-input mmn-input--num',
|
|
1205
|
+
value: webPort,
|
|
1206
|
+
onChange: (event) => setWebPort(event.target.value.replace(/[^0-9]/g, '')),
|
|
1207
|
+
}),
|
|
1208
|
+
}),
|
|
1209
|
+
h('button', { type: 'button', className: 'mmn-btn', disabled: busy, onClick: () => void savePorts() }, t('save')),
|
|
1210
|
+
),
|
|
1211
|
+
h(
|
|
1212
|
+
'div',
|
|
1213
|
+
{ className: 'mmn-kv' },
|
|
1214
|
+
h('span', null, `${t('apiPort')}: ${status.gateway?.listening ? `${status.gateway.host}:${status.gateway.port}` : '—'}`),
|
|
1215
|
+
h('span', null, `${t('webPort')}: ${status.remote?.listening ? `${status.remote.host}:${status.remote.port}` : '—'}`),
|
|
1216
|
+
status.gateway?.lastError ? h('span', { className: 'mmn-error' }, status.gateway.lastError) : null,
|
|
1217
|
+
status.remote?.lastError ? h('span', { className: 'mmn-error' }, status.remote.lastError) : null,
|
|
1218
|
+
),
|
|
1219
|
+
),
|
|
1220
|
+
h(AccountsSection, { status, t, busy, act }),
|
|
1221
|
+
h(ApiKeysSection, { status, t, busy, act, onKeyShown: (value) => { setOnceKey(value); setCopied(false); } }),
|
|
1222
|
+
h(BlenderSettingsSection, { status, t, busy, act }),
|
|
1223
|
+
)
|
|
1224
|
+
: h(
|
|
1225
|
+
React.Fragment,
|
|
1226
|
+
null,
|
|
1227
|
+
h(
|
|
1228
|
+
'div',
|
|
1229
|
+
{ className: 'mmn-card' },
|
|
1230
|
+
h(
|
|
1231
|
+
'div',
|
|
1232
|
+
{ className: 'mmn-card-head' },
|
|
1233
|
+
h('span', { className: 'mmn-section-title' }, t('secServiceNode')),
|
|
1234
|
+
h('span', { className: 'mmn-meta' }, t('secServiceNodeHint')),
|
|
1235
|
+
),
|
|
1236
|
+
h(
|
|
1237
|
+
'div',
|
|
1238
|
+
{ className: 'mmn-row' },
|
|
1239
|
+
h(Field, {
|
|
1240
|
+
label: t('publicUrl'),
|
|
1241
|
+
hint: t('publicUrlHint'),
|
|
1242
|
+
children: h('input', {
|
|
1243
|
+
className: 'mmn-input mmn-input--wide',
|
|
1244
|
+
value: publicUrl,
|
|
1245
|
+
placeholder: 'https://node.example.com:8287',
|
|
1246
|
+
onChange: (event) => setPublicUrl(event.target.value),
|
|
1247
|
+
}),
|
|
1248
|
+
}),
|
|
1249
|
+
),
|
|
1250
|
+
h(
|
|
1251
|
+
'div',
|
|
1252
|
+
{ className: 'mmn-row' },
|
|
1253
|
+
h(Field, {
|
|
1254
|
+
label: t('apiKey'),
|
|
1255
|
+
hint: t('apiKeyHint'),
|
|
1256
|
+
children: h('input', {
|
|
1257
|
+
className: 'mmn-input mmn-input--wide',
|
|
1258
|
+
type: 'password',
|
|
1259
|
+
value: apiKey,
|
|
1260
|
+
placeholder: status.node?.apiKeySet === true ? '••••••••(已保存)' : 'mmn_…',
|
|
1261
|
+
onChange: (event) => setApiKey(event.target.value),
|
|
1262
|
+
}),
|
|
1263
|
+
}),
|
|
1264
|
+
h(Field, {
|
|
1265
|
+
label: t('nodeLabel'),
|
|
1266
|
+
hint: t('nodeLabelHint'),
|
|
1267
|
+
children: h('input', {
|
|
1268
|
+
className: 'mmn-input',
|
|
1269
|
+
value: nodeLabel,
|
|
1270
|
+
onChange: (event) => setNodeLabel(event.target.value),
|
|
1271
|
+
}),
|
|
1272
|
+
}),
|
|
1273
|
+
),
|
|
1274
|
+
h(
|
|
1275
|
+
'div',
|
|
1276
|
+
{ className: 'mmn-row' },
|
|
1277
|
+
h('button', { type: 'button', className: 'mmn-btn mmn-btn--primary', disabled: busy, onClick: () => void testConnection() }, busy ? t('testing') : t('testConnection')),
|
|
1278
|
+
h(
|
|
1279
|
+
'button',
|
|
1280
|
+
{ type: 'button', className: 'mmn-btn', disabled: busy, onClick: async () => { await patchSettings({ node: { publicUrl, apiKey, label: nodeLabel } }); await load(); } },
|
|
1281
|
+
t('save'),
|
|
1282
|
+
),
|
|
1283
|
+
probe === undefined
|
|
1284
|
+
? null
|
|
1285
|
+
: probe.ok === true
|
|
1286
|
+
? h('span', { className: 'mmn-ok' }, `${t('connected')} · 队列 ${probe.queue?.queued ?? 0}/${probe.queue?.running ?? 0} · 模型 ${probe.models?.total ?? 0}`)
|
|
1287
|
+
: h('span', { className: 'mmn-error' }, probe.error ?? t('failed')),
|
|
1288
|
+
),
|
|
1289
|
+
),
|
|
1290
|
+
h(
|
|
1291
|
+
'div',
|
|
1292
|
+
{ className: 'mmn-card' },
|
|
1293
|
+
h(
|
|
1294
|
+
'div',
|
|
1295
|
+
{ className: 'mmn-card-head' },
|
|
1296
|
+
h('span', { className: 'mmn-section-title' }, t('secOutput')),
|
|
1297
|
+
h('span', { className: 'mmn-meta' }, t('outputDirHint')),
|
|
1298
|
+
),
|
|
1299
|
+
h(
|
|
1300
|
+
'div',
|
|
1301
|
+
{ className: 'mmn-row' },
|
|
1302
|
+
h('input', { className: 'mmn-input mmn-input--wide mmn-mono', value: outputDir, onChange: (event) => setOutputDir(event.target.value) }),
|
|
1303
|
+
h('button', { type: 'button', className: 'mmn-btn', disabled: busy, onClick: async () => { await patchSettings({ node: { outputDir } }); await load(); } }, t('save')),
|
|
1304
|
+
h('button', { type: 'button', className: 'mmn-btn', disabled: busy, onClick: async () => { await postJson(`${BASE}/output`, { action: 'mkdir' }); await loadProducts(); } }, t('openOutput')),
|
|
1305
|
+
h('button', { type: 'button', className: 'mmn-btn', onClick: () => void loadProducts() }, t('reload')),
|
|
1306
|
+
),
|
|
1307
|
+
products === undefined
|
|
1308
|
+
? null
|
|
1309
|
+
: h(
|
|
1310
|
+
'div',
|
|
1311
|
+
{ className: 'mmn-col' },
|
|
1312
|
+
h('span', { className: 'mmn-mono mmn-meta' }, products.dir),
|
|
1313
|
+
h('span', { className: 'mmn-meta' }, `${t('outputBytes')} ${Math.round((products.total ?? 0) / 1024 / 1024)} MB · ${(products.files ?? []).length}`),
|
|
1314
|
+
(products.files ?? []).length === 0 ? h('span', { className: 'mmn-meta' }, t('noFiles')) : null,
|
|
1315
|
+
),
|
|
1316
|
+
),
|
|
1317
|
+
h(BlenderSettingsSection, { status, t, busy, act }),
|
|
1318
|
+
),
|
|
1319
|
+
|
|
1320
|
+
onceKey === undefined
|
|
1321
|
+
? null
|
|
1322
|
+
: h(
|
|
1323
|
+
'div',
|
|
1324
|
+
{ className: 'mmn-key-once' },
|
|
1325
|
+
h('span', { className: 'mmn-section-title' }, t('keyOnce')),
|
|
1326
|
+
h('code', { className: 'mmn-key-value' }, onceKey.key),
|
|
1327
|
+
h(
|
|
1328
|
+
'div',
|
|
1329
|
+
{ className: 'mmn-row' },
|
|
1330
|
+
h(
|
|
1331
|
+
'button',
|
|
1332
|
+
{
|
|
1333
|
+
type: 'button',
|
|
1334
|
+
className: 'mmn-btn',
|
|
1335
|
+
onClick: async () => {
|
|
1336
|
+
try {
|
|
1337
|
+
await navigator.clipboard.writeText(onceKey.key);
|
|
1338
|
+
setCopied(true);
|
|
1339
|
+
} catch {
|
|
1340
|
+
setCopied(false);
|
|
1341
|
+
}
|
|
1342
|
+
},
|
|
1343
|
+
},
|
|
1344
|
+
copied ? t('copied') : t('copy'),
|
|
1345
|
+
),
|
|
1346
|
+
h('span', { className: 'mmn-hint' }, t('keyOnceHint')),
|
|
1347
|
+
h('span', { className: 'mmn-grow' }),
|
|
1348
|
+
h('button', { type: 'button', className: 'mmn-btn mmn-btn--ghost', onClick: () => setOnceKey(undefined) }, t('close')),
|
|
1349
|
+
),
|
|
1350
|
+
),
|
|
1351
|
+
);
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
// ---------------------------------------------------------------- 侧边栏 · 服务节点
|
|
1355
|
+
/**
|
|
1356
|
+
* 一个实例的模型文件夹扫描区。
|
|
1357
|
+
*
|
|
1358
|
+
* 用户要求:模型列表要在 ComfyUI 板块里、扫描本地模型文件夹、能设定文件夹地址。
|
|
1359
|
+
* 所以它不是独立 tab,而是每个实例卡片里的一段——因为「哪个模型在哪张卡上」
|
|
1360
|
+
* 是实例的属性,不是整个板块的属性。
|
|
1361
|
+
*
|
|
1362
|
+
* 三件事分开做,因为它们是三种不同的失败:
|
|
1363
|
+
* 1. 填地址 → 就地探测(目录不存在 / 没权限 / 填高了少一层,各有各的话)
|
|
1364
|
+
* 2. 保存 → 写到实例的 modelsRoot
|
|
1365
|
+
* 3. 扫描 → 读目录 + 文件,按目录分组,可搜文件名
|
|
1366
|
+
*/
|
|
1367
|
+
function ModelScanSection({ entry, t, busy, act }) {
|
|
1368
|
+
const [dir, setDir] = useState(entry.modelsRoot ?? '');
|
|
1369
|
+
const [probe, setProbe] = useState(undefined);
|
|
1370
|
+
const [scan, setScan] = useState(undefined);
|
|
1371
|
+
const [open, setOpen] = useState(true);
|
|
1372
|
+
const [query, setQuery] = useState('');
|
|
1373
|
+
const [scanning, setScanning] = useState(false);
|
|
1374
|
+
const [error, setError] = useState('');
|
|
1375
|
+
|
|
1376
|
+
useEffect(() => {
|
|
1377
|
+
setDir(entry.modelsRoot ?? '');
|
|
1378
|
+
}, [entry.modelsRoot]);
|
|
1379
|
+
|
|
1380
|
+
const inspect = async () => {
|
|
1381
|
+
setError('');
|
|
1382
|
+
setProbe(undefined);
|
|
1383
|
+
try {
|
|
1384
|
+
const result = await postJson(`${BASE}/models`, { action: 'inspect', dir });
|
|
1385
|
+
setProbe(result);
|
|
1386
|
+
} catch (cause) {
|
|
1387
|
+
setProbe({ ok: false, error: cause instanceof Error ? cause.message : String(cause) });
|
|
1388
|
+
}
|
|
1389
|
+
};
|
|
1390
|
+
|
|
1391
|
+
const save = async () => {
|
|
1392
|
+
setError('');
|
|
1393
|
+
try {
|
|
1394
|
+
await act(`${BASE}/models`, { action: 'setInstanceRoot', id: entry.id, dir });
|
|
1395
|
+
setProbe(undefined);
|
|
1396
|
+
} catch (cause) {
|
|
1397
|
+
setError(cause instanceof Error ? cause.message : String(cause));
|
|
1398
|
+
}
|
|
1399
|
+
};
|
|
1400
|
+
|
|
1401
|
+
const run = async () => {
|
|
1402
|
+
setError('');
|
|
1403
|
+
setScanning(true);
|
|
1404
|
+
try {
|
|
1405
|
+
const result = await postJson(`${BASE}/models`, { action: 'list', instanceId: entry.id, withSize: true });
|
|
1406
|
+
setScan(result);
|
|
1407
|
+
} catch (cause) {
|
|
1408
|
+
setScan({ ok: false, error: cause instanceof Error ? cause.message : String(cause) });
|
|
1409
|
+
} finally {
|
|
1410
|
+
setScanning(false);
|
|
1411
|
+
}
|
|
1412
|
+
};
|
|
1413
|
+
|
|
1414
|
+
const folders = scan?.folders ?? [];
|
|
1415
|
+
const total = scan?.total ?? 0;
|
|
1416
|
+
const needle = query.trim().toLowerCase();
|
|
1417
|
+
|
|
1418
|
+
return h(
|
|
1419
|
+
'div',
|
|
1420
|
+
{ className: 'mmn-col' },
|
|
1421
|
+
h(
|
|
1422
|
+
'div',
|
|
1423
|
+
{ className: 'mmn-row' },
|
|
1424
|
+
h('span', { className: 'mmn-section-title' }, t('modelScan')),
|
|
1425
|
+
h('span', { className: 'mmn-meta' }, t('modelScanHint')),
|
|
1426
|
+
h('span', { className: 'mmn-grow' }),
|
|
1427
|
+
h(
|
|
1428
|
+
'button',
|
|
1429
|
+
{ type: 'button', className: 'mmn-btn mmn-btn--ghost', onClick: () => setOpen(!open) },
|
|
1430
|
+
open ? '▾' : '▸',
|
|
1431
|
+
),
|
|
1432
|
+
),
|
|
1433
|
+
open
|
|
1434
|
+
? h(
|
|
1435
|
+
React.Fragment,
|
|
1436
|
+
null,
|
|
1437
|
+
h(
|
|
1438
|
+
'div',
|
|
1439
|
+
{ className: 'mmn-row' },
|
|
1440
|
+
h('input', {
|
|
1441
|
+
className: 'mmn-input mmn-input--wide mmn-mono',
|
|
1442
|
+
placeholder: t('modelsRootPlaceholder'),
|
|
1443
|
+
value: dir,
|
|
1444
|
+
onChange: (event) => setDir(event.target.value),
|
|
1445
|
+
}),
|
|
1446
|
+
h('button', { type: 'button', className: 'mmn-btn', disabled: busy || dir.trim() === '', onClick: () => void inspect() }, t('probeDir')),
|
|
1447
|
+
h('button', { type: 'button', className: 'mmn-btn', disabled: busy, onClick: () => void save() }, t('saveDir')),
|
|
1448
|
+
h('button', { type: 'button', className: 'mmn-btn mmn-btn--primary', disabled: busy || scanning, onClick: () => void run() }, scanning ? t('scanning') : t('scanNow')),
|
|
1449
|
+
),
|
|
1450
|
+
error === '' ? null : h('div', { className: 'mmn-error' }, error),
|
|
1451
|
+
|
|
1452
|
+
// 探测结果:说清「能不能扫」,以及最常见的填错一层
|
|
1453
|
+
probe === undefined
|
|
1454
|
+
? null
|
|
1455
|
+
: probe.ok !== true
|
|
1456
|
+
? h('div', { className: 'mmn-error' }, `${t('probeFailed')}:${probe.error ?? ''}${probe.path ? ` · ${probe.path}` : ''}`)
|
|
1457
|
+
: h(
|
|
1458
|
+
'div',
|
|
1459
|
+
{ className: 'mmn-col' },
|
|
1460
|
+
h(
|
|
1461
|
+
'div',
|
|
1462
|
+
{ className: 'mmn-kv' },
|
|
1463
|
+
h('span', { className: 'mmn-ok' }, t('probeOk')),
|
|
1464
|
+
h('span', { className: 'mmn-mono' }, probe.path),
|
|
1465
|
+
h('span', null, `${probe.folders?.length ?? 0} ${t('folders')}`),
|
|
1466
|
+
(probe.looseFiles?.length ?? 0) > 0 ? h('span', null, `${probe.looseFiles.length} ${t('files')}`) : null,
|
|
1467
|
+
),
|
|
1468
|
+
probe.hint === null || probe.hint === undefined ? null : h('div', { className: 'mmn-notice' }, probe.hint),
|
|
1469
|
+
(probe.folders ?? []).length === 0
|
|
1470
|
+
? null
|
|
1471
|
+
: h('div', { className: 'mmn-hint' }, probe.folders.slice(0, 14).join(' · ')),
|
|
1472
|
+
),
|
|
1473
|
+
|
|
1474
|
+
// 扫描结果
|
|
1475
|
+
scan === undefined
|
|
1476
|
+
? h('div', { className: 'mmn-meta' }, t('scanIdle'))
|
|
1477
|
+
: scan.ok !== true
|
|
1478
|
+
? h('div', { className: 'mmn-error' }, scan.error ?? t('noModels'))
|
|
1479
|
+
: h(
|
|
1480
|
+
React.Fragment,
|
|
1481
|
+
null,
|
|
1482
|
+
h(
|
|
1483
|
+
'div',
|
|
1484
|
+
{ className: 'mmn-row' },
|
|
1485
|
+
h(
|
|
1486
|
+
'div',
|
|
1487
|
+
{ className: 'mmn-kv' },
|
|
1488
|
+
h('span', null, `${t('folders')} ${folders.length}`),
|
|
1489
|
+
h('span', null, `${t('files')} ${total}`),
|
|
1490
|
+
...(scan.rootEntries ?? []).map((row) =>
|
|
1491
|
+
h(
|
|
1492
|
+
'span',
|
|
1493
|
+
{ className: 'mmn-mono', key: row.path, title: `${row.source}${row.instanceId ? ` ${row.instanceId}` : ''}` },
|
|
1494
|
+
row.path,
|
|
1495
|
+
),
|
|
1496
|
+
),
|
|
1497
|
+
),
|
|
1498
|
+
h('span', { className: 'mmn-grow' }),
|
|
1499
|
+
h('input', {
|
|
1500
|
+
className: 'mmn-input',
|
|
1501
|
+
placeholder: t('searchModels'),
|
|
1502
|
+
value: query,
|
|
1503
|
+
onChange: (event) => setQuery(event.target.value),
|
|
1504
|
+
}),
|
|
1505
|
+
),
|
|
1506
|
+
total === 0
|
|
1507
|
+
? h('div', { className: 'mmn-notice' }, t('scanEmpty'))
|
|
1508
|
+
: h(
|
|
1509
|
+
'div',
|
|
1510
|
+
{ className: 'mmn-scroll' },
|
|
1511
|
+
folders.map((folder) => {
|
|
1512
|
+
const items = (folder.items ?? []).filter((item) => {
|
|
1513
|
+
const name = typeof item === 'string' ? item : item.name;
|
|
1514
|
+
return needle === '' || name.toLowerCase().includes(needle);
|
|
1515
|
+
});
|
|
1516
|
+
if (items.length === 0) return null;
|
|
1517
|
+
return h(
|
|
1518
|
+
'div',
|
|
1519
|
+
{ key: folder.name },
|
|
1520
|
+
h(
|
|
1521
|
+
'div',
|
|
1522
|
+
{ className: 'mmn-row' },
|
|
1523
|
+
h('span', { className: 'mmn-section-title' }, folder.label ?? folder.name),
|
|
1524
|
+
h('span', { className: 'mmn-meta' }, `${items.length}${needle === '' ? `/${(folder.items ?? []).length}` : ''}`),
|
|
1525
|
+
),
|
|
1526
|
+
items.slice(0, 60).map((item) => {
|
|
1527
|
+
const name = typeof item === 'string' ? item : item.name;
|
|
1528
|
+
const size = typeof item === 'string' ? null : item.size;
|
|
1529
|
+
return h(
|
|
1530
|
+
'div',
|
|
1531
|
+
{ className: 'mmn-file-row', key: `${folder.name}/${name}` },
|
|
1532
|
+
h('span', { className: 'mmn-mono mmn-grow' }, name),
|
|
1533
|
+
size === null || size === undefined
|
|
1534
|
+
? null
|
|
1535
|
+
: h('span', { className: 'mmn-meta' }, `${Math.round(size / 1048576)} MB`),
|
|
1536
|
+
);
|
|
1537
|
+
}),
|
|
1538
|
+
);
|
|
1539
|
+
}),
|
|
1540
|
+
),
|
|
1541
|
+
),
|
|
1542
|
+
)
|
|
1543
|
+
: h(
|
|
1544
|
+
'div',
|
|
1545
|
+
{ className: 'mmn-kv' },
|
|
1546
|
+
h('span', { className: 'mmn-meta' }, entry.modelsRoot ? `${t('modelsRoot')}:${entry.modelsRoot}` : t('modelsRootUnset')),
|
|
1547
|
+
entry.modelsRoot ? null : h('span', { className: 'mmn-notice' }, t('modelsRootUnsetHint')),
|
|
1548
|
+
),
|
|
1549
|
+
);
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
/**
|
|
1553
|
+
* 全局附加扫描目录:不在任何 ComfyUI 安装里的模型(另一块盘、NAS 映射盘)。
|
|
1554
|
+
*
|
|
1555
|
+
* 单独一块而不是并进实例:这些目录不属于任何一张卡,
|
|
1556
|
+
* 混进实例清单会让人误以为「GPU1 上有这个文件」。
|
|
1557
|
+
*/
|
|
1558
|
+
function ExtraRootsSection({ status, t, busy, act }) {
|
|
1559
|
+
const [dir, setDir] = useState('');
|
|
1560
|
+
const [probe, setProbe] = useState(undefined);
|
|
1561
|
+
const roots = status.models?.extraRoots ?? [];
|
|
1562
|
+
|
|
1563
|
+
const add = async () => {
|
|
1564
|
+
const result = await postJson(`${BASE}/models`, { action: 'addRoot', dir });
|
|
1565
|
+
if (result.ok !== false) setDir('');
|
|
1566
|
+
};
|
|
1567
|
+
|
|
1568
|
+
return h(
|
|
1569
|
+
'div',
|
|
1570
|
+
{ className: 'mmn-card' },
|
|
1571
|
+
h(
|
|
1572
|
+
'div',
|
|
1573
|
+
{ className: 'mmn-card-head' },
|
|
1574
|
+
h('span', { className: 'mmn-section-title' }, t('extraRoots')),
|
|
1575
|
+
h('span', { className: 'mmn-meta' }, t('extraRootsHint')),
|
|
1576
|
+
),
|
|
1577
|
+
roots.length === 0
|
|
1578
|
+
? h('div', { className: 'mmn-meta' }, t('noExtraRoots'))
|
|
1579
|
+
: h(
|
|
1580
|
+
'div',
|
|
1581
|
+
{ className: 'mmn-col' },
|
|
1582
|
+
roots.map((root) =>
|
|
1583
|
+
h(
|
|
1584
|
+
'div',
|
|
1585
|
+
{ className: 'mmn-task', key: root },
|
|
1586
|
+
h('span', { className: 'mmn-mono mmn-grow' }, root),
|
|
1587
|
+
h(
|
|
1588
|
+
'button',
|
|
1589
|
+
{
|
|
1590
|
+
type: 'button',
|
|
1591
|
+
className: 'mmn-btn mmn-btn--ghost mmn-btn--danger',
|
|
1592
|
+
disabled: busy,
|
|
1593
|
+
onClick: () => void postJson(`${BASE}/models`, { action: 'removeRoot', dir: root }),
|
|
1594
|
+
},
|
|
1595
|
+
t('removeRoot'),
|
|
1596
|
+
),
|
|
1597
|
+
),
|
|
1598
|
+
),
|
|
1599
|
+
),
|
|
1600
|
+
h(
|
|
1601
|
+
'div',
|
|
1602
|
+
{ className: 'mmn-row' },
|
|
1603
|
+
h('input', {
|
|
1604
|
+
className: 'mmn-input mmn-input--wide mmn-mono',
|
|
1605
|
+
placeholder: t('modelsRootPlaceholder'),
|
|
1606
|
+
value: dir,
|
|
1607
|
+
onChange: (event) => setDir(event.target.value),
|
|
1608
|
+
}),
|
|
1609
|
+
h(
|
|
1610
|
+
'button',
|
|
1611
|
+
{
|
|
1612
|
+
type: 'button',
|
|
1613
|
+
className: 'mmn-btn',
|
|
1614
|
+
disabled: busy || dir.trim() === '',
|
|
1615
|
+
onClick: async () => {
|
|
1616
|
+
try {
|
|
1617
|
+
setProbe(await postJson(`${BASE}/models`, { action: 'inspect', dir }));
|
|
1618
|
+
} catch (cause) {
|
|
1619
|
+
setProbe({ ok: false, error: cause instanceof Error ? cause.message : String(cause) });
|
|
1620
|
+
}
|
|
1621
|
+
},
|
|
1622
|
+
},
|
|
1623
|
+
t('probeDir'),
|
|
1624
|
+
),
|
|
1625
|
+
h('button', { type: 'button', className: 'mmn-btn mmn-btn--primary', disabled: busy || dir.trim() === '', onClick: () => void add() }, t('addRoot')),
|
|
1626
|
+
),
|
|
1627
|
+
probe === undefined
|
|
1628
|
+
? null
|
|
1629
|
+
: probe.ok !== true
|
|
1630
|
+
? h('div', { className: 'mmn-error' }, `${t('probeFailed')}:${probe.error ?? ''}`)
|
|
1631
|
+
: h(
|
|
1632
|
+
'div',
|
|
1633
|
+
{ className: 'mmn-kv' },
|
|
1634
|
+
h('span', { className: 'mmn-ok' }, `${probe.folders?.length ?? 0} ${t('folders')}`),
|
|
1635
|
+
h('span', { className: 'mmn-mono' }, probe.path),
|
|
1636
|
+
probe.hint === null || probe.hint === undefined ? null : h('span', { className: 'mmn-notice' }, probe.hint),
|
|
1637
|
+
),
|
|
1638
|
+
);
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
function InstanceCard({ entry, t, busy, onAction, onPatch, onRemove, act }) {
|
|
1642
|
+
const control = entry.controlDetail ?? { type: entry.control ?? 'none', batPath: '', cwd: '' };
|
|
1643
|
+
const [bat, setBat] = useState(control.batPath ?? '');
|
|
1644
|
+
const [cwd, setCwd] = useState(control.cwd ?? '');
|
|
1645
|
+
|
|
1646
|
+
useEffect(() => {
|
|
1647
|
+
setBat(control.batPath ?? '');
|
|
1648
|
+
setCwd(control.cwd ?? '');
|
|
1649
|
+
}, [control.batPath, control.cwd]);
|
|
1650
|
+
|
|
1651
|
+
const vram = (entry.devices ?? [])
|
|
1652
|
+
.map((item) =>
|
|
1653
|
+
item.vramTotal === null || item.vramTotal === undefined
|
|
1654
|
+
? '?'
|
|
1655
|
+
: `${Math.round((item.vramTotal - (item.vramFree ?? 0)) / 1048576)}/${Math.round(item.vramTotal / 1048576)} MiB`,
|
|
1656
|
+
)
|
|
1657
|
+
.join(' · ');
|
|
1658
|
+
|
|
1659
|
+
return h(
|
|
1660
|
+
'div',
|
|
1661
|
+
{ className: 'mmn-card' },
|
|
1662
|
+
h(
|
|
1663
|
+
'div',
|
|
1664
|
+
{ className: 'mmn-card-head' },
|
|
1665
|
+
h('span', { className: 'mmn-section-title' }, `${entry.label} · ${entry.id}`),
|
|
1666
|
+
h('span', { className: 'mmn-pill', 'data-state': entry.healthy ? 'done' : 'failed' }, entry.healthy ? t('healthy') : t('unhealthy')),
|
|
1667
|
+
entry.enabled ? null : h('span', { className: 'mmn-pill', 'data-state': 'cancelled' }, 'off'),
|
|
1668
|
+
h('span', { className: 'mmn-grow' }),
|
|
1669
|
+
h(Toggle, {
|
|
1670
|
+
on: entry.enabled,
|
|
1671
|
+
disabled: busy,
|
|
1672
|
+
onChange: (next) => void onPatch(entry.id, { enabled: next }),
|
|
1673
|
+
}),
|
|
1674
|
+
),
|
|
1675
|
+
h(
|
|
1676
|
+
'div',
|
|
1677
|
+
{ className: 'mmn-kv' },
|
|
1678
|
+
h('span', { className: 'mmn-mono' }, entry.baseUrl),
|
|
1679
|
+
entry.version ? h('span', null, `v${entry.version}`) : null,
|
|
1680
|
+
vram === '' ? null : h('span', null, `${t('vram')} ${vram}`),
|
|
1681
|
+
h('span', null, `${t('maxConcurrent')} ${entry.maxConcurrent}`),
|
|
1682
|
+
h('span', null, `${t('weight')} ${entry.weight}`),
|
|
1683
|
+
h('span', null, control.type === 'none' ? t('processForeign') : `${t('processOwned')}${entry.pid ? ` pid ${entry.pid}` : ''}`),
|
|
1684
|
+
entry.starting ? h('span', { className: 'mmn-notice' }, t('starting')) : null,
|
|
1685
|
+
entry.stopping ? h('span', { className: 'mmn-notice' }, t('stopping')) : null,
|
|
1686
|
+
),
|
|
1687
|
+
entry.lastError ? h('div', { className: 'mmn-error' }, entry.lastError) : null,
|
|
1688
|
+
h(
|
|
1689
|
+
'div',
|
|
1690
|
+
{ className: 'mmn-row' },
|
|
1691
|
+
h('button', { type: 'button', className: 'mmn-btn', disabled: busy || entry.healthy, onClick: () => void onAction('start', entry.id) }, t('start')),
|
|
1692
|
+
h('button', { type: 'button', className: 'mmn-btn', disabled: busy, onClick: () => void onAction('stop', entry.id) }, t('stop')),
|
|
1693
|
+
h('button', { type: 'button', className: 'mmn-btn', disabled: busy, onClick: () => void onAction('restart', entry.id) }, t('restart')),
|
|
1694
|
+
h('span', { className: 'mmn-grow' }),
|
|
1695
|
+
h('button', { type: 'button', className: 'mmn-btn mmn-btn--ghost mmn-btn--danger', disabled: busy, onClick: () => void onRemove(entry.id) }, t('remove')),
|
|
1696
|
+
),
|
|
1697
|
+
// 模型文件夹地址与扫描结果都在这张卡里(用户要求:模型列表在 ComfyUI 板块内)
|
|
1698
|
+
h(ModelScanSection, { entry, t, busy, act }),
|
|
1699
|
+
h(
|
|
1700
|
+
'div',
|
|
1701
|
+
{ className: 'mmn-col' },
|
|
1702
|
+
h(
|
|
1703
|
+
'div',
|
|
1704
|
+
{ className: 'mmn-row' },
|
|
1705
|
+
h(
|
|
1706
|
+
'select',
|
|
1707
|
+
{
|
|
1708
|
+
className: 'mmn-select',
|
|
1709
|
+
value: control.type === 'script' ? 'script' : 'none',
|
|
1710
|
+
disabled: busy,
|
|
1711
|
+
onChange: (event) => {
|
|
1712
|
+
const type = event.target.value;
|
|
1713
|
+
void onPatch(entry.id, { control: { ...control, type: type === 'script' ? 'script' : 'none' } });
|
|
1714
|
+
},
|
|
1715
|
+
},
|
|
1716
|
+
h('option', { value: 'none' }, t('controlNone')),
|
|
1717
|
+
h('option', { value: 'script' }, t('controlScript')),
|
|
1718
|
+
),
|
|
1719
|
+
h('input', { className: 'mmn-input mmn-input--wide mmn-mono', placeholder: t('batPlaceholder'), value: bat, onChange: (event) => setBat(event.target.value) }),
|
|
1720
|
+
h('input', { className: 'mmn-input mmn-mono', placeholder: t('cwd'), value: cwd, onChange: (event) => setCwd(event.target.value) }),
|
|
1721
|
+
h(
|
|
1722
|
+
'button',
|
|
1723
|
+
{
|
|
1724
|
+
type: 'button',
|
|
1725
|
+
className: 'mmn-btn',
|
|
1726
|
+
disabled: busy,
|
|
1727
|
+
onClick: () => void onPatch(entry.id, { control: { ...control, batPath: bat, cwd } }),
|
|
1728
|
+
},
|
|
1729
|
+
t('apply'),
|
|
1730
|
+
),
|
|
1731
|
+
),
|
|
1732
|
+
h(
|
|
1733
|
+
'div',
|
|
1734
|
+
{ className: 'mmn-row' },
|
|
1735
|
+
h(Toggle, {
|
|
1736
|
+
on: entry.autoStart === true,
|
|
1737
|
+
disabled: busy,
|
|
1738
|
+
label: t('autoStart'),
|
|
1739
|
+
onChange: (next) => void onPatch(entry.id, { autoStart: next }),
|
|
1740
|
+
}),
|
|
1741
|
+
h(Toggle, {
|
|
1742
|
+
on: control.stopOnExit !== false,
|
|
1743
|
+
disabled: busy,
|
|
1744
|
+
label: t('stopOnExit'),
|
|
1745
|
+
onChange: (next) => void onPatch(entry.id, { control: { ...control, stopOnExit: next } }),
|
|
1746
|
+
}),
|
|
1747
|
+
),
|
|
1748
|
+
),
|
|
1749
|
+
);
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
function BlenderInstanceCard({ entry, t, busy, onRender, onCancel, onApply }) {
|
|
1753
|
+
const [blend, setBlend] = useState('');
|
|
1754
|
+
const [frames, setFrames] = useState('1');
|
|
1755
|
+
const [format, setFormat] = useState('PNG');
|
|
1756
|
+
const [engine, setEngine] = useState('');
|
|
1757
|
+
const [dir, setDir] = useState('');
|
|
1758
|
+
const [found, setFound] = useState([]);
|
|
1759
|
+
const isSpawn = entry.kind === 'spawn';
|
|
1760
|
+
|
|
1761
|
+
return h(
|
|
1762
|
+
'div',
|
|
1763
|
+
{ className: 'mmn-card' },
|
|
1764
|
+
h(
|
|
1765
|
+
'div',
|
|
1766
|
+
{ className: 'mmn-card-head' },
|
|
1767
|
+
h('span', { className: 'mmn-section-title' }, `${entry.label} · ${entry.id}`),
|
|
1768
|
+
h('span', { className: 'mmn-pill', 'data-state': entry.healthy ? 'done' : 'failed' }, entry.healthy ? t('healthy') : t('unhealthy')),
|
|
1769
|
+
h('span', { className: 'mmn-pill' }, isSpawn ? t('kindSpawn') : t('kindBridge')),
|
|
1770
|
+
h('span', { className: 'mmn-grow' }),
|
|
1771
|
+
h('button', { type: 'button', className: 'mmn-btn mmn-btn--ghost', disabled: busy, onClick: () => void onApply({ action: 'probe' }) }, t('probe')),
|
|
1772
|
+
),
|
|
1773
|
+
h(
|
|
1774
|
+
'div',
|
|
1775
|
+
{ className: 'mmn-kv' },
|
|
1776
|
+
isSpawn ? h('span', { className: 'mmn-mono' }, entry.executable || t('blenderNotFound')) : h('span', { className: 'mmn-mono' }, entry.baseUrl),
|
|
1777
|
+
entry.version ? h('span', null, entry.version) : null,
|
|
1778
|
+
h('span', null, `${t('maxConcurrent')} ${entry.maxConcurrent}`),
|
|
1779
|
+
entry.running > 0 ? h('span', { className: 'mmn-notice' }, `${t('running')} ${entry.running}`) : null,
|
|
1780
|
+
),
|
|
1781
|
+
entry.error ? h('div', { className: 'mmn-error' }, entry.error) : null,
|
|
1782
|
+
isSpawn
|
|
1783
|
+
? h(
|
|
1784
|
+
'div',
|
|
1785
|
+
{ className: 'mmn-col' },
|
|
1786
|
+
h(Field, {
|
|
1787
|
+
label: t('blendFile'),
|
|
1788
|
+
hint: t('blendHint'),
|
|
1789
|
+
children: h('input', { className: 'mmn-input mmn-mono', value: blend, onChange: (event) => setBlend(event.target.value) }),
|
|
1790
|
+
}),
|
|
1791
|
+
h(
|
|
1792
|
+
'div',
|
|
1793
|
+
{ className: 'mmn-row' },
|
|
1794
|
+
h('input', { className: 'mmn-input mmn-mono', placeholder: t('blendDir'), value: dir, onChange: (event) => setDir(event.target.value) }),
|
|
1795
|
+
h(
|
|
1796
|
+
'button',
|
|
1797
|
+
{
|
|
1798
|
+
type: 'button',
|
|
1799
|
+
className: 'mmn-btn',
|
|
1800
|
+
disabled: busy || dir.trim() === '',
|
|
1801
|
+
onClick: async () => {
|
|
1802
|
+
const result = await onApply({ action: 'blends', dir });
|
|
1803
|
+
setFound(Array.isArray(result?.files) ? result.files : []);
|
|
1804
|
+
},
|
|
1805
|
+
},
|
|
1806
|
+
t('browse'),
|
|
1807
|
+
),
|
|
1808
|
+
h(Field, { label: t('frames'), hint: t('framesHint'), children: h('input', { className: 'mmn-input', value: frames, onChange: (event) => setFrames(event.target.value) }) }),
|
|
1809
|
+
h(Field, { label: t('formatOut'), children: h('input', { className: 'mmn-input mmn-input--tiny', value: format, onChange: (event) => setFormat(event.target.value) }) }),
|
|
1810
|
+
h(Field, { label: t('engine'), children: h('input', { className: 'mmn-input', placeholder: 'CYCLES', value: engine, onChange: (event) => setEngine(event.target.value) }) }),
|
|
1811
|
+
),
|
|
1812
|
+
found.length === 0
|
|
1813
|
+
? null
|
|
1814
|
+
: h(
|
|
1815
|
+
'div',
|
|
1816
|
+
{ className: 'mmn-scroll' },
|
|
1817
|
+
found.slice(0, 30).map((file) =>
|
|
1818
|
+
h(
|
|
1819
|
+
'div',
|
|
1820
|
+
{ className: 'mmn-file-row', key: file.path },
|
|
1821
|
+
h('span', { className: 'mmn-mono mmn-grow' }, file.name),
|
|
1822
|
+
h('span', { className: 'mmn-meta' }, file.size === null ? '' : `${Math.round(file.size / 1024)} KB`),
|
|
1823
|
+
h('button', { type: 'button', className: 'mmn-btn mmn-btn--ghost', onClick: () => setBlend(file.path) }, '→'),
|
|
1824
|
+
),
|
|
1825
|
+
),
|
|
1826
|
+
),
|
|
1827
|
+
)
|
|
1828
|
+
: h('div', { className: 'mmn-hint' }, `${t('kindBridge')}: POST ${entry.baseUrl}/render`),
|
|
1829
|
+
h(
|
|
1830
|
+
'div',
|
|
1831
|
+
{ className: 'mmn-row' },
|
|
1832
|
+
h(
|
|
1833
|
+
'button',
|
|
1834
|
+
{
|
|
1835
|
+
type: 'button',
|
|
1836
|
+
className: 'mmn-btn mmn-btn--primary',
|
|
1837
|
+
disabled: busy || (isSpawn && blend.trim() === ''),
|
|
1838
|
+
onClick: () =>
|
|
1839
|
+
void onRender({
|
|
1840
|
+
instanceId: entry.id,
|
|
1841
|
+
blend: isSpawn ? blend.trim() : undefined,
|
|
1842
|
+
frames,
|
|
1843
|
+
format,
|
|
1844
|
+
engine,
|
|
1845
|
+
}),
|
|
1846
|
+
},
|
|
1847
|
+
t('render'),
|
|
1848
|
+
),
|
|
1849
|
+
),
|
|
1850
|
+
(entry.jobs ?? []).length === 0
|
|
1851
|
+
? null
|
|
1852
|
+
: h(
|
|
1853
|
+
'div',
|
|
1854
|
+
{ className: 'mmn-col' },
|
|
1855
|
+
h('span', { className: 'mmn-section-title' }, t('renderJobs')),
|
|
1856
|
+
(entry.jobs ?? []).slice().reverse().slice(0, 6).map((job) =>
|
|
1857
|
+
h(
|
|
1858
|
+
'div',
|
|
1859
|
+
{ className: 'mmn-task', key: job.id },
|
|
1860
|
+
h(Pill, { state: job.state, t }),
|
|
1861
|
+
h('span', { className: 'mmn-task-id' }, job.id.slice(0, 8)),
|
|
1862
|
+
h('span', { className: 'mmn-meta' }, job.mode),
|
|
1863
|
+
job.frames === null ? null : h('span', { className: 'mmn-meta' }, `${job.frames} f`),
|
|
1864
|
+
job.error ? h('span', { className: 'mmn-error' }, job.error) : null,
|
|
1865
|
+
h('span', { className: 'mmn-grow' }),
|
|
1866
|
+
(job.outputs ?? []).length > 0 ? h('span', { className: 'mmn-ok' }, `${job.outputs.length} 个产物`) : null,
|
|
1867
|
+
job.state === 'running'
|
|
1868
|
+
? h(
|
|
1869
|
+
'button',
|
|
1870
|
+
{ type: 'button', className: 'mmn-btn mmn-btn--ghost', disabled: busy, onClick: () => void onCancel(entry.id, job.id) },
|
|
1871
|
+
t('cancel'),
|
|
1872
|
+
)
|
|
1873
|
+
: null,
|
|
1874
|
+
),
|
|
1875
|
+
),
|
|
1876
|
+
),
|
|
1877
|
+
);
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
function ServiceNodePage({ status, t, busy, act, reload }) {
|
|
1881
|
+
const [url, setUrl] = useState('');
|
|
1882
|
+
const [tab, setTab] = useState('comfy');
|
|
1883
|
+
const [query, setQuery] = useState('');
|
|
1884
|
+
|
|
1885
|
+
const instances = status.instances ?? [];
|
|
1886
|
+
const queue = status.queue ?? { tasks: [] };
|
|
1887
|
+
const tasks = queue.tasks ?? [];
|
|
1888
|
+
const running = tasks.filter((task) => task.state === 'running');
|
|
1889
|
+
const queued = tasks.filter((task) => task.state === 'queued');
|
|
1890
|
+
const finished = tasks.filter((task) => task.state === 'done' || task.state === 'failed' || task.state === 'cancelled');
|
|
1891
|
+
|
|
1892
|
+
const patchInstance = async (id, changes) => {
|
|
1893
|
+
const next = instances.map((entry) => {
|
|
1894
|
+
if (entry.id !== id) {
|
|
1895
|
+
return {
|
|
1896
|
+
id: entry.id,
|
|
1897
|
+
label: entry.label,
|
|
1898
|
+
baseUrl: entry.baseUrl,
|
|
1899
|
+
weight: entry.weight,
|
|
1900
|
+
maxConcurrent: entry.maxConcurrent,
|
|
1901
|
+
enabled: entry.enabled,
|
|
1902
|
+
autoStart: entry.autoStart,
|
|
1903
|
+
modelsRoot: entry.modelsRoot ?? '',
|
|
1904
|
+
control: { ...(entry.controlDetail ?? {}) },
|
|
1905
|
+
};
|
|
1906
|
+
}
|
|
1907
|
+
const control = { ...(entry.controlDetail ?? {}), ...(changes.control ?? {}) };
|
|
1908
|
+
return {
|
|
1909
|
+
id: entry.id,
|
|
1910
|
+
label: changes.label ?? entry.label,
|
|
1911
|
+
baseUrl: entry.baseUrl,
|
|
1912
|
+
weight: changes.weight ?? entry.weight,
|
|
1913
|
+
maxConcurrent: changes.maxConcurrent ?? entry.maxConcurrent,
|
|
1914
|
+
enabled: changes.enabled === undefined ? entry.enabled : changes.enabled,
|
|
1915
|
+
autoStart: changes.autoStart === undefined ? entry.autoStart : changes.autoStart,
|
|
1916
|
+
modelsRoot: changes.modelsRoot === undefined ? entry.modelsRoot ?? '' : changes.modelsRoot,
|
|
1917
|
+
control,
|
|
1918
|
+
};
|
|
1919
|
+
});
|
|
1920
|
+
await act(`${BASE}/instances`, { action: 'apply', instances: next });
|
|
1921
|
+
};
|
|
1922
|
+
|
|
1923
|
+
const removeInstance = async (id) => {
|
|
1924
|
+
const next = instances.filter((entry) => entry.id !== id).map((entry) => ({
|
|
1925
|
+
id: entry.id,
|
|
1926
|
+
label: entry.label,
|
|
1927
|
+
baseUrl: entry.baseUrl,
|
|
1928
|
+
weight: entry.weight,
|
|
1929
|
+
maxConcurrent: entry.maxConcurrent,
|
|
1930
|
+
enabled: entry.enabled,
|
|
1931
|
+
autoStart: entry.autoStart,
|
|
1932
|
+
modelsRoot: entry.modelsRoot ?? '',
|
|
1933
|
+
control: { ...(entry.controlDetail ?? {}) },
|
|
1934
|
+
}));
|
|
1935
|
+
await act(`${BASE}/instances`, { action: 'apply', instances: next });
|
|
1936
|
+
};
|
|
1937
|
+
|
|
1938
|
+
const addInstance = async () => {
|
|
1939
|
+
const text = url.trim();
|
|
1940
|
+
if (text === '') return;
|
|
1941
|
+
const normalized = /^https?:\/\//i.test(text) ? text : `http://${text}`;
|
|
1942
|
+
const next = [
|
|
1943
|
+
...instances.map((entry) => ({
|
|
1944
|
+
id: entry.id,
|
|
1945
|
+
label: entry.label,
|
|
1946
|
+
baseUrl: entry.baseUrl,
|
|
1947
|
+
weight: entry.weight,
|
|
1948
|
+
maxConcurrent: entry.maxConcurrent,
|
|
1949
|
+
enabled: entry.enabled,
|
|
1950
|
+
autoStart: entry.autoStart,
|
|
1951
|
+
modelsRoot: entry.modelsRoot ?? '',
|
|
1952
|
+
control: { ...(entry.controlDetail ?? {}) },
|
|
1953
|
+
})),
|
|
1954
|
+
{
|
|
1955
|
+
id: `i${instances.length + 1}`,
|
|
1956
|
+
label: normalized.replace(/^https?:\/\//i, ''),
|
|
1957
|
+
baseUrl: normalized,
|
|
1958
|
+
weight: 1,
|
|
1959
|
+
maxConcurrent: 1,
|
|
1960
|
+
enabled: true,
|
|
1961
|
+
autoStart: false,
|
|
1962
|
+
modelsRoot: '',
|
|
1963
|
+
control: { type: 'none', batPath: '', cwd: '', stopTimeoutMs: 15000, readyTimeoutMs: 300000, stopOnExit: true },
|
|
1964
|
+
},
|
|
1965
|
+
];
|
|
1966
|
+
await act(`${BASE}/instances`, { action: 'apply', instances: next });
|
|
1967
|
+
setUrl('');
|
|
1968
|
+
};
|
|
1969
|
+
|
|
1970
|
+
|
|
1971
|
+
const blender = status.blender ?? { instances: [] };
|
|
1972
|
+
|
|
1973
|
+
return h(
|
|
1974
|
+
'div',
|
|
1975
|
+
{ className: 'mmn-page' },
|
|
1976
|
+
h(
|
|
1977
|
+
'div',
|
|
1978
|
+
{ className: 'mmn-head' },
|
|
1979
|
+
h('span', { className: 'mmn-title' }, t('plugin')),
|
|
1980
|
+
h('span', { className: 'mmn-mode-badge', 'data-mode': 'service' }, t('modeService')),
|
|
1981
|
+
h('span', { className: 'mmn-sub' }, t('subtitleService')),
|
|
1982
|
+
h('span', { className: 'mmn-grow' }),
|
|
1983
|
+
h('button', { type: 'button', className: 'mmn-btn', disabled: busy, onClick: () => void reload() }, t('probe')),
|
|
1984
|
+
),
|
|
1985
|
+
h(
|
|
1986
|
+
'div',
|
|
1987
|
+
{ className: 'mmn-kv' },
|
|
1988
|
+
h('span', null, status.gateway?.listening ? `API ${status.gateway.host}:${status.gateway.port}` : 'API 未监听'),
|
|
1989
|
+
h('span', null, status.remote?.listening ? `WEB ${status.remote.port}` : 'WEB 未监听'),
|
|
1990
|
+
h('span', null, `${t('queued')} ${queued.length}`),
|
|
1991
|
+
h('span', null, `${t('running')} ${running.length}`),
|
|
1992
|
+
h('span', null, `${t('estimate')} ${Math.round((queue.estimateMs ?? 0) / 1000)}s`),
|
|
1993
|
+
h('span', null, `${t('instances')} ${instances.length}`),
|
|
1994
|
+
),
|
|
1995
|
+
h(
|
|
1996
|
+
'div',
|
|
1997
|
+
{ className: 'mmn-tabs' },
|
|
1998
|
+
['comfy', 'blender', 'queue'].map((id) =>
|
|
1999
|
+
h(
|
|
2000
|
+
'button',
|
|
2001
|
+
{ type: 'button', key: id, className: 'mmn-tab', 'data-active': tab === id ? 'true' : 'false', onClick: () => setTab(id) },
|
|
2002
|
+
id === 'comfy' ? t('boardComfy') : id === 'blender' ? t('boardBlender') : t('boardQueue'),
|
|
2003
|
+
),
|
|
2004
|
+
),
|
|
2005
|
+
),
|
|
2006
|
+
|
|
2007
|
+
// ---- ComfyUI 板块
|
|
2008
|
+
tab === 'comfy'
|
|
2009
|
+
? h(
|
|
2010
|
+
React.Fragment,
|
|
2011
|
+
null,
|
|
2012
|
+
h(
|
|
2013
|
+
'div',
|
|
2014
|
+
{ className: 'mmn-card' },
|
|
2015
|
+
h(
|
|
2016
|
+
'div',
|
|
2017
|
+
{ className: 'mmn-card-head' },
|
|
2018
|
+
h('span', { className: 'mmn-section-title' }, `${t('boardComfy')} (${instances.length})`),
|
|
2019
|
+
h('span', { className: 'mmn-meta' }, t('boardComfyHint')),
|
|
2020
|
+
h('span', { className: 'mmn-grow' }),
|
|
2021
|
+
h('button', { type: 'button', className: 'mmn-btn', disabled: busy, onClick: () => void act(`${BASE}/pool`, { action: 'free' }) }, t('free')),
|
|
2022
|
+
),
|
|
2023
|
+
instances.length === 0
|
|
2024
|
+
? h('div', { className: 'mmn-meta' }, t('noInstances'))
|
|
2025
|
+
: h(
|
|
2026
|
+
'div',
|
|
2027
|
+
{ className: 'mmn-grid mmn-grid--wide' },
|
|
2028
|
+
instances.map((entry) =>
|
|
2029
|
+
h(InstanceCard, {
|
|
2030
|
+
key: entry.id,
|
|
2031
|
+
entry,
|
|
2032
|
+
t,
|
|
2033
|
+
busy,
|
|
2034
|
+
onAction: (action, id) => void act(`${BASE}/instances`, { action, id }),
|
|
2035
|
+
onPatch: (id, changes) => void patchInstance(id, changes),
|
|
2036
|
+
onRemove: (id) => void removeInstance(id),
|
|
2037
|
+
// 模型扫描要自己打 /multimodal/models,所以把 act 传下去
|
|
2038
|
+
act,
|
|
2039
|
+
}),
|
|
2040
|
+
),
|
|
2041
|
+
),
|
|
2042
|
+
h(
|
|
2043
|
+
'div',
|
|
2044
|
+
{ className: 'mmn-row' },
|
|
2045
|
+
h('input', {
|
|
2046
|
+
className: 'mmn-input mmn-input--wide',
|
|
2047
|
+
placeholder: t('newInstance'),
|
|
2048
|
+
value: url,
|
|
2049
|
+
onChange: (event) => setUrl(event.target.value),
|
|
2050
|
+
}),
|
|
2051
|
+
h('button', { type: 'button', className: 'mmn-btn', disabled: busy || url.trim() === '', onClick: () => void addInstance() }, t('addInstance')),
|
|
2052
|
+
),
|
|
2053
|
+
),
|
|
2054
|
+
// 不在任何实例里的模型目录:另一块盘、NAS 映射盘
|
|
2055
|
+
h(ExtraRootsSection, { status, t, busy, act }),
|
|
2056
|
+
)
|
|
2057
|
+
: null,
|
|
2058
|
+
|
|
2059
|
+
// ---- Blender 板块
|
|
2060
|
+
tab === 'blender'
|
|
2061
|
+
? h(
|
|
2062
|
+
React.Fragment,
|
|
2063
|
+
null,
|
|
2064
|
+
blender.enabled !== true
|
|
2065
|
+
? h('div', { className: 'mmn-note' }, 'Blender 板块当前是关闭的,去设置页打开。')
|
|
2066
|
+
: null,
|
|
2067
|
+
h(
|
|
2068
|
+
'div',
|
|
2069
|
+
{ className: 'mmn-card' },
|
|
2070
|
+
h(
|
|
2071
|
+
'div',
|
|
2072
|
+
{ className: 'mmn-card-head' },
|
|
2073
|
+
h('span', { className: 'mmn-section-title' }, `${t('boardBlender')} (${(blender.instances ?? []).length})`),
|
|
2074
|
+
h('span', { className: 'mmn-meta' }, t('boardBlenderHint')),
|
|
2075
|
+
h('span', { className: 'mmn-grow' }),
|
|
2076
|
+
h('span', { className: 'mmn-mono mmn-meta' }, blender.executable || t('blenderNotFound')),
|
|
2077
|
+
),
|
|
2078
|
+
(blender.instances ?? []).length === 0
|
|
2079
|
+
? h('div', { className: 'mmn-meta' }, t('noInstances'))
|
|
2080
|
+
: h(
|
|
2081
|
+
'div',
|
|
2082
|
+
{ className: 'mmn-grid mmn-grid--wide' },
|
|
2083
|
+
(blender.instances ?? []).map((entry) =>
|
|
2084
|
+
h(BlenderInstanceCard, {
|
|
2085
|
+
key: entry.id,
|
|
2086
|
+
entry,
|
|
2087
|
+
t,
|
|
2088
|
+
busy,
|
|
2089
|
+
onRender: (payload) => void act(`${BASE}/blender`, { action: 'render', ...payload }),
|
|
2090
|
+
onCancel: (instanceId, jobId) => void act(`${BASE}/blender`, { action: 'cancel', instanceId, jobId }),
|
|
2091
|
+
onApply: (payload) => act(`${BASE}/blender`, payload),
|
|
2092
|
+
}),
|
|
2093
|
+
),
|
|
2094
|
+
),
|
|
2095
|
+
),
|
|
2096
|
+
)
|
|
2097
|
+
: null,
|
|
2098
|
+
|
|
2099
|
+
// ---- 队列板块
|
|
2100
|
+
tab === 'queue'
|
|
2101
|
+
? h(
|
|
2102
|
+
React.Fragment,
|
|
2103
|
+
null,
|
|
2104
|
+
h(
|
|
2105
|
+
'div',
|
|
2106
|
+
{ className: 'mmn-card' },
|
|
2107
|
+
h(
|
|
2108
|
+
'div',
|
|
2109
|
+
{ className: 'mmn-card-head' },
|
|
2110
|
+
h('span', { className: 'mmn-section-title' }, `${t('boardQueue')} (${queued.length + running.length})`),
|
|
2111
|
+
h('span', { className: 'mmn-grow' }),
|
|
2112
|
+
h(Toggle, {
|
|
2113
|
+
on: queue.dynamic === true,
|
|
2114
|
+
disabled: busy,
|
|
2115
|
+
label: queue.dynamic ? t('dynamicOn') : t('dynamicOff'),
|
|
2116
|
+
onChange: (next) => void act(`${BASE}/pool`, { action: 'dynamic', enabled: next }),
|
|
2117
|
+
}),
|
|
2118
|
+
h(
|
|
2119
|
+
'button',
|
|
2120
|
+
{ type: 'button', className: 'mmn-btn mmn-btn--danger', disabled: busy || queued.length === 0, onClick: () => void act(`${BASE}/pool`, { action: 'clearQueue' }) },
|
|
2121
|
+
t('clearQueue'),
|
|
2122
|
+
),
|
|
2123
|
+
),
|
|
2124
|
+
queued.length + running.length === 0
|
|
2125
|
+
? h('div', { className: 'mmn-meta' }, t('empty'))
|
|
2126
|
+
: h(
|
|
2127
|
+
'div',
|
|
2128
|
+
null,
|
|
2129
|
+
[...running, ...queued].map((task) =>
|
|
2130
|
+
h(
|
|
2131
|
+
'div',
|
|
2132
|
+
{ className: 'mmn-task', key: task.id },
|
|
2133
|
+
h(Pill, { state: task.state, t }),
|
|
2134
|
+
h('span', { className: 'mmn-task-id' }, task.id.slice(0, 8)),
|
|
2135
|
+
h('span', { className: 'mmn-meta' }, `${t('cost')} ${task.cost}`),
|
|
2136
|
+
h('span', { className: 'mmn-meta' }, `${t('priority')} ${task.priority}`),
|
|
2137
|
+
h('span', { className: 'mmn-meta' }, task.backendId ?? t('unassigned')),
|
|
2138
|
+
task.error ? h('span', { className: 'mmn-error' }, task.error) : null,
|
|
2139
|
+
h('span', { className: 'mmn-grow' }),
|
|
2140
|
+
task.state === 'queued'
|
|
2141
|
+
? h(
|
|
2142
|
+
'button',
|
|
2143
|
+
{ type: 'button', className: 'mmn-btn mmn-btn--ghost', disabled: busy, onClick: () => void act(`${BASE}/pool`, { action: 'prioritize', id: task.id, delta: 1 }) },
|
|
2144
|
+
t('bump'),
|
|
2145
|
+
)
|
|
2146
|
+
: null,
|
|
2147
|
+
h(
|
|
2148
|
+
'button',
|
|
2149
|
+
{ type: 'button', className: 'mmn-btn mmn-btn--ghost', disabled: busy, onClick: () => void act(`${BASE}/pool`, { action: 'cancel', id: task.id }) },
|
|
2150
|
+
t('cancel'),
|
|
2151
|
+
),
|
|
2152
|
+
),
|
|
2153
|
+
),
|
|
2154
|
+
),
|
|
2155
|
+
),
|
|
2156
|
+
finished.length === 0
|
|
2157
|
+
? null
|
|
2158
|
+
: h(
|
|
2159
|
+
'div',
|
|
2160
|
+
{ className: 'mmn-card' },
|
|
2161
|
+
h(
|
|
2162
|
+
'div',
|
|
2163
|
+
{ className: 'mmn-card-head' },
|
|
2164
|
+
h('span', { className: 'mmn-section-title' }, `${t('done')} / ${t('failedState')} (${finished.length})`),
|
|
2165
|
+
),
|
|
2166
|
+
h(
|
|
2167
|
+
'div',
|
|
2168
|
+
{ className: 'mmn-scroll' },
|
|
2169
|
+
finished.slice(0, 20).map((task) =>
|
|
2170
|
+
h(
|
|
2171
|
+
'div',
|
|
2172
|
+
{ className: 'mmn-task', key: task.id },
|
|
2173
|
+
h(Pill, { state: task.state, t }),
|
|
2174
|
+
h('span', { className: 'mmn-task-id' }, task.id.slice(0, 8)),
|
|
2175
|
+
h('span', { className: 'mmn-meta' }, task.backendId ?? '—'),
|
|
2176
|
+
h('span', { className: 'mmn-meta' }, `${t('cost')} ${task.cost}`),
|
|
2177
|
+
h('span', { className: 'mmn-grow' }),
|
|
2178
|
+
task.finishedAt ? h('span', { className: 'mmn-meta' }, new Date(task.finishedAt).toLocaleTimeString()) : null,
|
|
2179
|
+
),
|
|
2180
|
+
),
|
|
2181
|
+
),
|
|
2182
|
+
),
|
|
2183
|
+
)
|
|
2184
|
+
: null,
|
|
2185
|
+
|
|
2186
|
+
// ---- 模板
|
|
2187
|
+
tab === 'templates'
|
|
2188
|
+
? h(
|
|
2189
|
+
'div',
|
|
2190
|
+
{ className: 'mmn-card' },
|
|
2191
|
+
h(
|
|
2192
|
+
'div',
|
|
2193
|
+
{ className: 'mmn-card-head' },
|
|
2194
|
+
h('span', { className: 'mmn-section-title' }, t('templates')),
|
|
2195
|
+
h('span', { className: 'mmn-meta' }, t('templatesHint')),
|
|
2196
|
+
),
|
|
2197
|
+
templates === undefined
|
|
2198
|
+
? h('div', { className: 'mmn-meta' }, t('loading'))
|
|
2199
|
+
: templates.length === 0
|
|
2200
|
+
? h('div', { className: 'mmn-meta' }, t('noTemplates'))
|
|
2201
|
+
: h(
|
|
2202
|
+
'div',
|
|
2203
|
+
{ className: 'mmn-grid' },
|
|
2204
|
+
templates.map((entry) =>
|
|
2205
|
+
h(
|
|
2206
|
+
'div',
|
|
2207
|
+
{ className: 'mmn-card', key: entry.id },
|
|
2208
|
+
h(
|
|
2209
|
+
'div',
|
|
2210
|
+
{ className: 'mmn-card-head' },
|
|
2211
|
+
h('span', { className: 'mmn-section-title' }, entry.name),
|
|
2212
|
+
h('span', { className: 'mmn-pill', 'data-tier': entry.tier }, entry.tier === 'nsfw' ? 'NSFW' : 'SFW'),
|
|
2213
|
+
),
|
|
2214
|
+
h(
|
|
2215
|
+
'div',
|
|
2216
|
+
{ className: 'mmn-kv' },
|
|
2217
|
+
h('span', null, entry.category),
|
|
2218
|
+
h('span', null, entry.needsText ? t('needText') : t('noText')),
|
|
2219
|
+
entry.nodeCount ? h('span', null, `${entry.nodeCount} 节点`) : null,
|
|
2220
|
+
(entry.images ?? 0) > 0 ? h('span', null, `图输入 ${entry.images}`) : null,
|
|
2221
|
+
),
|
|
2222
|
+
(entry.promptBoxes ?? []).length === 0
|
|
2223
|
+
? null
|
|
2224
|
+
: h(
|
|
2225
|
+
'div',
|
|
2226
|
+
{ className: 'mmn-hint' },
|
|
2227
|
+
(entry.promptBoxes ?? []).slice(0, 2).map((box) => `${box.role}:${box.head || '(空)'}`).join(' · '),
|
|
2228
|
+
),
|
|
2229
|
+
h(
|
|
2230
|
+
'div',
|
|
2231
|
+
{ className: 'mmn-row' },
|
|
2232
|
+
h('span', { className: 'mmn-grow' }),
|
|
2233
|
+
h('button', { type: 'button', className: 'mmn-btn mmn-btn--ghost', onClick: () => setDetail(entry) }, t('detail')),
|
|
2234
|
+
),
|
|
2235
|
+
),
|
|
2236
|
+
),
|
|
2237
|
+
),
|
|
2238
|
+
)
|
|
2239
|
+
: null,
|
|
2240
|
+
|
|
2241
|
+
// ---- 队列
|
|
2242
|
+
tab === 'queue'
|
|
2243
|
+
? h(
|
|
2244
|
+
'div',
|
|
2245
|
+
{ className: 'mmn-card' },
|
|
2246
|
+
h(
|
|
2247
|
+
'div',
|
|
2248
|
+
{ className: 'mmn-card-head' },
|
|
2249
|
+
h('span', { className: 'mmn-section-title' }, t('nodeQueue')),
|
|
2250
|
+
h('span', { className: 'mmn-grow' }),
|
|
2251
|
+
h('button', { type: 'button', className: 'mmn-btn', onClick: () => void loadQueue() }, t('refresh')),
|
|
2252
|
+
),
|
|
2253
|
+
queue === undefined
|
|
2254
|
+
? h('div', { className: 'mmn-meta' }, t('loading'))
|
|
2255
|
+
: tasks.length === 0
|
|
2256
|
+
? h('div', { className: 'mmn-meta' }, t('empty'))
|
|
2257
|
+
: h(
|
|
2258
|
+
'div',
|
|
2259
|
+
{ className: 'mmn-scroll' },
|
|
2260
|
+
tasks.slice(0, 40).map((task) =>
|
|
2261
|
+
h(
|
|
2262
|
+
'div',
|
|
2263
|
+
{ className: 'mmn-task', key: task.id },
|
|
2264
|
+
h(Pill, { state: task.state, t }),
|
|
2265
|
+
h('span', { className: 'mmn-task-id' }, task.id.slice(0, 8)),
|
|
2266
|
+
h('span', { className: 'mmn-meta' }, `${t('cost2')} ${task.cost}`),
|
|
2267
|
+
h('span', { className: 'mmn-meta' }, task.backendId ?? t('unassigned')),
|
|
2268
|
+
task.error ? h('span', { className: 'mmn-error' }, task.error) : null,
|
|
2269
|
+
),
|
|
2270
|
+
),
|
|
2271
|
+
),
|
|
2272
|
+
)
|
|
2273
|
+
: null,
|
|
2274
|
+
|
|
2275
|
+
// ---- 模型
|
|
2276
|
+
tab === 'models'
|
|
2277
|
+
? h(
|
|
2278
|
+
'div',
|
|
2279
|
+
{ className: 'mmn-card' },
|
|
2280
|
+
h(
|
|
2281
|
+
'div',
|
|
2282
|
+
{ className: 'mmn-card-head' },
|
|
2283
|
+
h('span', { className: 'mmn-section-title' }, t('nodeModels')),
|
|
2284
|
+
h('span', { className: 'mmn-grow' }),
|
|
2285
|
+
h('button', { type: 'button', className: 'mmn-btn', onClick: () => void loadModels() }, t('refresh')),
|
|
2286
|
+
),
|
|
2287
|
+
models === undefined
|
|
2288
|
+
? h('div', { className: 'mmn-meta' }, t('loading'))
|
|
2289
|
+
: models.ok === false
|
|
2290
|
+
? h('div', { className: 'mmn-error' }, models.error ?? t('noModels'))
|
|
2291
|
+
: models.locked === true
|
|
2292
|
+
? h('div', { className: 'mmn-notice' }, t('modelsLocked'))
|
|
2293
|
+
: h(
|
|
2294
|
+
'div',
|
|
2295
|
+
{ className: 'mmn-col' },
|
|
2296
|
+
h('span', { className: 'mmn-kv' }, `${t('folders')} ${(models.folders ?? []).length} · ${t('files')} ${models.total ?? 0}`),
|
|
2297
|
+
(models.folders ?? []).map((folder) =>
|
|
2298
|
+
h(
|
|
2299
|
+
'div',
|
|
2300
|
+
{ key: folder.name },
|
|
2301
|
+
h('span', { className: 'mmn-section-title' }, folder.label ?? folder.name),
|
|
2302
|
+
(folder.items ?? []).slice(0, 30).map((item) => {
|
|
2303
|
+
const name = typeof item === 'string' ? item : item.name;
|
|
2304
|
+
return h('div', { className: 'mmn-file-row', key: `${folder.name}/${name}` }, h('span', { className: 'mmn-mono mmn-grow' }, name));
|
|
2305
|
+
}),
|
|
2306
|
+
),
|
|
2307
|
+
),
|
|
2308
|
+
),
|
|
2309
|
+
)
|
|
2310
|
+
: null,
|
|
2311
|
+
|
|
2312
|
+
// ---- 本机产物
|
|
2313
|
+
tab === 'output'
|
|
2314
|
+
? h(
|
|
2315
|
+
'div',
|
|
2316
|
+
{ className: 'mmn-card' },
|
|
2317
|
+
h(
|
|
2318
|
+
'div',
|
|
2319
|
+
{ className: 'mmn-card-head' },
|
|
2320
|
+
h('span', { className: 'mmn-section-title' }, t('localOutput')),
|
|
2321
|
+
h('span', { className: 'mmn-meta' }, t('localOutputHint')),
|
|
2322
|
+
h('span', { className: 'mmn-grow' }),
|
|
2323
|
+
h('button', { type: 'button', className: 'mmn-btn', onClick: () => void loadProducts() }, t('refresh')),
|
|
2324
|
+
),
|
|
2325
|
+
h('span', { className: 'mmn-mono mmn-meta' }, products?.dir ?? status.node?.outputDir ?? ''),
|
|
2326
|
+
products === undefined
|
|
2327
|
+
? h('div', { className: 'mmn-meta' }, t('loading'))
|
|
2328
|
+
: (products.files ?? []).length === 0
|
|
2329
|
+
? h('div', { className: 'mmn-meta' }, t('noFiles'))
|
|
2330
|
+
: h(
|
|
2331
|
+
'div',
|
|
2332
|
+
{ className: 'mmn-grid' },
|
|
2333
|
+
(products.files ?? []).map((file) =>
|
|
2334
|
+
h(
|
|
2335
|
+
'div',
|
|
2336
|
+
{ className: 'mmn-card', key: file.path },
|
|
2337
|
+
/\.(png|jpe?g|webp|gif)$/i.test(file.name)
|
|
2338
|
+
? h('img', { className: 'mmn-thumb', src: `${BASE}/product?name=${encodeURIComponent(file.name)}`, alt: file.name })
|
|
2339
|
+
: null,
|
|
2340
|
+
h('span', { className: 'mmn-mono mmn-meta' }, file.name),
|
|
2341
|
+
h('span', { className: 'mmn-meta' }, `${Math.round(file.bytes / 1024)} KB`),
|
|
2342
|
+
),
|
|
2343
|
+
),
|
|
2344
|
+
),
|
|
2345
|
+
)
|
|
2346
|
+
: null,
|
|
2347
|
+
);
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
// ---------------------------------------------------------------- 侧边栏 · 用户节点
|
|
2351
|
+
function UserNodePage({ status, t, busy, act, reload }) {
|
|
2352
|
+
const [templates, setTemplates] = useState(undefined);
|
|
2353
|
+
const [queue, setQueue] = useState(undefined);
|
|
2354
|
+
const [models, setModels] = useState(undefined);
|
|
2355
|
+
const [products, setProducts] = useState(undefined);
|
|
2356
|
+
const [error, setError] = useState('');
|
|
2357
|
+
const [tab, setTab] = useState('templates');
|
|
2358
|
+
const [detail, setDetail] = useState(undefined);
|
|
2359
|
+
|
|
2360
|
+
const configured = status.node?.configured === true;
|
|
2361
|
+
const probe = status.node?.serviceNode;
|
|
2362
|
+
|
|
2363
|
+
const loadTemplates = useCallback(async () => {
|
|
2364
|
+
try {
|
|
2365
|
+
const data = await postJson(`${BASE}/node`, { action: 'templates' });
|
|
2366
|
+
setTemplates(Array.isArray(data.templates) ? data.templates : []);
|
|
2367
|
+
setError('');
|
|
2368
|
+
} catch (cause) {
|
|
2369
|
+
setTemplates([]);
|
|
2370
|
+
setError(cause instanceof Error ? cause.message : String(cause));
|
|
2371
|
+
}
|
|
2372
|
+
}, []);
|
|
2373
|
+
|
|
2374
|
+
const loadQueue = useCallback(async () => {
|
|
2375
|
+
try {
|
|
2376
|
+
const data = await postJson(`${BASE}/node`, { action: 'queue' });
|
|
2377
|
+
setQueue(data);
|
|
2378
|
+
} catch (cause) {
|
|
2379
|
+
setQueue({ error: cause instanceof Error ? cause.message : String(cause) });
|
|
2380
|
+
}
|
|
2381
|
+
}, []);
|
|
2382
|
+
|
|
2383
|
+
const loadModels = useCallback(async () => {
|
|
2384
|
+
try {
|
|
2385
|
+
const data = await postJson(`${BASE}/node`, { action: 'models' });
|
|
2386
|
+
setModels(data);
|
|
2387
|
+
} catch (cause) {
|
|
2388
|
+
setModels({ ok: false, error: cause instanceof Error ? cause.message : String(cause), folders: [] });
|
|
2389
|
+
}
|
|
2390
|
+
}, []);
|
|
2391
|
+
|
|
2392
|
+
const loadProducts = useCallback(async () => {
|
|
2393
|
+
try {
|
|
2394
|
+
const data = await postJson(`${BASE}/output`, { action: 'list' });
|
|
2395
|
+
setProducts(data);
|
|
2396
|
+
} catch (cause) {
|
|
2397
|
+
setProducts({ ok: false, error: cause instanceof Error ? cause.message : String(cause), files: [] });
|
|
2398
|
+
}
|
|
2399
|
+
}, []);
|
|
2400
|
+
|
|
2401
|
+
useEffect(() => {
|
|
2402
|
+
if (!configured) return;
|
|
2403
|
+
void loadTemplates();
|
|
2404
|
+
void loadQueue();
|
|
2405
|
+
void loadProducts();
|
|
2406
|
+
}, [configured, loadTemplates, loadQueue, loadProducts]);
|
|
2407
|
+
|
|
2408
|
+
useEffect(() => {
|
|
2409
|
+
if (tab === 'models' && configured && models === undefined) void loadModels();
|
|
2410
|
+
}, [tab, configured, models, loadModels]);
|
|
2411
|
+
|
|
2412
|
+
if (!configured) {
|
|
2413
|
+
return h(
|
|
2414
|
+
'div',
|
|
2415
|
+
{ className: 'mmn-page' },
|
|
2416
|
+
h(
|
|
2417
|
+
'div',
|
|
2418
|
+
{ className: 'mmn-head' },
|
|
2419
|
+
h('span', { className: 'mmn-title' }, t('plugin')),
|
|
2420
|
+
h('span', { className: 'mmn-mode-badge', 'data-mode': 'user' }, t('modeUser')),
|
|
2421
|
+
h('span', { className: 'mmn-sub' }, t('subtitleUser')),
|
|
2422
|
+
),
|
|
2423
|
+
h('div', { className: 'mmn-note' }, t('connectFirst')),
|
|
2424
|
+
);
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
/**
|
|
2428
|
+
* 队列数字有三个可能的来源,形状**不一样**:
|
|
2429
|
+
*
|
|
2430
|
+
* /node/queue → { dynamic, counters, estimateMs, tasks } (扁平)
|
|
2431
|
+
* /node/ping → { queue: { queued, running, estimateMs } } (嵌套)
|
|
2432
|
+
* status.node.serviceNode → 同 ping
|
|
2433
|
+
*
|
|
2434
|
+
* 第一版写成 `queue ?? probe`,于是「显式拉队列」成功之后(扁平形状),
|
|
2435
|
+
* 读数取的是 payload.queue.queued —— undefined,页面稳定显示「排队 0」,
|
|
2436
|
+
* 而真实队列里明明有任务。三种形状要分别认,不能靠 ?? 猜。
|
|
2437
|
+
*/
|
|
2438
|
+
const flatQueue = queue !== undefined && Array.isArray(queue.tasks) ? queue : undefined;
|
|
2439
|
+
const nestedQueue = queue?.queue ?? probe?.queue ?? {};
|
|
2440
|
+
const queueStats = {
|
|
2441
|
+
queued: flatQueue === undefined ? (nestedQueue.queued ?? 0) : flatQueue.tasks.filter((task) => task.state === 'queued').length,
|
|
2442
|
+
running: flatQueue === undefined ? (nestedQueue.running ?? 0) : flatQueue.tasks.filter((task) => task.state === 'running').length,
|
|
2443
|
+
estimateMs: flatQueue === undefined ? (nestedQueue.estimateMs ?? 0) : (flatQueue.estimateMs ?? 0),
|
|
2444
|
+
};
|
|
2445
|
+
const tasks = flatQueue === undefined ? [] : flatQueue.tasks;
|
|
2446
|
+
const instanceRows = flatQueue?.instances ?? probe?.instances ?? [];
|
|
2447
|
+
|
|
2448
|
+
return h(
|
|
2449
|
+
'div',
|
|
2450
|
+
{ className: 'mmn-page' },
|
|
2451
|
+
h(
|
|
2452
|
+
'div',
|
|
2453
|
+
{ className: 'mmn-head' },
|
|
2454
|
+
h('span', { className: 'mmn-title' }, t('plugin')),
|
|
2455
|
+
h('span', { className: 'mmn-mode-badge', 'data-mode': 'user' }, t('modeUser')),
|
|
2456
|
+
h('span', { className: 'mmn-sub' }, t('subtitleUser')),
|
|
2457
|
+
h('span', { className: 'mmn-grow' }),
|
|
2458
|
+
h(
|
|
2459
|
+
'button',
|
|
2460
|
+
{
|
|
2461
|
+
type: 'button',
|
|
2462
|
+
className: 'mmn-btn',
|
|
2463
|
+
disabled: busy,
|
|
2464
|
+
onClick: () => {
|
|
2465
|
+
void reload();
|
|
2466
|
+
void loadTemplates();
|
|
2467
|
+
void loadQueue();
|
|
2468
|
+
void loadProducts();
|
|
2469
|
+
},
|
|
2470
|
+
},
|
|
2471
|
+
t('refresh'),
|
|
2472
|
+
),
|
|
2473
|
+
),
|
|
2474
|
+
|
|
2475
|
+
error === '' ? null : h('div', { className: 'mmn-error' }, error),
|
|
2476
|
+
|
|
2477
|
+
h(
|
|
2478
|
+
'div',
|
|
2479
|
+
{ className: 'mmn-card' },
|
|
2480
|
+
h(
|
|
2481
|
+
'div',
|
|
2482
|
+
{ className: 'mmn-card-head' },
|
|
2483
|
+
h('span', { className: 'mmn-section-title' }, t('serviceStatus')),
|
|
2484
|
+
h('span', { className: 'mmn-mono mmn-meta' }, status.node?.publicUrl ?? ''),
|
|
2485
|
+
h('span', { className: 'mmn-grow' }),
|
|
2486
|
+
probe === undefined
|
|
2487
|
+
? null
|
|
2488
|
+
: probe.ok === true
|
|
2489
|
+
? h('span', { className: 'mmn-pill', 'data-state': 'done' }, t('healthy'))
|
|
2490
|
+
: h('span', { className: 'mmn-pill', 'data-state': 'failed' }, t('unhealthy')),
|
|
2491
|
+
),
|
|
2492
|
+
probe !== undefined && probe.ok === false ? h('div', { className: 'mmn-error' }, probe.error) : null,
|
|
2493
|
+
h(
|
|
2494
|
+
'div',
|
|
2495
|
+
{ className: 'mmn-kv' },
|
|
2496
|
+
h('span', null, `${t('queued')} ${queueStats.queued}`),
|
|
2497
|
+
h('span', null, `${t('running')} ${queueStats.running}`),
|
|
2498
|
+
h('span', null, `${t('estimate')} ${Math.round(queueStats.estimateMs / 1000)}s`),
|
|
2499
|
+
h('span', null, `${t('instances')} ${instanceRows.length}`),
|
|
2500
|
+
h('span', null, `模型 ${probe?.models?.total ?? '—'}`),
|
|
2501
|
+
),
|
|
2502
|
+
),
|
|
2503
|
+
|
|
2504
|
+
h(
|
|
2505
|
+
'div',
|
|
2506
|
+
{ className: 'mmn-tabs' },
|
|
2507
|
+
['templates', 'queue', 'models', 'output'].map((id) =>
|
|
2508
|
+
h(
|
|
2509
|
+
'button',
|
|
2510
|
+
{ type: 'button', key: id, className: 'mmn-tab', 'data-active': tab === id ? 'true' : 'false', onClick: () => setTab(id) },
|
|
2511
|
+
id === 'templates' ? t('templates') : id === 'queue' ? t('nodeQueue') : id === 'models' ? t('nodeModels') : t('localOutput'),
|
|
2512
|
+
),
|
|
2513
|
+
),
|
|
2514
|
+
),
|
|
2515
|
+
|
|
2516
|
+
// ---- 模板
|
|
2517
|
+
tab === 'templates'
|
|
2518
|
+
? h(
|
|
2519
|
+
'div',
|
|
2520
|
+
{ className: 'mmn-card' },
|
|
2521
|
+
h(
|
|
2522
|
+
'div',
|
|
2523
|
+
{ className: 'mmn-card-head' },
|
|
2524
|
+
h('span', { className: 'mmn-section-title' }, t('templates')),
|
|
2525
|
+
h('span', { className: 'mmn-meta' }, t('templatesHint')),
|
|
2526
|
+
),
|
|
2527
|
+
templates === undefined
|
|
2528
|
+
? h('div', { className: 'mmn-meta' }, t('loading'))
|
|
2529
|
+
: templates.length === 0
|
|
2530
|
+
? h('div', { className: 'mmn-meta' }, t('noTemplates'))
|
|
2531
|
+
: h(
|
|
2532
|
+
'div',
|
|
2533
|
+
{ className: 'mmn-grid' },
|
|
2534
|
+
templates.map((entry) =>
|
|
2535
|
+
h(
|
|
2536
|
+
'div',
|
|
2537
|
+
{ className: 'mmn-card', key: entry.id },
|
|
2538
|
+
h(
|
|
2539
|
+
'div',
|
|
2540
|
+
{ className: 'mmn-card-head' },
|
|
2541
|
+
h('span', { className: 'mmn-section-title' }, entry.name),
|
|
2542
|
+
h('span', { className: 'mmn-pill', 'data-tier': entry.tier }, entry.tier === 'nsfw' ? 'NSFW' : 'SFW'),
|
|
2543
|
+
),
|
|
2544
|
+
h(
|
|
2545
|
+
'div',
|
|
2546
|
+
{ className: 'mmn-kv' },
|
|
2547
|
+
h('span', null, entry.category),
|
|
2548
|
+
h('span', null, entry.needsText ? t('needText') : t('noText')),
|
|
2549
|
+
entry.nodeCount ? h('span', null, `${entry.nodeCount} 节点`) : null,
|
|
2550
|
+
(entry.images ?? 0) > 0 ? h('span', null, `图输入 ${entry.images}`) : null,
|
|
2551
|
+
),
|
|
2552
|
+
(entry.promptBoxes ?? []).length === 0
|
|
2553
|
+
? null
|
|
2554
|
+
: h(
|
|
2555
|
+
'div',
|
|
2556
|
+
{ className: 'mmn-hint' },
|
|
2557
|
+
(entry.promptBoxes ?? []).slice(0, 2).map((box) => `${box.role}:${box.head || '(空)'}`).join(' · '),
|
|
2558
|
+
),
|
|
2559
|
+
h(
|
|
2560
|
+
'div',
|
|
2561
|
+
{ className: 'mmn-row' },
|
|
2562
|
+
h('span', { className: 'mmn-grow' }),
|
|
2563
|
+
h('button', { type: 'button', className: 'mmn-btn mmn-btn--ghost', onClick: () => setDetail(entry) }, t('detail')),
|
|
2564
|
+
),
|
|
2565
|
+
),
|
|
2566
|
+
),
|
|
2567
|
+
),
|
|
2568
|
+
)
|
|
2569
|
+
: null,
|
|
2570
|
+
|
|
2571
|
+
// ---- 队列
|
|
2572
|
+
tab === 'queue'
|
|
2573
|
+
? h(
|
|
2574
|
+
'div',
|
|
2575
|
+
{ className: 'mmn-card' },
|
|
2576
|
+
h(
|
|
2577
|
+
'div',
|
|
2578
|
+
{ className: 'mmn-card-head' },
|
|
2579
|
+
h('span', { className: 'mmn-section-title' }, t('nodeQueue')),
|
|
2580
|
+
h('span', { className: 'mmn-grow' }),
|
|
2581
|
+
h('button', { type: 'button', className: 'mmn-btn', onClick: () => void loadQueue() }, t('refresh')),
|
|
2582
|
+
),
|
|
2583
|
+
queue === undefined
|
|
2584
|
+
? h('div', { className: 'mmn-meta' }, t('loading'))
|
|
2585
|
+
: tasks.length === 0
|
|
2586
|
+
? h('div', { className: 'mmn-meta' }, t('empty'))
|
|
2587
|
+
: h(
|
|
2588
|
+
'div',
|
|
2589
|
+
{ className: 'mmn-scroll' },
|
|
2590
|
+
tasks.slice(0, 40).map((task) =>
|
|
2591
|
+
h(
|
|
2592
|
+
'div',
|
|
2593
|
+
{ className: 'mmn-task', key: task.id },
|
|
2594
|
+
h(Pill, { state: task.state, t }),
|
|
2595
|
+
h('span', { className: 'mmn-task-id' }, task.id.slice(0, 8)),
|
|
2596
|
+
h('span', { className: 'mmn-meta' }, `${t('cost2')} ${task.cost}`),
|
|
2597
|
+
h('span', { className: 'mmn-meta' }, task.backendId ?? t('unassigned')),
|
|
2598
|
+
task.error ? h('span', { className: 'mmn-error' }, task.error) : null,
|
|
2599
|
+
),
|
|
2600
|
+
),
|
|
2601
|
+
),
|
|
2602
|
+
)
|
|
2603
|
+
: null,
|
|
2604
|
+
|
|
2605
|
+
// ---- 模型
|
|
2606
|
+
tab === 'models'
|
|
2607
|
+
? h(
|
|
2608
|
+
'div',
|
|
2609
|
+
{ className: 'mmn-card' },
|
|
2610
|
+
h(
|
|
2611
|
+
'div',
|
|
2612
|
+
{ className: 'mmn-card-head' },
|
|
2613
|
+
h('span', { className: 'mmn-section-title' }, t('nodeModels')),
|
|
2614
|
+
h('span', { className: 'mmn-grow' }),
|
|
2615
|
+
h('button', { type: 'button', className: 'mmn-btn', onClick: () => void loadModels() }, t('refresh')),
|
|
2616
|
+
),
|
|
2617
|
+
models === undefined
|
|
2618
|
+
? h('div', { className: 'mmn-meta' }, t('loading'))
|
|
2619
|
+
: models.ok === false
|
|
2620
|
+
? h('div', { className: 'mmn-error' }, models.error ?? t('noModels'))
|
|
2621
|
+
: models.locked === true
|
|
2622
|
+
? h('div', { className: 'mmn-notice' }, t('modelsLocked'))
|
|
2623
|
+
: h(
|
|
2624
|
+
'div',
|
|
2625
|
+
{ className: 'mmn-col' },
|
|
2626
|
+
h('span', { className: 'mmn-kv' }, `${t('folders')} ${(models.folders ?? []).length} · ${t('files')} ${models.total ?? 0}`),
|
|
2627
|
+
(models.folders ?? []).map((folder) =>
|
|
2628
|
+
h(
|
|
2629
|
+
'div',
|
|
2630
|
+
{ key: folder.name },
|
|
2631
|
+
h('span', { className: 'mmn-section-title' }, folder.label ?? folder.name),
|
|
2632
|
+
(folder.items ?? []).slice(0, 30).map((item) => {
|
|
2633
|
+
const name = typeof item === 'string' ? item : item.name;
|
|
2634
|
+
return h('div', { className: 'mmn-file-row', key: `${folder.name}/${name}` }, h('span', { className: 'mmn-mono mmn-grow' }, name));
|
|
2635
|
+
}),
|
|
2636
|
+
),
|
|
2637
|
+
),
|
|
2638
|
+
),
|
|
2639
|
+
)
|
|
2640
|
+
: null,
|
|
2641
|
+
|
|
2642
|
+
// ---- 本机产物
|
|
2643
|
+
tab === 'output'
|
|
2644
|
+
? h(
|
|
2645
|
+
'div',
|
|
2646
|
+
{ className: 'mmn-card' },
|
|
2647
|
+
h(
|
|
2648
|
+
'div',
|
|
2649
|
+
{ className: 'mmn-card-head' },
|
|
2650
|
+
h('span', { className: 'mmn-section-title' }, t('localOutput')),
|
|
2651
|
+
h('span', { className: 'mmn-meta' }, t('localOutputHint')),
|
|
2652
|
+
h('span', { className: 'mmn-grow' }),
|
|
2653
|
+
h('button', { type: 'button', className: 'mmn-btn', onClick: () => void loadProducts() }, t('refresh')),
|
|
2654
|
+
),
|
|
2655
|
+
h('span', { className: 'mmn-mono mmn-meta' }, products?.dir ?? status.node?.outputDir ?? ''),
|
|
2656
|
+
products === undefined
|
|
2657
|
+
? h('div', { className: 'mmn-meta' }, t('loading'))
|
|
2658
|
+
: (products.files ?? []).length === 0
|
|
2659
|
+
? h('div', { className: 'mmn-meta' }, t('noFiles'))
|
|
2660
|
+
: h(
|
|
2661
|
+
'div',
|
|
2662
|
+
{ className: 'mmn-grid' },
|
|
2663
|
+
(products.files ?? []).map((file) =>
|
|
2664
|
+
h(
|
|
2665
|
+
'div',
|
|
2666
|
+
{ className: 'mmn-card', key: file.path },
|
|
2667
|
+
/\.(png|jpe?g|webp|gif)$/i.test(file.name)
|
|
2668
|
+
? h('img', { className: 'mmn-thumb', src: `${BASE}/product?name=${encodeURIComponent(file.name)}`, alt: file.name })
|
|
2669
|
+
: null,
|
|
2670
|
+
h('span', { className: 'mmn-mono mmn-meta' }, file.name),
|
|
2671
|
+
h('span', { className: 'mmn-meta' }, `${Math.round(file.bytes / 1024)} KB`),
|
|
2672
|
+
),
|
|
2673
|
+
),
|
|
2674
|
+
),
|
|
2675
|
+
)
|
|
2676
|
+
: null,
|
|
2677
|
+
|
|
2678
|
+
detail === undefined
|
|
2679
|
+
? null
|
|
2680
|
+
: h(
|
|
2681
|
+
'div',
|
|
2682
|
+
{ className: 'mmn-key-once' },
|
|
2683
|
+
h('span', { className: 'mmn-section-title' }, detail.name),
|
|
2684
|
+
h('span', { className: 'mmn-hint' }, `id: ${detail.id} · ${detail.category} · tier ${detail.tier}`),
|
|
2685
|
+
(detail.promptBoxes ?? []).length === 0
|
|
2686
|
+
? null
|
|
2687
|
+
: h(
|
|
2688
|
+
'div',
|
|
2689
|
+
{ className: 'mmn-col' },
|
|
2690
|
+
(detail.promptBoxes ?? []).map((box) =>
|
|
2691
|
+
h('span', { className: 'mmn-mono mmn-meta', key: `${box.nodeId}-${box.input}` }, `${box.nodeId}·${box.input} [${box.role}] ${box.head || ''}`),
|
|
2692
|
+
),
|
|
2693
|
+
),
|
|
2694
|
+
h(
|
|
2695
|
+
'div',
|
|
2696
|
+
{ className: 'mmn-row' },
|
|
2697
|
+
h('span', { className: 'mmn-grow' }),
|
|
2698
|
+
h('button', { type: 'button', className: 'mmn-btn mmn-btn--ghost', onClick: () => setDetail(undefined) }, t('close')),
|
|
2699
|
+
),
|
|
2700
|
+
),
|
|
2701
|
+
);
|
|
2702
|
+
}
|
|
2703
|
+
|
|
2704
|
+
// ---------------------------------------------------------------- 侧边栏壳
|
|
2705
|
+
/**
|
|
2706
|
+
* 侧边栏那一个页面。
|
|
2707
|
+
*
|
|
2708
|
+
* 模式决定渲染哪一套:这不是「同一个页面上藏一半」——用户节点没有实例可以启停,
|
|
2709
|
+
* 服务节点也没有「从别人那里拉模板」这回事。混在一页里只会两边都做不对。
|
|
2710
|
+
*/
|
|
2711
|
+
function Page({ t }) {
|
|
2712
|
+
const [status, setStatus] = useState(undefined);
|
|
2713
|
+
const [error, setError] = useState('');
|
|
2714
|
+
const [busy, setBusy] = useState(false);
|
|
2715
|
+
|
|
2716
|
+
const reload = useCallback(async () => {
|
|
2717
|
+
try {
|
|
2718
|
+
const data = await getJson(`${BASE}/status`);
|
|
2719
|
+
setStatus(data);
|
|
2720
|
+
setError('');
|
|
2721
|
+
} catch (cause) {
|
|
2722
|
+
setError(cause instanceof Error ? cause.message : String(cause));
|
|
2723
|
+
}
|
|
2724
|
+
}, []);
|
|
2725
|
+
|
|
2726
|
+
useEffect(() => {
|
|
2727
|
+
void reload();
|
|
2728
|
+
const timer = setInterval(() => void reload(), 3000);
|
|
2729
|
+
return () => clearInterval(timer);
|
|
2730
|
+
}, [reload]);
|
|
2731
|
+
|
|
2732
|
+
const act = useCallback(
|
|
2733
|
+
async (path, body) => {
|
|
2734
|
+
setBusy(true);
|
|
2735
|
+
try {
|
|
2736
|
+
const result = await postJson(path, body);
|
|
2737
|
+
if (result.status !== undefined) setStatus(result.status);
|
|
2738
|
+
else await reload();
|
|
2739
|
+
return result;
|
|
2740
|
+
} catch (cause) {
|
|
2741
|
+
setError(cause instanceof Error ? cause.message : String(cause));
|
|
2742
|
+
return undefined;
|
|
2743
|
+
} finally {
|
|
2744
|
+
setBusy(false);
|
|
2745
|
+
}
|
|
2746
|
+
},
|
|
2747
|
+
[reload],
|
|
2748
|
+
);
|
|
2749
|
+
|
|
2750
|
+
if (status === undefined) {
|
|
2751
|
+
return h('div', { className: 'mmn-page' }, error === '' ? h('p', { className: 'mmn-meta' }, t('loading')) : h('p', { className: 'mmn-error' }, error));
|
|
2752
|
+
}
|
|
2753
|
+
|
|
2754
|
+
return h(
|
|
2755
|
+
React.Fragment,
|
|
2756
|
+
null,
|
|
2757
|
+
error === '' ? null : h('div', { className: 'mmn-page' }, h('div', { className: 'mmn-error' }, error)),
|
|
2758
|
+
status.mode === 'user'
|
|
2759
|
+
? h(UserNodePage, { status, t, busy, act, reload })
|
|
2760
|
+
: h(ServiceNodePage, { status, t, busy, act, reload }),
|
|
2761
|
+
);
|
|
2762
|
+
}
|
|
2763
|
+
|
|
2764
|
+
/** 侧边栏字形:两个方块(多模态)+ 一个箭头(节点之间)。 */
|
|
2765
|
+
function Glyph(props) {
|
|
2766
|
+
const size = typeof props?.size === 'number' ? props.size : 16;
|
|
2767
|
+
const active = props?.active === true;
|
|
2768
|
+
return h(
|
|
2769
|
+
'svg',
|
|
2770
|
+
{
|
|
2771
|
+
viewBox: '0 0 20 20',
|
|
2772
|
+
width: size,
|
|
2773
|
+
height: size,
|
|
2774
|
+
fill: 'none',
|
|
2775
|
+
stroke: 'currentColor',
|
|
2776
|
+
strokeWidth: 1.4,
|
|
2777
|
+
strokeLinecap: 'round',
|
|
2778
|
+
strokeLinejoin: 'round',
|
|
2779
|
+
'aria-hidden': true,
|
|
2780
|
+
style: { display: 'block', opacity: active ? 1 : 0.85 },
|
|
2781
|
+
},
|
|
2782
|
+
h('rect', { x: 2, y: 3.5, width: 7, height: 6, rx: 1.2 }),
|
|
2783
|
+
h('rect', { x: 11, y: 10.5, width: 7, height: 6, rx: 1.2 }),
|
|
2784
|
+
h('path', { d: 'M9 6.5h2.5a1.5 1.5 0 0 1 1.5 1.5v2.5' }),
|
|
2785
|
+
h('path', { d: 'M11.4 9.1 13 10.5l1.6-1.4' }),
|
|
2786
|
+
);
|
|
2787
|
+
}
|
|
2788
|
+
|
|
2789
|
+
function apply(ctx) {
|
|
2790
|
+
ctx.effect(() => {
|
|
2791
|
+
const style = document.createElement('style');
|
|
2792
|
+
style.dataset.plugin = 'dsh-multimodal-node';
|
|
2793
|
+
style.textContent = CSS;
|
|
2794
|
+
(document.head ?? document.documentElement).append(style);
|
|
2795
|
+
return () => style.remove();
|
|
2796
|
+
}, 'dsh-multimodal-node: styles');
|
|
2797
|
+
|
|
2798
|
+
const t = makeT(ctx);
|
|
2799
|
+
|
|
2800
|
+
// 侧边栏入口 + 主面板
|
|
2801
|
+
ctx.slots.inject('sidebar.panellist', () =>
|
|
2802
|
+
ctx.slots.register({ name: 'sidebar.panellist', id: PAGE_KEY, order: 43, label: () => t('tab') }, Glyph),
|
|
2803
|
+
);
|
|
2804
|
+
ctx.slots.inject('main', () => ctx.slots.register({ name: 'main', key: PAGE_KEY }, () => h(Page, { t })));
|
|
2805
|
+
|
|
2806
|
+
// 设置页那一节:工作模式、端口、账户、apikey、Blender、产物目录
|
|
2807
|
+
ctx.slots.inject('settings.section', function* () {
|
|
2808
|
+
yield ctx.slots.register(
|
|
2809
|
+
{ name: 'settings.section', id: 'multimodal-node', order: 34, label: () => t('settingsSection'), locale: NS },
|
|
2810
|
+
(props) => h(SettingsSection, { t: props?.t ?? t }),
|
|
2811
|
+
);
|
|
2812
|
+
});
|
|
2813
|
+
}
|
|
2814
|
+
|
|
2815
|
+
return { name: 'dsh-multimodal-node', inject: ['slots'], apply };
|
|
2816
|
+
},
|
|
2817
|
+
});
|