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 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) adopt(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.5.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: true,
882
- stdio: "ignore",
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. Run " +
910
- command +
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.5.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",