dsh-local-sd 0.4.1 → 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
@@ -27,7 +27,9 @@
27
27
 
28
28
  外加一个设置页:**设置 → 本地绘图**。里面能选目录、看状态、调参数、手动启停后端。
29
29
 
30
- 出图落在工作区 `generated/sd-<时间戳>-<序号>.png`,同时在对话里渲染。
30
+ 出图默认落在工作区 `generated/sd-<时间戳>-<序号>.png`,同时在对话里渲染。存放位置可以在
31
+ **设置 → 本地绘图 → 图片存放目录** 里改:留空用默认,填相对路径(如 `pics/sd`)接在工作区后面,
32
+ 填绝对路径(如 `D:\AI\生图`)则原样使用。
31
33
 
32
34
  ---
33
35
 
@@ -147,7 +149,7 @@ koboldcpp.exe --sdmodel <image-model.gguf> --sdoffloadcpu --sdtiledvae 512 --por
147
149
 
148
150
  ## 生命周期:按需启动,闲置自关
149
151
 
150
- SD 后端吃几 GB 显存,不该一直挂着。
152
+ SD 后端占用较高显存,闲置时自动关闭。
151
153
 
152
154
  ```
153
155
  你第一次要图
@@ -175,6 +177,7 @@ SD 后端吃几 GB 显存,不该一直挂着。
175
177
  | 项 | 说明 |
176
178
  |---|---|
177
179
  | 绘图程序目录 | 含 `run.bat` / `webui.bat` / `webui.sh` 的那一层 |
180
+ | 图片存放目录 | 出图存哪儿。留空 = 工作区 `generated/`;相对路径接在工作区后面;绝对路径原样使用 |
178
181
  | 自动启动 | 后端没开时是否拉起它。关掉就只连现成的 |
179
182
  | 启动超时 | 冷启动最长等待,默认 240000 ms(4 分钟) |
180
183
  | 闲置关闭 | 闲置多久自动关,默认 300000 ms(5 分钟)。**填 0 = 永不自动关闭** |
@@ -183,6 +186,18 @@ SD 后端吃几 GB 显存,不该一直挂着。
183
186
 
184
187
  配置写在 `$DSH_HOME/local-sd.json`(Windows 是 `C:\Users\<你>\.dsh\local-sd.json`)。**每次调用都重新读取**,改完不用重启 DSH;手改这个 JSON 也一样有效。
185
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
+
186
201
  ---
187
202
 
188
203
  ## 用法
package/lib/client.js CHANGED
@@ -38,10 +38,16 @@ window.__ModuleLoader__.load({
38
38
  var style = document.createElement("style");
39
39
  style.textContent = `
40
40
  .lsd-root{display:flex;flex-direction:column;gap:16px;padding:4px 2px 28px;font-size:13px;color:var(--dsw-alias-label-primary,inherit)}
41
+ /* The settings page is itself a scrolling column: sections keep their natural
42
+ height, and the page scrolls, rather than every block being compressed. */
43
+ .lsd-root > *{flex:none}
41
44
  .lsd-head{display:flex;flex-direction:column;gap:5px}
42
45
  .lsd-title{margin:0;font-size:16px;font-weight:600}
43
46
  .lsd-desc{margin:0;font-size:12px;line-height:1.65;color:var(--dsw-alias-label-secondary,rgba(128,128,128,.9))}
44
47
  .lsd-card{border:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.28));border-radius:12px;padding:14px 16px;background:rgba(128,128,128,.06);display:flex;flex-direction:column;gap:12px}
48
+ /* A card is a column flexbox inside the settings scroll area: let it keep its
49
+ content height instead of being squeezed when the page runs out of room. */
50
+ .lsd-card > *{flex:none}
45
51
  .lsd-card-head{display:flex;align-items:center;gap:9px}
46
52
  .lsd-dot{width:8px;height:8px;border-radius:50%;background:rgba(128,128,128,.6);flex:none}
