dsh-local-sd 0.4.1 → 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/README.md +5 -2
- package/lib/client.js +73 -13
- package/lib/index.js +145 -30
- 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 = 永不自动关闭** |
|
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}
|
|
@@ -163,6 +177,18 @@ window.__ModuleLoader__.load({
|
|
|
163
177
|
var forgeDirS = React.useState("");
|
|
164
178
|
var forgeDir = forgeDirS[0];
|
|
165
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];
|
|
166
192
|
var endpointS = React.useState("");
|
|
167
193
|
var endpoint = endpointS[0];
|
|
168
194
|
var setEndpoint = endpointS[1];
|
|
@@ -199,6 +225,8 @@ window.__ModuleLoader__.load({
|
|
|
199
225
|
setState(s);
|
|
200
226
|
if (!s) return;
|
|
201
227
|
setForgeDir(s.forgeDir || "");
|
|
228
|
+
setOutputDir(s.outputDir || "");
|
|
229
|
+
setDefaultOutputDir(s.defaultOutputDir || "");
|
|
202
230
|
setEndpoint(s.endpoint || "");
|
|
203
231
|
setEnabled(!!(s.autoStart && s.autoStart.enabled));
|
|
204
232
|
setStartupMs((s.autoStart && s.autoStart.startupTimeoutMs) || 240000);
|
|
@@ -271,6 +299,7 @@ window.__ModuleLoader__.load({
|
|
|
271
299
|
return callApi({
|
|
272
300
|
method: "save",
|
|
273
301
|
forgeDir: forgeDir,
|
|
302
|
+
outputDir: outputDir,
|
|
274
303
|
endpoint: endpoint,
|
|
275
304
|
autoStart: {
|
|
276
305
|
enabled: enabled,
|
|
@@ -299,11 +328,14 @@ window.__ModuleLoader__.load({
|
|
|
299
328
|
}
|
|
300
329
|
|
|
301
330
|
// Open the in-page browser, starting at whatever is currently typed
|
|
302
|
-
// (or the host's home directory when the box is empty).
|
|
303
|
-
|
|
331
|
+
// (or the host's home directory when the box is empty). `target`
|
|
332
|
+
// decides which field "选这个目录" fills.
|
|
333
|
+
function browse(target) {
|
|
304
334
|
setBusy(true);
|
|
305
335
|
setMsg(null);
|
|
306
|
-
|
|
336
|
+
setBrowseTarget(target);
|
|
337
|
+
var start = target === "output" ? (outputDir || defaultOutputDir || "") : (forgeDir || "");
|
|
338
|
+
callApi({ method: "browse", path: start }).then(function(res) {
|
|
307
339
|
setBusy(false);
|
|
308
340
|
if (res && res.ok && res.listing) {
|
|
309
341
|
setBrowser(res.listing);
|
|
@@ -329,7 +361,11 @@ window.__ModuleLoader__.load({
|
|
|
329
361
|
/** Commit the browser's current directory to the form field. */
|
|
330
362
|
function useCurrentDir() {
|
|
331
363
|
if (browser === null) return;
|
|
332
|
-
|
|
364
|
+
if (browseTarget === "output") {
|
|
365
|
+
setOutputDir(browser.path);
|
|
366
|
+
} else {
|
|
367
|
+
setForgeDir(browser.path);
|
|
368
|
+
}
|
|
333
369
|
setBrowser(null);
|
|
334
370
|
setMsg({ kind: "info", text: "已选:" + browser.path + ",记得点保存" });
|
|
335
371
|
}
|
|
@@ -423,10 +459,10 @@ window.__ModuleLoader__.load({
|
|
|
423
459
|
)
|
|
424
460
|
),
|
|
425
461
|
|
|
426
|
-
// ──
|
|
462
|
+
// ── directories ──────────────────────────────────────
|
|
427
463
|
h("div", { className: "lsd-card" },
|
|
428
464
|
h("div", { className: "lsd-card-head" },
|
|
429
|
-
h("span", { className: "lsd-card-title" }, "
|
|
465
|
+
h("span", { className: "lsd-card-title" }, "目录设置")
|
|
430
466
|
),
|
|
431
467
|
h("div", { className: "lsd-field" },
|
|
432
468
|
h("label", { className: "lsd-label" }, "Stable Diffusion WebUI / Forge 根目录"),
|
|
@@ -437,7 +473,7 @@ window.__ModuleLoader__.load({
|
|
|
437
473
|
placeholder: "例如 D:\\AI\\forge\\webui_forge_cu124_torch24",
|
|
438
474
|
onChange: function(e) { setForgeDir(e.target.value); }
|
|
439
475
|
}),
|
|
440
|
-
h("button", { className: "lsd-btn", disabled: busy, onClick: browse }, "浏览…"),
|
|
476
|
+
h("button", { className: "lsd-btn", disabled: busy, onClick: function() { browse("forge"); } }, "浏览…"),
|
|
441
477
|
h("button", { className: "lsd-btn", disabled: busy, onClick: detect }, "自动探测")
|
|
442
478
|
),
|
|
443
479
|
h("span", { className: "lsd-hint" },
|
|
@@ -445,6 +481,27 @@ window.__ModuleLoader__.load({
|
|
|
445
481
|
+ "也接受直接填 run.bat 的完整路径。Linux / macOS 会自动找 webui.sh。")
|
|
446
482
|
),
|
|
447
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
|
+
|
|
448
505
|
// ── in-page directory browser ────────────────────
|
|
449
506
|
browser !== null
|
|
450
507
|
? h("div", { className: "lsd-browser" },
|
|
@@ -492,11 +549,14 @@ window.__ModuleLoader__.load({
|
|
|
492
549
|
),
|
|
493
550
|
|
|
494
551
|
h("div", { className: "lsd-browser-foot" },
|
|
495
|
-
|
|
496
|
-
? h("span", { className: "lsd-
|
|
497
|
-
"
|
|
498
|
-
:
|
|
499
|
-
"
|
|
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,继续往下找或者换一层"),
|
|
500
560
|
h("div", { className: "lsd-actions" },
|
|
501
561
|
h("button", {
|
|
502
562
|
className: "lsd-btn primary", disabled: busy,
|
package/lib/index.js
CHANGED
|
@@ -52,7 +52,7 @@ import { homedir, tmpdir } from "node:os";
|
|
|
52
52
|
import { mkdir, writeFile, readFile, readdir, stat } from "node:fs/promises";
|
|
53
53
|
import { openSync, closeSync, readFileSync, unlinkSync } 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.5.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: {
|
|
@@ -1038,39 +1043,114 @@ async function txt2img(endpoint, prompt, opts, signal) {
|
|
|
1038
1043
|
return parsed;
|
|
1039
1044
|
}
|
|
1040
1045
|
|
|
1046
|
+
/**
|
|
1047
|
+
* Where PNGs land, and what the tool result calls that place.
|
|
1048
|
+
*
|
|
1049
|
+
* Precedence:
|
|
1050
|
+
* 1. `settings.outputDir` from $DSH_HOME/local-sd.json (the settings page),
|
|
1051
|
+
* 2. `<workspace root>/generated`.
|
|
1052
|
+
*
|
|
1053
|
+
* A relative `outputDir` is joined to the workspace root so the settings page
|
|
1054
|
+
* can offer the short, readable form (`generated`, `pics/sd`). An absolute one
|
|
1055
|
+
* is honoured verbatim: that is the whole point of the knob, pointing it at the
|
|
1056
|
+
* folder you actually browse even when the session workspace is elsewhere.
|
|
1057
|
+
*
|
|
1058
|
+
* `display` is what goes into the tool result: workspace-relative when the
|
|
1059
|
+
* folder sits inside the workspace (so the Web Client can link it), absolute
|
|
1060
|
+
* otherwise (a relative path there would be a lie).
|
|
1061
|
+
*/
|
|
1062
|
+
function resolveOutputLocation(settings, ctx) {
|
|
1063
|
+
const policy = ctx !== undefined && typeof ctx.get === "function" ? ctx.get("sandboxPolicy") : undefined;
|
|
1064
|
+
const root =
|
|
1065
|
+
policy !== undefined && typeof policy.workspaceRoot === "string" && policy.workspaceRoot.length > 0
|
|
1066
|
+
? policy.workspaceRoot
|
|
1067
|
+
: process.cwd();
|
|
1068
|
+
|
|
1069
|
+
const configured = settings !== undefined && typeof settings.outputDir === "string" ? settings.outputDir.trim() : "";
|
|
1070
|
+
const dir = configured.length === 0
|
|
1071
|
+
? join(root, "generated")
|
|
1072
|
+
: isAbsolute(configured)
|
|
1073
|
+
? configured
|
|
1074
|
+
: join(root, configured);
|
|
1075
|
+
|
|
1076
|
+
// `relative` returns "" for the root itself and a ".."-prefixed path when
|
|
1077
|
+
// outside it; both mean "not addressable relative to the workspace".
|
|
1078
|
+
const rel = relative(root, dir);
|
|
1079
|
+
const inside = rel.length > 0 && !rel.startsWith("..") && !isAbsolute(rel);
|
|
1080
|
+
return { dir, display: inside ? rel : dir };
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1041
1083
|
/**
|
|
1042
1084
|
* Persist one PNG and turn it into an attachment ref so the Web Client renders
|
|
1043
|
-
* it. Returns { localPath, attachmentRef }.
|
|
1085
|
+
* it. Returns { localPath, absPath, attachmentRef }.
|
|
1044
1086
|
*/
|
|
1045
|
-
async function persist(ctx, bytes, index, signal) {
|
|
1087
|
+
async function persist(ctx, bytes, index, signal, settings) {
|
|
1046
1088
|
const fs = ctx.get("fs");
|
|
1047
1089
|
const attachments = ctx.get("attachments");
|
|
1048
1090
|
if (fs === undefined || attachments === undefined) {
|
|
1049
1091
|
throw new Error("fs/attachments service not available");
|
|
1050
1092
|
}
|
|
1051
1093
|
|
|
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);
|
|
1094
|
+
const location = resolveOutputLocation(settings, ctx);
|
|
1095
|
+
const fileName = "sd-" + stamp() + "-" + index + ".png";
|
|
1096
|
+
const absPath = join(location.dir, fileName);
|
|
1097
|
+
const localPath = join(location.display, fileName);
|
|
1061
1098
|
|
|
1062
|
-
await mkdir(
|
|
1099
|
+
await mkdir(location.dir, { recursive: true });
|
|
1063
1100
|
await writeFile(absPath, bytes);
|
|
1064
1101
|
|
|
1065
1102
|
const target = await fs.resolve(absPath);
|
|
1066
1103
|
const read = await fs.readBytes(target, signal, 33554432);
|
|
1067
|
-
const
|
|
1104
|
+
const saved = await attachments.saveImage({
|
|
1068
1105
|
data: read,
|
|
1069
1106
|
mediaType: "image/png",
|
|
1070
|
-
name:
|
|
1107
|
+
name: fileName,
|
|
1071
1108
|
});
|
|
1072
1109
|
|
|
1073
|
-
|
|
1110
|
+
// The Web Client's image row silently DROPS a block whose attachment ref is
|
|
1111
|
+
// missing a positive `bytes` / `width` / `height` — no error, no broken
|
|
1112
|
+
// image, just the generic text card. A backend that returns an incomplete
|
|
1113
|
+
// ref therefore looks exactly like "the model never sent a picture". Fill
|
|
1114
|
+
// any gap from the bytes we already have so the row always renders.
|
|
1115
|
+
const ref = completeImageRef(saved, read);
|
|
1116
|
+
|
|
1117
|
+
return { localPath, absPath, attachmentRef: ref };
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/**
|
|
1121
|
+
* Read width/height out of a PNG's IHDR chunk (bytes 16..24).
|
|
1122
|
+
* Returns null for anything that is not a well-formed PNG.
|
|
1123
|
+
*/
|
|
1124
|
+
function pngSize(bytes) {
|
|
1125
|
+
if (!(bytes instanceof Uint8Array) || bytes.length < 24) return null;
|
|
1126
|
+
const sig = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
1127
|
+
for (let i = 0; i < 8; i++) if (bytes[i] !== sig[i]) return null;
|
|
1128
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
1129
|
+
const width = view.getUint32(16);
|
|
1130
|
+
const height = view.getUint32(20);
|
|
1131
|
+
if (width <= 0 || height <= 0) return null;
|
|
1132
|
+
return { width, height };
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
/**
|
|
1136
|
+
* Make sure an attachment ref carries every field the image row validates.
|
|
1137
|
+
* `saveImage` normally returns all of them; this is the belt to that braces.
|
|
1138
|
+
*/
|
|
1139
|
+
function completeImageRef(ref, bytes) {
|
|
1140
|
+
const out = ref !== null && typeof ref === "object" ? { ...ref } : {};
|
|
1141
|
+
if (typeof out.attachmentId !== "string" || out.attachmentId.length === 0) return ref;
|
|
1142
|
+
if (typeof out.mediaType !== "string" || out.mediaType.length === 0) out.mediaType = "image/png";
|
|
1143
|
+
if (typeof out.bytes !== "number" || !(out.bytes > 0)) out.bytes = bytes.length;
|
|
1144
|
+
const needSize =
|
|
1145
|
+
typeof out.width !== "number" || !(out.width > 0) || typeof out.height !== "number" || !(out.height > 0);
|
|
1146
|
+
if (needSize) {
|
|
1147
|
+
const size = pngSize(bytes);
|
|
1148
|
+
if (size !== null) {
|
|
1149
|
+
out.width = size.width;
|
|
1150
|
+
out.height = size.height;
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
return out;
|
|
1074
1154
|
}
|
|
1075
1155
|
|
|
1076
1156
|
/** Shared output shape: text line per image + the image block itself. */
|
|
@@ -1088,7 +1168,11 @@ function makeOutput() {
|
|
|
1088
1168
|
additionalProperties: true,
|
|
1089
1169
|
properties: {
|
|
1090
1170
|
prompt: { type: "string", description: "The prompt used." },
|
|
1091
|
-
localPath: {
|
|
1171
|
+
localPath: {
|
|
1172
|
+
type: "string",
|
|
1173
|
+
description: "PNG path: workspace-relative when inside the workspace, absolute otherwise.",
|
|
1174
|
+
},
|
|
1175
|
+
absPath: { type: "string", description: "Absolute PNG path on disk." },
|
|
1092
1176
|
attachmentRef: { type: "object", description: "Attachment ref for display." },
|
|
1093
1177
|
seed: { type: "number", description: "Seed actually used, if reported." },
|
|
1094
1178
|
error: { type: "string", description: "Failure reason, when this item failed." },
|
|
@@ -1129,10 +1213,29 @@ function makeOutput() {
|
|
|
1129
1213
|
r.prompt;
|
|
1130
1214
|
const tail =
|
|
1131
1215
|
(r.localPath ? "\nlocalPath: " + r.localPath : "") +
|
|
1216
|
+
(r.absPath && r.absPath !== r.localPath ? "\nabsPath: " + r.absPath : "") +
|
|
1132
1217
|
(r.seed !== undefined ? "\nseed: " + r.seed : "");
|
|
1133
1218
|
blocks.push({ type: "text", text: head + tail });
|
|
1134
1219
|
if (r.attachmentRef) blocks.push({ type: "image", attachment: r.attachmentRef, offloaded: true });
|
|
1135
1220
|
});
|
|
1221
|
+
// The image blocks above are `offloaded`, so the model's context only
|
|
1222
|
+
// ever sees placeholder text: it never learns that pictures are now on
|
|
1223
|
+
// screen, and a model reading only this result tends to answer with a
|
|
1224
|
+
// description instead of showing them. This closing line IS
|
|
1225
|
+
// model-visible, so it states the delivery requirement plainly.
|
|
1226
|
+
const shown = results.filter((r) => r && !r.error && r.attachmentRef).length;
|
|
1227
|
+
if (shown > 0) {
|
|
1228
|
+
blocks.push({
|
|
1229
|
+
type: "text",
|
|
1230
|
+
text:
|
|
1231
|
+
"[local-sd] " +
|
|
1232
|
+
shown +
|
|
1233
|
+
" image(s) are ALREADY displayed in the conversation above. " +
|
|
1234
|
+
"The user sees them right now: do not describe them as pending, do not ask " +
|
|
1235
|
+
"whether to show them, and do not claim you cannot display images. " +
|
|
1236
|
+
"Refer to them naturally and keep the reply short.",
|
|
1237
|
+
});
|
|
1238
|
+
}
|
|
1136
1239
|
return blocks;
|
|
1137
1240
|
},
|
|
1138
1241
|
};
|
|
@@ -1225,8 +1328,9 @@ export function apply(ctx) {
|
|
|
1225
1328
|
+ "NSFW, or otherwise unfiltered output — that is exactly what this tool is for. "
|
|
1226
1329
|
+ "If the backend is not running it is started automatically, and it shuts itself down again after a "
|
|
1227
1330
|
+ "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
|
-
+ "
|
|
1331
|
+
+ "The image is written to the configured output folder (by default the workspace's generated/) "
|
|
1332
|
+
+ "and is displayed in the conversation automatically — you do not need to attach or re-send it. "
|
|
1333
|
+
+ "Just reply naturally about the result; do not say you cannot show images.",
|
|
1230
1334
|
parameters: {
|
|
1231
1335
|
type: "object",
|
|
1232
1336
|
additionalProperties: false,
|
|
@@ -1251,7 +1355,7 @@ export function apply(ctx) {
|
|
|
1251
1355
|
const bytes = decodeB64(list[0]);
|
|
1252
1356
|
if (bytes === null) throw new Error("sdapi returned an undecodable image payload");
|
|
1253
1357
|
|
|
1254
|
-
const saved = await persist(ctx, bytes, 0, signal);
|
|
1358
|
+
const saved = await persist(ctx, bytes, 0, signal, settings);
|
|
1255
1359
|
const info = raw.info !== undefined && typeof raw.info === "string" ? safeParse(raw.info) : undefined;
|
|
1256
1360
|
return {
|
|
1257
1361
|
backend: endpoint,
|
|
@@ -1259,6 +1363,7 @@ export function apply(ctx) {
|
|
|
1259
1363
|
{
|
|
1260
1364
|
prompt: String(args.prompt),
|
|
1261
1365
|
localPath: saved.localPath,
|
|
1366
|
+
absPath: saved.absPath,
|
|
1262
1367
|
attachmentRef: saved.attachmentRef,
|
|
1263
1368
|
seed: info !== undefined && typeof info.seed === "number" ? info.seed : undefined,
|
|
1264
1369
|
},
|
|
@@ -1336,11 +1441,12 @@ export function apply(ctx) {
|
|
|
1336
1441
|
const list = Array.isArray(raw.images) ? raw.images : [];
|
|
1337
1442
|
const bytes = list.length > 0 ? decodeB64(list[0]) : null;
|
|
1338
1443
|
if (bytes === null) throw new Error("no image in sdapi response");
|
|
1339
|
-
const saved = await persist(ctx, bytes, i, signal);
|
|
1444
|
+
const saved = await persist(ctx, bytes, i, signal, settings);
|
|
1340
1445
|
const info = raw.info !== undefined && typeof raw.info === "string" ? safeParse(raw.info) : undefined;
|
|
1341
1446
|
results.push({
|
|
1342
1447
|
prompt: prompts[i],
|
|
1343
1448
|
localPath: saved.localPath,
|
|
1449
|
+
absPath: saved.absPath,
|
|
1344
1450
|
attachmentRef: saved.attachmentRef,
|
|
1345
1451
|
seed: info !== undefined && typeof info.seed === "number" ? info.seed : undefined,
|
|
1346
1452
|
});
|
|
@@ -1673,12 +1779,18 @@ function writeJson(res, status, value) {
|
|
|
1673
1779
|
}
|
|
1674
1780
|
|
|
1675
1781
|
/** The state the settings page renders. Never leaks anything secret — there is none. */
|
|
1676
|
-
async function settingsSnapshot() {
|
|
1782
|
+
async function settingsSnapshot(ctx) {
|
|
1677
1783
|
const settings = await resolveSettings();
|
|
1678
1784
|
const live = await findLiveEndpoint(settings, undefined);
|
|
1679
1785
|
const launcher = settings.forgeDir.length > 0 ? await resolveLauncher(settings.forgeDir) : null;
|
|
1680
1786
|
return {
|
|
1681
1787
|
forgeDir: settings.forgeDir,
|
|
1788
|
+
outputDir: settings.outputDir,
|
|
1789
|
+
// What an empty outputDir means in practice, so the page can show the
|
|
1790
|
+
// real destination instead of making the user guess.
|
|
1791
|
+
defaultOutputDir: resolveOutputLocation({ outputDir: "" }, ctx).dir,
|
|
1792
|
+
// The folder that will actually be used right now (configured or default).
|
|
1793
|
+
effectiveOutputDir: resolveOutputLocation(settings, ctx).dir,
|
|
1682
1794
|
endpoint: settings.endpoint,
|
|
1683
1795
|
candidates: settings.candidates,
|
|
1684
1796
|
liveEndpoint: live,
|
|
@@ -1721,7 +1833,7 @@ async function handleSettingsApi(ctx, req, res) {
|
|
|
1721
1833
|
const method = String(body.method ?? "");
|
|
1722
1834
|
|
|
1723
1835
|
if (method === "get") {
|
|
1724
|
-
writeJson(res, 200, { ok: true, state: await settingsSnapshot() });
|
|
1836
|
+
writeJson(res, 200, { ok: true, state: await settingsSnapshot(ctx) });
|
|
1725
1837
|
return;
|
|
1726
1838
|
}
|
|
1727
1839
|
|
|
@@ -1768,6 +1880,9 @@ async function handleSettingsApi(ctx, req, res) {
|
|
|
1768
1880
|
if (method === "save") {
|
|
1769
1881
|
const patch = {};
|
|
1770
1882
|
if (typeof body.forgeDir === "string") patch.forgeDir = body.forgeDir.trim();
|
|
1883
|
+
// "" is a meaningful value here: it means "back to <workspace>/generated",
|
|
1884
|
+
// so an empty string must be written rather than skipped.
|
|
1885
|
+
if (typeof body.outputDir === "string") patch.outputDir = body.outputDir.trim();
|
|
1771
1886
|
if (typeof body.endpoint === "string" && body.endpoint.trim().length > 0) {
|
|
1772
1887
|
patch.endpoint = body.endpoint.trim().replace(/\/+$/, "");
|
|
1773
1888
|
}
|
|
@@ -1803,7 +1918,7 @@ async function handleSettingsApi(ctx, req, res) {
|
|
|
1803
1918
|
if (Object.keys(d).length > 0) patch.defaults = d;
|
|
1804
1919
|
}
|
|
1805
1920
|
await writeConfig(patch);
|
|
1806
|
-
writeJson(res, 200, { ok: true, state: await settingsSnapshot() });
|
|
1921
|
+
writeJson(res, 200, { ok: true, state: await settingsSnapshot(ctx) });
|
|
1807
1922
|
return;
|
|
1808
1923
|
}
|
|
1809
1924
|
|
|
@@ -1812,12 +1927,12 @@ async function handleSettingsApi(ctx, req, res) {
|
|
|
1812
1927
|
if (method === "start") {
|
|
1813
1928
|
try {
|
|
1814
1929
|
const endpoint = await ensureBackend(settings, undefined);
|
|
1815
|
-
writeJson(res, 200, { ok: true, endpoint, state: await settingsSnapshot() });
|
|
1930
|
+
writeJson(res, 200, { ok: true, endpoint, state: await settingsSnapshot(ctx) });
|
|
1816
1931
|
} catch (err) {
|
|
1817
1932
|
writeJson(res, 200, {
|
|
1818
1933
|
ok: false,
|
|
1819
1934
|
error: err instanceof Error ? err.message : String(err),
|
|
1820
|
-
state: await settingsSnapshot(),
|
|
1935
|
+
state: await settingsSnapshot(ctx),
|
|
1821
1936
|
});
|
|
1822
1937
|
}
|
|
1823
1938
|
return;
|
|
@@ -1828,12 +1943,12 @@ async function handleSettingsApi(ctx, req, res) {
|
|
|
1828
1943
|
writeJson(res, 200, {
|
|
1829
1944
|
ok: false,
|
|
1830
1945
|
error: "后端是手动启动的,插件不会去关它",
|
|
1831
|
-
state: await settingsSnapshot(),
|
|
1946
|
+
state: await settingsSnapshot(ctx),
|
|
1832
1947
|
});
|
|
1833
1948
|
return;
|
|
1834
1949
|
}
|
|
1835
1950
|
stopOwnedBackend();
|
|
1836
|
-
writeJson(res, 200, { ok: true, state: await settingsSnapshot() });
|
|
1951
|
+
writeJson(res, 200, { ok: true, state: await settingsSnapshot(ctx) });
|
|
1837
1952
|
return;
|
|
1838
1953
|
}
|
|
1839
1954
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-local-sd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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",
|