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 CHANGED
@@ -27,7 +27,9 @@
27
27
 
28
28
  外加一个设置页:**设置 → 本地绘图**。里面能选目录、看状态、调参数、手动启停后端。
29
29
 
30
- 出图落在工作区 `generated/sd-<时间戳>-<序号>.png`,同时在对话里渲染。
30
+ 出图默认落在工作区 `generated/sd-<时间戳>-<序号>.png`,同时在对话里渲染。存放位置可以在
31
+ **设置 → 本地绘图 → 图片存放目录** 里改:留空用默认,填相对路径(如 `pics/sd`)接在工作区后面,
32
+ 填绝对路径(如 `D:\AI\生图`)则原样使用。
31
33
 
32
34
  ---
33
35
 
@@ -147,7 +149,7 @@ koboldcpp.exe --sdmodel <image-model.gguf> --sdoffloadcpu --sdtiledvae 512 --por
147
149
 
148
150
  ## 生命周期:按需启动,闲置自关
149
151
 
150
- SD 后端吃几 GB 显存,不该一直挂着。
152
+ SD 后端占用较高显存,闲置时自动关闭。
151
153
 
152
154
  ```
153
155
  你第一次要图
@@ -175,6 +177,7 @@ SD 后端吃几 GB 显存,不该一直挂着。
175
177
  | 项 | 说明 |
176
178
  |---|---|
177
179
  | 绘图程序目录 | 含 `run.bat` / `webui.bat` / `webui.sh` 的那一层 |
180
+ | 图片存放目录 | 出图存哪儿。留空 = 工作区 `generated/`;相对路径接在工作区后面;绝对路径原样使用 |
178
181
  | 自动启动 | 后端没开时是否拉起它。关掉就只连现成的 |
179
182
  | 启动超时 | 冷启动最长等待,默认 240000 ms(4 分钟) |
180
183
  | 闲置关闭 | 闲置多久自动关,默认 300000 ms(5 分钟)。**填 0 = 永不自动关闭** |
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
- function browse() {
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
- callApi({ method: "browse", path: forgeDir || "" }).then(function(res) {
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
- setForgeDir(browser.path);
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
- // ── directory ────────────────────────────────────────
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
- browser.launcher
496
- ? h("span", { className: "lsd-msg ok" },
497
- "✓ 这个目录里有 " + browser.launcher.launcher + ",就是它了")
498
- : h("span", { className: "lsd-hint" },
499
- "这个目录里没有 run.bat / webui.bat / webui.sh,继续往下找或者换一层"),
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.4.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 policy = ctx.get("sandboxPolicy");
1053
- const root =
1054
- policy !== undefined && typeof policy.workspaceRoot === "string" && policy.workspaceRoot.length > 0
1055
- ? policy.workspaceRoot
1056
- : process.cwd();
1057
-
1058
- const relDir = "generated";
1059
- const relPath = join(relDir, "sd-" + stamp() + "-" + index + ".png");
1060
- const absPath = join(root, relPath);
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(dirname(absPath), { recursive: true });
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 ref = await attachments.saveImage({
1104
+ const saved = await attachments.saveImage({
1068
1105
  data: read,
1069
1106
  mediaType: "image/png",
1070
- name: basename(relPath),
1107
+ name: fileName,
1071
1108
  });
1072
1109
 
1073
- return { localPath: relPath, attachmentRef: ref };
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: { type: "string", description: "Workspace-relative PNG path." },
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 into the workspace under generated/ and shown in the conversation UI; "
1229
- + "the tool result is text only, so do not echo paths beyond the one reported.",
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.4.1",
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",