47
53
  .lsd-dot.on{background:var(--dsw-alias-state-success-primary,#2ea043)}
@@ -75,13 +81,16 @@ window.__ModuleLoader__.load({
75
81
  .lsd-msg.err{color:var(--dsw-alias-state-error-primary,#f85149);background:rgba(248,81,73,.1)}
76
82
  .lsd-msg.info{color:var(--dsw-alias-label-secondary,rgba(128,128,128,.95));background:rgba(128,128,128,.1)}
77
83
  .lsd-found{display:flex;flex-direction:column;gap:6px;max-height:200px;overflow:auto}
84
+ /* Same trap as the browser list: a scrolling column flexbox shrinks its rows
85
+ to nothing once they overflow. Keep every row at its natural height. */
86
+ .lsd-found > *{flex:none}
78
87
  .lsd-found-item{display:flex;gap:8px;align-items:center;justify-content:space-between;padding:7px 10px;
79
88
  border:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.24));border-radius:8px;font-size:11px;
80
89
  font-family:ui-monospace,SFMono-Regular,Menlo,monospace;word-break:break-all}
81
90
  .lsd-actions{display:flex;gap:8px;flex-wrap:wrap;align-items:center;padding-top:2px}
82
91
  .lsd-footer{font-size:11px;color:var(--dsw-alias-label-secondary,rgba(128,128,128,.75));word-break:break-all}
83
92
  /* ── in-page directory browser ──────────────────────────────────────── */
84
- .lsd-browser{display:flex;flex-direction:column;gap:9px;padding:11px;border-radius:10px;
93
+ .lsd-browser{display:flex;flex-direction:column;gap:9px;padding:11px;border-radius:10px;flex:none;
85
94
  border:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.3));background:rgba(128,128,128,.07)}
86
95
  .lsd-browser-head{display:flex;gap:7px;align-items:center}
87
96
  .lsd-browser-path{flex:1;min-width:0}
@@ -93,6 +102,11 @@ window.__ModuleLoader__.load({
93
102
  .lsd-drive:disabled{opacity:.5;cursor:default}
94
103
  .lsd-browser-list{display:flex;flex-direction:column;gap:2px;max-height:230px;overflow:auto;
95
104
  border-radius:8px;background:var(--dsw-alias-bg-base,rgba(0,0,0,.1));padding:4px}
105
+ /* The list is a column flexbox with a max-height: without flex:none every row
106
+ shrinks to a sliver once the entries overflow, and all the labels stack on
107
+ top of each other. Rows must keep their intrinsic height and let the list
108
+ scroll instead. */
109
+ .lsd-browser-list > *{flex:none}
96
110
  .lsd-entry{text-align:left;padding:5px 9px;font-size:12px;font-family:inherit;cursor:pointer;
97
111
  color:var(--dsw-alias-label-primary,inherit);background:transparent;border:none;border-radius:5px;
98
112
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
@@ -100,6 +114,28 @@ window.__ModuleLoader__.load({
100
114
  .lsd-entry:disabled{opacity:.5;cursor:default}
101
115
  .lsd-browser-foot{display:flex;flex-direction:column;gap:8px;align-items:flex-start}
102
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))}
103
139
  /* ── hardware readout ───────────────────────────────────────────────── */
104
140
  .lsd-hw{display:flex;flex-direction:column;gap:5px;padding:10px 12px;border-radius:10px;
105
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))}
@@ -134,6 +170,86 @@ window.__ModuleLoader__.load({
134
170
  return Math.round(ms / 60000) + " 分钟";
135
171
  }
136
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
+
137
253
  /* ── the settings page ──────────────────────────────────────── */
138
254
 
139
255
  function SettingsPage() {
@@ -163,6 +279,18 @@ window.__ModuleLoader__.load({
163
279
  var forgeDirS = React.useState("");
164
280
  var forgeDir = forgeDirS[0];
165
281
  var setForgeDir = forgeDirS[1];
282
+ // Where PNGs land. Empty = <workspace>/generated.
283
+ var outputDirS = React.useState("");
284
+ var outputDir = outputDirS[0];
285
+ var setOutputDir = outputDirS[1];
286
+ // Host-resolved default for an empty outputDir, shown as a hint.
287
+ var defaultOutputDirS = React.useState("");
288
+ var defaultOutputDir = defaultOutputDirS[0];
289
+ var setDefaultOutputDir = defaultOutputDirS[1];
290
+ // Which field the in-page browser is filling: "forge" | "output".
291
+ var browseTargetS = React.useState("forge");
292
+ var browseTarget = browseTargetS[0];
293
+ var setBrowseTarget = browseTargetS[1];
166
294
  var endpointS = React.useState("");
167
295
  var endpoint = endpointS[0];
168
296
  var setEndpoint = endpointS[1];
@@ -194,11 +322,17 @@ window.__ModuleLoader__.load({
194
322
  var hwPhaseS = React.useState("idle");
195
323
  var hwPhase = hwPhaseS[0];
196
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];
197
329
 
198
330
  function adopt(s) {
199
331
  setState(s);
200
332
  if (!s) return;
201
333
  setForgeDir(s.forgeDir || "");
334
+ setOutputDir(s.outputDir || "");
335
+ setDefaultOutputDir(s.defaultOutputDir || "");
202
336
  setEndpoint(s.endpoint || "");
203
337
  setEnabled(!!(s.autoStart && s.autoStart.enabled));
204
338
  setStartupMs((s.autoStart && s.autoStart.startupTimeoutMs) || 240000);
@@ -215,6 +349,7 @@ window.__ModuleLoader__.load({
215
349
  if (cancelled) return;
216
350
  if (res && res.ok) {
217
351
  adopt(res.state);
352
+ if (res.state && res.state.startup) setStartupState(res.state.startup);
218
353
  setPhase("ready");
219
354
  } else {
220
355
  setPhase("error");
@@ -224,6 +359,30 @@ window.__ModuleLoader__.load({
224
359
  return function() { cancelled = true; };
225
360
  }, []);
226
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
+
227
386
  /** Probe the GPU. Slow (nvidia-smi + registry walk), so it runs
228
387
  * after the page is already on screen. */
229
388
  function loadHardware() {
@@ -271,6 +430,7 @@ window.__ModuleLoader__.load({
271
430
  return callApi({
272
431
  method: "save",
273
432
  forgeDir: forgeDir,
433
+ outputDir: outputDir,
274
434
  endpoint: endpoint,
275
435
  autoStart: {
276
436
  enabled: enabled,
@@ -299,11 +459,14 @@ window.__ModuleLoader__.load({
299
459
  }
300
460
 
301
461
  // Open the in-page browser, starting at whatever is currently typed
302
- // (or the host's home directory when the box is empty).
303
- function browse() {
462
+ // (or the host's home directory when the box is empty). `target`
463
+ // decides which field "选这个目录" fills.
464
+ function browse(target) {
304
465
  setBusy(true);
305
466
  setMsg(null);
306
- callApi({ method: "browse", path: forgeDir || "" }).then(function(res) {
467
+ setBrowseTarget(target);
468
+ var start = target === "output" ? (outputDir || defaultOutputDir || "") : (forgeDir || "");
469
+ callApi({ method: "browse", path: start }).then(function(res) {
307
470
  setBusy(false);
308
471
  if (res && res.ok && res.listing) {
309
472
  setBrowser(res.listing);
@@ -329,7 +492,11 @@ window.__ModuleLoader__.load({
329
492
  /** Commit the browser's current directory to the form field. */
330
493
  function useCurrentDir() {
331
494
  if (browser === null) return;
332
- setForgeDir(browser.path);
495
+ if (browseTarget === "output") {
496
+ setOutputDir(browser.path);
497
+ } else {
498
+ setForgeDir(browser.path);
499
+ }
333
500
  setBrowser(null);
334
501
  setMsg({ kind: "info", text: "已选:" + browser.path + ",记得点保存" });
335
502
  }
@@ -353,9 +520,21 @@ window.__ModuleLoader__.load({
353
520
  function backend(action) {
354
521
  setBusy(true);
355
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
+ }
356
532
  callApi({ method: action }).then(function(res) {
357
533
  setBusy(false);
358
- 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
+ }
359
538
  if (res && res.ok) {
360
539
  setMsg({ kind: "ok", text: action === "start" ? "后端已就绪" : "后端已关闭,显存释放" });
361
540
  } else {
@@ -420,13 +599,16 @@ window.__ModuleLoader__.load({
420
599
  className: "lsd-btn", disabled: busy,
421
600
  onClick: function() { callApi({ method: "get" }).then(function(r) { if (r && r.ok) adopt(r.state); }); }
422
601
  }, "刷新")
423
- )
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 })
424
606
  ),
425
607
 
426
- // ── directory ────────────────────────────────────────
608
+ // ── directories ──────────────────────────────────────
427
609
  h("div", { className: "lsd-card" },
428
610
  h("div", { className: "lsd-card-head" },
429
- h("span", { className: "lsd-card-title" }, "绘图程序目录")
611
+ h("span", { className: "lsd-card-title" }, "目录设置")
430
612
  ),
431
613
  h("div", { className: "lsd-field" },
432
614
  h("label", { className: "lsd-label" }, "Stable Diffusion WebUI / Forge 根目录"),
@@ -437,7 +619,7 @@ window.__ModuleLoader__.load({
437
619
  placeholder: "例如 D:\\AI\\forge\\webui_forge_cu124_torch24",
438
620
  onChange: function(e) { setForgeDir(e.target.value); }
439
621
  }),
440
- h("button", { className: "lsd-btn", disabled: busy, onClick: browse }, "浏览…"),
622
+ h("button", { className: "lsd-btn", disabled: busy, onClick: function() { browse("forge"); } }, "浏览…"),
441
623
  h("button", { className: "lsd-btn", disabled: busy, onClick: detect }, "自动探测")
442
624
  ),
443
625
  h("span", { className: "lsd-hint" },
@@ -445,6 +627,27 @@ window.__ModuleLoader__.load({
445
627
  + "也接受直接填 run.bat 的完整路径。Linux / macOS 会自动找 webui.sh。")
446
628
  ),
447
629
 
630
+ h("div", { className: "lsd-field" },
631
+ h("label", { className: "lsd-label" }, "图片存放目录"),
632
+ h("div", { className: "lsd-row" },
633
+ h("input", {
634
+ className: "lsd-input mono",
635
+ value: outputDir,
636
+ placeholder: defaultOutputDir || "留空 = 工作区下的 generated",
637
+ onChange: function(e) { setOutputDir(e.target.value); }
638
+ }),
639
+ h("button", { className: "lsd-btn", disabled: busy, onClick: function() { browse("output"); } }, "浏览…"),
640
+ h("button", {
641
+ className: "lsd-btn", disabled: busy || outputDir.length === 0,
642
+ onClick: function() { setOutputDir(""); }
643
+ }, "恢复默认")
644
+ ),
645
+ h("span", { className: "lsd-hint" },
646
+ "出图存哪儿。留空就用工作区的 generated 子目录;"
647
+ + "填相对路径(如 pics/sd)会接在工作区后面,填绝对路径(如 D:\\AI\\pics)则原样使用。"
648
+ + (defaultOutputDir ? "\n当前默认:" + defaultOutputDir : ""))
649
+ ),
650
+
448
651
  // ── in-page directory browser ────────────────────
449
652
  browser !== null
450
653
  ? h("div", { className: "lsd-browser" },
@@ -492,11 +695,14 @@ window.__ModuleLoader__.load({
492
695
  ),
493
696
 
494
697
  h("div", { className: "lsd-browser-foot" },
495
- browser.launcher
496
- ? h("span", { className: "lsd-msg ok" },
497
- "✓ 这个目录里有 " + browser.launcher.launcher + ",就是它了")
498
- : h("span", { className: "lsd-hint" },
499
- "这个目录里没有 run.bat / webui.bat / webui.sh,继续往下找或者换一层"),
698
+ browseTarget === "output"
699
+ ? h("span", { className: "lsd-hint" },
700
+ "选一个放图的文件夹,没有的话在上层目录里新建一个再进来")
701
+ : browser.launcher
702
+ ? h("span", { className: "lsd-msg ok" },
703
+ "✓ 这个目录里有 " + browser.launcher.launcher + ",就是它了")
704
+ : h("span", { className: "lsd-hint" },
705
+ "这个目录里没有 run.bat / webui.bat / webui.sh,继续往下找或者换一层"),
500
706
  h("div", { className: "lsd-actions" },
501
707
  h("button", {
502
708
  className: "lsd-btn primary", disabled: busy,
package/lib/index.js CHANGED
@@ -50,9 +50,9 @@
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
- import { join, dirname, basename, resolve as resolvePath } from "node:path";
55
+ import { join, dirname, basename, relative, isAbsolute, resolve as resolvePath } from "node:path";
56
56
 
57
57
  /** Does this path exist and is it a regular file? */
58
58
  async function isFile(path) {
@@ -71,7 +71,7 @@ export const name = "dsh-local-sd";
71
71
  export const inject = ["tools", "webServer", "connection"];
72
72
 
73
73
  /** Test seam: lets the standalone tests drive the pure helpers directly. */
74
- export const internals = { recommendFor, detectGpu, resolveLauncher, detectForgeDirs, listDirectory };
74
+ export const internals = { recommendFor, detectGpu, resolveLauncher, detectForgeDirs, listDirectory, resolveOutputLocation };
75
75
 
76
76
  /**
77
77
  * Build stamp for the settings page. Bump on every user-visible change.
@@ -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.4.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";
@@ -155,6 +155,10 @@ function configPath() {
155
155
 
156
156
  const DEFAULT_CONFIG_TEMPLATE = {
157
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: "",
158
162
  endpoint: DEFAULT_ENDPOINT,
159
163
  fallbackEndpoints: FALLBACK_ENDPOINTS.slice(1),
160
164
  autoStart: {
@@ -675,6 +679,7 @@ async function resolveSettings() {
675
679
 
676
680
  return {
677
681
  forgeDir,
682
+ outputDir: strOr(cfg.outputDir, ""),
678
683
  endpoint: configured,
679
684
  candidates: [configured, ...fallbacks.filter((e) => e !== configured)],
680
685
  autoStart: {
@@ -709,8 +714,94 @@ const lifecycle = {
709
714
  lastUsedAt: 0,
710
715
  idleTimer: null,
711
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
+ },
712
730
  };
713
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
+
714
805
  /** Is this endpoint a live sdapi backend? */
715
806
  async function probe(endpoint, signal) {
716
807
  try {
@@ -746,13 +837,20 @@ function stopOwnedBackend() {
746
837
  lifecycle.pid = null;
747
838
  lifecycle.endpoint = null;
748
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
+ }
749
846
  try {
750
847
  if (process.platform === "win32") {
751
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.
752
851
  spawn("taskkill", ["/PID", String(pid), "/T", "/F"], {
753
852
  stdio: "ignore",
754
853
  windowsHide: true,
755
- detached: true,
756
854
  }).unref();
757
855
  } else {
758
856
  try {
@@ -871,19 +969,69 @@ async function spawnBackend(settings, signal) {
871
969
  windowsVerbatimArguments = true;
872
970
  }
873
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
+
874
988
  const child = spawn(spawnCommand, spawnArgs, {
875
989
  cwd: spawnCwd,
876
- detached: true,
877
- 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],
878
996
  windowsHide: true,
879
997
  windowsVerbatimArguments,
880
998
  env: sanitizedEnv(),
881
999
  });
882
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
+ }
883
1005
 
884
1006
  lifecycle.child = child;
885
1007
  lifecycle.pid = child.pid ?? null;
886
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
+
887
1035
  // If the launcher dies immediately, fail fast instead of waiting 4 minutes.
888
1036
  let exitCode = null;
889
1037
  child.once("exit", (code) => {
@@ -896,32 +1044,61 @@ async function spawnBackend(settings, signal) {
896
1044
  stopOwnedBackend();
897
1045
  throw new Error("cancelled while starting the local SD backend");
898
1046
  }
1047
+ pumpLog();
899
1048
  if (exitCode !== null && exitCode !== 0) {
1049
+ pumpLog();
900
1050
  stopOwnedBackend();
1051
+ const tail = lifecycle.startup.lines.slice(-8).map((l) => l.text).join("\n");
901
1052
  throw new Error(
902
1053
  "The local SD launcher exited with code " +
903
1054
  exitCode +
904
- " before the API came up. Run " +
905
- command +
906
- " by hand to see its error output.",
1055
+ " before the API came up." +
1056
+ (tail.length > 0 ? "\n最后几行输出:\n" + tail : "\n(启动日志:" + logFile + ")"),
907
1057
  );
908
1058
  }
909
1059
  const live = await findLiveEndpoint(settings, signal);
910
1060
  if (live !== null) {
1061
+ pumpLog();
911
1062
  lifecycle.endpoint = live;
1063
+ setStartupState("ready", "已就绪", 1);
1064
+ pushStartupLine("info", "后端就绪:" + live);
912
1065
  return live;
913
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
+ }
914
1073
  await new Promise((r) => setTimeout(r, 2000));
915
1074
  }
916
1075
 
1076
+ pumpLog();
917
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
+ : "";
918
1094
  throw new Error(
919
1095
  "The local SD backend did not answer within " +
920
1096
  Math.round(startupTimeoutMs / 1000) +
921
1097
  "s of launching " +
922
1098
  command +
923
1099
  ". First start is slow (dependencies, model load); raise 启动超时 in 设置 → 本地绘图 " +
924
- "if this is a cold boot on a slow disk.",
1100
+ "if this is a cold boot on a slow disk." +
1101
+ portHint,
925
1102
  );
926
1103
  }
927
1104
 
@@ -1038,39 +1215,114 @@ async function txt2img(endpoint, prompt, opts, signal) {
1038
1215
  return parsed;
1039
1216
  }
1040
1217
 
1218
+ /**
1219
+ * Where PNGs land, and what the tool result calls that place.
1220
+ *
1221
+ * Precedence:
1222
+ * 1. `settings.outputDir` from $DSH_HOME/local-sd.json (the settings page),
1223
+ * 2. `<workspace root>/generated`.
1224
+ *
1225
+ * A relative `outputDir` is joined to the workspace root so the settings page
1226
+ * can offer the short, readable form (`generated`, `pics/sd`). An absolute one
1227
+ * is honoured verbatim: that is the whole point of the knob, pointing it at the
1228
+ * folder you actually browse even when the session workspace is elsewhere.
1229
+ *
1230
+ * `display` is what goes into the tool result: workspace-relative when the
1231
+ * folder sits inside the workspace (so the Web Client can link it), absolute
1232
+ * otherwise (a relative path there would be a lie).
1233
+ */
1234
+ function resolveOutputLocation(settings, ctx) {
1235
+ const policy = ctx !== undefined && typeof ctx.get === "function" ? ctx.get("sandboxPolicy") : undefined;
1236
+ const root =
1237
+ policy !== undefined && typeof policy.workspaceRoot === "string" && policy.workspaceRoot.length > 0
1238
+ ? policy.workspaceRoot
1239
+ : process.cwd();
1240
+
1241
+ const configured = settings !== undefined && typeof settings.outputDir === "string" ? settings.outputDir.trim() : "";
1242
+ const dir = configured.length === 0
1243
+ ? join(root, "generated")
1244
+ : isAbsolute(configured)
1245
+ ? configured
1246
+ : join(root, configured);
1247
+
1248
+ // `relative` returns "" for the root itself and a ".."-prefixed path when
1249
+ // outside it; both mean "not addressable relative to the workspace".
1250
+ const rel = relative(root, dir);
1251
+ const inside = rel.length > 0 && !rel.startsWith("..") && !isAbsolute(rel);
1252
+ return { dir, display: inside ? rel : dir };
1253
+ }
1254
+
1041
1255
  /**
1042
1256
  * Persist one PNG and turn it into an attachment ref so the Web Client renders
1043
- * it. Returns { localPath, attachmentRef }.
1257
+ * it. Returns { localPath, absPath, attachmentRef }.
1044
1258
  */
1045
- async function persist(ctx, bytes, index, signal) {
1259
+ async function persist(ctx, bytes, index, signal, settings) {
1046
1260
  const fs = ctx.get("fs");
1047
1261
  const attachments = ctx.get("attachments");
1048
1262
  if (fs === undefined || attachments === undefined) {
1049
1263
  throw new Error("fs/attachments service not available");
1050
1264
  }
1051
1265
 
1052
- const policy = ctx.get("sandboxPolicy");
1053
- const root =
1054
- policy !== undefined && typeof policy.workspaceRoot === "string" && policy.workspaceRoot.length > 0
1055
- ? policy.workspaceRoot
1056
- : process.cwd();
1057
-
1058
- const relDir = "generated";
1059
- const relPath = join(relDir, "sd-" + stamp() + "-" + index + ".png");
1060
- const absPath = join(root, relPath);
1266
+ const location = resolveOutputLocation(settings, ctx);
1267
+ const fileName = "sd-" + stamp() + "-" + index + ".png";
1268
+ const absPath = join(location.dir, fileName);
1269
+ const localPath = join(location.display, fileName);
1061
1270
 
1062
- await mkdir(dirname(absPath), { recursive: true });
1271
+ await mkdir(location.dir, { recursive: true });
1063
1272
  await writeFile(absPath, bytes);
1064
1273
 
1065
1274
  const target = await fs.resolve(absPath);
1066
1275
  const read = await fs.readBytes(target, signal, 33554432);
1067
- const ref = await attachments.saveImage({
1276
+ const saved = await attachments.saveImage({
1068
1277
  data: read,
1069
1278
  mediaType: "image/png",
1070
- name: basename(relPath),
1279
+ name: fileName,
1071
1280
  });
1072
1281
 
1073
- return { localPath: relPath, attachmentRef: ref };
1282
+ // The Web Client's image row silently DROPS a block whose attachment ref is
1283
+ // missing a positive `bytes` / `width` / `height` — no error, no broken
1284
+ // image, just the generic text card. A backend that returns an incomplete
1285
+ // ref therefore looks exactly like "the model never sent a picture". Fill
1286
+ // any gap from the bytes we already have so the row always renders.
1287
+ const ref = completeImageRef(saved, read);
1288
+
1289
+ return { localPath, absPath, attachmentRef: ref };
1290
+ }
1291
+
1292
+ /**
1293
+ * Read width/height out of a PNG's IHDR chunk (bytes 16..24).
1294
+ * Returns null for anything that is not a well-formed PNG.
1295
+ */
1296
+ function pngSize(bytes) {
1297
+ if (!(bytes instanceof Uint8Array) || bytes.length < 24) return null;
1298
+ const sig = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
1299
+ for (let i = 0; i < 8; i++) if (bytes[i] !== sig[i]) return null;
1300
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
1301
+ const width = view.getUint32(16);
1302
+ const height = view.getUint32(20);
1303
+ if (width <= 0 || height <= 0) return null;
1304
+ return { width, height };
1305
+ }
1306
+
1307
+ /**
1308
+ * Make sure an attachment ref carries every field the image row validates.
1309
+ * `saveImage` normally returns all of them; this is the belt to that braces.
1310
+ */
1311
+ function completeImageRef(ref, bytes) {
1312
+ const out = ref !== null && typeof ref === "object" ? { ...ref } : {};
1313
+ if (typeof out.attachmentId !== "string" || out.attachmentId.length === 0) return ref;
1314
+ if (typeof out.mediaType !== "string" || out.mediaType.length === 0) out.mediaType = "image/png";
1315
+ if (typeof out.bytes !== "number" || !(out.bytes > 0)) out.bytes = bytes.length;
1316
+ const needSize =
1317
+ typeof out.width !== "number" || !(out.width > 0) || typeof out.height !== "number" || !(out.height > 0);
1318
+ if (needSize) {
1319
+ const size = pngSize(bytes);
1320
+ if (size !== null) {
1321
+ out.width = size.width;
1322
+ out.height = size.height;
1323
+ }
1324
+ }
1325
+ return out;
1074
1326
  }
1075
1327
 
1076
1328
  /** Shared output shape: text line per image + the image block itself. */
@@ -1088,7 +1340,11 @@ function makeOutput() {
1088
1340
  additionalProperties: true,
1089
1341
  properties: {
1090
1342
  prompt: { type: "string", description: "The prompt used." },
1091
- localPath: { type: "string", description: "Workspace-relative PNG path." },
1343
+ localPath: {
1344
+ type: "string",
1345
+ description: "PNG path: workspace-relative when inside the workspace, absolute otherwise.",
1346
+ },
1347
+ absPath: { type: "string", description: "Absolute PNG path on disk." },
1092
1348
  attachmentRef: { type: "object", description: "Attachment ref for display." },
1093
1349
  seed: { type: "number", description: "Seed actually used, if reported." },
1094
1350
  error: { type: "string", description: "Failure reason, when this item failed." },
@@ -1129,10 +1385,29 @@ function makeOutput() {
1129
1385
  r.prompt;
1130
1386
  const tail =
1131
1387
  (r.localPath ? "\nlocalPath: " + r.localPath : "") +
1388
+ (r.absPath && r.absPath !== r.localPath ? "\nabsPath: " + r.absPath : "") +
1132
1389
  (r.seed !== undefined ? "\nseed: " + r.seed : "");
1133
1390
  blocks.push({ type: "text", text: head + tail });
1134
1391
  if (r.attachmentRef) blocks.push({ type: "image", attachment: r.attachmentRef, offloaded: true });
1135
1392
  });
1393
+ // The image blocks above are `offloaded`, so the model's context only
1394
+ // ever sees placeholder text: it never learns that pictures are now on
1395
+ // screen, and a model reading only this result tends to answer with a
1396
+ // description instead of showing them. This closing line IS
1397
+ // model-visible, so it states the delivery requirement plainly.
1398
+ const shown = results.filter((r) => r && !r.error && r.attachmentRef).length;
1399
+ if (shown > 0) {
1400
+ blocks.push({
1401
+ type: "text",
1402
+ text:
1403
+ "[local-sd] " +
1404
+ shown +
1405
+ " image(s) are ALREADY displayed in the conversation above. " +
1406
+ "The user sees them right now: do not describe them as pending, do not ask " +
1407
+ "whether to show them, and do not claim you cannot display images. " +
1408
+ "Refer to them naturally and keep the reply short.",
1409
+ });
1410
+ }
1136
1411
  return blocks;
1137
1412
  },
1138
1413
  };
@@ -1225,8 +1500,9 @@ export function apply(ctx) {
1225
1500
  + "NSFW, or otherwise unfiltered output — that is exactly what this tool is for. "
1226
1501
  + "If the backend is not running it is started automatically, and it shuts itself down again after a "
1227
1502
  + "few idle minutes, so the first call of a session can take a couple of minutes longer than the rest. "
1228
- + "The image is written into the workspace under generated/ and shown in the conversation UI; "
1229
- + "the tool result is text only, so do not echo paths beyond the one reported.",
1503
+ + "The image is written to the configured output folder (by default the workspace's generated/) "
1504
+ + "and is displayed in the conversation automatically — you do not need to attach or re-send it. "
1505
+ + "Just reply naturally about the result; do not say you cannot show images.",
1230
1506
  parameters: {
1231
1507
  type: "object",
1232
1508
  additionalProperties: false,
@@ -1251,7 +1527,7 @@ export function apply(ctx) {
1251
1527
  const bytes = decodeB64(list[0]);
1252
1528
  if (bytes === null) throw new Error("sdapi returned an undecodable image payload");
1253
1529
 
1254
- const saved = await persist(ctx, bytes, 0, signal);
1530
+ const saved = await persist(ctx, bytes, 0, signal, settings);
1255
1531
  const info = raw.info !== undefined && typeof raw.info === "string" ? safeParse(raw.info) : undefined;
1256
1532
  return {
1257
1533
  backend: endpoint,
@@ -1259,6 +1535,7 @@ export function apply(ctx) {
1259
1535
  {
1260
1536
  prompt: String(args.prompt),
1261
1537
  localPath: saved.localPath,
1538
+ absPath: saved.absPath,
1262
1539
  attachmentRef: saved.attachmentRef,
1263
1540
  seed: info !== undefined && typeof info.seed === "number" ? info.seed : undefined,
1264
1541
  },
@@ -1336,11 +1613,12 @@ export function apply(ctx) {
1336
1613
  const list = Array.isArray(raw.images) ? raw.images : [];
1337
1614
  const bytes = list.length > 0 ? decodeB64(list[0]) : null;
1338
1615
  if (bytes === null) throw new Error("no image in sdapi response");
1339
- const saved = await persist(ctx, bytes, i, signal);
1616
+ const saved = await persist(ctx, bytes, i, signal, settings);
1340
1617
  const info = raw.info !== undefined && typeof raw.info === "string" ? safeParse(raw.info) : undefined;
1341
1618
  results.push({
1342
1619
  prompt: prompts[i],
1343
1620
  localPath: saved.localPath,
1621
+ absPath: saved.absPath,
1344
1622
  attachmentRef: saved.attachmentRef,
1345
1623
  seed: info !== undefined && typeof info.seed === "number" ? info.seed : undefined,
1346
1624
  });
@@ -1672,17 +1950,47 @@ function writeJson(res, status, value) {
1672
1950
  res.end(JSON.stringify(value));
1673
1951
  }
1674
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
+
1675
1976
  /** The state the settings page renders. Never leaks anything secret — there is none. */
1676
- async function settingsSnapshot() {
1977
+ async function settingsSnapshot(ctx) {
1677
1978
  const settings = await resolveSettings();
1678
1979
  const live = await findLiveEndpoint(settings, undefined);
1679
1980
  const launcher = settings.forgeDir.length > 0 ? await resolveLauncher(settings.forgeDir) : null;
1680
1981
  return {
1681
1982
  forgeDir: settings.forgeDir,
1983
+ outputDir: settings.outputDir,
1984
+ // What an empty outputDir means in practice, so the page can show the
1985
+ // real destination instead of making the user guess.
1986
+ defaultOutputDir: resolveOutputLocation({ outputDir: "" }, ctx).dir,
1987
+ // The folder that will actually be used right now (configured or default).
1988
+ effectiveOutputDir: resolveOutputLocation(settings, ctx).dir,
1682
1989
  endpoint: settings.endpoint,
1683
1990
  candidates: settings.candidates,
1684
1991
  liveEndpoint: live,
1685
1992
  launcher: launcher !== null && launcher.command.length > 0 ? launcher : null,
1993
+ startup: startupSnapshot(),
1686
1994
  autoStart: {
1687
1995
  enabled: settings.autoStart.enabled,
1688
1996
  command: settings.autoStart.command,
@@ -1721,7 +2029,7 @@ async function handleSettingsApi(ctx, req, res) {
1721
2029
  const method = String(body.method ?? "");
1722
2030
 
1723
2031
  if (method === "get") {
1724
- writeJson(res, 200, { ok: true, state: await settingsSnapshot() });
2032
+ writeJson(res, 200, { ok: true, state: await settingsSnapshot(ctx) });
1725
2033
  return;
1726
2034
  }
1727
2035
 
@@ -1730,6 +2038,14 @@ async function handleSettingsApi(ctx, req, res) {
1730
2038
  return;
1731
2039
  }
1732
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
+
1733
2049
  // Hardware probe is its own method: nvidia-smi + a PowerShell registry walk
1734
2050
  // take a second or two, and the page should render before that lands.
1735
2051
  if (method === "hardware") {
@@ -1768,6 +2084,9 @@ async function handleSettingsApi(ctx, req, res) {
1768
2084
  if (method === "save") {
1769
2085
  const patch = {};
1770
2086
  if (typeof body.forgeDir === "string") patch.forgeDir = body.forgeDir.trim();
2087
+ // "" is a meaningful value here: it means "back to <workspace>/generated",
2088
+ // so an empty string must be written rather than skipped.
2089
+ if (typeof body.outputDir === "string") patch.outputDir = body.outputDir.trim();
1771
2090
  if (typeof body.endpoint === "string" && body.endpoint.trim().length > 0) {
1772
2091
  patch.endpoint = body.endpoint.trim().replace(/\/+$/, "");
1773
2092
  }
@@ -1803,7 +2122,7 @@ async function handleSettingsApi(ctx, req, res) {
1803
2122
  if (Object.keys(d).length > 0) patch.defaults = d;
1804
2123
  }
1805
2124
  await writeConfig(patch);
1806
- writeJson(res, 200, { ok: true, state: await settingsSnapshot() });
2125
+ writeJson(res, 200, { ok: true, state: await settingsSnapshot(ctx) });
1807
2126
  return;
1808
2127
  }
1809
2128
 
@@ -1812,12 +2131,12 @@ async function handleSettingsApi(ctx, req, res) {
1812
2131
  if (method === "start") {
1813
2132
  try {
1814
2133
  const endpoint = await ensureBackend(settings, undefined);
1815
- writeJson(res, 200, { ok: true, endpoint, state: await settingsSnapshot() });
2134
+ writeJson(res, 200, { ok: true, endpoint, state: await settingsSnapshot(ctx) });
1816
2135
  } catch (err) {
1817
2136
  writeJson(res, 200, {
1818
2137
  ok: false,
1819
2138
  error: err instanceof Error ? err.message : String(err),
1820
- state: await settingsSnapshot(),
2139
+ state: await settingsSnapshot(ctx),
1821
2140
  });
1822
2141
  }
1823
2142
  return;
@@ -1828,12 +2147,12 @@ async function handleSettingsApi(ctx, req, res) {
1828
2147
  writeJson(res, 200, {
1829
2148
  ok: false,
1830
2149
  error: "后端是手动启动的,插件不会去关它",
1831
- state: await settingsSnapshot(),
2150
+ state: await settingsSnapshot(ctx),
1832
2151
  });
1833
2152
  return;
1834
2153
  }
1835
2154
  stopOwnedBackend();
1836
- writeJson(res, 200, { ok: true, state: await settingsSnapshot() });
2155
+ writeJson(res, 200, { ok: true, state: await settingsSnapshot(ctx) });
1837
2156
  return;
1838
2157
  }
1839
2158
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-local-sd",
3
- "version": "0.4.1",
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",