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 +17 -2
- package/lib/client.js +221 -15
- package/lib/index.js +357 -38
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -27,7 +27,9 @@
|
|
|
27
27
|
|
|
28
28
|
外加一个设置页:**设置 → 本地绘图**。里面能选目录、看状态、调参数、手动启停后端。
|
|
29
29
|
|
|
30
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
// ──
|
|
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
|
-
|
|
496
|
-
? h("span", { className: "lsd-
|
|
497
|
-
"
|
|
498
|
-
:
|
|
499
|
-
"
|
|
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.
|
|
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
|
|
877
|
-
|
|
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.
|
|
905
|
-
|
|
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
|
|
1053
|
-
const
|
|
1054
|
-
|
|
1055
|
-
|
|
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(
|
|
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
|
|
1276
|
+
const saved = await attachments.saveImage({
|
|
1068
1277
|
data: read,
|
|
1069
1278
|
mediaType: "image/png",
|
|
1070
|
-
name:
|
|
1279
|
+
name: fileName,
|
|
1071
1280
|
});
|
|
1072
1281
|
|
|
1073
|
-
|
|
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: {
|
|
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
|
|
1229
|
-
+ "
|
|
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.
|
|
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",
|