dsh-local-sd 0.0.0-stage → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js ADDED
@@ -0,0 +1,782 @@
1
+ // dsh-local-sd — browser half (client plugin bundle).
2
+ //
3
+ // Loaded by dsh-client-modules at /plugins/dsh-local-sd/client.js and executed
4
+ // through the vendored cordis Loader's lazy-CJS module table
5
+ // (window.__ModuleLoader__.load). The factory body is plain CJS with require()
6
+ // resolved against the shell's module table. The `id` below MUST equal the
7
+ // package name, or the shell reports "loaded without registering".
8
+ //
9
+ // This bundle carries one thing: 设置 → 本地绘图 (a `settings.section` page).
10
+ // It exists because the whole point of the plugin is "install it and it works",
11
+ // and that means the Forge directory must be pickable with a button instead of
12
+ // hand-editing $DSH_HOME/local-sd.json.
13
+ //
14
+ // All state lives in the host half. The page is a thin form over
15
+ // POST /local-sd/api — { method: "get" | "save" | "detect" | "browse" |
16
+ // "start" | "stop" } — so the JSON file stays the single source of truth and a
17
+ // hand edit still takes effect.
18
+
19
+ window.__ModuleLoader__.load({
20
+ id: "dsh-local-sd",
21
+ factory: (require) => {
22
+ var module = { exports: {} };
23
+ var exports = module.exports;
24
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
25
+ var React = require("react");
26
+ var h = React.createElement;
27
+
28
+ exports.inject = ["slots"];
29
+ exports.apply = function apply(ctx) {
30
+ var API_URL = "/local-sd/api";
31
+ // Text used to locate the settings-nav cell when painting the plugin
32
+ // glyph: the shell's navIcon map is keyed by section id and offers no
33
+ // icon field to register.
34
+ var NAV_LABEL = "本地绘图";
35
+
36
+ /* ── stylesheet ─────────────────────────────────────────────── */
37
+ ctx.effect(function() {
38
+ var style = document.createElement("style");
39
+ style.textContent = `
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}
44
+ .lsd-head{display:flex;flex-direction:column;gap:5px}
45
+ .lsd-title{margin:0;font-size:16px;font-weight:600}
46
+ .lsd-desc{margin:0;font-size:12px;line-height:1.65;color:var(--dsw-alias-label-secondary,rgba(128,128,128,.9))}
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}
51
+ .lsd-card-head{display:flex;align-items:center;gap:9px}
52
+ .lsd-dot{width:8px;height:8px;border-radius:50%;background:rgba(128,128,128,.6);flex:none}
53
+ .lsd-dot.on{background:var(--dsw-alias-state-success-primary,#2ea043)}
54
+ .lsd-dot.warn{background:var(--dsw-alias-state-warning-primary,#d29922)}
55
+ .lsd-card-title{font-size:14px;font-weight:600}
56
+ .lsd-field{display:flex;flex-direction:column;gap:6px}
57
+ .lsd-label{font-size:12px;font-weight:500;color:var(--dsw-alias-label-primary,inherit)}
58
+ .lsd-hint{font-size:11px;line-height:1.6;color:var(--dsw-alias-label-secondary,rgba(128,128,128,.9))}
59
+ .lsd-row{display:flex;gap:8px;align-items:center}
60
+ .lsd-input{flex:1;min-width:0;box-sizing:border-box;padding:7px 10px;font-size:12px;font-family:inherit;
61
+ color:var(--dsw-alias-label-primary,inherit);background:var(--dsw-alias-bg-base,transparent);
62
+ border:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.32));border-radius:8px;outline:none}
63
+ .lsd-input:focus{border-color:var(--dsw-alias-brand-primary,#4a9eff)}
64
+ .lsd-input.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px}
65
+ .lsd-btn{flex:none;padding:7px 13px;font-size:12px;font-family:inherit;cursor:pointer;white-space:nowrap;
66
+ color:var(--dsw-alias-label-primary,inherit);background:rgba(128,128,128,.14);
67
+ border:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.32));border-radius:8px}
68
+ .lsd-btn:hover:not(:disabled){background:rgba(128,128,128,.24)}
69
+ .lsd-btn:disabled{opacity:.5;cursor:default}
70
+ .lsd-btn.primary{color:#fff;background:var(--dsw-alias-brand-primary,#4a9eff);border-color:transparent}
71
+ .lsd-btn.primary:hover:not(:disabled){filter:brightness(1.08)}
72
+ .lsd-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(132px,1fr));gap:10px}
73
+ .lsd-stat{border:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.22));border-radius:10px;
74
+ padding:9px 12px;background:var(--dsw-alias-bg-base,transparent);display:flex;flex-direction:column;gap:2px;min-width:0}
75
+ .lsd-stat-k{font-size:10px;text-transform:uppercase;letter-spacing:.04em;color:var(--dsw-alias-label-secondary,rgba(128,128,128,.9))}
76
+ .lsd-stat-v{font-size:12px;font-weight:500;word-break:break-all}
77
+ .lsd-toggle{display:flex;align-items:center;gap:8px;font-size:12px;cursor:pointer;user-select:none}
78
+ .lsd-toggle input{width:15px;height:15px;cursor:pointer;accent-color:var(--dsw-alias-brand-primary,#4a9eff)}
79
+ .lsd-msg{font-size:12px;line-height:1.6;padding:8px 11px;border-radius:8px;white-space:pre-wrap;word-break:break-word}
80
+ .lsd-msg.ok{color:var(--dsw-alias-state-success-primary,#2ea043);background:rgba(46,160,67,.1)}
81
+ .lsd-msg.err{color:var(--dsw-alias-state-error-primary,#f85149);background:rgba(248,81,73,.1)}
82
+ .lsd-msg.info{color:var(--dsw-alias-label-secondary,rgba(128,128,128,.95));background:rgba(128,128,128,.1)}
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}
87
+ .lsd-found-item{display:flex;gap:8px;align-items:center;justify-content:space-between;padding:7px 10px;
88
+ border:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.24));border-radius:8px;font-size:11px;
89
+ font-family:ui-monospace,SFMono-Regular,Menlo,monospace;word-break:break-all}
90
+ .lsd-actions{display:flex;gap:8px;flex-wrap:wrap;align-items:center;padding-top:2px}
91
+ .lsd-footer{font-size:11px;color:var(--dsw-alias-label-secondary,rgba(128,128,128,.75));word-break:break-all}
92
+ /* ── in-page directory browser ──────────────────────────────────────── */
93
+ .lsd-browser{display:flex;flex-direction:column;gap:9px;padding:11px;border-radius:10px;flex:none;
94
+ border:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.3));background:rgba(128,128,128,.07)}
95
+ .lsd-browser-head{display:flex;gap:7px;align-items:center}
96
+ .lsd-browser-path{flex:1;min-width:0}
97
+ .lsd-drives{display:flex;gap:5px;flex-wrap:wrap}
98
+ .lsd-drive{flex:none;padding:3px 9px;font-size:11px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;
99
+ cursor:pointer;color:var(--dsw-alias-label-primary,inherit);background:rgba(128,128,128,.14);
100
+ border:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.3));border-radius:6px}
101
+ .lsd-drive:hover:not(:disabled){background:rgba(128,128,128,.26)}
102
+ .lsd-drive:disabled{opacity:.5;cursor:default}
103
+ .lsd-browser-list{display:flex;flex-direction:column;gap:2px;max-height:230px;overflow:auto;
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}
110
+ .lsd-entry{text-align:left;padding:5px 9px;font-size:12px;font-family:inherit;cursor:pointer;
111
+ color:var(--dsw-alias-label-primary,inherit);background:transparent;border:none;border-radius:5px;
112
+ white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
113
+ .lsd-entry:hover:not(:disabled){background:rgba(128,128,128,.22)}
114
+ .lsd-entry:disabled{opacity:.5;cursor:default}
115
+ .lsd-browser-foot{display:flex;flex-direction:column;gap:8px;align-items:flex-start}
116
+ .lsd-browser-foot .lsd-actions{padding-top:0}
117
+ /* ── hardware readout ───────────────────────────────────────────────── */
118
+ .lsd-hw{display:flex;flex-direction:column;gap:5px;padding:10px 12px;border-radius:10px;
119
+ border:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.24));background:var(--dsw-alias-bg-base,rgba(128,128,128,.05))}
120
+ .lsd-hw-row{display:flex;gap:9px;align-items:baseline;font-size:12px;flex-wrap:wrap}
121
+ .lsd-hw-k{flex:none;min-width:52px;font-size:11px;color:var(--dsw-alias-label-secondary,rgba(128,128,128,.9))}
122
+ .lsd-hw-v{font-weight:500;word-break:break-word}
123
+ .lsd-hw-v.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px}
124
+ `;
125
+ document.head.appendChild(style);
126
+ return function() { style.remove(); };
127
+ });
128
+
129
+ /** POST one command to the host settings API. */
130
+ function callApi(payload) {
131
+ return fetch(API_URL, {
132
+ method: "POST",
133
+ headers: { "content-type": "application/json" },
134
+ body: JSON.stringify(payload)
135
+ }).then(function(r) {
136
+ return r.json().catch(function() {
137
+ return { ok: false, error: "HTTP " + r.status };
138
+ });
139
+ }).catch(function(e) {
140
+ return { ok: false, error: String(e && e.message || e) };
141
+ });
142
+ }
143
+
144
+ function fmtMs(ms) {
145
+ if (typeof ms !== "number" || !isFinite(ms)) return "—";
146
+ if (ms <= 0) return "永不";
147
+ if (ms < 60000) return Math.round(ms / 1000) + " 秒";
148
+ return Math.round(ms / 60000) + " 分钟";
149
+ }
150
+
151
+ /* ── the settings page ──────────────────────────────────────── */
152
+
153
+ function SettingsPage() {
154
+ var stateS = React.useState(null);
155
+ var state = stateS[0];
156
+ var setState = stateS[1];
157
+ var phaseS = React.useState("loading");
158
+ var phase = phaseS[0];
159
+ var setPhase = phaseS[1];
160
+ var msgS = React.useState(null);
161
+ var msg = msgS[0];
162
+ var setMsg = msgS[1];
163
+ var foundS = React.useState(null);
164
+ var found = foundS[0];
165
+ var setFound = foundS[1];
166
+ // In-page directory browser state. `browser` is null while closed,
167
+ // otherwise the listing the host returned for the current path.
168
+ var browserS = React.useState(null);
169
+ var browser = browserS[0];
170
+ var setBrowser = browserS[1];
171
+ var busyS = React.useState(false);
172
+ var busy = busyS[0];
173
+ var setBusy = busyS[1];
174
+
175
+ // Local form state, seeded from the host snapshot. Kept separate
176
+ // so typing does not round-trip until 保存.
177
+ var forgeDirS = React.useState("");
178
+ var forgeDir = forgeDirS[0];
179
+ var setForgeDir = forgeDirS[1];
180
+ // Where PNGs land. Empty = <workspace>/generated.
181
+ var outputDirS = React.useState("");
182
+ var outputDir = outputDirS[0];
183
+ var setOutputDir = outputDirS[1];
184
+ // Host-resolved default for an empty outputDir, shown as a hint.
185
+ var defaultOutputDirS = React.useState("");
186
+ var defaultOutputDir = defaultOutputDirS[0];
187
+ var setDefaultOutputDir = defaultOutputDirS[1];
188
+ // Which field the in-page browser is filling: "forge" | "output".
189
+ var browseTargetS = React.useState("forge");
190
+ var browseTarget = browseTargetS[0];
191
+ var setBrowseTarget = browseTargetS[1];
192
+ var endpointS = React.useState("");
193
+ var endpoint = endpointS[0];
194
+ var setEndpoint = endpointS[1];
195
+ var enabledS = React.useState(true);
196
+ var enabled = enabledS[0];
197
+ var setEnabled = enabledS[1];
198
+ var startupS = React.useState(240000);
199
+ var startupMs = startupS[0];
200
+ var setStartupMs = startupS[1];
201
+ var idleS = React.useState(300000);
202
+ var idleMs = idleS[0];
203
+ var setIdleMs = idleS[1];
204
+ var stepsS = React.useState(25);
205
+ var steps = stepsS[0];
206
+ var setSteps = stepsS[1];
207
+ var cfgS = React.useState(7);
208
+ var cfg = cfgS[0];
209
+ var setCfg = cfgS[1];
210
+ var wS = React.useState(512);
211
+ var width = wS[0];
212
+ var setWidth = wS[1];
213
+ var hS = React.useState(768);
214
+ var height = hS[0];
215
+ var setHeight = hS[1];
216
+ // Hardware snapshot: { gpu, recommend } once the probe lands.
217
+ var hwS = React.useState(null);
218
+ var hw = hwS[0];
219
+ var setHw = hwS[1];
220
+ var hwPhaseS = React.useState("idle");
221
+ var hwPhase = hwPhaseS[0];
222
+ var setHwPhase = hwPhaseS[1];
223
+
224
+ function adopt(s) {
225
+ setState(s);
226
+ if (!s) return;
227
+ setForgeDir(s.forgeDir || "");
228
+ setOutputDir(s.outputDir || "");
229
+ setDefaultOutputDir(s.defaultOutputDir || "");
230
+ setEndpoint(s.endpoint || "");
231
+ setEnabled(!!(s.autoStart && s.autoStart.enabled));
232
+ setStartupMs((s.autoStart && s.autoStart.startupTimeoutMs) || 240000);
233
+ setIdleMs((s.autoStart && s.autoStart.idleShutdownMs) || 0);
234
+ setSteps((s.defaults && s.defaults.steps) || 25);
235
+ setCfg((s.defaults && s.defaults.cfg) || 7);
236
+ setWidth((s.defaults && s.defaults.width) || 512);
237
+ setHeight((s.defaults && s.defaults.height) || 768);
238
+ }
239
+
240
+ React.useEffect(function() {
241
+ var cancelled = false;
242
+ callApi({ method: "get" }).then(function(res) {
243
+ if (cancelled) return;
244
+ if (res && res.ok) {
245
+ adopt(res.state);
246
+ setPhase("ready");
247
+ } else {
248
+ setPhase("error");
249
+ setMsg({ kind: "err", text: "读取配置失败:" + String(res && res.error || "未知错误") });
250
+ }
251
+ });
252
+ return function() { cancelled = true; };
253
+ }, []);
254
+
255
+ /** Probe the GPU. Slow (nvidia-smi + registry walk), so it runs
256
+ * after the page is already on screen. */
257
+ function loadHardware() {
258
+ setHwPhase("loading");
259
+ callApi({ method: "hardware" }).then(function(res) {
260
+ if (res && res.ok && res.hardware) {
261
+ setHw(res.hardware);
262
+ setHwPhase("ready");
263
+ } else {
264
+ setHwPhase("error");
265
+ }
266
+ });
267
+ }
268
+
269
+ React.useEffect(function() {
270
+ var cancelled = false;
271
+ setHwPhase("loading");
272
+ callApi({ method: "hardware" }).then(function(res) {
273
+ if (cancelled) return;
274
+ if (res && res.ok && res.hardware) {
275
+ setHw(res.hardware);
276
+ setHwPhase("ready");
277
+ } else {
278
+ setHwPhase("error");
279
+ }
280
+ });
281
+ return function() { cancelled = true; };
282
+ }, []);
283
+
284
+ /** Copy the recommended values into the form. Nothing is saved
285
+ * until 保存 is pressed — this only fills the inputs. */
286
+ function applyRecommend() {
287
+ if (hw === null || !hw.recommend) return;
288
+ var r = hw.recommend;
289
+ setWidth(r.width);
290
+ setHeight(r.height);
291
+ setSteps(r.steps);
292
+ setCfg(r.cfg);
293
+ setMsg({ kind: "info", text: "已填入推荐值:" + r.width + "×" + r.height + " / " + r.steps + " 步 / CFG " + r.cfg + ",记得点保存" });
294
+ }
295
+
296
+ function save() {
297
+ setBusy(true);
298
+ setMsg(null);
299
+ return callApi({
300
+ method: "save",
301
+ forgeDir: forgeDir,
302
+ outputDir: outputDir,
303
+ endpoint: endpoint,
304
+ autoStart: {
305
+ enabled: enabled,
306
+ startupTimeoutMs: Number(startupMs) || 240000,
307
+ idleShutdownMs: Number(idleMs) || 0
308
+ },
309
+ defaults: {
310
+ steps: Number(steps) || 25,
311
+ cfg: Number(cfg) || 7,
312
+ width: Number(width) || 512,
313
+ height: Number(height) || 768
314
+ }
315
+ }).then(function(res) {
316
+ setBusy(false);
317
+ if (res && res.ok) {
318
+ adopt(res.state);
319
+ var okLauncher = res.state && res.state.launcher;
320
+ setMsg(okLauncher
321
+ ? { kind: "ok", text: "已保存。识别到启动文件:" + okLauncher.launcher }
322
+ : { kind: "warn", text: "已保存,但那个目录里没找到 run.bat / webui.bat / webui.sh。" +
323
+ "\n填 WebUI 解压后的根目录(Forge 是外层那个带 run.bat 的文件夹)。" });
324
+ } else {
325
+ setMsg({ kind: "err", text: "保存失败:" + String(res && res.error || "未知错误") });
326
+ }
327
+ });
328
+ }
329
+
330
+ // Open the in-page browser, starting at whatever is currently typed
331
+ // (or the host's home directory when the box is empty). `target`
332
+ // decides which field "选这个目录" fills.
333
+ function browse(target) {
334
+ setBusy(true);
335
+ setMsg(null);
336
+ setBrowseTarget(target);
337
+ var start = target === "output" ? (outputDir || defaultOutputDir || "") : (forgeDir || "");
338
+ callApi({ method: "browse", path: start }).then(function(res) {
339
+ setBusy(false);
340
+ if (res && res.ok && res.listing) {
341
+ setBrowser(res.listing);
342
+ } else {
343
+ setMsg({ kind: "err", text: "打开目录失败:" + String(res && res.error || "未知错误") });
344
+ }
345
+ });
346
+ }
347
+
348
+ /** Load one directory into the open browser. */
349
+ function openDir(path) {
350
+ setBusy(true);
351
+ callApi({ method: "browse", path: path }).then(function(res) {
352
+ setBusy(false);
353
+ if (res && res.ok && res.listing) {
354
+ setBrowser(res.listing);
355
+ } else {
356
+ setMsg({ kind: "err", text: "打开目录失败:" + String(res && res.error || "未知错误") });
357
+ }
358
+ });
359
+ }
360
+
361
+ /** Commit the browser's current directory to the form field. */
362
+ function useCurrentDir() {
363
+ if (browser === null) return;
364
+ if (browseTarget === "output") {
365
+ setOutputDir(browser.path);
366
+ } else {
367
+ setForgeDir(browser.path);
368
+ }
369
+ setBrowser(null);
370
+ setMsg({ kind: "info", text: "已选:" + browser.path + ",记得点保存" });
371
+ }
372
+
373
+ function detect() {
374
+ setBusy(true);
375
+ setMsg(null);
376
+ callApi({ method: "detect" }).then(function(res) {
377
+ setBusy(false);
378
+ if (res && res.ok) {
379
+ setFound(res.found || []);
380
+ if (!res.found || res.found.length === 0) {
381
+ setMsg({ kind: "warn", text: "常见位置没扫到。手动填目录吧,就是含 run.bat 的那个文件夹。" });
382
+ }
383
+ } else {
384
+ setMsg({ kind: "err", text: "探测失败:" + String(res && res.error || "未知错误") });
385
+ }
386
+ });
387
+ }
388
+
389
+ function backend(action) {
390
+ setBusy(true);
391
+ setMsg(null);
392
+ callApi({ method: action }).then(function(res) {
393
+ setBusy(false);
394
+ if (res && res.state) adopt(res.state);
395
+ if (res && res.ok) {
396
+ setMsg({ kind: "ok", text: action === "start" ? "后端已就绪" : "后端已关闭,显存释放" });
397
+ } else {
398
+ setMsg({ kind: "err", text: String(res && res.error || "操作失败") });
399
+ }
400
+ });
401
+ }
402
+
403
+ if (phase === "loading") {
404
+ return h("div", { className: "lsd-root" },
405
+ h("div", { className: "lsd-desc" }, "读取配置中…")
406
+ );
407
+ }
408
+
409
+ var live = state && state.liveEndpoint;
410
+ var launcher = state && state.launcher;
411
+ var dotClass = "lsd-dot" + (live ? " on" : (launcher ? " warn" : ""));
412
+
413
+ return h("div", { className: "lsd-root" },
414
+ h("div", { className: "lsd-head" },
415
+ h("h2", { className: "lsd-title" }, "本地绘图"),
416
+ h("p", { className: "lsd-desc" },
417
+ "把本机的 Stable Diffusion WebUI / Forge 接到 DSH。提示词原样送进采样器,"
418
+ + "不经过任何云端服务、没有内容过滤。后端按需启动,闲置自动关闭。")
419
+ ),
420
+
421
+ // ── status ───────────────────────────────────────────
422
+ h("div", { className: "lsd-card" },
423
+ h("div", { className: "lsd-card-head" },
424
+ h("span", { className: dotClass }),
425
+ h("span", { className: "lsd-card-title" },
426
+ live ? "后端运行中" : (launcher ? "已配置,后端未启动" : "还没配置"))
427
+ ),
428
+ h("div", { className: "lsd-grid" },
429
+ h("div", { className: "lsd-stat" },
430
+ h("span", { className: "lsd-stat-k" }, "接口地址"),
431
+ h("span", { className: "lsd-stat-v" }, live || (state && state.endpoint) || "—")
432
+ ),
433
+ h("div", { className: "lsd-stat" },
434
+ h("span", { className: "lsd-stat-k" }, "启动文件"),
435
+ h("span", { className: "lsd-stat-v" }, launcher ? launcher.launcher : "未识别")
436
+ ),
437
+ h("div", { className: "lsd-stat" },
438
+ h("span", { className: "lsd-stat-k" }, "进程归属"),
439
+ h("span", { className: "lsd-stat-v" }, state && state.owned ? "插件启动" : "非插件启动")
440
+ ),
441
+ h("div", { className: "lsd-stat" },
442
+ h("span", { className: "lsd-stat-k" }, "闲置关闭"),
443
+ h("span", { className: "lsd-stat-v" }, fmtMs(idleMs))
444
+ )
445
+ ),
446
+ h("div", { className: "lsd-actions" },
447
+ h("button", {
448
+ className: "lsd-btn", disabled: busy,
449
+ onClick: function() { backend("start"); }
450
+ }, "预热后端"),
451
+ h("button", {
452
+ className: "lsd-btn", disabled: busy || !(state && state.owned),
453
+ onClick: function() { backend("stop"); }
454
+ }, "释放显存"),
455
+ h("button", {
456
+ className: "lsd-btn", disabled: busy,
457
+ onClick: function() { callApi({ method: "get" }).then(function(r) { if (r && r.ok) adopt(r.state); }); }
458
+ }, "刷新")
459
+ )
460
+ ),
461
+
462
+ // ── directories ──────────────────────────────────────
463
+ h("div", { className: "lsd-card" },
464
+ h("div", { className: "lsd-card-head" },
465
+ h("span", { className: "lsd-card-title" }, "目录设置")
466
+ ),
467
+ h("div", { className: "lsd-field" },
468
+ h("label", { className: "lsd-label" }, "Stable Diffusion WebUI / Forge 根目录"),
469
+ h("div", { className: "lsd-row" },
470
+ h("input", {
471
+ className: "lsd-input mono",
472
+ value: forgeDir,
473
+ placeholder: "例如 D:\\AI\\forge\\webui_forge_cu124_torch24",
474
+ onChange: function(e) { setForgeDir(e.target.value); }
475
+ }),
476
+ h("button", { className: "lsd-btn", disabled: busy, onClick: function() { browse("forge"); } }, "浏览…"),
477
+ h("button", { className: "lsd-btn", disabled: busy, onClick: detect }, "自动探测")
478
+ ),
479
+ h("span", { className: "lsd-hint" },
480
+ "填解压后带 run.bat 的那一层(Forge 是外层文件夹,不是 system 里面)。"
481
+ + "也接受直接填 run.bat 的完整路径。Linux / macOS 会自动找 webui.sh。")
482
+ ),
483
+
484
+ h("div", { className: "lsd-field" },
485
+ h("label", { className: "lsd-label" }, "图片存放目录"),
486
+ h("div", { className: "lsd-row" },
487
+ h("input", {
488
+ className: "lsd-input mono",
489
+ value: outputDir,
490
+ placeholder: defaultOutputDir || "留空 = 工作区下的 generated",
491
+ onChange: function(e) { setOutputDir(e.target.value); }
492
+ }),
493
+ h("button", { className: "lsd-btn", disabled: busy, onClick: function() { browse("output"); } }, "浏览…"),
494
+ h("button", {
495
+ className: "lsd-btn", disabled: busy || outputDir.length === 0,
496
+ onClick: function() { setOutputDir(""); }
497
+ }, "恢复默认")
498
+ ),
499
+ h("span", { className: "lsd-hint" },
500
+ "出图存哪儿。留空就用工作区的 generated 子目录;"
501
+ + "填相对路径(如 pics/sd)会接在工作区后面,填绝对路径(如 D:\\AI\\pics)则原样使用。"
502
+ + (defaultOutputDir ? "\n当前默认:" + defaultOutputDir : ""))
503
+ ),
504
+
505
+ // ── in-page directory browser ────────────────────
506
+ browser !== null
507
+ ? h("div", { className: "lsd-browser" },
508
+ h("div", { className: "lsd-browser-head" },
509
+ h("button", {
510
+ className: "lsd-btn", disabled: busy || browser.parent === null,
511
+ title: browser.parent === null ? "已经是根目录" : browser.parent,
512
+ onClick: function() { if (browser.parent) openDir(browser.parent); }
513
+ }, "↑ 上一层"),
514
+ h("input", {
515
+ className: "lsd-input mono lsd-browser-path",
516
+ value: browser.path,
517
+ onChange: function(e) { setBrowser(Object.assign({}, browser, { path: e.target.value })); },
518
+ onKeyDown: function(e) { if (e.key === "Enter") openDir(e.target.value); }
519
+ }),
520
+ h("button", { className: "lsd-btn", disabled: busy, onClick: function() { openDir(browser.path); } }, "前往")
521
+ ),
522
+
523
+ browser.roots && browser.roots.length > 1
524
+ ? h("div", { className: "lsd-drives" },
525
+ browser.roots.map(function(drive) {
526
+ return h("button", {
527
+ className: "lsd-drive", key: drive, disabled: busy,
528
+ onClick: function() { openDir(drive); }
529
+ }, drive);
530
+ })
531
+ )
532
+ : null,
533
+
534
+ browser.error
535
+ ? h("div", { className: "lsd-msg err" }, browser.error)
536
+ : null,
537
+
538
+ h("div", { className: "lsd-browser-list" },
539
+ browser.entries && browser.entries.length > 0
540
+ ? browser.entries.map(function(name) {
541
+ var sep = browser.path.indexOf("\\") >= 0 ? "\\" : "/";
542
+ var child = browser.path.replace(/[\\/]+$/, "") + sep + name;
543
+ return h("button", {
544
+ className: "lsd-entry", key: name, disabled: busy,
545
+ onClick: function() { openDir(child); }
546
+ }, "📁 " + name);
547
+ })
548
+ : h("div", { className: "lsd-hint" }, browser.error ? "" : "这个目录里没有子文件夹")
549
+ ),
550
+
551
+ h("div", { className: "lsd-browser-foot" },
552
+ browseTarget === "output"
553
+ ? h("span", { className: "lsd-hint" },
554
+ "选一个放图的文件夹,没有的话在上层目录里新建一个再进来")
555
+ : browser.launcher
556
+ ? h("span", { className: "lsd-msg ok" },
557
+ "✓ 这个目录里有 " + browser.launcher.launcher + ",就是它了")
558
+ : h("span", { className: "lsd-hint" },
559
+ "这个目录里没有 run.bat / webui.bat / webui.sh,继续往下找或者换一层"),
560
+ h("div", { className: "lsd-actions" },
561
+ h("button", {
562
+ className: "lsd-btn primary", disabled: busy,
563
+ onClick: useCurrentDir
564
+ }, "选这个目录"),
565
+ h("button", {
566
+ className: "lsd-btn", disabled: busy,
567
+ onClick: function() { setBrowser(null); }
568
+ }, "取消")
569
+ )
570
+ )
571
+ )
572
+ : null,
573
+
574
+ found !== null && found.length > 0
575
+ ? h("div", { className: "lsd-found" },
576
+ found.map(function(item, i) {
577
+ return h("div", { className: "lsd-found-item", key: i },
578
+ h("span", null, item.dir),
579
+ h("button", {
580
+ className: "lsd-btn",
581
+ onClick: function() {
582
+ setForgeDir(item.dir);
583
+ setFound(null);
584
+ setMsg({ kind: "info", text: "已选:" + item.dir + ",记得点保存" });
585
+ }
586
+ }, "用这个")
587
+ );
588
+ })
589
+ )
590
+ : null
591
+ ),
592
+
593
+ // ── backend behaviour ────────────────────────────────
594
+ h("div", { className: "lsd-card" },
595
+ h("div", { className: "lsd-card-head" },
596
+ h("span", { className: "lsd-card-title" }, "后端行为")
597
+ ),
598
+ h("label", { className: "lsd-toggle" },
599
+ h("input", {
600
+ type: "checkbox", checked: enabled,
601
+ onChange: function(e) { setEnabled(e.target.checked); }
602
+ }),
603
+ h("span", null, "后端没开时自动启动它")
604
+ ),
605
+ h("div", { className: "lsd-grid" },
606
+ h("div", { className: "lsd-field" },
607
+ h("label", { className: "lsd-label" }, "启动超时(毫秒)"),
608
+ h("input", {
609
+ className: "lsd-input", type: "number", min: 30000, step: 10000,
610
+ value: startupMs,
611
+ onChange: function(e) { setStartupMs(e.target.value); }
612
+ }),
613
+ h("span", { className: "lsd-hint" }, "冷启动要 2~3 分钟,别调太小")
614
+ ),
615
+ h("div", { className: "lsd-field" },
616
+ h("label", { className: "lsd-label" }, "闲置多久自动关闭(毫秒)"),
617
+ h("input", {
618
+ className: "lsd-input", type: "number", min: 0, step: 60000,
619
+ value: idleMs,
620
+ onChange: function(e) { setIdleMs(e.target.value); }
621
+ }),
622
+ h("span", { className: "lsd-hint" }, "填 0 = 永不自动关闭")
623
+ ),
624
+ h("div", { className: "lsd-field" },
625
+ h("label", { className: "lsd-label" }, "接口地址"),
626
+ h("input", {
627
+ className: "lsd-input mono", value: endpoint,
628
+ onChange: function(e) { setEndpoint(e.target.value); }
629
+ }),
630
+ h("span", { className: "lsd-hint" }, "默认 7860,改过端口才需要动")
631
+ )
632
+ )
633
+ ),
634
+
635
+ // ── defaults ─────────────────────────────────────────
636
+ h("div", { className: "lsd-card" },
637
+ h("div", { className: "lsd-card-head" },
638
+ h("span", { className: "lsd-card-title" }, "默认出图参数")
639
+ ),
640
+
641
+ // Hardware readout + one-click recommended values.
642
+ hwPhase === "loading"
643
+ ? h("div", { className: "lsd-hint" }, "正在识别显卡…")
644
+ : hwPhase === "error"
645
+ ? h("div", { className: "lsd-hint" },
646
+ "没读到显卡信息,按最保守的参数来。可以点「重新识别」再试一次。"
647
+ + "\n如果一直是这样,多半是 DSH 没重启 —— 插件换了文件但宿主还跑着旧代码。")
648
+ : hw !== null
649
+ ? h("div", { className: "lsd-hw" },
650
+ h("div", { className: "lsd-hw-row" },
651
+ h("span", { className: "lsd-hw-k" }, "显卡"),
652
+ h("span", { className: "lsd-hw-v" },
653
+ hw.gpu
654
+ ? hw.gpu.name + " " + (hw.gpu.vramMb / 1024).toFixed(1).replace(/\.0$/, "") + " GB 显存"
655
+ : "未识别"
656
+ )
657
+ ),
658
+ hw.gpu && !hw.gpu.exact
659
+ ? h("div", { className: "lsd-hint" },
660
+ "(显存数值来自 WMI,8 GB 以上的卡可能显示不准,仅供参考)")
661
+ : null,
662
+ // When nothing was found, show what each probe
663
+ // actually returned — a bare "not detected"
664
+ // leaves no way to tell a driver problem from a
665
+ // sandbox one.
666
+ !hw.gpu && hw.tried && hw.tried.length > 0
667
+ ? h("div", { className: "lsd-hint" },
668
+ "试过:" + hw.tried.map(function(t) {
669
+ return t.source + (t.ok ? " ✓" : " ✗");
670
+ }).join(" ")
671
+ + "。全都没结果,就手动填参数吧。")
672
+ : null,
673
+ hw.build
674
+ ? h("div", { className: "lsd-footer" }, "插件构建 " + hw.build)
675
+ : null,
676
+ hw.recommend
677
+ ? h("div", { className: "lsd-hw-row" },
678
+ h("span", { className: "lsd-hw-k" }, "推荐"),
679
+ h("span", { className: "lsd-hw-v" },
680
+ hw.recommend.width + "×" + hw.recommend.height
681
+ + " " + hw.recommend.steps + " 步 CFG " + hw.recommend.cfg
682
+ )
683
+ )
684
+ : null,
685
+ hw.recommend && hw.recommend.note
686
+ ? h("div", { className: "lsd-hint" }, hw.recommend.note)
687
+ : null,
688
+ hw.recommend && hw.recommend.launchArgs
689
+ ? h("div", { className: "lsd-hw-row" },
690
+ h("span", { className: "lsd-hw-k" }, "启动参数"),
691
+ h("span", { className: "lsd-hw-v mono" }, hw.recommend.launchArgs)
692
+ )
693
+ : null
694
+ )
695
+ : null,
696
+
697
+ hw !== null && hw.recommend
698
+ ? h("div", { className: "lsd-actions" },
699
+ h("button", {
700
+ className: "lsd-btn", disabled: busy,
701
+ onClick: applyRecommend
702
+ }, "套用推荐值"),
703
+ h("button", {
704
+ className: "lsd-btn", disabled: busy || hwPhase === "loading",
705
+ onClick: loadHardware
706
+ }, "重新识别")
707
+ )
708
+ : hwPhase !== "loading"
709
+ ? h("div", { className: "lsd-actions" },
710
+ h("button", {
711
+ className: "lsd-btn", disabled: busy || hwPhase === "loading",
712
+ onClick: loadHardware
713
+ }, "识别显卡")
714
+ )
715
+ : null,
716
+
717
+ h("div", { className: "lsd-grid" },
718
+ h("div", { className: "lsd-field" },
719
+ h("label", { className: "lsd-label" }, "宽度"),
720
+ h("input", {
721
+ className: "lsd-input", type: "number", min: 64, step: 64, value: width,
722
+ onChange: function(e) { setWidth(e.target.value); }
723
+ })
724
+ ),
725
+ h("div", { className: "lsd-field" },
726
+ h("label", { className: "lsd-label" }, "高度"),
727
+ h("input", {
728
+ className: "lsd-input", type: "number", min: 64, step: 64, value: height,
729
+ onChange: function(e) { setHeight(e.target.value); }
730
+ })
731
+ ),
732
+ h("div", { className: "lsd-field" },
733
+ h("label", { className: "lsd-label" }, "步数"),
734
+ h("input", {
735
+ className: "lsd-input", type: "number", min: 1, max: 150, value: steps,
736
+ onChange: function(e) { setSteps(e.target.value); }
737
+ })
738
+ ),
739
+ h("div", { className: "lsd-field" },
740
+ h("label", { className: "lsd-label" }, "CFG"),
741
+ h("input", {
742
+ className: "lsd-input", type: "number", min: 1, max: 30, step: 0.5, value: cfg,
743
+ onChange: function(e) { setCfg(e.target.value); }
744
+ })
745
+ )
746
+ ),
747
+ h("span", { className: "lsd-hint" },
748
+ hw !== null && hw.recommend
749
+ ? "按你现在的显卡,上面这组是能跑满又不容易爆显存的组合。手动改也行。"
750
+ : "没识别到显卡时按最保守的来:SD1.5 用 512×768 基本不会爆。")
751
+ ),
752
+
753
+ msg !== null
754
+ ? h("div", { className: "lsd-msg " + (msg.kind === "warn" ? "info" : msg.kind) }, msg.text)
755
+ : null,
756
+
757
+ h("div", { className: "lsd-actions" },
758
+ h("button", {
759
+ className: "lsd-btn primary", disabled: busy, onClick: save
760
+ }, busy ? "处理中…" : "保存"),
761
+ state
762
+ ? h("span", { className: "lsd-footer" }, "配置文件:" + state.configPath)
763
+ : null
764
+ )
765
+ );
766
+ }
767
+
768
+ /* ── registration ───────────────────────────────────────────── */
769
+
770
+ ctx.slots.inject("settings.section", function() {
771
+ return ctx.slots.register({
772
+ name: "settings.section",
773
+ id: "local-sd",
774
+ // After the shipped pages, next to the other plugin pages.
775
+ order: 210,
776
+ label: NAV_LABEL
777
+ }, SettingsPage);
778
+ });
779
+ };
780
+ return module.exports;
781
+ }
782
+ });