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/lib/tiers.js ADDED
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Content tiers: SFW / NSFW, and who may see what.
3
+ *
4
+ * Three things are classified, because a remote console that filters only one of
5
+ * them is worse than one that filters none — a SFW account reading an adult
6
+ * workflow's *name* in the picker is already a leak:
7
+ *
8
+ * text — the strings inside a prompt graph (positive and negative alike)
9
+ * workflow — its graph, plus its name and tags, because "NSFW_xxx" as a title
10
+ * is a disclosure even when the graph is a resized cat
11
+ * run/asset — the workflow that produced it, plus whatever the run recorded
12
+ *
13
+ * The gate is a **keyword gate, not a classifier**. It is tuned to be broad on
14
+ * the terms that actually appear in generation prompts and silent on medical,
15
+ * anatomical and art-history vocabulary, because a false positive blocks
16
+ * legitimate work every day while a false negative on inventive euphemism is
17
+ * rare. Where it cannot decide, it decides *against* disclosure: an SFW account
18
+ * sees a workflow only when nothing in it matched.
19
+ */
20
+ import tagdata from './tagdata.json' with { type: 'json' };
21
+
22
+ /**
23
+ * Adult terms, grouped by why they are here so a later reader can judge whether
24
+ * a rule still belongs instead of treating the list as a magic block.
25
+ */
26
+ export const ADULT_TERMS = [
27
+ // Explicit sexual acts — the words an actual request for adult imagery uses.
28
+ '性交', '性行为', '做爱', '口交', '肛交', '性爱', '交媾', '性高潮', '高潮脸', '自慰', '手淫', '性器',
29
+ 'sex act', 'sexual intercourse', 'intercourse', 'fellatio', 'cunnilingus', 'masturbation',
30
+ 'anal sex', 'oral sex', 'orgasm', 'cumshot', 'creampie', 'gangbang', 'clothed sex', 'after sex',
31
+
32
+ // Genitals and nudity stated plainly.
33
+ '裸体', '全裸', '裸照', '胴体', '生殖器', '阴茎', '阴道', '阴部', '阴蒂', '乳房', '乳头', '乳晕', '臀部特写',
34
+ 'nude', 'naked', 'topless', 'bottomless', 'genitals', 'penis', 'vagina', 'vulva', 'clitoris',
35
+ 'nipples', 'areola', 'pubic', 'anus', 'testicles', 'cameltoe', 'crotchless',
36
+
37
+ // Pornographic framing and industry vocabulary.
38
+ '色情', '成人视频', '成人图片', '黄片', '毛片', '情色', '淫', '骚', '嫖',
39
+ 'porn', 'porno', 'pornographic', 'xxx', 'nsfw', 'hentai', 'erotic',
40
+ 'explicit sex', 'adult video', 'adult content', 'onlyfans',
41
+
42
+ // Genre labels that exist only to mark content adult.
43
+ '无码', '有码', '里番', '工口', '痴女', '痴汉', '露出play',
44
+ 'uncensored hentai', 'rule34', 'lewd', 'r18', 'nsfl',
45
+
46
+ // Bodily-fluids framing that appears in adult prompts specifically.
47
+ '精液', '淫水', '爱液', 'semen', 'pussy', 'cum',
48
+ ];
49
+
50
+ const LATIN = /[a-z]/;
51
+
52
+ /** Does one lower-cased string contain an adult term? Returns the term. */
53
+ function matchIn(text) {
54
+ for (const term of ADULT_TERMS) {
55
+ const needle = term.toLowerCase();
56
+ if (!LATIN.test(needle)) {
57
+ if (text.includes(needle)) return term;
58
+ continue;
59
+ }
60
+ // Latin terms need a boundary: `cum` must not fire on `circumference`.
61
+ let from = 0;
62
+ for (;;) {
63
+ const at = text.indexOf(needle, from);
64
+ if (at === -1) break;
65
+ const before = at === 0 ? '' : text[at - 1];
66
+ const after = text[at + needle.length] ?? '';
67
+ if ((before === '' || !/[a-z0-9]/.test(before)) && (after === '' || !/[a-z0-9]/.test(after))) return term;
68
+ from = at + 1;
69
+ }
70
+ }
71
+ return undefined;
72
+ }
73
+
74
+ /** Tags from the verified dictionary that are themselves adult. */
75
+ const ADULT_TAGS = new Set(tagdata.filter((row) => row.n === 1).map((row) => row.t));
76
+ /** Every known tag, so `tag`-shaped findings can be recognised inside a string. */
77
+ const KNOWN_TAGS = new Map(tagdata.map((row) => [row.t, row]));
78
+
79
+ export function detectAdult(value) {
80
+ if (typeof value !== 'string' || value === '') return { hit: false };
81
+ const text = value.toLowerCase();
82
+ const term = matchIn(text);
83
+ if (term !== undefined) return { hit: true, matched: term };
84
+ // A prompt is a list of underscore tags; the dictionary knows which of those
85
+ // are adult even when the plain word is not in ADULT_TERMS (e.g. `sideboob`).
86
+ for (const word of text.split(/[,\n]/)) {
87
+ const tag = word.replace(/[()\[\]{}]/g, ' ').trim().split(/\s+/).find((part) => KNOWN_TAGS.has(part));
88
+ if (tag !== undefined && ADULT_TAGS.has(tag)) return { hit: true, matched: tag };
89
+ }
90
+ return { hit: false };
91
+ }
92
+
93
+ /** Scan every string in an arbitrary value; returns the first hit with its path. */
94
+ export function scanStrings(value, path = '') {
95
+ if (typeof value === 'string') {
96
+ const result = detectAdult(value);
97
+ return result.hit ? { hit: true, matched: result.matched, path } : { hit: false };
98
+ }
99
+ if (Array.isArray(value)) {
100
+ for (let index = 0; index < value.length; index += 1) {
101
+ const result = scanStrings(value[index], `${path}[${index}]`);
102
+ if (result.hit) return result;
103
+ }
104
+ return { hit: false };
105
+ }
106
+ if (value !== null && typeof value === 'object') {
107
+ for (const [key, entry] of Object.entries(value)) {
108
+ const keyHit = detectAdult(key);
109
+ if (keyHit.hit) return { hit: true, matched: keyHit.matched, path: `${path}.${key}` };
110
+ const result = scanStrings(entry, `${path}.${key}`);
111
+ if (result.hit) return result;
112
+ }
113
+ }
114
+ return { hit: false };
115
+ }
116
+
117
+ /**
118
+ * Classify one workflow record.
119
+ *
120
+ * `declared` in the library lets an author mark a workflow NSFW explicitly; the
121
+ * scan can only add to that, never subtract — "I said this is safe" is not a
122
+ * defence when the prompt strings say otherwise.
123
+ */
124
+ export function classifyWorkflow(record) {
125
+ const nameHit = detectAdult(String(record?.name ?? ''));
126
+ if (nameHit.hit) return { tier: 'nsfw', reason: `name:${nameHit.matched}` };
127
+ for (const tag of Array.isArray(record?.tags) ? record.tags : []) {
128
+ const tagHit = detectAdult(String(tag));
129
+ if (tagHit.hit) return { tier: 'nsfw', reason: `tag:${tagHit.matched}` };
130
+ }
131
+ if (record?.declared === 'nsfw') return { tier: 'nsfw', reason: 'declared' };
132
+ /**
133
+ * Only the POSITIVE side counts.
134
+ *
135
+ * Every Pony-family workflow carries `rating_explicit, nsfw, nude, ...` in its NEGATIVE
136
+ * encoder — that is how it excludes those results, and the terms are there *because* it
137
+ * is safe. Scanning the whole graph therefore classified essentially every SFW workflow
138
+ * on this machine as adult, which hides them from every SFW account now that the library
139
+ * is shared.
140
+ *
141
+ * Positive-only is the honest reading of "what does this workflow produce".
142
+ */
143
+ const graphHit = scanPositiveText(record?.api ?? record?.graph ?? {});
144
+ if (graphHit.hit) return { tier: 'nsfw', reason: `prompt:${graphHit.matched}` };
145
+ return { tier: 'sfw', reason: null };
146
+ }
147
+
148
+ /**
149
+ * Scan only the text a graph feeds into its positive encoder.
150
+ *
151
+ * A negative encoder is identified by being the target of a sampler input whose name
152
+ * mentions "negative". Everything else counts as positive. When there is no such link to
153
+ * follow (a bare node map), every text node counts — erring toward disclosure rather than
154
+ * hiding content the operator meant to be visible.
155
+ */
156
+ export function scanPositiveText(graph) {
157
+ if (graph === null || typeof graph !== 'object') return { hit: false };
158
+ const negativeIds = new Set();
159
+ for (const node of Object.values(graph)) {
160
+ for (const [key, value] of Object.entries(node?.inputs ?? {})) {
161
+ if (!/negative/i.test(key)) continue;
162
+ if (Array.isArray(value) && value.length > 0) negativeIds.add(String(value[0]));
163
+ }
164
+ }
165
+ const positive = {};
166
+ for (const [id, node] of Object.entries(graph)) {
167
+ if (negativeIds.has(String(id))) continue;
168
+ positive[id] = node;
169
+ }
170
+ return scanStrings(positive);
171
+ }
172
+
173
+ /** Classify one queued/finished run from what it carried. */
174
+ export function classifyRun(task) {
175
+ const workflowName = detectAdult(String(task?.extraData?.workflowName ?? ''));
176
+ if (workflowName.hit) return 'nsfw';
177
+ const stored = task?.extraData?.tier;
178
+ if (stored === 'nsfw') return 'nsfw';
179
+ const hit = scanStrings(task?.prompt ?? {});
180
+ return hit.hit ? 'nsfw' : 'sfw';
181
+ }
182
+
183
+ /**
184
+ * May this tier see this classification?
185
+ *
186
+ * SFW sees SFW only. NSFW sees everything — the tier is a *ceiling*, not a
187
+ * preference: an account that is allowed adult content still wants to use the
188
+ * family-friendly workflows.
189
+ */
190
+ export function visibleTo(accountTier, classification) {
191
+ if (accountTier === 'nsfw') return true;
192
+ return classification !== 'nsfw';
193
+ }
194
+
195
+ export function normalizeTier(value) {
196
+ return value === 'nsfw' ? 'nsfw' : 'sfw';
197
+ }
198
+
199
+ /** The dictionary, for the prompt assistant and the console's tag browser. */
200
+ export function tagDictionary() {
201
+ return tagdata;
202
+ }
203
+
204
+ export function isAdultTag(tag) {
205
+ return ADULT_TAGS.has(String(tag));
206
+ }
207
+
208
+ const IMAGE_SYSTEM = [
209
+ 'You classify one image for a content filter.',
210
+ 'Answer with exactly one word on the first line: NSFW or SFW.',
211
+ 'Then, on the second line, one short reason in Chinese.',
212
+ '',
213
+ 'NSFW means: visible nudity, exposed genitals or buttocks, sexual acts, or explicit erotic framing.',
214
+ 'SFW means everything else, including swimwear, lingerie worn as clothing, cleavage,',
215
+ 'medical or anatomical diagrams, classical nude sculpture, and artistic figure studies',
216
+ 'that do not show genitals.',
217
+ 'When genuinely unsure, answer SFW — a false block stops legitimate work every day,',
218
+ 'and the operator can see the reason either way.',
219
+ ].join('\n');
220
+
221
+ /**
222
+ * Ask the model whether an uploaded image is adult.
223
+ *
224
+ * Returns { nsfw, reason, checked }. `checked: false` means the question could not be put
225
+ * to a model at all (no llm service, no route, or the call failed) — the caller decides
226
+ * what to do with that, which is the important part: a broken classifier must not silently
227
+ * pass everything, and must not silently block everything either.
228
+ *
229
+ * This is deliberately one pass, no second opinion, no ensemble: the operator asked for a
230
+ * simple judgement, and a filter that argues with itself costs more than it catches.
231
+ */
232
+ export async function screenImage(llm, route, imageBuffer, mime = 'image/png') {
233
+ if (llm === undefined || route === undefined || route.provider === undefined || route.model === undefined) {
234
+ return { nsfw: false, reason: '没有可用的模型,未做图片判定', checked: false };
235
+ }
236
+ if (!Buffer.isBuffer(imageBuffer) || imageBuffer.length === 0) {
237
+ return { nsfw: false, reason: '图片为空', checked: false };
238
+ }
239
+ try {
240
+ // `maxTokens`/`temperature` belong to preparation, not only to dispatch:
241
+ // `prepareCall()` freezes a config and `stream()` rejects a request that
242
+ // differs from it (`INVALID_PREPARED_CALL`), so pinning them here as well
243
+ // keeps the two halves identical. Sending them only at dispatch time made
244
+ // every screening call fail before reaching the adapter.
245
+ const controls = { maxTokens: 120, temperature: 0 };
246
+ const prepared = await llm.prepareCall(
247
+ { provider: route.provider, model: route.model, ...controls },
248
+ AbortSignal.timeout(60_000),
249
+ );
250
+ const options = {
251
+ ...prepared.config,
252
+ messages: [
253
+ {
254
+ role: 'user',
255
+ content: [
256
+ { type: 'text', text: '判断这张图片是否属于成人内容。' },
257
+ { type: 'image', image: imageBuffer.toString('base64'), mimeType: mime },
258
+ ],
259
+ },
260
+ ],
261
+ system: IMAGE_SYSTEM,
262
+ ...controls,
263
+ };
264
+ let out = '';
265
+ for await (const chunk of prepared.stream(options)) {
266
+ if (chunk.type === 'text-delta') out += chunk.text;
267
+ if (chunk.type === 'finish' && chunk.reason?.kind === 'error') {
268
+ throw new Error(chunk.reason.failure?.message ?? 'model error');
269
+ }
270
+ }
271
+ const text = out.trim();
272
+ if (text === '') return { nsfw: false, reason: '模型没有回答,未做判定', checked: false };
273
+ const firstLine = text.split(/\r?\n/)[0].toUpperCase();
274
+ // Only an explicit NSFW blocks. Anything else — including a malformed answer — is
275
+ // treated as SFW, because blocking on an unparseable reply would make the filter
276
+ // fire on its own failures.
277
+ const nsfw = /\bNSFW\b/.test(firstLine) && !/\bSFW\b(?!.*NSFW)/.test(firstLine.replace('NSFW', ''));
278
+ const reason = text.split(/\r?\n/).slice(1).join(' ').trim().slice(0, 120);
279
+ return { nsfw, reason: reason || firstLine.slice(0, 60), checked: true };
280
+ } catch (error) {
281
+ return { nsfw: false, reason: `图片判定失败:${error instanceof Error ? error.message : String(error)}`, checked: false };
282
+ }
283
+ }
package/lib/tools.js ADDED
@@ -0,0 +1,230 @@
1
+ /**
2
+ * `multimodal_node` —— 智能体面向同一个节点的门。
3
+ *
4
+ * 面板上每个动作这里都有,所以一个 agent 可以不开界面地判断:「队列里有什么、
5
+ * 8189 睡着了吗、把它拉起来、Blender 那边渲染完了没」。
6
+ *
7
+ * 参数故意做得比面板窄:agent 是来读状态的,不是来重配整个部署的。
8
+ * 改设置(换模式、改端口、加 key)仍然只在设置页里做,那里有确认与回显。
9
+ */
10
+
11
+ const TIMEOUT_MS = 180_000;
12
+
13
+ const lines = (rows) => rows.filter((row) => row !== '').join('\n');
14
+
15
+ export function multimodalDefinition(runtime, helpers) {
16
+ const status = () => helpers.statusOf();
17
+
18
+ const renderStatus = (data) => {
19
+ const mode = data.mode === 'user' ? '用户节点' : '服务节点';
20
+ const head = lines([
21
+ `模式:${mode}${data.mode === 'service' ? ` · API 端口 ${data.gateway.listening ? `监听 ${data.gateway.host}:${data.gateway.port}` : '未监听'} · 网页端口 ${data.remote.listening ? data.remote.port : '未监听'}` : ''}`,
22
+ data.mode === 'service'
23
+ ? `队列:排队 ${(data.queue.tasks ?? []).filter((task) => task.state === 'queued').length} · 执行中 ${(data.queue.tasks ?? []).filter((task) => task.state === 'running').length} · 剩余约 ${Math.round((data.queue.estimateMs ?? 0) / 1000)}s · 排序 ${data.queue.dynamic ? '动态(最短总耗时)' : '先后顺序'}`
24
+ : `服务节点 ${data.node.publicUrl || '(未填地址)'} → ${data.node.serviceNode?.ok === true ? '在线' : `不可用:${data.node.serviceNode?.error ?? '未探测'}`}`,
25
+ ]);
26
+ const rows = [];
27
+ rows.push('ComfyUI 实例:');
28
+ if ((data.instances ?? []).length === 0) rows.push('- 没有绑定实例');
29
+ for (const entry of data.instances ?? []) {
30
+ const vram = (entry.devices ?? [])
31
+ .map((item) => (item.vramTotal === null || item.vramTotal === undefined ? '?' : `${Math.round((item.vramTotal - (item.vramFree ?? 0)) / 1048576)}/${Math.round(item.vramTotal / 1048576)} MiB`))
32
+ .join(', ');
33
+ const bits = [
34
+ `${entry.id}${entry.label !== entry.id ? ` (${entry.label})` : ''}`,
35
+ entry.baseUrl,
36
+ entry.healthy ? '在线' : '离线',
37
+ entry.enabled ? '派发中' : '派发已停',
38
+ `并发 ${entry.maxConcurrent}`,
39
+ ];
40
+ if (entry.version !== null && entry.version !== undefined) bits.push(`v${entry.version}`);
41
+ if (vram !== '') bits.push(vram);
42
+ bits.push(entry.control === 'none' ? '进程非托管' : `进程 ${entry.control}${entry.pid ? ` pid ${entry.pid}` : ''}`);
43
+ rows.push(`- ${bits.join(' · ')}`);
44
+ if (entry.lastError) rows.push(` ${entry.lastError}`);
45
+ }
46
+ if (data.blender?.enabled === true) {
47
+ rows.push('Blender 实例:');
48
+ if ((data.blender.instances ?? []).length === 0) rows.push('- 没有配置');
49
+ for (const entry of data.blender.instances ?? []) {
50
+ const bits = [
51
+ `${entry.id} (${entry.label})`,
52
+ entry.kind === 'spawn' ? `本机 ${entry.executable || '找不到 blender.exe'}` : entry.baseUrl,
53
+ entry.healthy ? '就绪' : `不可用${entry.error ? `:${entry.error}` : ''}`,
54
+ `并发 ${entry.maxConcurrent}`,
55
+ ];
56
+ if (entry.running > 0) bits.push(`进行中 ${entry.running}`);
57
+ rows.push(`- ${bits.join(' · ')}`);
58
+ for (const job of (entry.jobs ?? []).slice(-3)) {
59
+ rows.push(` · ${job.id.slice(0, 8)} ${job.state}${job.error ? ` — ${job.error}` : ''}`);
60
+ }
61
+ }
62
+ }
63
+ const active = (data.queue.tasks ?? []).filter((task) => task.state === 'queued' || task.state === 'running');
64
+ if (active.length > 0) {
65
+ rows.push('队列:');
66
+ for (const task of active.slice(0, 12)) {
67
+ rows.push(`- ${task.id.slice(0, 8)} · ${task.state} · ${task.backendId ?? '未分配'} · 代价 ${task.cost} · 优先级 ${task.priority}${task.error ? ` — ${task.error}` : ''}`);
68
+ }
69
+ }
70
+ rows.push(`模型:${data.models.ok ? `${data.models.total} 个文件 · ${data.models.folders.length} 个目录` : `读不到(${data.models.error})`}`);
71
+ rows.push(`产物目录:${data.node.outputDir}`);
72
+ return lines([head, ...rows]);
73
+ };
74
+
75
+ return {
76
+ name: 'multimodal_node',
77
+ description: [
78
+ '管理多模态节点插件:一个插件两种身份。服务节点开门(API 接入端口 + 网页端口),把任务排队分发到绑定的 ComfyUI 实例,并托管本机 Blender 渲染;用户节点不开门,它连服务节点拿工作流模板、当前队列、模型列表,把产物落到本机目录。',
79
+ '`action: status`(默认)报模式、两个端口、队列与预计剩余时间、每个 ComfyUI 实例的健康/显存/进程归属、Blender 实例与进行中的渲染、模型统计、产物目录。',
80
+ '`action: instances` 列出 ComfyUI 实例;`start` / `stop` / `restart` 作用于一个实例,且只在 control.type 为 script(本机托管了一个 bat)时有效。',
81
+ '`action: queue` 给队列快照;`cancel` / `prioritize` 作用一条任务;`free` 让所有实例卸载模型释放显存;`dynamic` 切换排序策略。',
82
+ '`action: blender` 给 Blender 板块状态;`blenderRender` 提交一次渲染(spawn 模式需要工程文件绝对路径与帧范围);`blenderCancel` 取消一个渲染任务。',
83
+ '`action: templates` 列出工作流模板(服务节点看本机库,用户节点问远端);`models` 给模型清单;`output` 列出本机产物文件夹。',
84
+ '`action: probe` 立刻重读所有实例与 Blender 的健康状态。',
85
+ '提交任务前先跑一次 status:它告诉你某个实例是不是睡着了、需不需要先启动。',
86
+ ].join(' '),
87
+ parameters: {
88
+ type: 'object',
89
+ properties: {
90
+ action: {
91
+ type: 'string',
92
+ enum: [
93
+ 'status',
94
+ 'instances',
95
+ 'start',
96
+ 'stop',
97
+ 'restart',
98
+ 'probe',
99
+ 'queue',
100
+ 'dynamic',
101
+ 'cancel',
102
+ 'prioritize',
103
+ 'free',
104
+ 'blender',
105
+ 'blenderRender',
106
+ 'blenderCancel',
107
+ 'templates',
108
+ 'models',
109
+ 'output',
110
+ ],
111
+ description: 'status 报全部;instances 列实例;start/stop/restart 作用于一个 ComfyUI 实例;queue 队列快照;dynamic 切排序;cancel/prioritize 作用一条任务;free 释放显存;blender 板块状态;blenderRender 提交渲染;blenderCancel 取消渲染;templates 工作流模板;models 模型清单;output 本机产物。',
112
+ },
113
+ id: {
114
+ type: 'string',
115
+ description: '实例 id(start/stop/restart/blenderRender/blenderCancel)或任务 id(cancel/prioritize)。',
116
+ },
117
+ enabled: { type: 'boolean', description: 'action: dynamic —— true 开自动排序,false 严格先进先出。' },
118
+ delta: { type: 'number', description: 'action: prioritize —— 优先级增量,默认 1,越大越先跑。' },
119
+ instanceId: { type: 'string', description: 'action: blenderRender / blenderCancel —— Blender 实例 id。' },
120
+ blend: { type: 'string', description: 'action: blenderRender —— .blend 工程文件的绝对路径(spawn 模式必需)。' },
121
+ frames: { type: 'string', description: 'action: blenderRender —— 帧范围,如 "1"、"1-20"、"1,5,9"、"1-20:2"。' },
122
+ jobId: { type: 'string', description: 'action: blenderCancel —— 要取消的渲染任务 id。' },
123
+ format: { type: 'string', description: 'action: blenderRender —— 输出格式,默认 PNG。' },
124
+ engine: { type: 'string', description: 'action: blenderRender —— 渲染引擎覆盖,如 CYCLES / BLENDER_EEVEE_NEXT。' },
125
+ outputDir: { type: 'string', description: 'action: blenderRender —— 产物目录(绝对路径)。' },
126
+ },
127
+ required: [],
128
+ },
129
+ output: {
130
+ schema: { type: 'object' },
131
+ render(_args, value) {
132
+ const data = value;
133
+ if (data === null || typeof data !== 'object') return [{ type: 'text', text: 'multimodal_node: 没有返回值' }];
134
+ if (data.ok === false && data.error !== undefined && data.mode === undefined) {
135
+ return [{ type: 'text', text: `multimodal_node: ${data.error}` }];
136
+ }
137
+ if (data.mode !== undefined) return [{ type: 'text', text: renderStatus(data) }];
138
+ return [{ type: 'text', text: JSON.stringify(data).slice(0, 4000) }];
139
+ },
140
+ },
141
+ timeoutMs: TIMEOUT_MS,
142
+ async execute(args) {
143
+ const action = typeof args?.action === 'string' ? args.action : 'status';
144
+ try {
145
+ if (action === 'status') return status();
146
+ if (action === 'probe') {
147
+ await runtime.instances.probeAll(true);
148
+ await runtime.blender.probeAll(true);
149
+ return status();
150
+ }
151
+ if (action === 'instances') {
152
+ const data = await status();
153
+ return { ok: true, instances: data.instances, blender: data.blender };
154
+ }
155
+ if (action === 'start' || action === 'stop' || action === 'restart') {
156
+ const result = await runtime.instances[action](String(args?.id ?? ''));
157
+ return { ok: result.ok !== false, action, ...result, status: await status() };
158
+ }
159
+ if (action === 'queue') {
160
+ const data = await status();
161
+ return { ok: true, queue: data.queue, instances: data.instances };
162
+ }
163
+ if (action === 'dynamic') {
164
+ runtime.pool.toggleDynamic(args?.enabled !== false);
165
+ return { ok: true, dynamic: runtime.settings.get().dynamic, status: await status() };
166
+ }
167
+ if (action === 'cancel') {
168
+ const result = runtime.pool.cancel(String(args?.id ?? ''));
169
+ return { ...result, status: await status() };
170
+ }
171
+ if (action === 'prioritize') {
172
+ const result = runtime.pool.prioritize(String(args?.id ?? ''), args?.delta ?? 1);
173
+ return { ...result, status: await status() };
174
+ }
175
+ if (action === 'free') {
176
+ await Promise.all(
177
+ runtime.instances.enabled().map(async (item) => {
178
+ try {
179
+ await runtime.pool.backend(item).free({ unload_models: true, free_memory: true });
180
+ } catch {
181
+ // 停着的实例没有可释放的东西
182
+ }
183
+ }),
184
+ );
185
+ return { ok: true, status: await status() };
186
+ }
187
+ if (action === 'blender') {
188
+ return { ok: true, blender: runtime.blender.statusPayload(), mode: runtime.mode };
189
+ }
190
+ if (action === 'blenderRender') {
191
+ const instanceId = String(args?.instanceId ?? args?.id ?? '');
192
+ if (instanceId === '') return { ok: false, error: 'blenderRender 需要 instanceId' };
193
+ const result = await runtime.blender.submit(instanceId, {
194
+ blend: String(args?.blend ?? ''),
195
+ frames: String(args?.frames ?? '1'),
196
+ format: args?.format === undefined ? 'PNG' : String(args.format),
197
+ engine: args?.engine === undefined ? '' : String(args.engine),
198
+ outputDir: args?.outputDir === undefined ? '' : String(args.outputDir),
199
+ });
200
+ return { ok: true, job: result, blender: runtime.blender.statusPayload() };
201
+ }
202
+ if (action === 'blenderCancel') {
203
+ const result = await runtime.blender.cancel(String(args?.instanceId ?? args?.id ?? ''), String(args?.jobId ?? ''));
204
+ return { ok: result.ok === true, ...result, blender: runtime.blender.statusPayload() };
205
+ }
206
+ if (action === 'templates') {
207
+ if (runtime.mode === 'user') return { ok: true, source: 'service-node', templates: await runtime.nodeClient.templates() };
208
+ return {
209
+ ok: true,
210
+ source: 'local',
211
+ templates: runtime.library.list('nsfw').map((row) => ({ id: row.id, name: row.name, category: row.category, tier: row.tier })),
212
+ };
213
+ }
214
+ if (action === 'models') {
215
+ if (runtime.mode === 'user') return { ok: true, source: 'service-node', ...(await runtime.nodeClient.models()) };
216
+ const { modelInventory } = await import('./models.js');
217
+ const inventory = modelInventory(runtime.settings.get(), { withSize: true });
218
+ return { ok: inventory.ok !== false, source: 'disk', ...inventory };
219
+ }
220
+ if (action === 'output') {
221
+ const data = await status();
222
+ return { ok: true, dir: data.node.outputDir };
223
+ }
224
+ return { ok: false, error: `未知动作 ${action}` };
225
+ } catch (error) {
226
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
227
+ }
228
+ },
229
+ };
230
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Which layout a page should be painted in.
3
+ *
4
+ * Two things decide it, in this order:
5
+ *
6
+ * 1. The user agent, on the server, so the first paint is already right. A page
7
+ * that renders the desktop grid and then collapses is a visible jump on a
8
+ * phone, and on a slow connection the jump is the first thing you notice.
9
+ * 2. The viewport, in the browser, because a UA is a claim, not a fact: a
10
+ * desktop browser resized to phone width, a tablet in split view, and any
11
+ * user who has told their browser to request the desktop site all disagree
12
+ * with their UA. The script corrects the server's guess.
13
+ *
14
+ * The result is one attribute on <html> — `data-layout="phone" | "desktop"` —
15
+ * and the stylesheet keys off it. No user-agent sniffing reaches into behaviour:
16
+ * both layouts run the same script and call the same routes.
17
+ */
18
+
19
+ /** A phone: an explicit mobile token, and not a tablet. */
20
+ const MOBILE_UA = /iphone|ipod|android.*mobile|windows phone|blackberry|opera mini|iemobile/i;
21
+ /** A tablet is wide enough for the two-column layout at typical zoom. */
22
+ const TABLET_UA = /ipad|android(?!.*mobile)|tablet|kindle|silk/i;
23
+ /** A desktop that happens to be a very small window still gets the grid. */
24
+ const DESKTOP_UA = /windows nt|macintosh|linux x86_64|cros\b/i;
25
+
26
+ /**
27
+ * @param {string} userAgent
28
+ * @returns {'phone' | 'desktop'}
29
+ */
30
+ export function layoutFor(userAgent) {
31
+ const ua = String(userAgent ?? '');
32
+ if (ua === '') return 'desktop';
33
+ // Check tablet before phone: `Android` without `Mobile` is a tablet, and the
34
+ // tablet regex would otherwise be shadowed by the phone one.
35
+ if (TABLET_UA.test(ua)) return 'desktop';
36
+ if (MOBILE_UA.test(ua)) return 'phone';
37
+ if (DESKTOP_UA.test(ua)) return 'desktop';
38
+ // Anything else (a bot, a TV, an embedded webview reporting nothing useful)
39
+ // gets the layout that degrades better: one column still works everywhere.
40
+ return 'phone';
41
+ }
42
+
43
+ /**
44
+ * Stamp the chosen layout onto a page's <html> element.
45
+ *
46
+ * The pages are template strings with `<html lang="zh-CN">` as their root; the
47
+ * attribute goes there so CSS can key off it before the body exists.
48
+ */
49
+ export function withLayout(html, layout) {
50
+ return html.replace('<html lang="zh-CN"', `<html lang="zh-CN" data-layout="${layout}"`);
51
+ }
52
+
53
+ /**
54
+ * The browser half: correct the server's guess from the real viewport, and keep
55
+ * it correct when a window is resized or a device is rotated.
56
+ *
57
+ * 720px is the breakpoint both layouts are built around: below it two columns
58
+ * cannot hold a textarea and a queue at once.
59
+ */
60
+ export const LAYOUT_SCRIPT = [
61
+ '<script>',
62
+ '(function(){',
63
+ " var mq = window.matchMedia('(max-width: 720px)');",
64
+ ' function apply(){',
65
+ " var wanted = mq.matches ? 'phone' : 'desktop';",
66
+ " if (document.documentElement.dataset.layout !== wanted) {",
67
+ " document.documentElement.dataset.layout = wanted;",
68
+ ' }',
69
+ ' }',
70
+ ' apply();',
71
+ ' // Safari before 14 only has the deprecated listener API.',
72
+ " if (mq.addEventListener) mq.addEventListener('change', apply);",
73
+ " else if (mq.addListener) mq.addListener(apply);",
74
+ '})();',
75
+ '</script>',
76
+ ].join('\n');
77
+
78
+ /**
79
+ * Theme, applied before first paint.
80
+ *
81
+ * This runs in <head>, above the stylesheet-dependent markup, for one reason: reading the
82
+ * saved choice *after* the body renders shows the dark palette for a frame and then snaps
83
+ * to light. That flash is worse than either theme.
84
+ *
85
+ * The preference is per-browser (localStorage), not per-account: it is a display setting
86
+ * about this screen, not something another device should inherit. `system` is the third
87
+ * state and follows `prefers-color-scheme`, so the page is usable before anyone chooses.
88
+ */
89
+ export const THEME_SCRIPT = [
90
+ '<script>',
91
+ '(function(){',
92
+ " var KEY = 'cgw-theme';",
93
+ ' function system(){',
94
+ " return window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';",
95
+ ' }',
96
+ ' function resolve(){',
97
+ ' var saved = null;',
98
+ ' try { saved = localStorage.getItem(KEY); } catch (e) { saved = null; }',
99
+ " if (saved === 'light' || saved === 'dark') return saved;",
100
+ ' return system();',
101
+ ' }',
102
+ ' function apply(theme){',
103
+ ' document.documentElement.dataset.theme = theme;',
104
+ ' document.documentElement.style.colorScheme = theme;',
105
+ ' var button = document.getElementById("themetoggle");',
106
+ ' if (button) {',
107
+ ' button.textContent = theme === "light" ? "☾" : "☀";',
108
+ ' button.title = theme === "light" ? "切到暗色" : "切到亮色";',
109
+ ' button.setAttribute("aria-label", button.title);',
110
+ ' }',
111
+ ' }',
112
+ ' apply(resolve());',
113
+ ' window.__cgwSetTheme = function(theme){',
114
+ ' apply(theme);',
115
+ ' try { localStorage.setItem(KEY, theme); } catch (e) {}',
116
+ ' };',
117
+ ' window.__cgwToggleTheme = function(){',
118
+ ' window.__cgwSetTheme(document.documentElement.dataset.theme === "light" ? "dark" : "light");',
119
+ ' };',
120
+ ' // Only follow the OS while the person has not chosen explicitly.',
121
+ ' if (window.matchMedia) {',
122
+ " var mq = window.matchMedia('(prefers-color-scheme: light)');",
123
+ ' var onSystem = function(){',
124
+ ' var saved = null;',
125
+ ' try { saved = localStorage.getItem(KEY); } catch (e) { saved = null; }',
126
+ ' if (saved !== "light" && saved !== "dark") apply(system());',
127
+ ' };',
128
+ ' if (mq.addEventListener) mq.addEventListener("change", onSystem);',
129
+ ' else if (mq.addListener) mq.addListener(onSystem);',
130
+ ' }',
131
+ ' document.addEventListener("DOMContentLoaded", function(){ apply(document.documentElement.dataset.theme || resolve()); });',
132
+ '})();',
133
+ '</script>',
134
+ ].join('\n');