dsh-local-sd 0.0.0-stage → 0.5.0
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/LICENSE +21 -0
- package/README.md +307 -2
- package/cordis.patch.yml +13 -0
- package/lib/client.js +782 -0
- package/lib/index.js +2051 -0
- package/package.json +61 -4
package/lib/index.js
ADDED
|
@@ -0,0 +1,2051 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-local-sd — host half.
|
|
3
|
+
*
|
|
4
|
+
* Registers tools that drive a LOCAL Stable Diffusion backend:
|
|
5
|
+
*
|
|
6
|
+
* generate_image_local — one prompt, one image
|
|
7
|
+
* generate_image_batch — N prompts, N images (sequential)
|
|
8
|
+
* sd_backend_status — probe the backend, list checkpoints/samplers
|
|
9
|
+
* sd_backend_control — start / stop the backend on demand
|
|
10
|
+
*
|
|
11
|
+
* Backend: any A1111-compatible `/sdapi/v1/txt2img` endpoint. That covers
|
|
12
|
+
* SD WebUI Forge (default port 7860), A1111 (7860), and KoboldCpp (`--sdmodel`,
|
|
13
|
+
* default port 5001).
|
|
14
|
+
*
|
|
15
|
+
* Why this exists: the shipped `generate_image` (dsh-agnes-multimodal) is a
|
|
16
|
+
* hosted API with a content filter. A local sdapi backend has no filter at
|
|
17
|
+
* all — the prompt goes straight into the sampler. This plugin is the bridge
|
|
18
|
+
* that lets the model call that backend from inside DSH.
|
|
19
|
+
*
|
|
20
|
+
* LIFECYCLE — the backend is heavy (several GB of VRAM), so it is started
|
|
21
|
+
* lazily on the first generation and shut down again after a period of
|
|
22
|
+
* inactivity. Nothing runs while you are not drawing.
|
|
23
|
+
*
|
|
24
|
+
* first generate call → probe; if dead and autoStart.enabled → spawn
|
|
25
|
+
* autoStart.command, poll until /sdapi/v1/options
|
|
26
|
+
* answers (up to startupTimeoutMs)
|
|
27
|
+
* every generate call → reset the idle timer
|
|
28
|
+
* idle timer expires → kill the process tree WE started (never one the
|
|
29
|
+
* user started by hand) and release the VRAM
|
|
30
|
+
*
|
|
31
|
+
* Config file: $DSH_HOME/local-sd.json (written automatically on first run)
|
|
32
|
+
* {
|
|
33
|
+
* "endpoint": "http://127.0.0.1:7860",
|
|
34
|
+
* "fallbackEndpoints": ["http://127.0.0.1:5001"],
|
|
35
|
+
* "autoStart": {
|
|
36
|
+
* "enabled": true,
|
|
37
|
+
* "command": "D:\\AI\\forge\\webui_forge_cu124_torch24\\run.bat",
|
|
38
|
+
* "workdir": "D:\\AI\\forge\\webui_forge_cu124_torch24",
|
|
39
|
+
* "startupTimeoutMs": 240000,
|
|
40
|
+
* "idleShutdownMs": 300000
|
|
41
|
+
* },
|
|
42
|
+
* "defaults": { "steps": 25, "cfg": 7, "sampler": "euler_a",
|
|
43
|
+
* "width": 512, "height": 768, "negative": "..." }
|
|
44
|
+
* }
|
|
45
|
+
*
|
|
46
|
+
* The file is re-read on every call, so edits take effect without a restart.
|
|
47
|
+
* A UTF-8 BOM in that file is tolerated (stripped before parsing) — earlier
|
|
48
|
+
* builds silently fell back to the default port because of it.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
import { homedir, tmpdir } from "node:os";
|
|
52
|
+
import { mkdir, writeFile, readFile, readdir, stat } from "node:fs/promises";
|
|
53
|
+
import { openSync, closeSync, readFileSync, unlinkSync } from "node:fs";
|
|
54
|
+
import { spawn } from "node:child_process";
|
|
55
|
+
import { join, dirname, basename, relative, isAbsolute, resolve as resolvePath } from "node:path";
|
|
56
|
+
|
|
57
|
+
/** Does this path exist and is it a regular file? */
|
|
58
|
+
async function isFile(path) {
|
|
59
|
+
try {
|
|
60
|
+
const st = await stat(path);
|
|
61
|
+
return st.isFile();
|
|
62
|
+
} catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const name = "dsh-local-sd";
|
|
68
|
+
// `webServer` carries the settings-page API; `connection` is the 0.2.x fence
|
|
69
|
+
// service that decides whether a request may reach those routes. Same triple
|
|
70
|
+
// the shipped dsh-agnes-multimodal declares for its config API.
|
|
71
|
+
export const inject = ["tools", "webServer", "connection"];
|
|
72
|
+
|
|
73
|
+
/** Test seam: lets the standalone tests drive the pure helpers directly. */
|
|
74
|
+
export const internals = { recommendFor, detectGpu, resolveLauncher, detectForgeDirs, listDirectory, resolveOutputLocation };
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Build stamp for the settings page. Bump on every user-visible change.
|
|
78
|
+
*
|
|
79
|
+
* The host process is NOT hot-reloaded: reinstalling the plugin only swaps the
|
|
80
|
+
* files on disk, and the running host keeps the code it loaded at startup.
|
|
81
|
+
* A stale host answers old routes (or 400s for new ones), which looks
|
|
82
|
+
* indistinguishable from a broken feature. Showing this value in the page makes
|
|
83
|
+
* "you need to restart DSH" a visible fact instead of a guess.
|
|
84
|
+
*/
|
|
85
|
+
const PLUGIN_BUILD = "0.5.0";
|
|
86
|
+
|
|
87
|
+
/** Forge and A1111 both listen here. */
|
|
88
|
+
const DEFAULT_ENDPOINT = "http://127.0.0.1:7860";
|
|
89
|
+
|
|
90
|
+
/** Tried in order when the configured endpoint is not answering. */
|
|
91
|
+
const FALLBACK_ENDPOINTS = [
|
|
92
|
+
"http://127.0.0.1:7860",
|
|
93
|
+
"http://127.0.0.1:5001",
|
|
94
|
+
"http://127.0.0.1:7861",
|
|
95
|
+
"http://127.0.0.1:8080",
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
const DEFAULT_NEGATIVE =
|
|
99
|
+
"lowres, bad anatomy, bad hands, text, error, missing fingers, extra digit, " +
|
|
100
|
+
"fewer digits, cropped, worst quality, low quality, normal quality, " +
|
|
101
|
+
"jpeg artifacts, signature, watermark, username, blurry, artist name";
|
|
102
|
+
|
|
103
|
+
const FALLBACK_DEFAULTS = {
|
|
104
|
+
steps: 25,
|
|
105
|
+
cfg: 7,
|
|
106
|
+
sampler: "euler_a",
|
|
107
|
+
width: 512,
|
|
108
|
+
height: 768,
|
|
109
|
+
negative: DEFAULT_NEGATIVE,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/** 4 minutes to boot a cold Forge on a spinning disk is already generous. */
|
|
113
|
+
const DEFAULT_STARTUP_TIMEOUT_MS = 240_000;
|
|
114
|
+
|
|
115
|
+
/** 5 idle minutes then the VRAM goes back to the system. */
|
|
116
|
+
const DEFAULT_IDLE_SHUTDOWN_MS = 300_000;
|
|
117
|
+
|
|
118
|
+
/** A1111/Forge sampler_name is human-readable; accept snake_case aliases too. */
|
|
119
|
+
const SAMPLER_ALIASES = {
|
|
120
|
+
euler_a: "Euler a",
|
|
121
|
+
euler: "Euler",
|
|
122
|
+
lms: "LMS",
|
|
123
|
+
heun: "Heun",
|
|
124
|
+
dpm2: "DPM2",
|
|
125
|
+
dpm2_a: "DPM2 a",
|
|
126
|
+
dpmpp_2s_a: "DPM++ 2S a",
|
|
127
|
+
dpmpp_2m: "DPM++ 2M",
|
|
128
|
+
dpmpp_sde: "DPM++ SDE",
|
|
129
|
+
dpmpp_2m_sde: "DPM++ 2M SDE",
|
|
130
|
+
dpmpp_2m_sde_gpu: "DPM++ 2M SDE",
|
|
131
|
+
dpmpp_3m_sde: "DPM++ 3M SDE",
|
|
132
|
+
dpmpp_3m_sde_gpu: "DPM++ 3M SDE",
|
|
133
|
+
ddim: "DDIM",
|
|
134
|
+
unipc: "UniPC",
|
|
135
|
+
restart: "Restart",
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
function normalizeSampler(value) {
|
|
139
|
+
if (typeof value !== "string" || value.length === 0) return FALLBACK_DEFAULTS.sampler;
|
|
140
|
+
const key = value.trim().toLowerCase().replace(/[\s-]+/g, "_");
|
|
141
|
+
if (SAMPLER_ALIASES[key] !== undefined) return SAMPLER_ALIASES[key];
|
|
142
|
+
// Already a display name (contains a space or uppercase) — pass through.
|
|
143
|
+
return value;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function dshHome() {
|
|
147
|
+
return process.env.DSH_HOME && process.env.DSH_HOME.length > 0
|
|
148
|
+
? process.env.DSH_HOME
|
|
149
|
+
: join(homedir(), ".dsh");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function configPath() {
|
|
153
|
+
return join(dshHome(), "local-sd.json");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const DEFAULT_CONFIG_TEMPLATE = {
|
|
157
|
+
forgeDir: "",
|
|
158
|
+
// Where PNGs land. "" -> "<workspace>/generated". A relative path is joined
|
|
159
|
+
// to the workspace root; an absolute one is used as-is (handy when the
|
|
160
|
+
// workspace root the host reports is not the folder you actually look at).
|
|
161
|
+
outputDir: "",
|
|
162
|
+
endpoint: DEFAULT_ENDPOINT,
|
|
163
|
+
fallbackEndpoints: FALLBACK_ENDPOINTS.slice(1),
|
|
164
|
+
autoStart: {
|
|
165
|
+
enabled: true,
|
|
166
|
+
command: "",
|
|
167
|
+
workdir: "",
|
|
168
|
+
startupTimeoutMs: DEFAULT_STARTUP_TIMEOUT_MS,
|
|
169
|
+
idleShutdownMs: DEFAULT_IDLE_SHUTDOWN_MS,
|
|
170
|
+
},
|
|
171
|
+
defaults: { ...FALLBACK_DEFAULTS },
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/* ------------------------------------------------------------------ */
|
|
175
|
+
/* Forge directory → launch command */
|
|
176
|
+
/* ------------------------------------------------------------------ */
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Launcher file names, per platform, in preference order.
|
|
180
|
+
*
|
|
181
|
+
* Forge's portable build ships `run.bat` at the ROOT of the extracted
|
|
182
|
+
* directory (the one that `cd`s into `system/`). A1111 ships `webui.bat` on
|
|
183
|
+
* Windows and `webui.sh` on POSIX. Picking the first that exists means the
|
|
184
|
+
* user only has to point at the folder.
|
|
185
|
+
*/
|
|
186
|
+
const LAUNCHER_CANDIDATES = {
|
|
187
|
+
win32: ["run.bat", "webui.bat", "webui-user.bat", "start.bat"],
|
|
188
|
+
posix: ["webui.sh", "run.sh", "webui-user.sh"],
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/** Directories that commonly hold a Forge / A1111 install, per platform. */
|
|
192
|
+
const PROBE_ROOTS = {
|
|
193
|
+
win32: [
|
|
194
|
+
"D:\\AI\\forge",
|
|
195
|
+
"D:\\forge",
|
|
196
|
+
"C:\\AI\\forge",
|
|
197
|
+
"C:\\forge",
|
|
198
|
+
"D:\\stable-diffusion-webui",
|
|
199
|
+
"C:\\stable-diffusion-webui",
|
|
200
|
+
"D:\\AI",
|
|
201
|
+
],
|
|
202
|
+
posix: [
|
|
203
|
+
join(homedir(), "stable-diffusion-webui"),
|
|
204
|
+
join(homedir(), "stable-diffusion-webui-forge"),
|
|
205
|
+
"/opt/stable-diffusion-webui",
|
|
206
|
+
],
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
/** Is this a directory we can read? */
|
|
210
|
+
async function isDir(path) {
|
|
211
|
+
try {
|
|
212
|
+
const st = await stat(path);
|
|
213
|
+
return st.isDirectory();
|
|
214
|
+
} catch {
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/* ------------------------------------------------------------------ */
|
|
220
|
+
/* GPU / VRAM detection */
|
|
221
|
+
/* ------------------------------------------------------------------ */
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Ask nvidia-smi for the GPU name and total VRAM.
|
|
225
|
+
*
|
|
226
|
+
* Three sources exist on Windows and they are NOT equally trustworthy:
|
|
227
|
+
*
|
|
228
|
+
* - `nvidia-smi` — exact, and the only one that also names the card.
|
|
229
|
+
* - Registry `HardwareInformation.qwMemorySize` — 64-bit, exact, but
|
|
230
|
+
* NVIDIA/AMD only and needs the driver key walk.
|
|
231
|
+
* - WMI `Win32_VideoController.AdapterRAM` — a 32-BIT field. It wraps at
|
|
232
|
+
* 4 GB, so a 8 GB or 12 GB card reports 0 or a nonsense small number.
|
|
233
|
+
* That is the trap: it looks authoritative and is silently wrong exactly
|
|
234
|
+
* for the cards people are most likely to be asking about.
|
|
235
|
+
*
|
|
236
|
+
* So: nvidia-smi, then registry, and WMI only as a last resort with the
|
|
237
|
+
* result explicitly flagged as possibly truncated.
|
|
238
|
+
*/
|
|
239
|
+
async function detectGpu() {
|
|
240
|
+
const tried = [];
|
|
241
|
+
if (process.platform === "win32" || process.platform === "linux") {
|
|
242
|
+
const smi = await readNvidiaSmi();
|
|
243
|
+
tried.push({ source: "nvidia-smi", ok: smi !== null, detail: smi !== null ? smi.name : "no usable output" });
|
|
244
|
+
if (smi !== null) return { gpu: smi, tried };
|
|
245
|
+
}
|
|
246
|
+
const reg = await readRegistryVram();
|
|
247
|
+
tried.push({ source: "registry", ok: reg !== null, detail: reg !== null ? reg.name : "no usable output" });
|
|
248
|
+
if (reg !== null) return { gpu: reg, tried };
|
|
249
|
+
const wmi = await readWmiVram();
|
|
250
|
+
tried.push({ source: "wmi", ok: wmi !== null, detail: wmi !== null ? wmi.name : "no usable output" });
|
|
251
|
+
if (wmi !== null) return { gpu: wmi, tried };
|
|
252
|
+
return { gpu: null, tried };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Run one command and capture its stdout, or null on any failure.
|
|
257
|
+
*
|
|
258
|
+
* WHY A TEMP FILE INSTEAD OF A PIPE: DSH confines spawned processes so that a
|
|
259
|
+
* child cannot open a named pipe. `spawn(..., { stdio: "pipe" })` therefore
|
|
260
|
+
* fails with EPERM the moment the parent tries to read — which is exactly what
|
|
261
|
+
* happened here: every probe silently returned null and the settings page said
|
|
262
|
+
* "no GPU detected" on a machine with a perfectly good nvidia-smi. Writing
|
|
263
|
+
* stdout to a real file and reading it back is allowed under the same sandbox
|
|
264
|
+
* and behaves identically otherwise.
|
|
265
|
+
*
|
|
266
|
+
* The file is created with a unique name, removed on every exit path, and the
|
|
267
|
+
* whole call is bounded by `timeoutMs` so a hung driver query cannot wedge the
|
|
268
|
+
* settings page.
|
|
269
|
+
*/
|
|
270
|
+
function runCapture(command, args, timeoutMs) {
|
|
271
|
+
return new Promise((resolve) => {
|
|
272
|
+
const outPath = join(
|
|
273
|
+
tmpdir(),
|
|
274
|
+
"dsh-localsd-" + process.pid + "-" + Date.now().toString(36) + "-" +
|
|
275
|
+
Math.random().toString(36).slice(2, 8) + ".txt",
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
let fd;
|
|
279
|
+
try {
|
|
280
|
+
fd = openSync(outPath, "w");
|
|
281
|
+
} catch {
|
|
282
|
+
resolve(null);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
let done = false;
|
|
287
|
+
let timer = null;
|
|
288
|
+
|
|
289
|
+
const cleanup = () => {
|
|
290
|
+
try { closeSync(fd); } catch { /* already closed */ }
|
|
291
|
+
try { unlinkSync(outPath); } catch { /* already gone */ }
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const finish = (value) => {
|
|
295
|
+
if (done) return;
|
|
296
|
+
done = true;
|
|
297
|
+
if (timer !== null) clearTimeout(timer);
|
|
298
|
+
try { child.kill(); } catch { /* already gone */ }
|
|
299
|
+
cleanup();
|
|
300
|
+
resolve(value);
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
let child;
|
|
304
|
+
try {
|
|
305
|
+
child = spawn(command, args, {
|
|
306
|
+
// stdout goes to the file; nothing else is captured.
|
|
307
|
+
stdio: ["ignore", fd, "ignore"],
|
|
308
|
+
windowsHide: true,
|
|
309
|
+
});
|
|
310
|
+
} catch {
|
|
311
|
+
done = true;
|
|
312
|
+
cleanup();
|
|
313
|
+
resolve(null);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
timer = setTimeout(() => finish(null), timeoutMs);
|
|
318
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
319
|
+
|
|
320
|
+
child.on("error", () => finish(null));
|
|
321
|
+
child.on("close", (code) => {
|
|
322
|
+
if (done) return;
|
|
323
|
+
let out = "";
|
|
324
|
+
try {
|
|
325
|
+
out = readFileSync(outPath, "utf8");
|
|
326
|
+
} catch {
|
|
327
|
+
finish(null);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
finish(code === 0 ? out : null);
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** `nvidia-smi --query-gpu=name,memory.total --format=csv,noheader,nounits` */
|
|
336
|
+
async function readNvidiaSmi() {
|
|
337
|
+
const out = await runCapture(
|
|
338
|
+
"nvidia-smi",
|
|
339
|
+
["--query-gpu=name,memory.total", "--format=csv,noheader,nounits"],
|
|
340
|
+
4000,
|
|
341
|
+
);
|
|
342
|
+
if (out === null) return null;
|
|
343
|
+
// One line per GPU; take the first (the one CUDA will use by default).
|
|
344
|
+
const line = out.split(/\r?\n/).map((s) => s.trim()).find((s) => s.length > 0);
|
|
345
|
+
if (line === undefined) return null;
|
|
346
|
+
const parts = line.split(",").map((s) => s.trim());
|
|
347
|
+
const name = parts[0] ?? "";
|
|
348
|
+
const mib = Number(parts[1]);
|
|
349
|
+
if (name.length === 0 || !Number.isFinite(mib) || mib <= 0) return null;
|
|
350
|
+
return { name, vramMb: Math.round(mib), source: "nvidia-smi", exact: true };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Registry walk for `HardwareInformation.qwMemorySize` (bytes, 64-bit).
|
|
355
|
+
* Only on Windows, only for the display-adapter class key.
|
|
356
|
+
*/
|
|
357
|
+
async function readRegistryVram() {
|
|
358
|
+
if (process.platform !== "win32") return null;
|
|
359
|
+
// PowerShell is always present on Windows and handles the registry walk
|
|
360
|
+
// without a native module.
|
|
361
|
+
const script =
|
|
362
|
+
"$b='HKLM:\\SYSTEM\\CurrentControlSet\\Control\\Class\\{4d36e968-e325-11ce-bfc1-08002be10318}';" +
|
|
363
|
+
"Get-ChildItem $b -EA 0 | ForEach-Object { $p=Get-ItemProperty $_.PSPath -EA 0;" +
|
|
364
|
+
" if ($p.'HardwareInformation.qwMemorySize' -and $p.DriverDesc) {" +
|
|
365
|
+
" [pscustomobject]@{n=$p.DriverDesc;b=$p.'HardwareInformation.qwMemorySize'} } } |" +
|
|
366
|
+
" Sort-Object b -Descending | Select-Object -First 1 | ConvertTo-Json -Compress";
|
|
367
|
+
const out = await runCapture(
|
|
368
|
+
"powershell.exe",
|
|
369
|
+
["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script],
|
|
370
|
+
8000,
|
|
371
|
+
);
|
|
372
|
+
if (out === null) return null;
|
|
373
|
+
const text = out.trim();
|
|
374
|
+
if (text.length === 0) return null;
|
|
375
|
+
try {
|
|
376
|
+
const parsed = JSON.parse(text);
|
|
377
|
+
const name = String(parsed.n ?? "").trim();
|
|
378
|
+
const bytes = Number(parsed.b);
|
|
379
|
+
if (name.length === 0 || !Number.isFinite(bytes) || bytes <= 0) return null;
|
|
380
|
+
// Virtual display adapters (ToDesk, Oray, Parsec) report 0 or tiny
|
|
381
|
+
// values and would otherwise win the "largest" sort by being present.
|
|
382
|
+
if (/virtual|idd|remote|basic display|parsec|todesk|oray/i.test(name)) return null;
|
|
383
|
+
return {
|
|
384
|
+
name,
|
|
385
|
+
vramMb: Math.round(bytes / (1024 * 1024)),
|
|
386
|
+
source: "registry",
|
|
387
|
+
exact: true,
|
|
388
|
+
};
|
|
389
|
+
} catch {
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Last resort: WMI AdapterRAM (32-bit, truncates above 4 GB). */
|
|
395
|
+
async function readWmiVram() {
|
|
396
|
+
if (process.platform !== "win32") return null;
|
|
397
|
+
const script =
|
|
398
|
+
"Get-CimInstance Win32_VideoController -EA 0 |" +
|
|
399
|
+
" Where-Object { $_.AdapterRAM -gt 0 -and $_.Name -notmatch 'virtual|idd|remote|basic display|parsec|todesk|oray' } |" +
|
|
400
|
+
" Sort-Object AdapterRAM -Descending | Select-Object -First 1 |" +
|
|
401
|
+
" Select-Object Name,AdapterRAM | ConvertTo-Json -Compress";
|
|
402
|
+
const out = await runCapture(
|
|
403
|
+
"powershell.exe",
|
|
404
|
+
["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script],
|
|
405
|
+
8000,
|
|
406
|
+
);
|
|
407
|
+
if (out === null) return null;
|
|
408
|
+
const text = out.trim();
|
|
409
|
+
if (text.length === 0) return null;
|
|
410
|
+
try {
|
|
411
|
+
const parsed = JSON.parse(text);
|
|
412
|
+
const name = String(parsed.Name ?? "").trim();
|
|
413
|
+
const bytes = Number(parsed.AdapterRAM);
|
|
414
|
+
if (name.length === 0 || !Number.isFinite(bytes) || bytes <= 0) return null;
|
|
415
|
+
return {
|
|
416
|
+
name,
|
|
417
|
+
vramMb: Math.round(bytes / (1024 * 1024)),
|
|
418
|
+
source: "wmi",
|
|
419
|
+
// AdapterRAM is a 32-bit field: anything at or above ~4 GB is a
|
|
420
|
+
// wrapped value and must not be trusted as-is.
|
|
421
|
+
exact: bytes < 3_900_000_000,
|
|
422
|
+
};
|
|
423
|
+
} catch {
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Turn a VRAM size into concrete advice.
|
|
430
|
+
*
|
|
431
|
+
* The tiers are drawn from what actually fits, not from a formula:
|
|
432
|
+
*
|
|
433
|
+
* <= 4 GB SD1.5 at 512×768 only. SDXL needs --lowvram and will crawl.
|
|
434
|
+
* 6-7 GB SD1.5 up to 768×1024; SDXL 1024×1024 works but is slow.
|
|
435
|
+
* 8-11 GB SDXL comfortable; SD1.5 can go 832×1216 for portraits.
|
|
436
|
+
* >= 12 GB SDXL 1024×1024 with room for a batch, or SD1.5 at 1024².
|
|
437
|
+
*
|
|
438
|
+
* `launchArgs` is the `--medvram` / `--lowvram` choice, which matters more
|
|
439
|
+
* than the resolution: the wrong one is what actually causes an OOM.
|
|
440
|
+
*/
|
|
441
|
+
function recommendFor(vramMb) {
|
|
442
|
+
const gb = vramMb / 1024;
|
|
443
|
+
if (!Number.isFinite(gb) || gb <= 0) {
|
|
444
|
+
return {
|
|
445
|
+
tier: "unknown",
|
|
446
|
+
label: "未知",
|
|
447
|
+
width: 512,
|
|
448
|
+
height: 768,
|
|
449
|
+
steps: 25,
|
|
450
|
+
cfg: 7,
|
|
451
|
+
launchArgs: "--medvram --xformers --no-half-vae --api",
|
|
452
|
+
note: "没识别到显卡,按最保守的配置来。",
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
if (gb < 5) {
|
|
456
|
+
return {
|
|
457
|
+
tier: "4gb",
|
|
458
|
+
label: "4 GB 级",
|
|
459
|
+
width: 512,
|
|
460
|
+
height: 768,
|
|
461
|
+
steps: 25,
|
|
462
|
+
cfg: 7,
|
|
463
|
+
launchArgs: "--medvram --xformers --no-half-vae --api",
|
|
464
|
+
note: "只建议跑 SD1.5。SDXL 会爆显存,非要跑就换 --lowvram 并把尺寸压到 768×768。",
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
if (gb < 8) {
|
|
468
|
+
return {
|
|
469
|
+
tier: "6gb",
|
|
470
|
+
label: "6~7 GB",
|
|
471
|
+
width: 512,
|
|
472
|
+
height: 768,
|
|
473
|
+
steps: 25,
|
|
474
|
+
cfg: 7,
|
|
475
|
+
launchArgs: "--medvram --xformers --no-half-vae --api",
|
|
476
|
+
note: "SD1.5 可以上 768×1024;SDXL 能跑 1024×1024 但慢,建议加 --lowvram。",
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
if (gb < 12) {
|
|
480
|
+
return {
|
|
481
|
+
tier: "8gb",
|
|
482
|
+
label: "8~11 GB",
|
|
483
|
+
width: 832,
|
|
484
|
+
height: 1216,
|
|
485
|
+
steps: 28,
|
|
486
|
+
cfg: 7,
|
|
487
|
+
launchArgs: "--xformers --no-half-vae --api",
|
|
488
|
+
note: "SDXL 1024×1024 很舒服;SD1.5 竖图可以上 832×1216。",
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
if (gb < 16) {
|
|
492
|
+
return {
|
|
493
|
+
tier: "12gb",
|
|
494
|
+
label: "12~15 GB",
|
|
495
|
+
width: 1024,
|
|
496
|
+
height: 1024,
|
|
497
|
+
steps: 30,
|
|
498
|
+
cfg: 7,
|
|
499
|
+
launchArgs: "--xformers --no-half-vae --api",
|
|
500
|
+
note: "SDXL 1024×1024 可以批量出;SD1.5 能跑 1024×1536。",
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
return {
|
|
504
|
+
tier: "16gb+",
|
|
505
|
+
label: "16 GB 以上",
|
|
506
|
+
width: 1024,
|
|
507
|
+
height: 1536,
|
|
508
|
+
steps: 30,
|
|
509
|
+
cfg: 7,
|
|
510
|
+
launchArgs: "--xformers --no-half-vae --api",
|
|
511
|
+
note: "随便跑。SDXL 批量、SD1.5 高分辨率都没问题。",
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Resolve a user-supplied Forge directory into a concrete launch command.
|
|
517
|
+
*
|
|
518
|
+
* Accepts the folder, or the launcher itself (a friend who pastes
|
|
519
|
+
* `...\run.bat` should not be told they did it wrong). Returns
|
|
520
|
+
* `{ command, workdir, launcher }` — `command` is "" when nothing usable was
|
|
521
|
+
* found, which the settings page reports instead of failing at draw time.
|
|
522
|
+
*/
|
|
523
|
+
async function resolveLauncher(forgeDir) {
|
|
524
|
+
// Always hand back ABSOLUTE paths. A relative one (say the user typed
|
|
525
|
+
// `run.bat`) resolves against whatever cwd the launcher process happens to
|
|
526
|
+
// have, and `cd /d` into it then fails with a syntax error instead of a
|
|
527
|
+
// useful "not found".
|
|
528
|
+
const raw = typeof forgeDir === "string" ? forgeDir.trim().replace(/^"(.*)"$/, "$1") : "";
|
|
529
|
+
// Empty check BEFORE resolving: resolvePath("") happily returns the current
|
|
530
|
+
// working directory, which would make an unset setting look configured.
|
|
531
|
+
if (raw.length === 0) return { command: "", workdir: "", launcher: "" };
|
|
532
|
+
const dir = resolvePath(raw);
|
|
533
|
+
|
|
534
|
+
// The user pointed at the launcher file itself.
|
|
535
|
+
if (/\.(bat|cmd|sh)$/i.test(dir)) {
|
|
536
|
+
if (!(await isFile(dir))) return { command: "", workdir: "", launcher: "" };
|
|
537
|
+
return { command: dir, workdir: dirname(dir), launcher: basename(dir) };
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
if (!(await isDir(dir))) return { command: "", workdir: "", launcher: "" };
|
|
541
|
+
|
|
542
|
+
const names = LAUNCHER_CANDIDATES[process.platform === "win32" ? "win32" : "posix"];
|
|
543
|
+
for (const name of names) {
|
|
544
|
+
const candidate = join(dir, name);
|
|
545
|
+
if (await isFile(candidate)) {
|
|
546
|
+
return { command: candidate, workdir: dir, launcher: name };
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
return { command: "", workdir: "", launcher: "" };
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Best-effort scan for an existing install, so the first run needs no typing.
|
|
554
|
+
* Bounded: one level below each probe root, and only directories whose name
|
|
555
|
+
* looks like a WebUI checkout.
|
|
556
|
+
*/
|
|
557
|
+
async function detectForgeDirs() {
|
|
558
|
+
const roots = PROBE_ROOTS[process.platform === "win32" ? "win32" : "posix"];
|
|
559
|
+
const found = [];
|
|
560
|
+
const seen = new Set();
|
|
561
|
+
|
|
562
|
+
const consider = async (dir) => {
|
|
563
|
+
if (seen.has(dir)) return;
|
|
564
|
+
seen.add(dir);
|
|
565
|
+
const resolved = await resolveLauncher(dir);
|
|
566
|
+
if (resolved.command.length > 0) {
|
|
567
|
+
found.push({ dir, launcher: resolved.launcher, command: resolved.command });
|
|
568
|
+
}
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
for (const root of roots) {
|
|
572
|
+
await consider(root);
|
|
573
|
+
if (found.length > 0) continue;
|
|
574
|
+
let entries;
|
|
575
|
+
try {
|
|
576
|
+
entries = await readdir(root, { withFileTypes: true });
|
|
577
|
+
} catch {
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
for (const entry of entries) {
|
|
581
|
+
if (!entry.isDirectory()) continue;
|
|
582
|
+
if (!/forge|webui|stable-?diffusion|sd-?webui/i.test(entry.name)) continue;
|
|
583
|
+
await consider(join(root, entry.name));
|
|
584
|
+
if (found.length >= 5) break;
|
|
585
|
+
}
|
|
586
|
+
if (found.length >= 5) break;
|
|
587
|
+
}
|
|
588
|
+
return found;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** Write the template once, so the file is discoverable and editable. */
|
|
592
|
+
async function ensureConfig() {
|
|
593
|
+
const path = configPath();
|
|
594
|
+
try {
|
|
595
|
+
await readFile(path, "utf8");
|
|
596
|
+
return;
|
|
597
|
+
} catch {
|
|
598
|
+
// Missing — fall through and create it.
|
|
599
|
+
}
|
|
600
|
+
try {
|
|
601
|
+
await mkdir(dirname(path), { recursive: true });
|
|
602
|
+
await writeFile(path, JSON.stringify(DEFAULT_CONFIG_TEMPLATE, null, 2) + "\n", "utf8");
|
|
603
|
+
} catch {
|
|
604
|
+
// Read-only home or a sandbox denial: defaults still work in memory.
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
async function readConfig() {
|
|
609
|
+
await ensureConfig();
|
|
610
|
+
try {
|
|
611
|
+
let raw = await readFile(configPath(), "utf8");
|
|
612
|
+
// Strip a UTF-8 BOM: JSON.parse rejects it, and silently falling back
|
|
613
|
+
// to the default endpoint is the exact bug this guard prevents.
|
|
614
|
+
if (raw.charCodeAt(0) === 0xfeff) raw = raw.slice(1);
|
|
615
|
+
const parsed = JSON.parse(raw);
|
|
616
|
+
return parsed !== null && typeof parsed === "object" ? parsed : {};
|
|
617
|
+
} catch {
|
|
618
|
+
return {};
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** Merge one patch into the config file and write it back. */
|
|
623
|
+
async function writeConfig(patch) {
|
|
624
|
+
const current = await readConfig();
|
|
625
|
+
const next = { ...current };
|
|
626
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
627
|
+
if (value === null || value === undefined) continue;
|
|
628
|
+
if (key === "autoStart" || key === "defaults") {
|
|
629
|
+
next[key] = { ...(current[key] ?? {}), ...value };
|
|
630
|
+
} else {
|
|
631
|
+
next[key] = value;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
const path = configPath();
|
|
635
|
+
await mkdir(dirname(path), { recursive: true });
|
|
636
|
+
// No BOM — see readConfig.
|
|
637
|
+
await writeFile(path, JSON.stringify(next, null, 2) + "\n", "utf8");
|
|
638
|
+
return next;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
async function resolveSettings() {
|
|
642
|
+
const cfg = await readConfig();
|
|
643
|
+
const configured =
|
|
644
|
+
typeof cfg.endpoint === "string" && cfg.endpoint.length > 0
|
|
645
|
+
? cfg.endpoint.replace(/\/+$/, "")
|
|
646
|
+
: DEFAULT_ENDPOINT;
|
|
647
|
+
|
|
648
|
+
const fallbacks = Array.isArray(cfg.fallbackEndpoints)
|
|
649
|
+
? cfg.fallbackEndpoints
|
|
650
|
+
.filter((e) => typeof e === "string" && e.length > 0)
|
|
651
|
+
.map((e) => e.replace(/\/+$/, ""))
|
|
652
|
+
: FALLBACK_ENDPOINTS.slice(1);
|
|
653
|
+
|
|
654
|
+
const autoRaw = cfg.autoStart !== null && typeof cfg.autoStart === "object" ? cfg.autoStart : {};
|
|
655
|
+
const numOr = (v, fallback) => (typeof v === "number" && Number.isFinite(v) && v > 0 ? v : fallback);
|
|
656
|
+
const strOr = (v, fallback) => (typeof v === "string" && v.length > 0 ? v : fallback);
|
|
657
|
+
// `idleShutdownMs: 0` means "never shut down", so 0 is a VALID value here —
|
|
658
|
+
// `numOr` would treat it as missing and quietly restore the 5-minute default.
|
|
659
|
+
const idleOr = (v, fallback) =>
|
|
660
|
+
typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : fallback;
|
|
661
|
+
|
|
662
|
+
const raw = cfg.defaults !== null && typeof cfg.defaults === "object" ? cfg.defaults : {};
|
|
663
|
+
|
|
664
|
+
// `forgeDir` is the friendly knob (one folder path); `autoStart.command`
|
|
665
|
+
// is the escape hatch for anything unusual. The explicit command wins.
|
|
666
|
+
const forgeDir = strOr(cfg.forgeDir, "");
|
|
667
|
+
const explicitCommand = strOr(autoRaw.command, "");
|
|
668
|
+
// Absolute again: the escape hatch is often typed by hand, and a relative
|
|
669
|
+
// command breaks the Windows `cd /d` prefix exactly like a relative
|
|
670
|
+
// forgeDir does.
|
|
671
|
+
let command = explicitCommand.length > 0 ? resolvePath(explicitCommand) : "";
|
|
672
|
+
let workdir = strOr(autoRaw.workdir, "");
|
|
673
|
+
if (workdir.length > 0) workdir = resolvePath(workdir);
|
|
674
|
+
if (command.length === 0 && forgeDir.length > 0) {
|
|
675
|
+
const resolved = await resolveLauncher(forgeDir);
|
|
676
|
+
command = resolved.command;
|
|
677
|
+
if (workdir.length === 0) workdir = resolved.workdir;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
return {
|
|
681
|
+
forgeDir,
|
|
682
|
+
outputDir: strOr(cfg.outputDir, ""),
|
|
683
|
+
endpoint: configured,
|
|
684
|
+
candidates: [configured, ...fallbacks.filter((e) => e !== configured)],
|
|
685
|
+
autoStart: {
|
|
686
|
+
enabled: autoRaw.enabled === true,
|
|
687
|
+
command,
|
|
688
|
+
workdir,
|
|
689
|
+
startupTimeoutMs: numOr(autoRaw.startupTimeoutMs, DEFAULT_STARTUP_TIMEOUT_MS),
|
|
690
|
+
idleShutdownMs: idleOr(autoRaw.idleShutdownMs, DEFAULT_IDLE_SHUTDOWN_MS),
|
|
691
|
+
},
|
|
692
|
+
steps: numOr(raw.steps, FALLBACK_DEFAULTS.steps),
|
|
693
|
+
cfg: numOr(raw.cfg, FALLBACK_DEFAULTS.cfg),
|
|
694
|
+
sampler: normalizeSampler(strOr(raw.sampler, FALLBACK_DEFAULTS.sampler)),
|
|
695
|
+
width: numOr(raw.width, FALLBACK_DEFAULTS.width),
|
|
696
|
+
height: numOr(raw.height, FALLBACK_DEFAULTS.height),
|
|
697
|
+
negative: strOr(raw.negative, FALLBACK_DEFAULTS.negative),
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/* ------------------------------------------------------------------ */
|
|
702
|
+
/* Backend lifecycle */
|
|
703
|
+
/* ------------------------------------------------------------------ */
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Process we spawned, and the timer that will kill it. Kept at module scope so
|
|
707
|
+
* every tool call shares the same view. A backend the USER started by hand is
|
|
708
|
+
* never in here — and therefore never killed.
|
|
709
|
+
*/
|
|
710
|
+
const lifecycle = {
|
|
711
|
+
child: null,
|
|
712
|
+
pid: null,
|
|
713
|
+
endpoint: null,
|
|
714
|
+
lastUsedAt: 0,
|
|
715
|
+
idleTimer: null,
|
|
716
|
+
starting: null, // Promise while a spawn+wait is in flight
|
|
717
|
+
};
|
|
718
|
+
|
|
719
|
+
/** Is this endpoint a live sdapi backend? */
|
|
720
|
+
async function probe(endpoint, signal) {
|
|
721
|
+
try {
|
|
722
|
+
const timeout = AbortSignal.timeout(2500);
|
|
723
|
+
const combined = signal !== undefined ? AbortSignal.any([signal, timeout]) : timeout;
|
|
724
|
+
const res = await fetch(endpoint + "/sdapi/v1/options", { method: "GET", signal: combined });
|
|
725
|
+
return res.ok;
|
|
726
|
+
} catch {
|
|
727
|
+
return false;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/** First endpoint from settings.candidates that answers, or null. */
|
|
732
|
+
async function findLiveEndpoint(settings, signal) {
|
|
733
|
+
for (const endpoint of settings.candidates) {
|
|
734
|
+
if (await probe(endpoint, signal)) return endpoint;
|
|
735
|
+
}
|
|
736
|
+
return null;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function clearIdleTimer() {
|
|
740
|
+
if (lifecycle.idleTimer !== null) {
|
|
741
|
+
clearTimeout(lifecycle.idleTimer);
|
|
742
|
+
lifecycle.idleTimer = null;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/** Kill the process tree we started. Never touches a user-started backend. */
|
|
747
|
+
function stopOwnedBackend() {
|
|
748
|
+
clearIdleTimer();
|
|
749
|
+
const pid = lifecycle.pid;
|
|
750
|
+
lifecycle.child = null;
|
|
751
|
+
lifecycle.pid = null;
|
|
752
|
+
lifecycle.endpoint = null;
|
|
753
|
+
if (pid === null) return false;
|
|
754
|
+
try {
|
|
755
|
+
if (process.platform === "win32") {
|
|
756
|
+
// Forge's run.bat spawns python.exe; /T takes the whole tree.
|
|
757
|
+
spawn("taskkill", ["/PID", String(pid), "/T", "/F"], {
|
|
758
|
+
stdio: "ignore",
|
|
759
|
+
windowsHide: true,
|
|
760
|
+
detached: true,
|
|
761
|
+
}).unref();
|
|
762
|
+
} else {
|
|
763
|
+
try {
|
|
764
|
+
process.kill(-pid, "SIGTERM");
|
|
765
|
+
} catch {
|
|
766
|
+
process.kill(pid, "SIGTERM");
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
} catch {
|
|
770
|
+
// Already gone.
|
|
771
|
+
}
|
|
772
|
+
return true;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/** Arm (or re-arm) the idle timer that releases the backend's VRAM. */
|
|
776
|
+
function touch(settings) {
|
|
777
|
+
lifecycle.lastUsedAt = Date.now();
|
|
778
|
+
clearIdleTimer();
|
|
779
|
+
if (lifecycle.pid === null) return; // Nothing we own to shut down.
|
|
780
|
+
const idleMs = settings.autoStart.idleShutdownMs;
|
|
781
|
+
// 0 = keep the backend warm forever. Without this guard `setTimeout(0)`
|
|
782
|
+
// would fire on the next tick and kill the backend we just started.
|
|
783
|
+
if (idleMs <= 0) return;
|
|
784
|
+
lifecycle.idleTimer = setTimeout(() => {
|
|
785
|
+
if (lifecycle.pid === null) return;
|
|
786
|
+
const idleFor = Date.now() - lifecycle.lastUsedAt;
|
|
787
|
+
if (idleFor < idleMs) {
|
|
788
|
+
// A generation finished after the timer was armed — re-arm.
|
|
789
|
+
touch(settings);
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
stopOwnedBackend();
|
|
793
|
+
}, idleMs);
|
|
794
|
+
// Do not hold the DSH process open just for this timer.
|
|
795
|
+
if (typeof lifecycle.idleTimer.unref === "function") lifecycle.idleTimer.unref();
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* Environment for the spawned backend.
|
|
800
|
+
*
|
|
801
|
+
* Two problems get inherited from the parent shell and both break Forge:
|
|
802
|
+
*
|
|
803
|
+
* 1. A proxy in HTTP_PROXY/HTTPS_PROXY. The backend only serves 127.0.0.1, so
|
|
804
|
+
* a proxy is never wanted, and httpx picks it up during import.
|
|
805
|
+
* 2. A NO_PROXY entry like `[::1]`. httpx parses NO_PROXY with its own URL
|
|
806
|
+
* parser and raises `InvalidURL: Invalid port: ':1]'` on the bracketed IPv6
|
|
807
|
+
* form, which kills the whole import chain (gradio → httpx) before the API
|
|
808
|
+
* ever binds. Proxy clients inject exactly that string.
|
|
809
|
+
*
|
|
810
|
+
* So: drop the proxy variables, and rebuild NO_PROXY from only the entries
|
|
811
|
+
* httpx can actually parse.
|
|
812
|
+
*/
|
|
813
|
+
function sanitizedEnv() {
|
|
814
|
+
const env = { ...process.env };
|
|
815
|
+
for (const key of [
|
|
816
|
+
"HTTP_PROXY",
|
|
817
|
+
"HTTPS_PROXY",
|
|
818
|
+
"ALL_PROXY",
|
|
819
|
+
"http_proxy",
|
|
820
|
+
"https_proxy",
|
|
821
|
+
"all_proxy",
|
|
822
|
+
]) {
|
|
823
|
+
delete env[key];
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
const rawNoProxy = env.NO_PROXY ?? env.no_proxy ?? "";
|
|
827
|
+
const safe = rawNoProxy
|
|
828
|
+
.split(",")
|
|
829
|
+
.map((entry) => entry.trim())
|
|
830
|
+
.filter((entry) => entry.length > 0)
|
|
831
|
+
// httpx chokes on bracketed IPv6 and on bare `::1`; keep hostnames and
|
|
832
|
+
// IPv4 only. localhost + 127.0.0.1 are the ones that actually matter.
|
|
833
|
+
.filter((entry) => !entry.includes("[") && !entry.includes("]") && !entry.includes(":"));
|
|
834
|
+
if (!safe.includes("127.0.0.1")) safe.push("127.0.0.1");
|
|
835
|
+
if (!safe.includes("localhost")) safe.push("localhost");
|
|
836
|
+
|
|
837
|
+
const noProxy = safe.join(",");
|
|
838
|
+
env.NO_PROXY = noProxy;
|
|
839
|
+
env.no_proxy = noProxy;
|
|
840
|
+
return env;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/** Spawn the configured launch command and wait for sdapi to answer. */
|
|
844
|
+
async function spawnBackend(settings, signal) {
|
|
845
|
+
const { command, workdir, startupTimeoutMs } = settings.autoStart;
|
|
846
|
+
if (command.length === 0) {
|
|
847
|
+
throw new Error(
|
|
848
|
+
"No launch command resolved: autoStart.enabled is true but no Forge directory is configured. " +
|
|
849
|
+
"Open 设置 → 本地绘图 and set 绘图程序目录 to your Stable Diffusion WebUI / Forge folder " +
|
|
850
|
+
"(the one containing run.bat), or run `sd_backend_control` action=detect to scan for it.",
|
|
851
|
+
);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// Forge's run.bat does `call environment.bat` — a RELATIVE path. Launching
|
|
855
|
+
// it with the wrong working directory makes that fail silently, the bundled
|
|
856
|
+
// Python 3.10 never lands on PATH, and the launcher then tries to build a
|
|
857
|
+
// venv from whatever system Python it finds (3.14 → "Couldn't install
|
|
858
|
+
// torch"). So: on Windows always `cd /d` into workdir first, and fall back
|
|
859
|
+
// to the command's own directory when workdir was not configured.
|
|
860
|
+
const isWin = process.platform === "win32";
|
|
861
|
+
let spawnCommand = command;
|
|
862
|
+
let spawnArgs = [];
|
|
863
|
+
let spawnCwd = workdir.length > 0 ? workdir : undefined;
|
|
864
|
+
// Windows only: Node's default argv quoting wraps the whole `/c` payload in
|
|
865
|
+
// another pair of quotes, cmd.exe /s strips the outermost pair, and what is
|
|
866
|
+
// left is a bare path — cmd answers "The filename, directory name, or volume
|
|
867
|
+
// label syntax is incorrect" and exits 1 before the backend ever answers.
|
|
868
|
+
// Verbatim hands the payload through untouched.
|
|
869
|
+
let windowsVerbatimArguments = false;
|
|
870
|
+
|
|
871
|
+
if (isWin) {
|
|
872
|
+
const dir = spawnCwd ?? dirname(command);
|
|
873
|
+
spawnCommand = "cmd.exe";
|
|
874
|
+
spawnArgs = ["/d", "/s", "/c", `cd /d "${dir}" && "${command}"`];
|
|
875
|
+
spawnCwd = dir;
|
|
876
|
+
windowsVerbatimArguments = true;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
const child = spawn(spawnCommand, spawnArgs, {
|
|
880
|
+
cwd: spawnCwd,
|
|
881
|
+
detached: true,
|
|
882
|
+
stdio: "ignore",
|
|
883
|
+
windowsHide: true,
|
|
884
|
+
windowsVerbatimArguments,
|
|
885
|
+
env: sanitizedEnv(),
|
|
886
|
+
});
|
|
887
|
+
child.unref();
|
|
888
|
+
|
|
889
|
+
lifecycle.child = child;
|
|
890
|
+
lifecycle.pid = child.pid ?? null;
|
|
891
|
+
|
|
892
|
+
// If the launcher dies immediately, fail fast instead of waiting 4 minutes.
|
|
893
|
+
let exitCode = null;
|
|
894
|
+
child.once("exit", (code) => {
|
|
895
|
+
exitCode = code ?? -1;
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
const deadline = Date.now() + startupTimeoutMs;
|
|
899
|
+
while (Date.now() < deadline) {
|
|
900
|
+
if (signal !== undefined && signal.aborted) {
|
|
901
|
+
stopOwnedBackend();
|
|
902
|
+
throw new Error("cancelled while starting the local SD backend");
|
|
903
|
+
}
|
|
904
|
+
if (exitCode !== null && exitCode !== 0) {
|
|
905
|
+
stopOwnedBackend();
|
|
906
|
+
throw new Error(
|
|
907
|
+
"The local SD launcher exited with code " +
|
|
908
|
+
exitCode +
|
|
909
|
+
" before the API came up. Run " +
|
|
910
|
+
command +
|
|
911
|
+
" by hand to see its error output.",
|
|
912
|
+
);
|
|
913
|
+
}
|
|
914
|
+
const live = await findLiveEndpoint(settings, signal);
|
|
915
|
+
if (live !== null) {
|
|
916
|
+
lifecycle.endpoint = live;
|
|
917
|
+
return live;
|
|
918
|
+
}
|
|
919
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
stopOwnedBackend();
|
|
923
|
+
throw new Error(
|
|
924
|
+
"The local SD backend did not answer within " +
|
|
925
|
+
Math.round(startupTimeoutMs / 1000) +
|
|
926
|
+
"s of launching " +
|
|
927
|
+
command +
|
|
928
|
+
". First start is slow (dependencies, model load); raise 启动超时 in 设置 → 本地绘图 " +
|
|
929
|
+
"if this is a cold boot on a slow disk.",
|
|
930
|
+
);
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* Make sure a backend is answering, starting one if allowed. Returns the
|
|
935
|
+
* endpoint to use. Also re-arms the idle timer.
|
|
936
|
+
*/
|
|
937
|
+
async function ensureBackend(settings, signal) {
|
|
938
|
+
const live = await findLiveEndpoint(settings, signal);
|
|
939
|
+
if (live !== null) {
|
|
940
|
+
lifecycle.endpoint = live;
|
|
941
|
+
touch(settings);
|
|
942
|
+
return live;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
if (!settings.autoStart.enabled) {
|
|
946
|
+
throw new Error(
|
|
947
|
+
"No local Stable Diffusion backend answered at " +
|
|
948
|
+
settings.candidates.join(", ") +
|
|
949
|
+
", and auto-start is disabled.\n" +
|
|
950
|
+
"Start one by hand (double-click Forge's outer run.bat, wait for " +
|
|
951
|
+
"`Running on local URL: http://127.0.0.1:7860`), or enable 自动启动 in " +
|
|
952
|
+
"设置 → 本地绘图.",
|
|
953
|
+
);
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// Collapse concurrent callers onto one spawn.
|
|
957
|
+
if (lifecycle.starting === null) {
|
|
958
|
+
lifecycle.starting = spawnBackend(settings, signal).finally(() => {
|
|
959
|
+
lifecycle.starting = null;
|
|
960
|
+
});
|
|
961
|
+
}
|
|
962
|
+
const endpoint = await lifecycle.starting;
|
|
963
|
+
touch(settings);
|
|
964
|
+
return endpoint;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/* ------------------------------------------------------------------ */
|
|
968
|
+
/* sdapi plumbing */
|
|
969
|
+
/* ------------------------------------------------------------------ */
|
|
970
|
+
|
|
971
|
+
/** Strip a `data:image/...;base64,` prefix and decode to a Buffer. */
|
|
972
|
+
function decodeB64(s) {
|
|
973
|
+
if (typeof s !== "string" || s.length === 0) return null;
|
|
974
|
+
const clean = s.replace(/^data:image\/[a-zA-Z0-9.+-]+;base64,/, "");
|
|
975
|
+
try {
|
|
976
|
+
return Buffer.from(clean, "base64");
|
|
977
|
+
} catch {
|
|
978
|
+
return null;
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
function stamp() {
|
|
983
|
+
const d = new Date();
|
|
984
|
+
const p = (n, w = 2) => String(n).padStart(w, "0");
|
|
985
|
+
return (
|
|
986
|
+
d.getFullYear() +
|
|
987
|
+
p(d.getMonth() + 1) +
|
|
988
|
+
p(d.getDate()) +
|
|
989
|
+
"-" +
|
|
990
|
+
p(d.getHours()) +
|
|
991
|
+
p(d.getMinutes()) +
|
|
992
|
+
p(d.getSeconds()) +
|
|
993
|
+
"-" +
|
|
994
|
+
p(d.getMilliseconds(), 3)
|
|
995
|
+
);
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/** One txt2img call. Returns the raw API JSON. */
|
|
999
|
+
async function txt2img(endpoint, prompt, opts, signal) {
|
|
1000
|
+
const body = {
|
|
1001
|
+
prompt,
|
|
1002
|
+
negative_prompt: opts.negative,
|
|
1003
|
+
steps: opts.steps,
|
|
1004
|
+
cfg_scale: opts.cfg,
|
|
1005
|
+
width: opts.width,
|
|
1006
|
+
height: opts.height,
|
|
1007
|
+
sampler_name: opts.sampler,
|
|
1008
|
+
seed: opts.seed,
|
|
1009
|
+
batch_size: 1,
|
|
1010
|
+
n_iter: 1,
|
|
1011
|
+
save_images: false,
|
|
1012
|
+
send_images: true,
|
|
1013
|
+
};
|
|
1014
|
+
if (Array.isArray(opts.lora) && opts.lora.length > 0) {
|
|
1015
|
+
body.lora = opts.lora.map((entry) =>
|
|
1016
|
+
typeof entry === "string"
|
|
1017
|
+
? { name: entry, multiplier: 1.0 }
|
|
1018
|
+
: { name: String(entry.name ?? ""), multiplier: Number(entry.multiplier ?? 1.0) },
|
|
1019
|
+
);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
const res = await fetch(endpoint + "/sdapi/v1/txt2img", {
|
|
1023
|
+
method: "POST",
|
|
1024
|
+
headers: { "content-type": "application/json" },
|
|
1025
|
+
body: JSON.stringify(body),
|
|
1026
|
+
signal,
|
|
1027
|
+
}).catch((err) => {
|
|
1028
|
+
if (err instanceof Error && err.name === "AbortError") throw err;
|
|
1029
|
+
const why = err instanceof Error ? err.message : String(err);
|
|
1030
|
+
throw new Error("local SD backend unreachable at " + endpoint + " (" + why + ")");
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
const text = await res.text();
|
|
1034
|
+
if (!res.ok) {
|
|
1035
|
+
throw new Error("sdapi " + res.status + " " + res.statusText + ": " + text.slice(0, 400));
|
|
1036
|
+
}
|
|
1037
|
+
let parsed;
|
|
1038
|
+
try {
|
|
1039
|
+
parsed = JSON.parse(text);
|
|
1040
|
+
} catch {
|
|
1041
|
+
throw new Error("sdapi returned non-JSON: " + text.slice(0, 400));
|
|
1042
|
+
}
|
|
1043
|
+
return parsed;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
/**
|
|
1047
|
+
* Where PNGs land, and what the tool result calls that place.
|
|
1048
|
+
*
|
|
1049
|
+
* Precedence:
|
|
1050
|
+
* 1. `settings.outputDir` from $DSH_HOME/local-sd.json (the settings page),
|
|
1051
|
+
* 2. `<workspace root>/generated`.
|
|
1052
|
+
*
|
|
1053
|
+
* A relative `outputDir` is joined to the workspace root so the settings page
|
|
1054
|
+
* can offer the short, readable form (`generated`, `pics/sd`). An absolute one
|
|
1055
|
+
* is honoured verbatim: that is the whole point of the knob, pointing it at the
|
|
1056
|
+
* folder you actually browse even when the session workspace is elsewhere.
|
|
1057
|
+
*
|
|
1058
|
+
* `display` is what goes into the tool result: workspace-relative when the
|
|
1059
|
+
* folder sits inside the workspace (so the Web Client can link it), absolute
|
|
1060
|
+
* otherwise (a relative path there would be a lie).
|
|
1061
|
+
*/
|
|
1062
|
+
function resolveOutputLocation(settings, ctx) {
|
|
1063
|
+
const policy = ctx !== undefined && typeof ctx.get === "function" ? ctx.get("sandboxPolicy") : undefined;
|
|
1064
|
+
const root =
|
|
1065
|
+
policy !== undefined && typeof policy.workspaceRoot === "string" && policy.workspaceRoot.length > 0
|
|
1066
|
+
? policy.workspaceRoot
|
|
1067
|
+
: process.cwd();
|
|
1068
|
+
|
|
1069
|
+
const configured = settings !== undefined && typeof settings.outputDir === "string" ? settings.outputDir.trim() : "";
|
|
1070
|
+
const dir = configured.length === 0
|
|
1071
|
+
? join(root, "generated")
|
|
1072
|
+
: isAbsolute(configured)
|
|
1073
|
+
? configured
|
|
1074
|
+
: join(root, configured);
|
|
1075
|
+
|
|
1076
|
+
// `relative` returns "" for the root itself and a ".."-prefixed path when
|
|
1077
|
+
// outside it; both mean "not addressable relative to the workspace".
|
|
1078
|
+
const rel = relative(root, dir);
|
|
1079
|
+
const inside = rel.length > 0 && !rel.startsWith("..") && !isAbsolute(rel);
|
|
1080
|
+
return { dir, display: inside ? rel : dir };
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* Persist one PNG and turn it into an attachment ref so the Web Client renders
|
|
1085
|
+
* it. Returns { localPath, absPath, attachmentRef }.
|
|
1086
|
+
*/
|
|
1087
|
+
async function persist(ctx, bytes, index, signal, settings) {
|
|
1088
|
+
const fs = ctx.get("fs");
|
|
1089
|
+
const attachments = ctx.get("attachments");
|
|
1090
|
+
if (fs === undefined || attachments === undefined) {
|
|
1091
|
+
throw new Error("fs/attachments service not available");
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
const location = resolveOutputLocation(settings, ctx);
|
|
1095
|
+
const fileName = "sd-" + stamp() + "-" + index + ".png";
|
|
1096
|
+
const absPath = join(location.dir, fileName);
|
|
1097
|
+
const localPath = join(location.display, fileName);
|
|
1098
|
+
|
|
1099
|
+
await mkdir(location.dir, { recursive: true });
|
|
1100
|
+
await writeFile(absPath, bytes);
|
|
1101
|
+
|
|
1102
|
+
const target = await fs.resolve(absPath);
|
|
1103
|
+
const read = await fs.readBytes(target, signal, 33554432);
|
|
1104
|
+
const saved = await attachments.saveImage({
|
|
1105
|
+
data: read,
|
|
1106
|
+
mediaType: "image/png",
|
|
1107
|
+
name: fileName,
|
|
1108
|
+
});
|
|
1109
|
+
|
|
1110
|
+
// The Web Client's image row silently DROPS a block whose attachment ref is
|
|
1111
|
+
// missing a positive `bytes` / `width` / `height` — no error, no broken
|
|
1112
|
+
// image, just the generic text card. A backend that returns an incomplete
|
|
1113
|
+
// ref therefore looks exactly like "the model never sent a picture". Fill
|
|
1114
|
+
// any gap from the bytes we already have so the row always renders.
|
|
1115
|
+
const ref = completeImageRef(saved, read);
|
|
1116
|
+
|
|
1117
|
+
return { localPath, absPath, attachmentRef: ref };
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/**
|
|
1121
|
+
* Read width/height out of a PNG's IHDR chunk (bytes 16..24).
|
|
1122
|
+
* Returns null for anything that is not a well-formed PNG.
|
|
1123
|
+
*/
|
|
1124
|
+
function pngSize(bytes) {
|
|
1125
|
+
if (!(bytes instanceof Uint8Array) || bytes.length < 24) return null;
|
|
1126
|
+
const sig = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
1127
|
+
for (let i = 0; i < 8; i++) if (bytes[i] !== sig[i]) return null;
|
|
1128
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
1129
|
+
const width = view.getUint32(16);
|
|
1130
|
+
const height = view.getUint32(20);
|
|
1131
|
+
if (width <= 0 || height <= 0) return null;
|
|
1132
|
+
return { width, height };
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
/**
|
|
1136
|
+
* Make sure an attachment ref carries every field the image row validates.
|
|
1137
|
+
* `saveImage` normally returns all of them; this is the belt to that braces.
|
|
1138
|
+
*/
|
|
1139
|
+
function completeImageRef(ref, bytes) {
|
|
1140
|
+
const out = ref !== null && typeof ref === "object" ? { ...ref } : {};
|
|
1141
|
+
if (typeof out.attachmentId !== "string" || out.attachmentId.length === 0) return ref;
|
|
1142
|
+
if (typeof out.mediaType !== "string" || out.mediaType.length === 0) out.mediaType = "image/png";
|
|
1143
|
+
if (typeof out.bytes !== "number" || !(out.bytes > 0)) out.bytes = bytes.length;
|
|
1144
|
+
const needSize =
|
|
1145
|
+
typeof out.width !== "number" || !(out.width > 0) || typeof out.height !== "number" || !(out.height > 0);
|
|
1146
|
+
if (needSize) {
|
|
1147
|
+
const size = pngSize(bytes);
|
|
1148
|
+
if (size !== null) {
|
|
1149
|
+
out.width = size.width;
|
|
1150
|
+
out.height = size.height;
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
return out;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
/** Shared output shape: text line per image + the image block itself. */
|
|
1157
|
+
function makeOutput() {
|
|
1158
|
+
return {
|
|
1159
|
+
schema: {
|
|
1160
|
+
type: "object",
|
|
1161
|
+
additionalProperties: true,
|
|
1162
|
+
properties: {
|
|
1163
|
+
results: {
|
|
1164
|
+
type: "array",
|
|
1165
|
+
description: "Array of generated image results.",
|
|
1166
|
+
items: {
|
|
1167
|
+
type: "object",
|
|
1168
|
+
additionalProperties: true,
|
|
1169
|
+
properties: {
|
|
1170
|
+
prompt: { type: "string", description: "The prompt used." },
|
|
1171
|
+
localPath: {
|
|
1172
|
+
type: "string",
|
|
1173
|
+
description: "PNG path: workspace-relative when inside the workspace, absolute otherwise.",
|
|
1174
|
+
},
|
|
1175
|
+
absPath: { type: "string", description: "Absolute PNG path on disk." },
|
|
1176
|
+
attachmentRef: { type: "object", description: "Attachment ref for display." },
|
|
1177
|
+
seed: { type: "number", description: "Seed actually used, if reported." },
|
|
1178
|
+
error: { type: "string", description: "Failure reason, when this item failed." },
|
|
1179
|
+
},
|
|
1180
|
+
},
|
|
1181
|
+
},
|
|
1182
|
+
backend: { type: "string", description: "The sdapi endpoint that produced these." },
|
|
1183
|
+
},
|
|
1184
|
+
},
|
|
1185
|
+
// `offloaded: true` keeps the model context text-only while the Web
|
|
1186
|
+
// Client still renders the image — same mechanism the shipped
|
|
1187
|
+
// generate_image uses.
|
|
1188
|
+
render: (_args, value) => {
|
|
1189
|
+
const results = Array.isArray(value.results) ? value.results : [];
|
|
1190
|
+
const blocks = [];
|
|
1191
|
+
results.forEach((r, i) => {
|
|
1192
|
+
if (r.error) {
|
|
1193
|
+
blocks.push({
|
|
1194
|
+
type: "text",
|
|
1195
|
+
text:
|
|
1196
|
+
"[local-sd] Image " +
|
|
1197
|
+
(i + 1) +
|
|
1198
|
+
"/" +
|
|
1199
|
+
results.length +
|
|
1200
|
+
" FAILED: " +
|
|
1201
|
+
r.error +
|
|
1202
|
+
"\nPrompt: " +
|
|
1203
|
+
r.prompt,
|
|
1204
|
+
});
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
1207
|
+
const head =
|
|
1208
|
+
"[local-sd] Image " +
|
|
1209
|
+
(i + 1) +
|
|
1210
|
+
"/" +
|
|
1211
|
+
results.length +
|
|
1212
|
+
" generated locally (no content filter).\nPrompt: " +
|
|
1213
|
+
r.prompt;
|
|
1214
|
+
const tail =
|
|
1215
|
+
(r.localPath ? "\nlocalPath: " + r.localPath : "") +
|
|
1216
|
+
(r.absPath && r.absPath !== r.localPath ? "\nabsPath: " + r.absPath : "") +
|
|
1217
|
+
(r.seed !== undefined ? "\nseed: " + r.seed : "");
|
|
1218
|
+
blocks.push({ type: "text", text: head + tail });
|
|
1219
|
+
if (r.attachmentRef) blocks.push({ type: "image", attachment: r.attachmentRef, offloaded: true });
|
|
1220
|
+
});
|
|
1221
|
+
// The image blocks above are `offloaded`, so the model's context only
|
|
1222
|
+
// ever sees placeholder text: it never learns that pictures are now on
|
|
1223
|
+
// screen, and a model reading only this result tends to answer with a
|
|
1224
|
+
// description instead of showing them. This closing line IS
|
|
1225
|
+
// model-visible, so it states the delivery requirement plainly.
|
|
1226
|
+
const shown = results.filter((r) => r && !r.error && r.attachmentRef).length;
|
|
1227
|
+
if (shown > 0) {
|
|
1228
|
+
blocks.push({
|
|
1229
|
+
type: "text",
|
|
1230
|
+
text:
|
|
1231
|
+
"[local-sd] " +
|
|
1232
|
+
shown +
|
|
1233
|
+
" image(s) are ALREADY displayed in the conversation above. " +
|
|
1234
|
+
"The user sees them right now: do not describe them as pending, do not ask " +
|
|
1235
|
+
"whether to show them, and do not claim you cannot display images. " +
|
|
1236
|
+
"Refer to them naturally and keep the reply short.",
|
|
1237
|
+
});
|
|
1238
|
+
}
|
|
1239
|
+
return blocks;
|
|
1240
|
+
},
|
|
1241
|
+
};
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
const SIZE_PRESETS = {
|
|
1245
|
+
"512x512": [512, 512],
|
|
1246
|
+
"512x768": [512, 768],
|
|
1247
|
+
"768x512": [768, 512],
|
|
1248
|
+
"640x640": [640, 640],
|
|
1249
|
+
"768x768": [768, 768],
|
|
1250
|
+
"832x1216": [832, 1216],
|
|
1251
|
+
"1216x832": [1216, 832],
|
|
1252
|
+
"1024x1024": [1024, 1024],
|
|
1253
|
+
};
|
|
1254
|
+
|
|
1255
|
+
export function apply(ctx) {
|
|
1256
|
+
const output = makeOutput();
|
|
1257
|
+
|
|
1258
|
+
const pickOpts = (args, settings) => {
|
|
1259
|
+
let w = settings.width;
|
|
1260
|
+
let h = settings.height;
|
|
1261
|
+
if (typeof args.size === "string" && SIZE_PRESETS[args.size] !== undefined) {
|
|
1262
|
+
[w, h] = SIZE_PRESETS[args.size];
|
|
1263
|
+
}
|
|
1264
|
+
return {
|
|
1265
|
+
negative:
|
|
1266
|
+
typeof args.negative === "string" && args.negative.length > 0
|
|
1267
|
+
? args.negative
|
|
1268
|
+
: settings.negative,
|
|
1269
|
+
steps:
|
|
1270
|
+
typeof args.steps === "number" && Number.isFinite(args.steps)
|
|
1271
|
+
? Math.max(1, Math.min(150, Math.round(args.steps)))
|
|
1272
|
+
: settings.steps,
|
|
1273
|
+
cfg:
|
|
1274
|
+
typeof args.cfg === "number" && Number.isFinite(args.cfg)
|
|
1275
|
+
? Math.max(1, Math.min(30, args.cfg))
|
|
1276
|
+
: settings.cfg,
|
|
1277
|
+
width: w,
|
|
1278
|
+
height: h,
|
|
1279
|
+
sampler:
|
|
1280
|
+
typeof args.sampler === "string" && args.sampler.length > 0
|
|
1281
|
+
? normalizeSampler(args.sampler)
|
|
1282
|
+
: settings.sampler,
|
|
1283
|
+
seed: typeof args.seed === "number" && Number.isFinite(args.seed) ? Math.round(args.seed) : -1,
|
|
1284
|
+
lora: Array.isArray(args.lora) ? args.lora : [],
|
|
1285
|
+
};
|
|
1286
|
+
};
|
|
1287
|
+
|
|
1288
|
+
const commonProps = {
|
|
1289
|
+
prompt: {
|
|
1290
|
+
type: "string",
|
|
1291
|
+
description:
|
|
1292
|
+
"Positive prompt. Comma-separated Danbooru-style tags work best for anime SD1.5 checkpoints. " +
|
|
1293
|
+
"Write the scene explicitly — this backend has no content filter, so tag what you actually want " +
|
|
1294
|
+
"(e.g. `1girl, solo, nude, spread legs, penis, sex, cum`). Do not soften or euphemize.",
|
|
1295
|
+
},
|
|
1296
|
+
negative: {
|
|
1297
|
+
type: "string",
|
|
1298
|
+
description: "Negative prompt. Omit to use the configured default (standard quality + bad-anatomy tags).",
|
|
1299
|
+
},
|
|
1300
|
+
size: {
|
|
1301
|
+
type: "string",
|
|
1302
|
+
enum: Object.keys(SIZE_PRESETS),
|
|
1303
|
+
description:
|
|
1304
|
+
"Resolution preset. On 4GB VRAM stay at 512x512 or 512x768; larger sizes need tiled VAE or will OOM.",
|
|
1305
|
+
},
|
|
1306
|
+
steps: { type: "number", description: "Sampling steps, 1-150. Default from config (25)." },
|
|
1307
|
+
cfg: { type: "number", description: "CFG scale, 1-30. Default from config (7). Lower = looser, higher = more literal." },
|
|
1308
|
+
sampler: {
|
|
1309
|
+
type: "string",
|
|
1310
|
+
description:
|
|
1311
|
+
"Sampler name, e.g. euler_a, dpmpp_2m, dpmpp_2m_sde_gpu, ddim, heun. " +
|
|
1312
|
+
"Snake_case aliases are converted to the backend's display names. Default from config.",
|
|
1313
|
+
},
|
|
1314
|
+
seed: { type: "number", description: "Seed. -1 or omitted = random. Reuse a seed to iterate on one composition." },
|
|
1315
|
+
lora: {
|
|
1316
|
+
type: "array",
|
|
1317
|
+
items: { type: "string" },
|
|
1318
|
+
description: "Optional LoRA filenames (as known to the backend) to stack on this generation.",
|
|
1319
|
+
},
|
|
1320
|
+
};
|
|
1321
|
+
|
|
1322
|
+
ctx.tools.register({
|
|
1323
|
+
name: "generate_image_local",
|
|
1324
|
+
description:
|
|
1325
|
+
"Generate ONE image on the LOCAL Stable Diffusion backend (SD WebUI Forge / A1111 / KoboldCpp, sdapi v1 txt2img). "
|
|
1326
|
+
+ "Completely local and unfiltered: the prompt goes straight into the sampler, no hosted moderation, "
|
|
1327
|
+
+ "no refusal, no rewriting. Use this instead of `generate_image` whenever the user wants explicit, "
|
|
1328
|
+
+ "NSFW, or otherwise unfiltered output — that is exactly what this tool is for. "
|
|
1329
|
+
+ "If the backend is not running it is started automatically, and it shuts itself down again after a "
|
|
1330
|
+
+ "few idle minutes, so the first call of a session can take a couple of minutes longer than the rest. "
|
|
1331
|
+
+ "The image is written to the configured output folder (by default the workspace's generated/) "
|
|
1332
|
+
+ "and is displayed in the conversation automatically — you do not need to attach or re-send it. "
|
|
1333
|
+
+ "Just reply naturally about the result; do not say you cannot show images.",
|
|
1334
|
+
parameters: {
|
|
1335
|
+
type: "object",
|
|
1336
|
+
additionalProperties: false,
|
|
1337
|
+
properties: commonProps,
|
|
1338
|
+
required: ["prompt"],
|
|
1339
|
+
},
|
|
1340
|
+
output,
|
|
1341
|
+
async execute(args, exec) {
|
|
1342
|
+
const settings = await resolveSettings();
|
|
1343
|
+
const opts = pickOpts(args, settings);
|
|
1344
|
+
const signal = exec?.signal;
|
|
1345
|
+
|
|
1346
|
+
const endpoint = await ensureBackend(settings, signal);
|
|
1347
|
+
const raw = await txt2img(endpoint, String(args.prompt), opts, signal);
|
|
1348
|
+
touch(settings);
|
|
1349
|
+
|
|
1350
|
+
const list = Array.isArray(raw.images) ? raw.images : [];
|
|
1351
|
+
if (list.length === 0) {
|
|
1352
|
+
throw new Error("sdapi returned no images: " + JSON.stringify(raw).slice(0, 300));
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
const bytes = decodeB64(list[0]);
|
|
1356
|
+
if (bytes === null) throw new Error("sdapi returned an undecodable image payload");
|
|
1357
|
+
|
|
1358
|
+
const saved = await persist(ctx, bytes, 0, signal, settings);
|
|
1359
|
+
const info = raw.info !== undefined && typeof raw.info === "string" ? safeParse(raw.info) : undefined;
|
|
1360
|
+
return {
|
|
1361
|
+
backend: endpoint,
|
|
1362
|
+
results: [
|
|
1363
|
+
{
|
|
1364
|
+
prompt: String(args.prompt),
|
|
1365
|
+
localPath: saved.localPath,
|
|
1366
|
+
absPath: saved.absPath,
|
|
1367
|
+
attachmentRef: saved.attachmentRef,
|
|
1368
|
+
seed: info !== undefined && typeof info.seed === "number" ? info.seed : undefined,
|
|
1369
|
+
},
|
|
1370
|
+
],
|
|
1371
|
+
};
|
|
1372
|
+
},
|
|
1373
|
+
});
|
|
1374
|
+
|
|
1375
|
+
ctx.tools.register({
|
|
1376
|
+
name: "generate_image_batch",
|
|
1377
|
+
description:
|
|
1378
|
+
"Generate SEVERAL images on the LOCAL Stable Diffusion backend in one call — either N variations of one "
|
|
1379
|
+
+ "prompt (use `count`), or a list of different prompts (use `prompts`). Same unfiltered local pipeline as "
|
|
1380
|
+
+ "generate_image_local. Items run sequentially against the backend (a single local GPU cannot parallelize "
|
|
1381
|
+
+ "sampling), so expect one generation time per image. Per-item failures are reported inline instead of "
|
|
1382
|
+
+ "aborting the whole batch. The backend is started automatically if needed and the idle shutdown timer is "
|
|
1383
|
+
+ "reset by every image.",
|
|
1384
|
+
parameters: {
|
|
1385
|
+
type: "object",
|
|
1386
|
+
additionalProperties: false,
|
|
1387
|
+
properties: {
|
|
1388
|
+
prompts: {
|
|
1389
|
+
type: "array",
|
|
1390
|
+
items: { type: "string" },
|
|
1391
|
+
description: "One prompt per image. Takes precedence over `prompt` + `count` when both are given.",
|
|
1392
|
+
},
|
|
1393
|
+
prompt: {
|
|
1394
|
+
type: "string",
|
|
1395
|
+
description: "A single prompt to repeat `count` times with different random seeds.",
|
|
1396
|
+
},
|
|
1397
|
+
count: {
|
|
1398
|
+
type: "number",
|
|
1399
|
+
description: "How many variations of `prompt` to generate, 1-8. Ignored when `prompts` is given.",
|
|
1400
|
+
},
|
|
1401
|
+
negative: commonProps.negative,
|
|
1402
|
+
size: commonProps.size,
|
|
1403
|
+
steps: commonProps.steps,
|
|
1404
|
+
cfg: commonProps.cfg,
|
|
1405
|
+
sampler: commonProps.sampler,
|
|
1406
|
+
seed: commonProps.seed,
|
|
1407
|
+
lora: commonProps.lora,
|
|
1408
|
+
},
|
|
1409
|
+
required: [],
|
|
1410
|
+
},
|
|
1411
|
+
output,
|
|
1412
|
+
async execute(args, exec) {
|
|
1413
|
+
const settings = await resolveSettings();
|
|
1414
|
+
const opts = pickOpts(args, settings);
|
|
1415
|
+
const signal = exec?.signal;
|
|
1416
|
+
|
|
1417
|
+
let prompts;
|
|
1418
|
+
if (Array.isArray(args.prompts) && args.prompts.length > 0) {
|
|
1419
|
+
prompts = args.prompts.map((p) => String(p));
|
|
1420
|
+
} else if (typeof args.prompt === "string" && args.prompt.length > 0) {
|
|
1421
|
+
const n = typeof args.count === "number" && Number.isFinite(args.count)
|
|
1422
|
+
? Math.max(1, Math.min(8, Math.round(args.count)))
|
|
1423
|
+
: 1;
|
|
1424
|
+
prompts = Array.from({ length: n }, () => String(args.prompt));
|
|
1425
|
+
} else {
|
|
1426
|
+
throw new Error("provide either `prompts` or `prompt`");
|
|
1427
|
+
}
|
|
1428
|
+
if (prompts.length > 8) prompts = prompts.slice(0, 8);
|
|
1429
|
+
|
|
1430
|
+
const endpoint = await ensureBackend(settings, signal);
|
|
1431
|
+
|
|
1432
|
+
const results = [];
|
|
1433
|
+
for (let i = 0; i < prompts.length; i++) {
|
|
1434
|
+
if (signal !== undefined && signal.aborted) {
|
|
1435
|
+
results.push({ prompt: prompts[i], error: "cancelled" });
|
|
1436
|
+
continue;
|
|
1437
|
+
}
|
|
1438
|
+
try {
|
|
1439
|
+
const raw = await txt2img(endpoint, prompts[i], opts, signal);
|
|
1440
|
+
touch(settings);
|
|
1441
|
+
const list = Array.isArray(raw.images) ? raw.images : [];
|
|
1442
|
+
const bytes = list.length > 0 ? decodeB64(list[0]) : null;
|
|
1443
|
+
if (bytes === null) throw new Error("no image in sdapi response");
|
|
1444
|
+
const saved = await persist(ctx, bytes, i, signal, settings);
|
|
1445
|
+
const info = raw.info !== undefined && typeof raw.info === "string" ? safeParse(raw.info) : undefined;
|
|
1446
|
+
results.push({
|
|
1447
|
+
prompt: prompts[i],
|
|
1448
|
+
localPath: saved.localPath,
|
|
1449
|
+
absPath: saved.absPath,
|
|
1450
|
+
attachmentRef: saved.attachmentRef,
|
|
1451
|
+
seed: info !== undefined && typeof info.seed === "number" ? info.seed : undefined,
|
|
1452
|
+
});
|
|
1453
|
+
} catch (err) {
|
|
1454
|
+
results.push({
|
|
1455
|
+
prompt: prompts[i],
|
|
1456
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1457
|
+
});
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
return { backend: endpoint, results };
|
|
1462
|
+
},
|
|
1463
|
+
});
|
|
1464
|
+
|
|
1465
|
+
ctx.tools.register({
|
|
1466
|
+
name: "sd_backend_status",
|
|
1467
|
+
description:
|
|
1468
|
+
"Check whether a LOCAL Stable Diffusion backend is reachable, and report what it has loaded: "
|
|
1469
|
+
+ "active checkpoint, available checkpoints, samplers, the endpoint that answered, and whether the "
|
|
1470
|
+
+ "auto-start lifecycle currently owns a running process (plus how long until it idles out). "
|
|
1471
|
+
+ "Call this when image generation fails, before blaming the prompt, or when the user asks "
|
|
1472
|
+
+ "which model is loaded / wants to switch models.",
|
|
1473
|
+
parameters: {
|
|
1474
|
+
type: "object",
|
|
1475
|
+
additionalProperties: false,
|
|
1476
|
+
properties: {
|
|
1477
|
+
listModels: {
|
|
1478
|
+
type: "boolean",
|
|
1479
|
+
description: "Include the full checkpoint list and sampler list. Defaults to true.",
|
|
1480
|
+
},
|
|
1481
|
+
},
|
|
1482
|
+
required: [],
|
|
1483
|
+
},
|
|
1484
|
+
output: {
|
|
1485
|
+
schema: {
|
|
1486
|
+
type: "object",
|
|
1487
|
+
additionalProperties: true,
|
|
1488
|
+
properties: {
|
|
1489
|
+
reachable: { type: "boolean" },
|
|
1490
|
+
endpoint: { type: "string" },
|
|
1491
|
+
checkpoint: { type: "string" },
|
|
1492
|
+
models: { type: "array", items: { type: "string" } },
|
|
1493
|
+
samplers: { type: "array", items: { type: "string" } },
|
|
1494
|
+
ownedByAutoStart: { type: "boolean" },
|
|
1495
|
+
idleShutdownInSeconds: { type: "number" },
|
|
1496
|
+
error: { type: "string" },
|
|
1497
|
+
},
|
|
1498
|
+
},
|
|
1499
|
+
render: (_args, value) => {
|
|
1500
|
+
if (!value.reachable) {
|
|
1501
|
+
return [{ type: "text", text: "[local-sd] backend NOT reachable\n" + (value.error ?? "") }];
|
|
1502
|
+
}
|
|
1503
|
+
const lines = [
|
|
1504
|
+
"[local-sd] backend OK at " + value.endpoint,
|
|
1505
|
+
"active checkpoint: " + (value.checkpoint ?? "(unknown)"),
|
|
1506
|
+
value.ownedByAutoStart
|
|
1507
|
+
? "lifecycle: started by this plugin; auto-shutdown in " +
|
|
1508
|
+
Math.max(0, Math.round(value.idleShutdownInSeconds ?? 0)) +
|
|
1509
|
+
"s of inactivity"
|
|
1510
|
+
: "lifecycle: started outside the plugin — it will NOT be auto-shutdown",
|
|
1511
|
+
];
|
|
1512
|
+
if (Array.isArray(value.models) && value.models.length > 0) {
|
|
1513
|
+
lines.push("checkpoints (" + value.models.length + "):");
|
|
1514
|
+
value.models.slice(0, 20).forEach((m) => lines.push(" • " + m));
|
|
1515
|
+
}
|
|
1516
|
+
if (Array.isArray(value.samplers) && value.samplers.length > 0) {
|
|
1517
|
+
lines.push("samplers: " + value.samplers.join(", "));
|
|
1518
|
+
}
|
|
1519
|
+
return [{ type: "text", text: lines.join("\n") }];
|
|
1520
|
+
},
|
|
1521
|
+
},
|
|
1522
|
+
async execute(args, exec) {
|
|
1523
|
+
const settings = await resolveSettings();
|
|
1524
|
+
const signal = exec?.signal;
|
|
1525
|
+
const wantLists = args.listModels !== false;
|
|
1526
|
+
|
|
1527
|
+
const endpoint = await findLiveEndpoint(settings, signal);
|
|
1528
|
+
if (endpoint === null) {
|
|
1529
|
+
return {
|
|
1530
|
+
reachable: false,
|
|
1531
|
+
ownedByAutoStart: lifecycle.pid !== null,
|
|
1532
|
+
error:
|
|
1533
|
+
"No backend answered at " +
|
|
1534
|
+
settings.candidates.join(", ") +
|
|
1535
|
+
". autoStart.enabled=" +
|
|
1536
|
+
settings.autoStart.enabled +
|
|
1537
|
+
(settings.autoStart.enabled
|
|
1538
|
+
? ", command=" + (settings.autoStart.command || "(empty — set it in " + configPath() + ")")
|
|
1539
|
+
: ""),
|
|
1540
|
+
};
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
const get = async (path) => {
|
|
1544
|
+
const res = await fetch(endpoint + path, { method: "GET", signal });
|
|
1545
|
+
if (!res.ok) throw new Error(path + " -> " + res.status);
|
|
1546
|
+
return res.json();
|
|
1547
|
+
};
|
|
1548
|
+
|
|
1549
|
+
const value = {
|
|
1550
|
+
reachable: true,
|
|
1551
|
+
endpoint,
|
|
1552
|
+
ownedByAutoStart: lifecycle.pid !== null && lifecycle.endpoint === endpoint,
|
|
1553
|
+
idleShutdownInSeconds: Math.max(
|
|
1554
|
+
0,
|
|
1555
|
+
Math.round((settings.autoStart.idleShutdownMs - (Date.now() - lifecycle.lastUsedAt)) / 1000),
|
|
1556
|
+
),
|
|
1557
|
+
};
|
|
1558
|
+
try {
|
|
1559
|
+
const options = await get("/sdapi/v1/options");
|
|
1560
|
+
if (typeof options.sd_model_checkpoint === "string") {
|
|
1561
|
+
value.checkpoint = options.sd_model_checkpoint;
|
|
1562
|
+
}
|
|
1563
|
+
} catch {
|
|
1564
|
+
// options is optional on some backends
|
|
1565
|
+
}
|
|
1566
|
+
if (wantLists) {
|
|
1567
|
+
try {
|
|
1568
|
+
const models = await get("/sdapi/v1/sd-models");
|
|
1569
|
+
if (Array.isArray(models)) {
|
|
1570
|
+
value.models = models
|
|
1571
|
+
.map((m) => (typeof m.title === "string" ? m.title : m.model_name))
|
|
1572
|
+
.filter((s) => typeof s === "string");
|
|
1573
|
+
}
|
|
1574
|
+
} catch {
|
|
1575
|
+
// ignore
|
|
1576
|
+
}
|
|
1577
|
+
try {
|
|
1578
|
+
const samplers = await get("/sdapi/v1/samplers");
|
|
1579
|
+
if (Array.isArray(samplers)) {
|
|
1580
|
+
value.samplers = samplers.map((s) => s.name).filter((s) => typeof s === "string");
|
|
1581
|
+
}
|
|
1582
|
+
} catch {
|
|
1583
|
+
// ignore
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
return value;
|
|
1587
|
+
},
|
|
1588
|
+
});
|
|
1589
|
+
|
|
1590
|
+
ctx.tools.register({
|
|
1591
|
+
name: "sd_backend_control",
|
|
1592
|
+
description:
|
|
1593
|
+
"Start or stop the LOCAL Stable Diffusion backend on demand. "
|
|
1594
|
+
+ "action=start launches the configured launch command and waits until sdapi answers; "
|
|
1595
|
+
+ "action=stop shuts down the backend that this plugin started (a backend the user launched by hand is "
|
|
1596
|
+
+ "left alone) and frees the VRAM; action=status is a shortcut for sd_backend_status; "
|
|
1597
|
+
+ "action=detect scans the usual install locations for a Forge / A1111 folder and reports what it found "
|
|
1598
|
+
+ "(use this when the user has not configured 绘图程序目录 yet). "
|
|
1599
|
+
+ "Use this to warm the backend up before a long batch, or to free VRAM right away instead of waiting "
|
|
1600
|
+
+ "for the idle timer.",
|
|
1601
|
+
parameters: {
|
|
1602
|
+
type: "object",
|
|
1603
|
+
additionalProperties: false,
|
|
1604
|
+
properties: {
|
|
1605
|
+
action: {
|
|
1606
|
+
type: "string",
|
|
1607
|
+
enum: ["start", "stop", "status", "detect"],
|
|
1608
|
+
description: "What to do.",
|
|
1609
|
+
},
|
|
1610
|
+
},
|
|
1611
|
+
required: ["action"],
|
|
1612
|
+
},
|
|
1613
|
+
output: {
|
|
1614
|
+
schema: {
|
|
1615
|
+
type: "object",
|
|
1616
|
+
additionalProperties: true,
|
|
1617
|
+
properties: {
|
|
1618
|
+
action: { type: "string" },
|
|
1619
|
+
ok: { type: "boolean" },
|
|
1620
|
+
endpoint: { type: "string" },
|
|
1621
|
+
detail: { type: "string" },
|
|
1622
|
+
found: { type: "array", description: "action=detect: candidate installs." },
|
|
1623
|
+
},
|
|
1624
|
+
},
|
|
1625
|
+
render: (_args, value) => [
|
|
1626
|
+
{
|
|
1627
|
+
type: "text",
|
|
1628
|
+
text:
|
|
1629
|
+
"[local-sd] " +
|
|
1630
|
+
value.action +
|
|
1631
|
+
": " +
|
|
1632
|
+
(value.ok ? "OK" : "FAILED") +
|
|
1633
|
+
(value.endpoint ? " (" + value.endpoint + ")" : "") +
|
|
1634
|
+
(value.detail ? "\n" + value.detail : ""),
|
|
1635
|
+
},
|
|
1636
|
+
],
|
|
1637
|
+
},
|
|
1638
|
+
async execute(args, exec) {
|
|
1639
|
+
const settings = await resolveSettings();
|
|
1640
|
+
const signal = exec?.signal;
|
|
1641
|
+
const action = String(args.action);
|
|
1642
|
+
|
|
1643
|
+
if (action === "start") {
|
|
1644
|
+
try {
|
|
1645
|
+
const endpoint = await ensureBackend(settings, signal);
|
|
1646
|
+
return { action, ok: true, endpoint, detail: "backend answering; idle shutdown armed" };
|
|
1647
|
+
} catch (err) {
|
|
1648
|
+
return { action, ok: false, detail: err instanceof Error ? err.message : String(err) };
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
if (action === "stop") {
|
|
1653
|
+
const live = await findLiveEndpoint(settings, signal);
|
|
1654
|
+
if (live === null) {
|
|
1655
|
+
stopOwnedBackend();
|
|
1656
|
+
return { action, ok: true, detail: "nothing was running" };
|
|
1657
|
+
}
|
|
1658
|
+
const owned = lifecycle.pid !== null && lifecycle.endpoint === live;
|
|
1659
|
+
if (!owned) {
|
|
1660
|
+
return {
|
|
1661
|
+
action,
|
|
1662
|
+
ok: false,
|
|
1663
|
+
endpoint: live,
|
|
1664
|
+
detail:
|
|
1665
|
+
"a backend is running at " +
|
|
1666
|
+
live +
|
|
1667
|
+
" but it was not started by this plugin, so it is left alone — close it by hand if you want the VRAM back",
|
|
1668
|
+
};
|
|
1669
|
+
}
|
|
1670
|
+
stopOwnedBackend();
|
|
1671
|
+
return { action, ok: true, endpoint: live, detail: "process tree terminated; VRAM released" };
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
if (action === "detect") {
|
|
1675
|
+
const found = await detectForgeDirs();
|
|
1676
|
+
return {
|
|
1677
|
+
action,
|
|
1678
|
+
ok: found.length > 0,
|
|
1679
|
+
detail:
|
|
1680
|
+
found.length > 0
|
|
1681
|
+
? "found " +
|
|
1682
|
+
found.length +
|
|
1683
|
+
" candidate install(s); set 绘图程序目录 in 设置 → 本地绘图 to one of them"
|
|
1684
|
+
: "no install found in the usual locations — set 绘图程序目录 by hand in 设置 → 本地绘图",
|
|
1685
|
+
found,
|
|
1686
|
+
};
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
const live = await findLiveEndpoint(settings, signal);
|
|
1690
|
+
return {
|
|
1691
|
+
action,
|
|
1692
|
+
ok: live !== null,
|
|
1693
|
+
endpoint: live ?? undefined,
|
|
1694
|
+
detail: live !== null ? "backend answering" : "no backend answering",
|
|
1695
|
+
};
|
|
1696
|
+
},
|
|
1697
|
+
});
|
|
1698
|
+
|
|
1699
|
+
/* ---------------------------------------------------------------- */
|
|
1700
|
+
/* Settings page API */
|
|
1701
|
+
/* ---------------------------------------------------------------- */
|
|
1702
|
+
|
|
1703
|
+
// The browser half talks to this over a same-origin prefix route. Every
|
|
1704
|
+
// mutating call lands in $DSH_HOME/local-sd.json through writeConfig, so
|
|
1705
|
+
// the file stays the single source of truth and a hand edit still works.
|
|
1706
|
+
if (ctx.webServer !== undefined && typeof ctx.webServer.register === "function") {
|
|
1707
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1708
|
+
kind: "prefix",
|
|
1709
|
+
path: SETTINGS_API,
|
|
1710
|
+
handler: async (req, res) => {
|
|
1711
|
+
// Same fence the shipped web routes use: reject a foreign
|
|
1712
|
+
// Host/Origin before touching the filesystem.
|
|
1713
|
+
const rejection = requestRejection(ctx, req);
|
|
1714
|
+
if (rejection !== undefined) {
|
|
1715
|
+
writeJson(res, rejection, { ok: false, error: "forbidden" });
|
|
1716
|
+
return;
|
|
1717
|
+
}
|
|
1718
|
+
try {
|
|
1719
|
+
await handleSettingsApi(ctx, req, res);
|
|
1720
|
+
} catch (err) {
|
|
1721
|
+
console.error("[dsh-local-sd] settings API error:", err);
|
|
1722
|
+
writeJson(res, 500, { ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
1723
|
+
}
|
|
1724
|
+
},
|
|
1725
|
+
}), "dsh-local-sd: settings API routes");
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
/* ------------------------------------------------------------------ */
|
|
1730
|
+
/* Settings API helpers */
|
|
1731
|
+
/* ------------------------------------------------------------------ */
|
|
1732
|
+
|
|
1733
|
+
const SETTINGS_API = "/local-sd/api";
|
|
1734
|
+
const MAX_BODY_BYTES = 1 << 20;
|
|
1735
|
+
|
|
1736
|
+
/** Delegate the browser-authentication / Host fence to the host service. */
|
|
1737
|
+
function requestRejection(ctx, req) {
|
|
1738
|
+
const connection = ctx.connection ?? ctx.get?.("connection");
|
|
1739
|
+
if (connection !== undefined && typeof connection.requestRejection === "function") {
|
|
1740
|
+
return connection.requestRejection(req);
|
|
1741
|
+
}
|
|
1742
|
+
// No fence service (older or headless composition): the route is
|
|
1743
|
+
// loopback-only by construction, so admit the request.
|
|
1744
|
+
return undefined;
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
function readJsonBody(req) {
|
|
1748
|
+
return new Promise((resolve) => {
|
|
1749
|
+
const chunks = [];
|
|
1750
|
+
let size = 0;
|
|
1751
|
+
let aborted = false;
|
|
1752
|
+
req.on("data", (chunk) => {
|
|
1753
|
+
size += chunk.length;
|
|
1754
|
+
if (size > MAX_BODY_BYTES && !aborted) {
|
|
1755
|
+
aborted = true;
|
|
1756
|
+
req.destroy();
|
|
1757
|
+
resolve(null);
|
|
1758
|
+
return;
|
|
1759
|
+
}
|
|
1760
|
+
if (!aborted) chunks.push(chunk);
|
|
1761
|
+
});
|
|
1762
|
+
req.on("end", () => {
|
|
1763
|
+
if (aborted) return;
|
|
1764
|
+
try {
|
|
1765
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
|
|
1766
|
+
} catch {
|
|
1767
|
+
resolve(null);
|
|
1768
|
+
}
|
|
1769
|
+
});
|
|
1770
|
+
req.on("error", () => {
|
|
1771
|
+
if (!aborted) resolve(null);
|
|
1772
|
+
});
|
|
1773
|
+
});
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
function writeJson(res, status, value) {
|
|
1777
|
+
res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
|
|
1778
|
+
res.end(JSON.stringify(value));
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
/** The state the settings page renders. Never leaks anything secret — there is none. */
|
|
1782
|
+
async function settingsSnapshot(ctx) {
|
|
1783
|
+
const settings = await resolveSettings();
|
|
1784
|
+
const live = await findLiveEndpoint(settings, undefined);
|
|
1785
|
+
const launcher = settings.forgeDir.length > 0 ? await resolveLauncher(settings.forgeDir) : null;
|
|
1786
|
+
return {
|
|
1787
|
+
forgeDir: settings.forgeDir,
|
|
1788
|
+
outputDir: settings.outputDir,
|
|
1789
|
+
// What an empty outputDir means in practice, so the page can show the
|
|
1790
|
+
// real destination instead of making the user guess.
|
|
1791
|
+
defaultOutputDir: resolveOutputLocation({ outputDir: "" }, ctx).dir,
|
|
1792
|
+
// The folder that will actually be used right now (configured or default).
|
|
1793
|
+
effectiveOutputDir: resolveOutputLocation(settings, ctx).dir,
|
|
1794
|
+
endpoint: settings.endpoint,
|
|
1795
|
+
candidates: settings.candidates,
|
|
1796
|
+
liveEndpoint: live,
|
|
1797
|
+
launcher: launcher !== null && launcher.command.length > 0 ? launcher : null,
|
|
1798
|
+
autoStart: {
|
|
1799
|
+
enabled: settings.autoStart.enabled,
|
|
1800
|
+
command: settings.autoStart.command,
|
|
1801
|
+
workdir: settings.autoStart.workdir,
|
|
1802
|
+
startupTimeoutMs: settings.autoStart.startupTimeoutMs,
|
|
1803
|
+
idleShutdownMs: settings.autoStart.idleShutdownMs,
|
|
1804
|
+
},
|
|
1805
|
+
defaults: {
|
|
1806
|
+
steps: settings.steps,
|
|
1807
|
+
cfg: settings.cfg,
|
|
1808
|
+
sampler: settings.sampler,
|
|
1809
|
+
width: settings.width,
|
|
1810
|
+
height: settings.height,
|
|
1811
|
+
negative: settings.negative,
|
|
1812
|
+
},
|
|
1813
|
+
owned: lifecycle.pid !== null,
|
|
1814
|
+
configPath: configPath(),
|
|
1815
|
+
platform: process.platform,
|
|
1816
|
+
};
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
/**
|
|
1820
|
+
* One POST endpoint, `{ method, ...args }`. Keeping it a single route avoids
|
|
1821
|
+
* a router and matches how the shipped plugin APIs are shaped.
|
|
1822
|
+
*/
|
|
1823
|
+
async function handleSettingsApi(ctx, req, res) {
|
|
1824
|
+
if (req.method !== "POST") {
|
|
1825
|
+
writeJson(res, 405, { ok: false, error: "POST only" });
|
|
1826
|
+
return;
|
|
1827
|
+
}
|
|
1828
|
+
const body = await readJsonBody(req);
|
|
1829
|
+
if (body === null || typeof body !== "object") {
|
|
1830
|
+
writeJson(res, 400, { ok: false, error: "invalid JSON body" });
|
|
1831
|
+
return;
|
|
1832
|
+
}
|
|
1833
|
+
const method = String(body.method ?? "");
|
|
1834
|
+
|
|
1835
|
+
if (method === "get") {
|
|
1836
|
+
writeJson(res, 200, { ok: true, state: await settingsSnapshot(ctx) });
|
|
1837
|
+
return;
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
if (method === "detect") {
|
|
1841
|
+
writeJson(res, 200, { ok: true, found: await detectForgeDirs() });
|
|
1842
|
+
return;
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
// Hardware probe is its own method: nvidia-smi + a PowerShell registry walk
|
|
1846
|
+
// take a second or two, and the page should render before that lands.
|
|
1847
|
+
if (method === "hardware") {
|
|
1848
|
+
const probe = await detectGpu();
|
|
1849
|
+
writeJson(res, 200, {
|
|
1850
|
+
ok: true,
|
|
1851
|
+
hardware: {
|
|
1852
|
+
gpu: probe.gpu,
|
|
1853
|
+
recommend: recommendFor(probe.gpu !== null ? probe.gpu.vramMb : 0),
|
|
1854
|
+
// Every source that was tried, so the page can show why a probe
|
|
1855
|
+
// failed instead of a bare "not detected".
|
|
1856
|
+
tried: probe.tried,
|
|
1857
|
+
// Bumped by hand. If the page shows an older value than the
|
|
1858
|
+
// installed package, the HOST is still running pre-reload code —
|
|
1859
|
+
// a restart is needed, and that is worth saying out loud
|
|
1860
|
+
// because a stale host looks exactly like a broken feature.
|
|
1861
|
+
build: PLUGIN_BUILD,
|
|
1862
|
+
},
|
|
1863
|
+
});
|
|
1864
|
+
return;
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
// Directory browsing. The host runs as a CHILD of the Electron main process
|
|
1868
|
+
// (DSH NEXT.exe --expose-internals host.js), so `import("electron")` there
|
|
1869
|
+
// has no `dialog` — showOpenDialog is main-process only and this plugin
|
|
1870
|
+
// cannot reach it. So the picker is served as data instead: `browse` lists
|
|
1871
|
+
// one directory, the page draws its own browser. That works everywhere,
|
|
1872
|
+
// including `dsh web` in a plain browser.
|
|
1873
|
+
if (method === "browse") {
|
|
1874
|
+
const target = typeof body.path === "string" ? body.path : "";
|
|
1875
|
+
const listing = await listDirectory(target);
|
|
1876
|
+
writeJson(res, 200, { ok: true, listing });
|
|
1877
|
+
return;
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
if (method === "save") {
|
|
1881
|
+
const patch = {};
|
|
1882
|
+
if (typeof body.forgeDir === "string") patch.forgeDir = body.forgeDir.trim();
|
|
1883
|
+
// "" is a meaningful value here: it means "back to <workspace>/generated",
|
|
1884
|
+
// so an empty string must be written rather than skipped.
|
|
1885
|
+
if (typeof body.outputDir === "string") patch.outputDir = body.outputDir.trim();
|
|
1886
|
+
if (typeof body.endpoint === "string" && body.endpoint.trim().length > 0) {
|
|
1887
|
+
patch.endpoint = body.endpoint.trim().replace(/\/+$/, "");
|
|
1888
|
+
}
|
|
1889
|
+
if (body.autoStart !== null && typeof body.autoStart === "object") {
|
|
1890
|
+
const a = {};
|
|
1891
|
+
if (typeof body.autoStart.enabled === "boolean") a.enabled = body.autoStart.enabled;
|
|
1892
|
+
if (typeof body.autoStart.startupTimeoutMs === "number") {
|
|
1893
|
+
a.startupTimeoutMs = Math.max(30_000, Math.round(body.autoStart.startupTimeoutMs));
|
|
1894
|
+
}
|
|
1895
|
+
if (typeof body.autoStart.idleShutdownMs === "number") {
|
|
1896
|
+
a.idleShutdownMs = Math.max(0, Math.round(body.autoStart.idleShutdownMs));
|
|
1897
|
+
}
|
|
1898
|
+
// Clearing the folder must also clear the derived command, or the
|
|
1899
|
+
// stale one keeps launching the old install.
|
|
1900
|
+
if (patch.forgeDir !== undefined && patch.forgeDir.length === 0) {
|
|
1901
|
+
a.command = "";
|
|
1902
|
+
a.workdir = "";
|
|
1903
|
+
}
|
|
1904
|
+
if (Object.keys(a).length > 0) patch.autoStart = a;
|
|
1905
|
+
}
|
|
1906
|
+
if (body.defaults !== null && typeof body.defaults === "object") {
|
|
1907
|
+
const d = {};
|
|
1908
|
+
for (const key of ["steps", "cfg", "width", "height"]) {
|
|
1909
|
+
if (typeof body.defaults[key] === "number" && Number.isFinite(body.defaults[key])) {
|
|
1910
|
+
d[key] = Math.round(body.defaults[key]);
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
for (const key of ["sampler", "negative"]) {
|
|
1914
|
+
if (typeof body.defaults[key] === "string" && body.defaults[key].length > 0) {
|
|
1915
|
+
d[key] = body.defaults[key];
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
if (Object.keys(d).length > 0) patch.defaults = d;
|
|
1919
|
+
}
|
|
1920
|
+
await writeConfig(patch);
|
|
1921
|
+
writeJson(res, 200, { ok: true, state: await settingsSnapshot(ctx) });
|
|
1922
|
+
return;
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
if (method === "start" || method === "stop") {
|
|
1926
|
+
const settings = await resolveSettings();
|
|
1927
|
+
if (method === "start") {
|
|
1928
|
+
try {
|
|
1929
|
+
const endpoint = await ensureBackend(settings, undefined);
|
|
1930
|
+
writeJson(res, 200, { ok: true, endpoint, state: await settingsSnapshot(ctx) });
|
|
1931
|
+
} catch (err) {
|
|
1932
|
+
writeJson(res, 200, {
|
|
1933
|
+
ok: false,
|
|
1934
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1935
|
+
state: await settingsSnapshot(ctx),
|
|
1936
|
+
});
|
|
1937
|
+
}
|
|
1938
|
+
return;
|
|
1939
|
+
}
|
|
1940
|
+
const live = await findLiveEndpoint(settings, undefined);
|
|
1941
|
+
const owned = lifecycle.pid !== null && lifecycle.endpoint === live;
|
|
1942
|
+
if (live !== null && !owned) {
|
|
1943
|
+
writeJson(res, 200, {
|
|
1944
|
+
ok: false,
|
|
1945
|
+
error: "后端是手动启动的,插件不会去关它",
|
|
1946
|
+
state: await settingsSnapshot(ctx),
|
|
1947
|
+
});
|
|
1948
|
+
return;
|
|
1949
|
+
}
|
|
1950
|
+
stopOwnedBackend();
|
|
1951
|
+
writeJson(res, 200, { ok: true, state: await settingsSnapshot(ctx) });
|
|
1952
|
+
return;
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
writeJson(res, 400, { ok: false, error: "unknown method: " + method });
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
/**
|
|
1959
|
+
* List one directory for the in-page browser.
|
|
1960
|
+
*
|
|
1961
|
+
* Deliberately narrow: only directories come back (files are what the user is
|
|
1962
|
+
* looking for the CONTAINER of, not the file itself), and only the immediate
|
|
1963
|
+
* children. `parent` is precomputed so the page never has to do path
|
|
1964
|
+
* arithmetic — that is where cross-platform code usually goes wrong.
|
|
1965
|
+
*
|
|
1966
|
+
* `target` empty means "start here": the home directory, which is where a
|
|
1967
|
+
* person actually begins looking.
|
|
1968
|
+
*/
|
|
1969
|
+
async function listDirectory(target) {
|
|
1970
|
+
const dir = typeof target === "string" && target.trim().length > 0
|
|
1971
|
+
? target.trim().replace(/^"(.*)"$/, "$1")
|
|
1972
|
+
: homedir();
|
|
1973
|
+
|
|
1974
|
+
const parent = dirname(dir);
|
|
1975
|
+
let entries;
|
|
1976
|
+
try {
|
|
1977
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
1978
|
+
} catch (err) {
|
|
1979
|
+
const code = err !== null && typeof err === "object" ? err.code : undefined;
|
|
1980
|
+
return {
|
|
1981
|
+
path: dir,
|
|
1982
|
+
parent: parent !== dir ? parent : null,
|
|
1983
|
+
entries: [],
|
|
1984
|
+
error:
|
|
1985
|
+
code === "ENOENT"
|
|
1986
|
+
? "目录不存在"
|
|
1987
|
+
: code === "EACCES" || code === "EPERM"
|
|
1988
|
+
? "没有权限读取这个目录"
|
|
1989
|
+
: code === "ENOTDIR"
|
|
1990
|
+
? "这不是一个目录"
|
|
1991
|
+
: "读取失败:" + (err instanceof Error ? err.message : String(err)),
|
|
1992
|
+
};
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
const dirs = [];
|
|
1996
|
+
for (const entry of entries) {
|
|
1997
|
+
// Symlinks are followed: a linked models/ or a junction to a big drive
|
|
1998
|
+
// is common in these installs, and `isDirectory()` alone would hide it.
|
|
1999
|
+
let isDir = entry.isDirectory();
|
|
2000
|
+
if (!isDir && entry.isSymbolicLink()) {
|
|
2001
|
+
isDir = await isDirSafe(join(dir, entry.name));
|
|
2002
|
+
}
|
|
2003
|
+
if (!isDir) continue;
|
|
2004
|
+
// Skip hidden entries on POSIX; on Windows a leading dot is not the
|
|
2005
|
+
// hidden convention, so keep everything there.
|
|
2006
|
+
if (process.platform !== "win32" && entry.name.startsWith(".")) continue;
|
|
2007
|
+
dirs.push(entry.name);
|
|
2008
|
+
}
|
|
2009
|
+
dirs.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
|
|
2010
|
+
|
|
2011
|
+
// Does this directory itself look like a WebUI install? Then the page can
|
|
2012
|
+
// say "this one" instead of making the user keep descending.
|
|
2013
|
+
const launcher = await resolveLauncher(dir);
|
|
2014
|
+
|
|
2015
|
+
return {
|
|
2016
|
+
path: dir,
|
|
2017
|
+
parent: parent !== dir ? parent : null,
|
|
2018
|
+
entries: dirs,
|
|
2019
|
+
launcher: launcher.command.length > 0 ? launcher : null,
|
|
2020
|
+
// Drive roots, so the page can offer a jump strip on Windows.
|
|
2021
|
+
roots: process.platform === "win32" ? await listDrives() : ["/"],
|
|
2022
|
+
};
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
/** Cheap "is this a directory" that never throws. */
|
|
2026
|
+
async function isDirSafe(path) {
|
|
2027
|
+
try {
|
|
2028
|
+
const st = await stat(path);
|
|
2029
|
+
return st.isDirectory();
|
|
2030
|
+
} catch {
|
|
2031
|
+
return false;
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
/** Existing drive roots on Windows (`C:\`, `D:\`, …), for the jump strip. */
|
|
2036
|
+
async function listDrives() {
|
|
2037
|
+
const drives = [];
|
|
2038
|
+
for (let i = 65; i <= 90; i++) {
|
|
2039
|
+
const letter = String.fromCharCode(i) + ":\\";
|
|
2040
|
+
if (await isDirSafe(letter)) drives.push(letter);
|
|
2041
|
+
}
|
|
2042
|
+
return drives;
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
function safeParse(s) {
|
|
2046
|
+
try {
|
|
2047
|
+
return JSON.parse(s);
|
|
2048
|
+
} catch {
|
|
2049
|
+
return undefined;
|
|
2050
|
+
}
|
|
2051
|
+
}
|