dsh-local-sd 0.5.0 → 0.6.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/README.md +12 -0
- package/lib/client.js +148 -2
- package/lib/index.js +213 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -186,6 +186,18 @@ SD 后端占用较高显存,闲置时自动关闭。
|
|
|
186
186
|
|
|
187
187
|
配置写在 `$DSH_HOME/local-sd.json`(Windows 是 `C:\Users\<你>\.dsh\local-sd.json`)。**每次调用都重新读取**,改完不用重启 DSH;手改这个 JSON 也一样有效。
|
|
188
188
|
|
|
189
|
+
### 启动进度和命令行输出
|
|
190
|
+
|
|
191
|
+
后端启动时**不再弹出黑色控制台窗口**,进度和输出都搬到设置页里:
|
|
192
|
+
|
|
193
|
+
- **进度条**:读绘图程序自己的输出(加载扩展、加载权重、起 API),大致对应当前阶段;没有新输出时会缓慢爬升,不会看起来卡死。
|
|
194
|
+
- **查看命令行输出**:展开后就是绘图程序真实打印的内容,出错时能看到完整的 traceback,不用再手动跑一遍 `run.bat` 找原因。
|
|
195
|
+
- 日志同时写在 `$DSH_HOME/local-sd-startup.log`,每次启动覆盖。
|
|
196
|
+
|
|
197
|
+
> **`--nowebui` 必须配 `--port`**:Forge 在 `--nowebui` 下如果没指定端口会默认绑 **7861**
|
|
198
|
+
> (`webui.py` 里 `port=cmd_opts.port if cmd_opts.port else 7861`),而插件默认找 7860。
|
|
199
|
+
> 想在后台只跑 API 不弹网页,启动参数要写 `--api --nowebui --port 7860`。
|
|
200
|
+
|
|
189
201
|
---
|
|
190
202
|
|
|
191
203
|
## 用法
|
package/lib/client.js
CHANGED
|
@@ -114,6 +114,28 @@ window.__ModuleLoader__.load({
|
|
|
114
114
|
.lsd-entry:disabled{opacity:.5;cursor:default}
|
|
115
115
|
.lsd-browser-foot{display:flex;flex-direction:column;gap:8px;align-items:flex-start}
|
|
116
116
|
.lsd-browser-foot .lsd-actions{padding-top:0}
|
|
117
|
+
/* ── startup progress + live log ────────────────────────────────────── */
|
|
118
|
+
.lsd-prog{display:flex;flex-direction:column;gap:7px;padding:11px 13px;border-radius:10px;
|
|
119
|
+
border:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.26));background:var(--dsw-alias-bg-base,rgba(128,128,128,.05))}
|
|
120
|
+
.lsd-prog-head{display:flex;align-items:center;gap:8px;font-size:12px}
|
|
121
|
+
.lsd-prog-phase{flex:1;min-width:0;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
122
|
+
.lsd-prog-pct{flex:none;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-secondary,rgba(128,128,128,.95))}
|
|
123
|
+
.lsd-bar{position:relative;height:7px;border-radius:4px;overflow:hidden;background:rgba(128,128,128,.22)}
|
|
124
|
+
.lsd-bar-fill{position:absolute;inset:0 auto 0 0;border-radius:4px;
|
|
125
|
+
background:var(--dsw-alias-brand-primary,#4a9eff);transition:width .45s ease}
|
|
126
|
+
.lsd-bar-fill.ok{background:var(--dsw-alias-state-success-primary,#2ea043)}
|
|
127
|
+
.lsd-bar-fill.err{background:var(--dsw-alias-state-error-primary,#f85149)}
|
|
128
|
+
/* Indeterminate: the launcher is alive but has not printed anything yet. */
|
|
129
|
+
.lsd-bar-fill.indet{width:35% !important;animation:lsd-slide 1.3s ease-in-out infinite}
|
|
130
|
+
@keyframes lsd-slide{0%{left:-35%}100%{left:100%}}
|
|
131
|
+
.lsd-log{margin:0;padding:9px 11px;border-radius:8px;max-height:260px;overflow:auto;
|
|
132
|
+
font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;line-height:1.55;
|
|
133
|
+
white-space:pre-wrap;word-break:break-word;background:var(--dsw-alias-bg-base,rgba(0,0,0,.18));
|
|
134
|
+
border:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.22));
|
|
135
|
+
color:var(--dsw-alias-label-secondary,rgba(128,128,128,.95))}
|
|
136
|
+
.lsd-log .err{color:var(--dsw-alias-state-error-primary,#f85149)}
|
|
137
|
+
.lsd-log .info{color:var(--dsw-alias-brand-primary,#4a9eff)}
|
|
138
|
+
.lsd-log-empty{font-size:11px;color:var(--dsw-alias-label-secondary,rgba(128,128,128,.8))}
|
|
117
139
|
/* ── hardware readout ───────────────────────────────────────────────── */
|
|
118
140
|
.lsd-hw{display:flex;flex-direction:column;gap:5px;padding:10px 12px;border-radius:10px;
|
|
119
141
|
border:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.24));background:var(--dsw-alias-bg-base,rgba(128,128,128,.05))}
|
|
@@ -148,6 +170,86 @@ window.__ModuleLoader__.load({
|
|
|
148
170
|
return Math.round(ms / 60000) + " 分钟";
|
|
149
171
|
}
|
|
150
172
|
|
|
173
|
+
function fmtElapsed(ms) {
|
|
174
|
+
if (typeof ms !== "number" || !isFinite(ms) || ms <= 0) return "0s";
|
|
175
|
+
var s = Math.round(ms / 1000);
|
|
176
|
+
if (s < 60) return s + "s";
|
|
177
|
+
return Math.floor(s / 60) + "m " + (s % 60) + "s";
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Startup progress + the launcher's real console output.
|
|
182
|
+
*
|
|
183
|
+
* The whole point is that the user never has to look at a console
|
|
184
|
+
* window: the bar says roughly where the boot is, and the log can be
|
|
185
|
+
* expanded to see exactly what the launcher printed — including the
|
|
186
|
+
* error when it dies, which used to require re-running it by hand.
|
|
187
|
+
*/
|
|
188
|
+
function StartupPanel(props) {
|
|
189
|
+
var st = props.startup;
|
|
190
|
+
var openS = React.useState(false);
|
|
191
|
+
var open = openS[0];
|
|
192
|
+
var setOpen = openS[1];
|
|
193
|
+
var tailS = React.useRef(null);
|
|
194
|
+
|
|
195
|
+
// Keep the newest line in view while it is streaming.
|
|
196
|
+
React.useEffect(function() {
|
|
197
|
+
if (open && tailS.current) tailS.current.scrollTop = tailS.current.scrollHeight;
|
|
198
|
+
}, [open, st && st.lines ? st.lines.length : 0]);
|
|
199
|
+
|
|
200
|
+
if (!st) return null;
|
|
201
|
+
var starting = st.state === "starting";
|
|
202
|
+
var failed = st.state === "failed";
|
|
203
|
+
var ready = st.state === "ready";
|
|
204
|
+
if (st.state === "idle" && (!st.lines || st.lines.length === 0)) return null;
|
|
205
|
+
|
|
206
|
+
var pct = Math.round((st.progress || 0) * 100);
|
|
207
|
+
var fillClass = "lsd-bar-fill" + (ready ? " ok" : failed ? " err" : "");
|
|
208
|
+
// No output yet: show motion rather than a bar pinned at 2%.
|
|
209
|
+
var indet = starting && (!st.lines || st.lines.length === 0);
|
|
210
|
+
var label = failed ? "启动失败" : ready ? "已就绪" : st.state === "stopped" ? "已关闭" : (st.phase || "启动中");
|
|
211
|
+
|
|
212
|
+
return h("div", { className: "lsd-prog" },
|
|
213
|
+
h("div", { className: "lsd-prog-head" },
|
|
214
|
+
h("span", { className: "lsd-prog-phase" }, label),
|
|
215
|
+
st.elapsedMs > 0
|
|
216
|
+
? h("span", { className: "lsd-prog-pct" }, fmtElapsed(st.elapsedMs))
|
|
217
|
+
: null,
|
|
218
|
+
starting ? h("span", { className: "lsd-prog-pct" }, pct + "%") : null
|
|
219
|
+
),
|
|
220
|
+
h("div", { className: "lsd-bar" },
|
|
221
|
+
h("div", {
|
|
222
|
+
className: fillClass + (indet ? " indet" : ""),
|
|
223
|
+
style: indet ? undefined : { width: (ready ? 100 : pct) + "%" }
|
|
224
|
+
})
|
|
225
|
+
),
|
|
226
|
+
failed && st.error
|
|
227
|
+
? h("div", { className: "lsd-msg err" }, st.error)
|
|
228
|
+
: null,
|
|
229
|
+
h("div", { className: "lsd-actions" },
|
|
230
|
+
h("button", {
|
|
231
|
+
className: "lsd-btn", disabled: props.busy,
|
|
232
|
+
onClick: function() { setOpen(!open); }
|
|
233
|
+
}, open ? "收起命令行输出" : "查看命令行输出"),
|
|
234
|
+
st.logFile
|
|
235
|
+
? h("span", { className: "lsd-footer" }, "日志:" + st.logFile)
|
|
236
|
+
: null
|
|
237
|
+
),
|
|
238
|
+
open
|
|
239
|
+
? (st.lines && st.lines.length > 0
|
|
240
|
+
? h("pre", { className: "lsd-log", ref: tailS },
|
|
241
|
+
st.lines.map(function(line, i) {
|
|
242
|
+
return h("div", {
|
|
243
|
+
key: i,
|
|
244
|
+
className: line.stream === "err" ? "err" : line.stream === "info" ? "info" : ""
|
|
245
|
+
}, line.text);
|
|
246
|
+
})
|
|
247
|
+
)
|
|
248
|
+
: h("div", { className: "lsd-log-empty" }, "还没有输出。绘图程序刚启动,稍等一下…"))
|
|
249
|
+
: null
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
151
253
|
/* ── the settings page ──────────────────────────────────────── */
|
|
152
254
|
|
|
153
255
|
function SettingsPage() {
|
|
@@ -220,6 +322,10 @@ window.__ModuleLoader__.load({
|
|
|
220
322
|
var hwPhaseS = React.useState("idle");
|
|
221
323
|
var hwPhase = hwPhaseS[0];
|
|
222
324
|
var setHwPhase = hwPhaseS[1];
|
|
325
|
+
// Startup progress + launcher output, polled while a boot runs.
|
|
326
|
+
var startupS = React.useState(null);
|
|
327
|
+
var startupState = startupS[0];
|
|
328
|
+
var setStartupState = startupS[1];
|
|
223
329
|
|
|
224
330
|
function adopt(s) {
|
|
225
331
|
setState(s);
|
|
@@ -243,6 +349,7 @@ window.__ModuleLoader__.load({
|
|
|
243
349
|
if (cancelled) return;
|
|
244
350
|
if (res && res.ok) {
|
|
245
351
|
adopt(res.state);
|
|
352
|
+
if (res.state && res.state.startup) setStartupState(res.state.startup);
|
|
246
353
|
setPhase("ready");
|
|
247
354
|
} else {
|
|
248
355
|
setPhase("error");
|
|
@@ -252,6 +359,30 @@ window.__ModuleLoader__.load({
|
|
|
252
359
|
return function() { cancelled = true; };
|
|
253
360
|
}, []);
|
|
254
361
|
|
|
362
|
+
// Poll the startup state once a second. Only while something is
|
|
363
|
+
// actually happening: a settled page should not keep a timer alive.
|
|
364
|
+
React.useEffect(function() {
|
|
365
|
+
var cancelled = false;
|
|
366
|
+
var timer = null;
|
|
367
|
+
var tick = function() {
|
|
368
|
+
callApi({ method: "startup" }).then(function(res) {
|
|
369
|
+
if (cancelled) return;
|
|
370
|
+
if (res && res.ok && res.startup) {
|
|
371
|
+
setStartupState(res.startup);
|
|
372
|
+
var live = res.startup.state === "starting";
|
|
373
|
+
timer = setTimeout(tick, live ? 1000 : 4000);
|
|
374
|
+
} else {
|
|
375
|
+
timer = setTimeout(tick, 8000);
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
};
|
|
379
|
+
timer = setTimeout(tick, 1200);
|
|
380
|
+
return function() {
|
|
381
|
+
cancelled = true;
|
|
382
|
+
if (timer !== null) clearTimeout(timer);
|
|
383
|
+
};
|
|
384
|
+
}, []);
|
|
385
|
+
|
|
255
386
|
/** Probe the GPU. Slow (nvidia-smi + registry walk), so it runs
|
|
256
387
|
* after the page is already on screen. */
|
|
257
388
|
function loadHardware() {
|
|
@@ -389,9 +520,21 @@ window.__ModuleLoader__.load({
|
|
|
389
520
|
function backend(action) {
|
|
390
521
|
setBusy(true);
|
|
391
522
|
setMsg(null);
|
|
523
|
+
// A cold boot takes minutes. Show the panel immediately with a
|
|
524
|
+
// pending state instead of leaving the page looking frozen while
|
|
525
|
+
// the request is in flight — the poll picks up real progress.
|
|
526
|
+
if (action === "start") {
|
|
527
|
+
setStartupState({
|
|
528
|
+
state: "starting", phase: "启动绘图程序", progress: 0.02,
|
|
529
|
+
startedAt: Date.now(), endedAt: 0, error: "", lines: [], elapsedMs: 0
|
|
530
|
+
});
|
|
531
|
+
}
|
|
392
532
|
callApi({ method: action }).then(function(res) {
|
|
393
533
|
setBusy(false);
|
|
394
|
-
if (res && res.state)
|
|
534
|
+
if (res && res.state) {
|
|
535
|
+
adopt(res.state);
|
|
536
|
+
if (res.state.startup) setStartupState(res.state.startup);
|
|
537
|
+
}
|
|
395
538
|
if (res && res.ok) {
|
|
396
539
|
setMsg({ kind: "ok", text: action === "start" ? "后端已就绪" : "后端已关闭,显存释放" });
|
|
397
540
|
} else {
|
|
@@ -456,7 +599,10 @@ window.__ModuleLoader__.load({
|
|
|
456
599
|
className: "lsd-btn", disabled: busy,
|
|
457
600
|
onClick: function() { callApi({ method: "get" }).then(function(r) { if (r && r.ok) adopt(r.state); }); }
|
|
458
601
|
}, "刷新")
|
|
459
|
-
)
|
|
602
|
+
),
|
|
603
|
+
// Progress + the launcher's real output, so the console
|
|
604
|
+
// window never has to be looked at (or clicked into).
|
|
605
|
+
h(StartupPanel, { startup: startupState, busy: busy })
|
|
460
606
|
),
|
|
461
607
|
|
|
462
608
|
// ── directories ──────────────────────────────────────
|
package/lib/index.js
CHANGED
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
|
|
51
51
|
import { homedir, tmpdir } from "node:os";
|
|
52
52
|
import { mkdir, writeFile, readFile, readdir, stat } from "node:fs/promises";
|
|
53
|
-
import { openSync, closeSync, readFileSync, unlinkSync } from "node:fs";
|
|
53
|
+
import { openSync, closeSync, readFileSync, unlinkSync, createWriteStream, existsSync, statSync } from "node:fs";
|
|
54
54
|
import { spawn } from "node:child_process";
|
|
55
55
|
import { join, dirname, basename, relative, isAbsolute, resolve as resolvePath } from "node:path";
|
|
56
56
|
|
|
@@ -82,7 +82,7 @@ export const internals = { recommendFor, detectGpu, resolveLauncher, detectForge
|
|
|
82
82
|
* indistinguishable from a broken feature. Showing this value in the page makes
|
|
83
83
|
* "you need to restart DSH" a visible fact instead of a guess.
|
|
84
84
|
*/
|
|
85
|
-
const PLUGIN_BUILD = "0.
|
|
85
|
+
const PLUGIN_BUILD = "0.6.0";
|
|
86
86
|
|
|
87
87
|
/** Forge and A1111 both listen here. */
|
|
88
88
|
const DEFAULT_ENDPOINT = "http://127.0.0.1:7860";
|
|
@@ -714,8 +714,94 @@ const lifecycle = {
|
|
|
714
714
|
lastUsedAt: 0,
|
|
715
715
|
idleTimer: null,
|
|
716
716
|
starting: null, // Promise while a spawn+wait is in flight
|
|
717
|
+
// Startup log + progress, so the settings page can show what the launcher
|
|
718
|
+
// is actually doing instead of a bare "starting…". Kept in memory: it is
|
|
719
|
+
// diagnostic for the current attempt, not a durable log.
|
|
720
|
+
startup: {
|
|
721
|
+
state: "idle", // idle | starting | ready | failed | stopped
|
|
722
|
+
startedAt: 0,
|
|
723
|
+
endedAt: 0,
|
|
724
|
+
progress: 0, // 0..1
|
|
725
|
+
phase: "", // human-readable current step
|
|
726
|
+
lines: [], // ring buffer of {t, stream, text}
|
|
727
|
+
error: "",
|
|
728
|
+
logFile: "", // path of the captured log on disk
|
|
729
|
+
},
|
|
717
730
|
};
|
|
718
731
|
|
|
732
|
+
/** How many launcher lines to keep in memory for the settings panel. */
|
|
733
|
+
const STARTUP_LOG_LIMIT = 400;
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Append one launcher output line to the startup ring buffer.
|
|
737
|
+
* @param stream - "out" | "err" | "info"
|
|
738
|
+
*/
|
|
739
|
+
function pushStartupLine(stream, text) {
|
|
740
|
+
const entry = { t: Date.now(), stream, text: String(text).replace(/\r$/, "") };
|
|
741
|
+
if (entry.text.length === 0) return;
|
|
742
|
+
const lines = lifecycle.startup.lines;
|
|
743
|
+
lines.push(entry);
|
|
744
|
+
if (lines.length > STARTUP_LOG_LIMIT) lines.splice(0, lines.length - STARTUP_LOG_LIMIT);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/** Move the startup state machine forward. */
|
|
748
|
+
function setStartupState(state, phase, progress, error) {
|
|
749
|
+
const s = lifecycle.startup;
|
|
750
|
+
s.state = state;
|
|
751
|
+
if (typeof phase === "string") s.phase = phase;
|
|
752
|
+
if (typeof progress === "number") s.progress = Math.max(0, Math.min(1, progress));
|
|
753
|
+
if (typeof error === "string") s.error = error;
|
|
754
|
+
if (state === "starting" && s.startedAt === 0) s.startedAt = Date.now();
|
|
755
|
+
if (state === "ready" || state === "failed" || state === "stopped") s.endedAt = Date.now();
|
|
756
|
+
if (state === "idle") {
|
|
757
|
+
s.startedAt = 0;
|
|
758
|
+
s.endedAt = 0;
|
|
759
|
+
s.progress = 0;
|
|
760
|
+
s.phase = "";
|
|
761
|
+
s.error = "";
|
|
762
|
+
s.lines = [];
|
|
763
|
+
s.logFile = "";
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* Guess how far along a cold Forge boot is from its own output.
|
|
769
|
+
*
|
|
770
|
+
* Forge prints a fairly stable sequence: python import → loading extensions →
|
|
771
|
+
* loading the checkpoint → starting the API. Matching those markers gives a
|
|
772
|
+
* real progress bar instead of a spinner that means nothing. The weights are
|
|
773
|
+
* deliberately coarse: the point is "roughly where are we", not a measurement.
|
|
774
|
+
*/
|
|
775
|
+
const STARTUP_MARKERS = [
|
|
776
|
+
{ re: /Python\s+3\.\d+/i, progress: 0.08, phase: "启动 Python" },
|
|
777
|
+
{ re: /Launching Web UI|Running on local URL/i, progress: 0.9, phase: "启动 Web 服务" },
|
|
778
|
+
{ re: /Startup time:|Model loaded in|Loaded a model/i, progress: 0.95, phase: "模型就绪" },
|
|
779
|
+
{ re: /Loading extensions|Installing|extensions/i, progress: 0.25, phase: "加载扩展" },
|
|
780
|
+
{ re: /Loading (?:stable-diffusion|weights)|Loading checkpoint/i, progress: 0.55, phase: "加载模型权重" },
|
|
781
|
+
{ re: /Applying (?:attention|xformers)|xformers/i, progress: 0.4, phase: "优化注意力" },
|
|
782
|
+
{ re: /VAE|vae/i, progress: 0.7, phase: "加载 VAE" },
|
|
783
|
+
{ re: /API|api/i, progress: 0.85, phase: "启动 API" },
|
|
784
|
+
{ re: /error|traceback|exception|failed/i, progress: null, phase: "出错" },
|
|
785
|
+
];
|
|
786
|
+
|
|
787
|
+
/** Advance progress from one launcher line, never moving backwards. */
|
|
788
|
+
function noteStartupLine(text) {
|
|
789
|
+
const s = lifecycle.startup;
|
|
790
|
+
for (const marker of STARTUP_MARKERS) {
|
|
791
|
+
if (!marker.re.test(text)) continue;
|
|
792
|
+
if (marker.progress === null) {
|
|
793
|
+
s.phase = marker.phase;
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
if (marker.progress > s.progress) {
|
|
797
|
+
s.progress = marker.progress;
|
|
798
|
+
s.phase = marker.phase;
|
|
799
|
+
}
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
|
|
719
805
|
/** Is this endpoint a live sdapi backend? */
|
|
720
806
|
async function probe(endpoint, signal) {
|
|
721
807
|
try {
|
|
@@ -751,13 +837,20 @@ function stopOwnedBackend() {
|
|
|
751
837
|
lifecycle.pid = null;
|
|
752
838
|
lifecycle.endpoint = null;
|
|
753
839
|
if (pid === null) return false;
|
|
840
|
+
// Reflect the shutdown in the panel: a bar frozen at 60% with no process
|
|
841
|
+
// behind it is worse than no bar at all.
|
|
842
|
+
if (lifecycle.startup.state === "starting" || lifecycle.startup.state === "ready") {
|
|
843
|
+
setStartupState("stopped", "已关闭", 0);
|
|
844
|
+
pushStartupLine("info", "后端已关闭,显存已释放");
|
|
845
|
+
}
|
|
754
846
|
try {
|
|
755
847
|
if (process.platform === "win32") {
|
|
756
848
|
// Forge's run.bat spawns python.exe; /T takes the whole tree.
|
|
849
|
+
// No `detached`: on Windows that creates a console for taskkill
|
|
850
|
+
// itself, which is another black window flashing on screen.
|
|
757
851
|
spawn("taskkill", ["/PID", String(pid), "/T", "/F"], {
|
|
758
852
|
stdio: "ignore",
|
|
759
853
|
windowsHide: true,
|
|
760
|
-
detached: true,
|
|
761
854
|
}).unref();
|
|
762
855
|
} else {
|
|
763
856
|
try {
|
|
@@ -876,19 +969,69 @@ async function spawnBackend(settings, signal) {
|
|
|
876
969
|
windowsVerbatimArguments = true;
|
|
877
970
|
}
|
|
878
971
|
|
|
972
|
+
// Where the launcher's own stdout/stderr go. A pipe is not an option: the
|
|
973
|
+
// sandbox forbids the parent from reading a child's pipe (EPERM), which is
|
|
974
|
+
// why runCapture above already writes to a temp file. A real file is both
|
|
975
|
+
// allowed and better here — the settings panel can re-read it any time.
|
|
976
|
+
const logFile = join(dshHome(), "local-sd-startup.log");
|
|
977
|
+
let logFd = null;
|
|
978
|
+
try {
|
|
979
|
+
await mkdir(dirname(logFile), { recursive: true });
|
|
980
|
+
// Truncate: this log describes one launch attempt, and a stale tail from
|
|
981
|
+
// the previous boot would make the progress markers lie.
|
|
982
|
+
logFd = openSync(logFile, "w");
|
|
983
|
+
} catch {
|
|
984
|
+
logFd = null; // Read-only home: still start, just without a log.
|
|
985
|
+
}
|
|
986
|
+
lifecycle.startup.logFile = logFile;
|
|
987
|
+
|
|
879
988
|
const child = spawn(spawnCommand, spawnArgs, {
|
|
880
989
|
cwd: spawnCwd,
|
|
881
|
-
detached
|
|
882
|
-
|
|
990
|
+
// detached is needed on POSIX to kill the whole process group, but on
|
|
991
|
+
// Windows DETACHED_PROCESS gives the launcher its own console — that is
|
|
992
|
+
// the black window the user sees. windowsHide alone does not win against
|
|
993
|
+
// it, so Windows stays attached and relies on `taskkill /T` instead.
|
|
994
|
+
detached: process.platform !== "win32",
|
|
995
|
+
stdio: logFd === null ? "ignore" : ["ignore", logFd, logFd],
|
|
883
996
|
windowsHide: true,
|
|
884
997
|
windowsVerbatimArguments,
|
|
885
998
|
env: sanitizedEnv(),
|
|
886
999
|
});
|
|
887
1000
|
child.unref();
|
|
1001
|
+
if (logFd !== null) {
|
|
1002
|
+
// The child holds its own duplicate; ours is only needed to create it.
|
|
1003
|
+
try { closeSync(logFd); } catch { /* already closed */ }
|
|
1004
|
+
}
|
|
888
1005
|
|
|
889
1006
|
lifecycle.child = child;
|
|
890
1007
|
lifecycle.pid = child.pid ?? null;
|
|
891
1008
|
|
|
1009
|
+
setStartupState("starting", "启动绘图程序", 0.02);
|
|
1010
|
+
pushStartupLine("info", "$ " + command);
|
|
1011
|
+
if (logFile.length > 0) pushStartupLine("info", "日志文件:" + logFile);
|
|
1012
|
+
|
|
1013
|
+
// Tail the log into the in-memory ring buffer so the panel can show real
|
|
1014
|
+
// output without re-reading the file on every poll.
|
|
1015
|
+
let logOffset = 0;
|
|
1016
|
+
const pumpLog = () => {
|
|
1017
|
+
if (logFile.length === 0) return;
|
|
1018
|
+
let text;
|
|
1019
|
+
try {
|
|
1020
|
+
const size = statSync(logFile).size;
|
|
1021
|
+
if (size <= logOffset) return;
|
|
1022
|
+
const buf = readFileSync(logFile);
|
|
1023
|
+
text = buf.subarray(logOffset).toString("utf8");
|
|
1024
|
+
logOffset = buf.length;
|
|
1025
|
+
} catch {
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
for (const line of text.split(/\r?\n/)) {
|
|
1029
|
+
if (line.trim().length === 0) continue;
|
|
1030
|
+
pushStartupLine("out", line);
|
|
1031
|
+
noteStartupLine(line);
|
|
1032
|
+
}
|
|
1033
|
+
};
|
|
1034
|
+
|
|
892
1035
|
// If the launcher dies immediately, fail fast instead of waiting 4 minutes.
|
|
893
1036
|
let exitCode = null;
|
|
894
1037
|
child.once("exit", (code) => {
|
|
@@ -901,32 +1044,61 @@ async function spawnBackend(settings, signal) {
|
|
|
901
1044
|
stopOwnedBackend();
|
|
902
1045
|
throw new Error("cancelled while starting the local SD backend");
|
|
903
1046
|
}
|
|
1047
|
+
pumpLog();
|
|
904
1048
|
if (exitCode !== null && exitCode !== 0) {
|
|
1049
|
+
pumpLog();
|
|
905
1050
|
stopOwnedBackend();
|
|
1051
|
+
const tail = lifecycle.startup.lines.slice(-8).map((l) => l.text).join("\n");
|
|
906
1052
|
throw new Error(
|
|
907
1053
|
"The local SD launcher exited with code " +
|
|
908
1054
|
exitCode +
|
|
909
|
-
" before the API came up.
|
|
910
|
-
|
|
911
|
-
" by hand to see its error output.",
|
|
1055
|
+
" before the API came up." +
|
|
1056
|
+
(tail.length > 0 ? "\n最后几行输出:\n" + tail : "\n(启动日志:" + logFile + ")"),
|
|
912
1057
|
);
|
|
913
1058
|
}
|
|
914
1059
|
const live = await findLiveEndpoint(settings, signal);
|
|
915
1060
|
if (live !== null) {
|
|
1061
|
+
pumpLog();
|
|
916
1062
|
lifecycle.endpoint = live;
|
|
1063
|
+
setStartupState("ready", "已就绪", 1);
|
|
1064
|
+
pushStartupLine("info", "后端就绪:" + live);
|
|
917
1065
|
return live;
|
|
918
1066
|
}
|
|
1067
|
+
// Keep the bar creeping while nothing new is printed: a cold Forge can
|
|
1068
|
+
// sit on "loading checkpoint" for a minute, and a frozen bar reads as a
|
|
1069
|
+
// hang.
|
|
1070
|
+
if (lifecycle.startup.progress < 0.92) {
|
|
1071
|
+
lifecycle.startup.progress = Math.min(0.92, lifecycle.startup.progress + 0.01);
|
|
1072
|
+
}
|
|
919
1073
|
await new Promise((r) => setTimeout(r, 2000));
|
|
920
1074
|
}
|
|
921
1075
|
|
|
1076
|
+
pumpLog();
|
|
922
1077
|
stopOwnedBackend();
|
|
1078
|
+
// A very common self-inflicted failure: `--nowebui` without `--port`.
|
|
1079
|
+
// Forge then binds 7861 (webui.py: `port=cmd_opts.port if cmd_opts.port
|
|
1080
|
+
// else 7861`), the plugin keeps probing 7860, and the timeout message says
|
|
1081
|
+
// nothing useful. Detect it from the captured output and say it plainly.
|
|
1082
|
+
const sawApiPort = lifecycle.startup.lines
|
|
1083
|
+
.map((l) => l.text)
|
|
1084
|
+
.join("\n")
|
|
1085
|
+
.match(/Uvicorn running on http:\/\/[^:]+:(\d+)/);
|
|
1086
|
+
const portHint =
|
|
1087
|
+
sawApiPort !== null && Number(sawApiPort[1]) !== Number(new URL(settings.endpoint).port || 80)
|
|
1088
|
+
? "\n后端实际监听在 " +
|
|
1089
|
+
sawApiPort[1] +
|
|
1090
|
+
" 端口,但插件在等 " +
|
|
1091
|
+
settings.endpoint +
|
|
1092
|
+
"。如果用 --nowebui,记得同时加 --port 7860(否则 Forge 会默认用 7861)。"
|
|
1093
|
+
: "";
|
|
923
1094
|
throw new Error(
|
|
924
1095
|
"The local SD backend did not answer within " +
|
|
925
1096
|
Math.round(startupTimeoutMs / 1000) +
|
|
926
1097
|
"s of launching " +
|
|
927
1098
|
command +
|
|
928
1099
|
". First start is slow (dependencies, model load); raise 启动超时 in 设置 → 本地绘图 " +
|
|
929
|
-
"if this is a cold boot on a slow disk."
|
|
1100
|
+
"if this is a cold boot on a slow disk." +
|
|
1101
|
+
portHint,
|
|
930
1102
|
);
|
|
931
1103
|
}
|
|
932
1104
|
|
|
@@ -1778,6 +1950,29 @@ function writeJson(res, status, value) {
|
|
|
1778
1950
|
res.end(JSON.stringify(value));
|
|
1779
1951
|
}
|
|
1780
1952
|
|
|
1953
|
+
/**
|
|
1954
|
+
* Startup progress + the launcher's real output.
|
|
1955
|
+
*
|
|
1956
|
+
* Deliberately a separate poll endpoint: the settings page polls it every
|
|
1957
|
+
* second while a backend boots, and folding this into `get` would re-probe
|
|
1958
|
+
* every endpoint (and re-run the hardware walk) once per second.
|
|
1959
|
+
*/
|
|
1960
|
+
function startupSnapshot() {
|
|
1961
|
+
const s = lifecycle.startup;
|
|
1962
|
+
return {
|
|
1963
|
+
state: s.state,
|
|
1964
|
+
phase: s.phase,
|
|
1965
|
+
progress: s.progress,
|
|
1966
|
+
startedAt: s.startedAt,
|
|
1967
|
+
endedAt: s.endedAt,
|
|
1968
|
+
error: s.error,
|
|
1969
|
+
logFile: s.logFile,
|
|
1970
|
+
// Only the tail matters to the panel; the full log is on disk.
|
|
1971
|
+
lines: s.lines.slice(-200),
|
|
1972
|
+
elapsedMs: s.startedAt > 0 ? (s.endedAt > 0 ? s.endedAt : Date.now()) - s.startedAt : 0,
|
|
1973
|
+
};
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1781
1976
|
/** The state the settings page renders. Never leaks anything secret — there is none. */
|
|
1782
1977
|
async function settingsSnapshot(ctx) {
|
|
1783
1978
|
const settings = await resolveSettings();
|
|
@@ -1795,6 +1990,7 @@ async function settingsSnapshot(ctx) {
|
|
|
1795
1990
|
candidates: settings.candidates,
|
|
1796
1991
|
liveEndpoint: live,
|
|
1797
1992
|
launcher: launcher !== null && launcher.command.length > 0 ? launcher : null,
|
|
1993
|
+
startup: startupSnapshot(),
|
|
1798
1994
|
autoStart: {
|
|
1799
1995
|
enabled: settings.autoStart.enabled,
|
|
1800
1996
|
command: settings.autoStart.command,
|
|
@@ -1842,6 +2038,14 @@ async function handleSettingsApi(ctx, req, res) {
|
|
|
1842
2038
|
return;
|
|
1843
2039
|
}
|
|
1844
2040
|
|
|
2041
|
+
// Polled once a second by the settings page while a backend boots. Cheap on
|
|
2042
|
+
// purpose: no endpoint probing, no filesystem walk, just the in-memory
|
|
2043
|
+
// state machine plus the ring buffer.
|
|
2044
|
+
if (method === "startup") {
|
|
2045
|
+
writeJson(res, 200, { ok: true, startup: startupSnapshot() });
|
|
2046
|
+
return;
|
|
2047
|
+
}
|
|
2048
|
+
|
|
1845
2049
|
// Hardware probe is its own method: nvidia-smi + a PowerShell registry walk
|
|
1846
2050
|
// take a second or two, and the page should render before that lands.
|
|
1847
2051
|
if (method === "hardware") {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-local-sd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "本地 Stable Diffusion 桥接插件:在「设置 → 本地绘图」里选一下 WebUI / Forge 目录就能用,注册 generate_image_local / generate_image_batch / sd_backend_status / sd_backend_control 四个工具,把本机 sdapi 后端直接接到模型手上。完全本地、零内容过滤、断网可用、后端按需启停。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|