dsh-tool-vision 0.7.0 → 0.8.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
@@ -67,6 +67,7 @@ Or load it from a local path without npm:
67
67
 
68
68
  | Field | Default | Meaning |
69
69
  |---|---|---|
70
+ | `enabled` | `true` | **Master switch (v0.8.0).** Off unregisters everything this plugin contributes — `inspect_image`, the 14 `vision_*` tools, the image bridge, the preview route and the image-capability declaration. The settings section stays mounted so the switch can turn it back on. Hot-applies; no dsh restart. |
70
71
  | `baseURL` | `https://api.openai.com/v1` | OpenAI-compatible API base URL. |
71
72
  | `apiKey` | `''` | API key (takes precedence over env). |
72
73
  | `apiKeyEnv` | `VISION_API_KEY` | Env var holding the key. |
@@ -221,6 +222,31 @@ degrade lazily with an install hint and never break other tools).
221
222
  default** — set `desktopScreenshot: true` in the tool-vision settings to
222
223
  enable desktop capture.
223
224
 
225
+ ## v0.8.0: master switch, and the save-path fix
226
+
227
+ **Master switch.** `enabled`, plus a one-click button at the top of the section
228
+ (`Disable all` / `Re-enable`). Registrations are effects on the cordis fiber
229
+ that makes them, so the plugin now puts every tool, the image bridge, the
230
+ preview route and the image-capability declaration in a **child fiber**: turning
231
+ the switch off disposes it, and all 15 tools leave the model's tool list
232
+ together. The settings section stays on the parent fiber, so the switch can turn
233
+ the plugin back on. No dsh restart.
234
+
235
+ **Save-path fix.** The form used to submit its 18 fields as **parallel**
236
+ `scope.set()/unset()` calls. Each write carries its own revision fence, a fence
237
+ behind the Host document is refused with `settings/conflict`, and **a refused
238
+ write still resolves** — the scope's contract is "settle after the write and any
239
+ recovery read", not "throw on refusal". The section therefore reported "Saved"
240
+ while the edits silently reverted, which reads as "settings cannot be saved at
241
+ all".
242
+
243
+ Writes are now **one atomic `mutate()`**, so the whole batch shares one fence and
244
+ one persistence decision, and the section is inspected after the write settles:
245
+ "Saved" only when the change is really there, otherwise "Write did not take
246
+ effect" plus a reload of the form. Hosts without `mutate()` fall back to
247
+ **sequential** writes (each waits for its predecessor, keeping the revision chain
248
+ intact) — never parallel.
249
+
224
250
  ## Limitations
225
251
 
226
252
  - A bridged image enters the conversation as a text hint (a transcript, not
package/README.zh.md CHANGED
@@ -42,6 +42,7 @@ DSH 0.1.1 已为 DeepSeek 视觉模型目录加入原生图片输入。本插件
42
42
 
43
43
  | 字段 | 默认值 | 含义 |
44
44
  |---|---|---|
45
+ | `enabled` | `true` | **总开关(v0.8.0)**。关闭后插件不注册任何东西:`inspect_image`、14 个 `vision_*` 工具、图片桥、预览路由、图片能力声明统统下线;设置栏本身保留,所以随时能再打开。热生效,无需重启 dsh |
45
46
  | `baseURL` | `https://api.openai.com/v1` | OpenAI 兼容 API 基地址 |
46
47
  | `apiKey` | `''` | API 密钥(优先于环境变量) |
47
48
  | `apiKeyEnv` | `VISION_API_KEY` | 存放密钥的环境变量名 |
@@ -156,6 +157,25 @@ DSH 0.1.1 已为 DeepSeek 视觉模型目录加入原生图片输入。本插件
156
157
  `vision_screenshot` 属于隐私敏感能力,**默认不注册**——在 tool-vision 设置中
157
158
  开启 `desktopScreenshot: true` 后才会注册桌面截屏工具。
158
159
 
160
+ ## v0.8.0:总开关,以及保存修复
161
+
162
+ **总开关。** `enabled` 字段加设置栏顶部的一键按钮(`一键关闭` / `重新启用`)。
163
+ 注册本身就是绑定在「发起注册的 cordis fiber」上的副作用,所以插件把所有工具、
164
+ 图片桥、预览路由和图片能力声明都放进一个**子 fiber**:关掉开关就是销毁这个子
165
+ fiber,15 个工具一并从模型侧消失。设置栏留在父 fiber 上,因此关掉之后还能再打开。
166
+ 无需重启 dsh。
167
+
168
+ **保存修复。** 此前表单把 18 个字段作为**并行** `scope.set()/unset()` 提交。每个
169
+ 写入各自携带一个 revision 栅栏,而栅栏落后于宿主文档的写入会被 `settings/conflict`
170
+ 拒绝——**且被拒绝的写入仍然 resolve**(scope 的契约是「完成写入与恢复读取后结算」,
171
+ 不是「被拒就抛错」)。于是设置栏显示「已保存」,而编辑内容静默回退,看起来就是
172
+ 「完全存不了」。
173
+
174
+ 现在改为**一次原子 `mutate()`**,整批修改共用一个栅栏和一次持久化决策;写入结算后
175
+ 再回读命名空间section做校验,只有确认生效才报「已保存」,否则弹出「写入未生效」并
176
+ 重新载入表单。没有 `mutate()` 的旧宿主回退为**串行**写入(每个等前一个完成,revision
177
+ 链条依然正确),绝不并行。
178
+
159
179
  ## 限制
160
180
 
161
181
  - 从 `0.6.3` 起最低支持 DSH `0.1.0-rc.7`,已针对 `0.1.0-rc.7`、
package/client.js CHANGED
@@ -40,6 +40,10 @@ window.__ModuleLoader__.load({
40
40
  ".__tv_btn:hover:not(:disabled){border-color:var(--dsw-alias-state-business-primary)}" +
41
41
  ".__tv_btn:disabled{opacity:.5;cursor:default}" +
42
42
  ".__tv_btnPrimary{border-color:var(--dsw-alias-state-business-primary, #3964fe);background:var(--dsw-alias-state-business-primary, #3964fe);color:#fff}" +
43
+ ".__tv_btnDanger{border-color:var(--dsw-alias-state-error-primary, #e5484d);color:var(--dsw-alias-state-error-primary, #e5484d)}" +
44
+ ".__tv_btnDanger:hover:not(:disabled){background:var(--dsw-alias-state-error-primary, #e5484d);color:#fff}" +
45
+ ".__tv_master{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 10px;margin:2px 0 6px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-2)}" +
46
+ ".__tv_masterLabel{font-size:13px;color:var(--dsw-alias-label-primary)}" +
43
47
  ".__tv_status{font-size:12px;color:var(--dsw-alias-label-tertiary)}" +
44
48
  ".__tv_error{font-size:12px;color:var(--dsw-alias-state-error-primary)}" +
45
49
  ".__tv_unavailable{font-size:13px;color:var(--dsw-alias-label-tertiary)}";
@@ -97,7 +101,14 @@ window.__ModuleLoader__.load({
97
101
  error: "保存失败",
98
102
  unavailable: "设置命名空间不可用(服务端未注册 tool-vision 命名空间?)",
99
103
  overridden: "已覆盖",
100
- loading: "加载中…"
104
+ loading: "加载中…",
105
+ fieldEnabled: "启用插件(关闭后所有视觉工具下线)",
106
+ masterSwitch: "总开关",
107
+ turnOff: "一键关闭",
108
+ turnOn: "重新启用",
109
+ enabledNotice: "已启用:视觉工具已上线",
110
+ disabledNotice: "已关闭:所有视觉工具已下线",
111
+ notApplied: "写入未生效"
101
112
  };
102
113
  var en = {
103
114
  nav: "Vision Model",
@@ -141,11 +152,19 @@ window.__ModuleLoader__.load({
141
152
  error: "Save failed",
142
153
  unavailable: "Settings namespace unavailable (tool-vision namespace not registered server-side?)",
143
154
  overridden: "overridden",
144
- loading: "Loading…"
155
+ loading: "Loading…",
156
+ fieldEnabled: "Enable plugin (off takes every vision tool offline)",
157
+ masterSwitch: "Master switch",
158
+ turnOff: "Disable all",
159
+ turnOn: "Re-enable",
160
+ enabledNotice: "Enabled: vision tools are live",
161
+ disabledNotice: "Disabled: every vision tool is offline",
162
+ notApplied: "Write did not take effect"
145
163
  };
146
164
 
147
165
  // ── field spec ────────────────────────────────────────────────────────
148
166
  var FIELDS = [
167
+ { key: "enabled", label: "fieldEnabled", type: "checkbox" },
149
168
  { key: "baseURL", label: "fieldBaseUrl", type: "text", placeholder: "https://api.openai.com/v1" },
150
169
  { key: "apiKey", label: "fieldApiKey", type: "password", secret: true },
151
170
  { key: "apiKeyEnv", label: "fieldApiKeyEnv", type: "text" },
@@ -196,7 +215,10 @@ window.__ModuleLoader__.load({
196
215
  var [error, setError] = react.useState(null);
197
216
 
198
217
  react.useEffect(function () {
199
- if (typeof scope.load === "function") scope.load();
218
+ // No refresh call: the scope's public seam has no load() — reads ride the
219
+ // shared describe mirror, which re-reads on every Host
220
+ // `settings/document-updated`. The guarded scope.load() that used to sit
221
+ // here was dead code that read like a refresh that never happened.
200
222
  var alive = true;
201
223
  var sync = function () { if (alive) setSnapshot(scope.getSnapshot()); };
202
224
  var un = typeof scope.subscribe === "function" ? scope.subscribe(sync) : null;
@@ -239,66 +261,154 @@ window.__ModuleLoader__.load({
239
261
  setError(null);
240
262
  }
241
263
 
242
- function onSave() {
243
- setBusy(true); setNotice(null); setError(null);
244
- var writes = FIELDS.map(function (f) {
264
+ // One atomic mutation carrying ONE revision fence.
265
+ //
266
+ // The fields used to be issued as parallel scope.set()/unset() calls. Every
267
+ // write is queued and re-reads the namespace revision, but a write whose
268
+ // fence is behind the Host document is REFUSED with settings/conflict — and
269
+ // a refused write still resolves (the scope's contract is "settle after any
270
+ // recovery read", not "throw on refusal"). The section therefore reported
271
+ // "Saved" while the edits silently reverted. Batching every change into a
272
+ // single mutate() gives it one fence and one persistence decision.
273
+ function buildOps() {
274
+ var ops = [];
275
+ FIELDS.forEach(function (f) {
245
276
  var d = fieldDraft(f);
246
277
  if (f.type === "csv") {
247
278
  var arr = String(d).split(",").map(function (s) { return s.trim(); }).filter(Boolean);
248
279
  var cur = value[f.key] || [];
249
- if (arr.length === cur.length && arr.every(function (x, i) { return x === cur[i]; })) return Promise.resolve();
250
- return scope.set(f.key, arr);
280
+ if (arr.length === cur.length && arr.every(function (x, i) { return x === cur[i]; })) return;
281
+ ops.push({ op: "set", path: [f.key], value: arr });
282
+ return;
251
283
  }
252
284
  if (f.type === "checkbox") {
253
- if (Boolean(d) === Boolean(value[f.key])) return Promise.resolve();
254
- return Boolean(d) ? scope.set(f.key, true) : scope.unset(f.key);
285
+ if (Boolean(d) === Boolean(value[f.key])) return;
286
+ ops.push(Boolean(d) ? { op: "set", path: [f.key], value: true }
287
+ : { op: "unset", path: [f.key] });
288
+ return;
255
289
  }
256
290
  if (f.type === "password") {
257
- if (!d) return Promise.resolve(); // blank keeps the current key
258
- if (d === String(value[f.key] ?? "")) return Promise.resolve();
259
- return scope.set(f.key, d);
291
+ if (!d) return; // blank keeps the current key
292
+ if (d === String(value[f.key] ?? "")) return;
293
+ ops.push({ op: "set", path: [f.key], value: d });
294
+ return;
260
295
  }
261
- if (String(d) === String(value[f.key] ?? "")) return Promise.resolve();
262
- if (String(d).trim() === "" && !(f.key in user)) return Promise.resolve();
263
- return String(d).trim() === "" ? scope.unset(f.key) : scope.set(f.key, f.type === "number" ? Number(d) : d);
296
+ if (String(d) === String(value[f.key] ?? "")) return;
297
+ if (String(d).trim() === "" && !(f.key in user)) return;
298
+ ops.push(String(d).trim() === ""
299
+ ? { op: "unset", path: [f.key] }
300
+ : { op: "set", path: [f.key], value: f.type === "number" ? Number(d) : d });
301
+ });
302
+ return ops;
303
+ }
304
+
305
+ /** Whether the namespace section now reflects every queued operation. */
306
+ function opsApplied(ops, snap) {
307
+ if (snap.status !== "ready" || snap.value === void 0) return false;
308
+ return ops.every(function (op) {
309
+ var key = op.path[0];
310
+ if (op.op === "unset") return !(snap.user && key in snap.user);
311
+ return JSON.stringify(snap.value[key]) === JSON.stringify(op.value);
312
+ });
313
+ }
314
+
315
+ /**
316
+ * Settle one namespace change and report what actually happened.
317
+ *
318
+ * Reads settle after the write and any recovery read, so the section is
319
+ * inspected afterwards instead of trusting the promise: that check is the
320
+ * only way to tell a committed change from a refused one. Hosts without
321
+ * mutate() apply the same ops in order — each waits for its predecessor, so
322
+ * the revision chain still holds.
323
+ */
324
+ function commit(ops) {
325
+ var run;
326
+ if (typeof scope.mutate === "function") {
327
+ run = scope.mutate(ops, scope.getSnapshot().revision);
328
+ } else {
329
+ run = ops.reduce(function (chain, op) {
330
+ return chain.then(function () {
331
+ return op.op === "unset" ? scope.unset(op.path[0]) : scope.set(op.path[0], op.value);
332
+ });
333
+ }, Promise.resolve());
334
+ }
335
+ return Promise.resolve(run).then(function () {
336
+ return opsApplied(ops, scope.getSnapshot());
337
+ });
338
+ }
339
+
340
+ function reportOutcome(ok, okMessage) {
341
+ setBusy(false);
342
+ if (ok) {
343
+ setNotice(okMessage || t("saved"));
344
+ return;
345
+ }
346
+ setError(t("error") + ":" + t("notApplied"));
347
+ reseedDraft();
348
+ }
349
+
350
+ function onSave() {
351
+ setBusy(true); setNotice(null); setError(null);
352
+ var ops = buildOps();
353
+ if (ops.length === 0) { setBusy(false); setNotice(t("saved")); return; }
354
+ commit(ops).then(function (ok) {
355
+ reportOutcome(ok);
356
+ }).catch(function (e) {
357
+ setBusy(false); setError(t("error") + ":" + String(e && e.message || e));
264
358
  });
265
- Promise.all(writes).then(function () {
266
- setBusy(false); setNotice(t("saved"));
267
- if (typeof scope.load === "function") scope.load();
359
+ }
360
+
361
+ // One-click master switch: the same field the checkbox edits, written on its
362
+ // own so taking the plugin offline never depends on the rest of the form.
363
+ function onToggleEnabled() {
364
+ setBusy(true); setNotice(null); setError(null);
365
+ var next = value.enabled === false;
366
+ commit([{ op: "set", path: ["enabled"], value: next }]).then(function (ok) {
367
+ reportOutcome(ok, next ? t("enabledNotice") : t("disabledNotice"));
268
368
  }).catch(function (e) {
269
- setBusy(false); setError(t("error") + ": " + String(e && e.message || e));
369
+ setBusy(false); setError(t("error") + ":" + String(e && e.message || e));
270
370
  });
271
371
  }
272
372
 
273
373
  function reseedDraft() {
274
- if (typeof scope.load === "function") {
275
- var p = scope.load();
276
- if (p && typeof p.then === "function") {
277
- p.then(function () {
278
- var fresh = scope.getSnapshot();
279
- if (fresh.status === "ready" && fresh.value !== void 0) setDraft(Object.assign({}, valueToDraft(fresh.value)));
280
- }).catch(function () {});
281
- return;
282
- }
374
+ // The mirror folds a write's answer in before the promise settles, so the
375
+ // snapshot is already current; the timeout only covers a host that
376
+ // answers late.
377
+ var fresh = scope.getSnapshot();
378
+ if (fresh.status === "ready" && fresh.value !== void 0) {
379
+ setDraft(Object.assign({}, valueToDraft(fresh.value)));
380
+ return;
283
381
  }
284
382
  setTimeout(function () {
285
- var fresh = scope.getSnapshot();
286
- if (fresh.status === "ready" && fresh.value !== void 0) setDraft(Object.assign({}, valueToDraft(fresh.value)));
383
+ var later = scope.getSnapshot();
384
+ if (later.status === "ready" && later.value !== void 0) setDraft(Object.assign({}, valueToDraft(later.value)));
287
385
  }, 120);
288
386
  }
289
387
 
290
388
  function onReset() {
291
389
  setBusy(true); setNotice(null); setError(null);
292
- Promise.all(FIELDS.map(function (f) { return scope.unset(f.key); })).then(function () {
293
- setBusy(false); setNotice(t("saved"));
294
- reseedDraft();
390
+ var ops = FIELDS
391
+ .filter(function (f) { return f.key in user; })
392
+ .map(function (f) { return { op: "unset", path: [f.key] }; });
393
+ if (ops.length === 0) { setBusy(false); setNotice(t("saved")); return; }
394
+ commit(ops).then(function (ok) {
395
+ reportOutcome(ok);
295
396
  }).catch(function (e) {
296
- setBusy(false); setError(t("error") + ": " + String(e && e.message || e));
397
+ setBusy(false); setError(t("error") + ":" + String(e && e.message || e));
297
398
  });
298
399
  }
299
400
 
300
401
  return h("div", { className: "__tv_root" },
301
402
  h("p", { className: "__tv_hint", style: { margin: "0 0 4px" } }, t("intro")),
403
+ h("div", { className: "__tv_master" },
404
+ h("span", { className: "__tv_masterLabel" },
405
+ t("masterSwitch") + (value.enabled === false ? ":" + t("disabledNotice") : "")),
406
+ h("button", {
407
+ type: "button",
408
+ className: "__tv_btn " + (value.enabled === false ? "__tv_btnPrimary" : "__tv_btnDanger"),
409
+ onClick: onToggleEnabled,
410
+ disabled: busy || !snapshot.writable
411
+ }, value.enabled === false ? t("turnOn") : t("turnOff"))),
302
412
  FIELDS.map(function (f) {
303
413
  var overridden = f.key in user;
304
414
  if (f.type === "checkbox") {
@@ -500,7 +610,6 @@ window.__ModuleLoader__.load({
500
610
  var t = ctx.locale.bind(NS);
501
611
  ctx.effect(function () { return ctx.locale.register(NS, { zh: zh, en: en }); }, "dsh-tool-vision: dictionaries");
502
612
  var scope = ctx.settingsScope.bind({ namespace: "tool-vision" });
503
- if (typeof scope.load === "function") scope.load();
504
613
  attachBridgePreview(ctx, scope);
505
614
  ctx.slots.inject("settings.section", function () {
506
615
  return ctx.slots.register({
package/index.js CHANGED
@@ -82,6 +82,15 @@ const DEFAULT_DESCRIPTION =
82
82
 
83
83
  /** Runtime schema for the tool-vision row. */
84
84
  const Config = z.object({
85
+ /**
86
+ * Master switch, hot-applied. When off the plugin registers nothing at all —
87
+ * no `inspect_image`, none of the 14 `vision_*` tools, no pre-step bridge, no
88
+ * preview route, no llm capability wrap — while the settings section itself
89
+ * stays mounted so the switch can turn it back on. Turning it off is the way
90
+ * to stop every vision tool from reaching the model without uninstalling the
91
+ * package or restarting dsh.
92
+ */
93
+ enabled: z.boolean().default(true),
85
94
  /** Base URL of an OpenAI-compatible API, e.g. https://api.openai.com/v1 or https://dashscope.aliyuncs.com/compatible-mode/v1 */
86
95
  baseURL: z.string().default("https://api.openai.com/v1"),
87
96
  /** API key; takes precedence over apiKeyEnv. Rendered as a write-only secret in the Web UI. */
@@ -561,6 +570,133 @@ function apply(ctx, config) {
561
570
  let current = config;
562
571
  let sourceGetter = null;
563
572
  const getConfig = () => (sourceGetter ? sourceGetter() : current);
573
+
574
+ // ── master switch: a child fiber owns every registration ──────────────────
575
+ // `ctx.tools.register`, `ctx.effect` and `ctx.on` are all effects on the fiber
576
+ // that makes them, so confining the tool/bridge/route registrations to a child
577
+ // fiber makes `enabled` real: disposing that fiber unregisters all 15 tools,
578
+ // removes the pre-step listener and the preview route, and restores the llm
579
+ // capability wrap. The settings registration deliberately stays on the parent
580
+ // fiber — it has to outlive the switch, or the section that turns the plugin
581
+ // back on would vanish with it.
582
+ const REGISTRATION_KEYS = [
583
+ "enabled",
584
+ "bridgeTextOnly",
585
+ "bridgeExportDir",
586
+ "bridgeAutoImage",
587
+ "bridgePreview",
588
+ ];
589
+ let featureFiber = null;
590
+ let lastRegistrationKey = null;
591
+
592
+ function registrationKey(cfg) {
593
+ return REGISTRATION_KEYS.map((key) => String(cfg[key])).join("|");
594
+ }
595
+
596
+ function installFeatures() {
597
+ if (featureFiber !== null) return;
598
+ featureFiber = ctx.plugin({
599
+ name: "dsh-tool-vision:features",
600
+ apply(inner) {
601
+ // ── image bridge: pasted images become inspect_image hints on text-only models ──
602
+ if (getConfig().bridgeTextOnly) {
603
+ const exportDir = getConfig().bridgeExportDir || join(os.tmpdir(), "dsh-vision-bridge");
604
+ mkdir(exportDir, { recursive: true }).catch(() => {});
605
+ // Root-level listener: agent-scoped waterfalls admit untagged listeners,
606
+ // so one registration serves every agent (new and resumed alike) and the
607
+ // agent is read from the fused payload.
608
+ attachPreStepBridge(inner, getConfig, exportDir);
609
+
610
+ // ── automatic image admission: let pasted images through on text-only models ──
611
+ // The host gate refuses images unless the model declares `image` input;
612
+ // the bridge handles them anyway, so report image support for all models.
613
+ // The wrap is installed on the shared llm service instance (idempotent,
614
+ // restored on dispose/HMR).
615
+ if (getConfig().bridgeAutoImage) {
616
+ const unwrap = installAutoImageAdmission(inner.get("llm"), inner.logger);
617
+ inner.effect(() => unwrap, "dsh-tool-vision: automatic image admission");
618
+ }
619
+
620
+ // ── bridge image preview: same-origin thumbnails for bridged images ──
621
+ if (getConfig().bridgePreview) {
622
+ registerBridgePreviewRoute(inner, exportDir, inner.logger);
623
+ }
624
+ }
625
+
626
+ inner.tools.register(defineTool({
627
+ name: "inspect_image",
628
+ description: getConfig().description,
629
+ parameters: {
630
+ path: {
631
+ type: "string",
632
+ required: true,
633
+ description: "Path to the image file (absolute, or relative to the current workspace) or an http(s) URL.",
634
+ },
635
+ question: {
636
+ type: "string",
637
+ description: "Optional specific question about the image. Omit for a general detailed description.",
638
+ },
639
+ detail: {
640
+ type: "string",
641
+ enum: ["auto", "low", "high"],
642
+ description: "Optional image resolution hint for the vision API (auto by default).",
643
+ },
644
+ },
645
+ output: {
646
+ schema: { type: "string" },
647
+ render: (_args, value) => [{ type: "text", text: value }],
648
+ },
649
+ async execute(args, exec) {
650
+ const cfg = getConfig();
651
+ const cwd = exec.agent?.session?.header?.cwd ?? process.cwd();
652
+ const { url, note } = await toImageUrl(args.path, cwd, cfg);
653
+ try {
654
+ const answer = await callVision(cfg, url, args.question, args.detail, exec.signal, exec);
655
+ return note === url ? answer : `${answer}\n\n(image: ${note})`;
656
+ } catch (error) {
657
+ const raw = error && error.message ? String(error.message) : String(error);
658
+ if (CONTENT_FILTER_RE.test(raw)) {
659
+ throw new Error(
660
+ "inspect_image: 图片被视觉端点的内容安全策略拒绝(检测到敏感或不安全内容)。" +
661
+ "这不是网络或配置问题,请换一张图片或调整图片内容后再试。",
662
+ );
663
+ }
664
+ throw error;
665
+ }
666
+ },
667
+ }));
668
+
669
+ // ── pixel-level vision tools (ported from dsh-vision-router) ─────────────
670
+ // 14 vision_* tools driven by the SAME configured endpoint as inspect_image
671
+ // (baseURL/apiKey/model). No provider chain, no local models, no extra
672
+ // settings: everything comes from the existing tool-vision configuration.
673
+ registerVisionTools(inner, getConfig);
674
+ },
675
+ });
676
+ }
677
+
678
+ function uninstallFeatures() {
679
+ if (featureFiber === null) return;
680
+ const fiber = featureFiber;
681
+ featureFiber = null;
682
+ // dispose() settles asynchronously and must never take the plugin down; the
683
+ // next install starts a fresh fiber regardless of how this one ends.
684
+ Promise.resolve(fiber.dispose()).catch((error) => {
685
+ ctx.logger?.warn?.(`[tool-vision] feature teardown failed: ${String(error)}`);
686
+ });
687
+ }
688
+
689
+ // Re-install only when a field that gates a registration actually changed, so
690
+ // editing, say, `model` does not tear the tools down and back up mid-session.
691
+ function syncFeatures() {
692
+ const cfg = getConfig();
693
+ const key = registrationKey(cfg);
694
+ if (featureFiber !== null && key === lastRegistrationKey) return;
695
+ uninstallFeatures();
696
+ lastRegistrationKey = key;
697
+ if (cfg.enabled) installFeatures();
698
+ }
699
+
564
700
  // Compat shim: dsh-settings 0.1.2-rc.1 removed the module-level
565
701
  // `installSettingsSection` export (the provider now lives at ctx.settings).
566
702
  // Inline the same logic via ctx.inject(["settings"]) — works on both
@@ -571,82 +707,17 @@ function apply(ctx, config) {
571
707
  sctx.effect(() => () => {
572
708
  sourceGetter = null;
573
709
  });
574
- scope.watch(() => {});
710
+ // Hot-apply: `enabled` switches the child fiber, and any field that gates a
711
+ // registration re-installs it, so none of them needs a dsh restart anymore.
712
+ scope.watch(() => syncFeatures());
713
+ sctx.effect(() => () => uninstallFeatures());
714
+ // The stored value governs from here on; the composition entry was only a
715
+ // placeholder until the provider answered.
716
+ syncFeatures();
575
717
  });
576
718
 
577
- // ── image bridge: pasted images become inspect_image hints on text-only models ──
578
- if (getConfig().bridgeTextOnly) {
579
- const exportDir = getConfig().bridgeExportDir || join(os.tmpdir(), "dsh-vision-bridge");
580
- mkdir(exportDir, { recursive: true }).catch(() => {});
581
- // Root-level listener: agent-scoped waterfalls admit untagged listeners,
582
- // so one registration serves every agent (new and resumed alike) and the
583
- // agent is read from the fused payload.
584
- attachPreStepBridge(ctx, getConfig, exportDir);
585
-
586
- // ── automatic image admission: let pasted images through on text-only models ──
587
- // The host gate refuses images unless the model declares `image` input;
588
- // the bridge handles them anyway, so report image support for all models.
589
- // The wrap is installed on the shared llm service instance (idempotent,
590
- // restored on dispose/HMR).
591
- if (getConfig().bridgeAutoImage) {
592
- const unwrap = installAutoImageAdmission(ctx.get("llm"), ctx.logger);
593
- ctx.effect(() => unwrap, "dsh-tool-vision: automatic image admission");
594
- }
595
-
596
- // ── bridge image preview: same-origin thumbnails for bridged images ──
597
- if (getConfig().bridgePreview) {
598
- registerBridgePreviewRoute(ctx, exportDir, ctx.logger);
599
- }
600
- }
601
-
602
- ctx.tools.register(defineTool({
603
- name: "inspect_image",
604
- description: getConfig().description,
605
- parameters: {
606
- path: {
607
- type: "string",
608
- required: true,
609
- description: "Path to the image file (absolute, or relative to the current workspace) or an http(s) URL.",
610
- },
611
- question: {
612
- type: "string",
613
- description: "Optional specific question about the image. Omit for a general detailed description.",
614
- },
615
- detail: {
616
- type: "string",
617
- enum: ["auto", "low", "high"],
618
- description: "Optional image resolution hint for the vision API (auto by default).",
619
- },
620
- },
621
- output: {
622
- schema: { type: "string" },
623
- render: (_args, value) => [{ type: "text", text: value }],
624
- },
625
- async execute(args, exec) {
626
- const cfg = getConfig();
627
- const cwd = exec.agent?.session?.header?.cwd ?? process.cwd();
628
- const { url, note } = await toImageUrl(args.path, cwd, cfg);
629
- try {
630
- const answer = await callVision(cfg, url, args.question, args.detail, exec.signal, exec);
631
- return note === url ? answer : `${answer}\n\n(image: ${note})`;
632
- } catch (error) {
633
- const raw = error && error.message ? String(error.message) : String(error);
634
- if (CONTENT_FILTER_RE.test(raw)) {
635
- throw new Error(
636
- "inspect_image: 图片被视觉端点的内容安全策略拒绝(检测到敏感或不安全内容)。" +
637
- "这不是网络或配置问题,请换一张图片或调整图片内容后再试。",
638
- );
639
- }
640
- throw error;
641
- }
642
- },
643
- }));
644
-
645
- // ── pixel-level vision tools (ported from dsh-vision-router) ─────────────
646
- // 14 vision_* tools driven by the SAME configured endpoint as inspect_image
647
- // (baseURL/apiKey/model). No provider chain, no local models, no extra
648
- // settings: everything comes from the existing tool-vision configuration.
649
- registerVisionTools(ctx, getConfig);
719
+ // No settings provider: the composition entry is the whole configuration.
720
+ syncFeatures();
650
721
  }
651
722
 
652
723
  export {
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "dsh-tool-vision",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "DeepSeek Harness 外置视觉模型插件:inspect_image 把本地图片或 http(s) 图片 URL 发给任意 OpenAI 兼容端点,视觉模型看图的文字回答直接带回对话;附 Web UI 设置栏。",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "scripts": {
8
- "test": "node tests/preview-regex.test.mjs && node tests/preview-route.test.mjs && node tests/auto-image.test.mjs && node --test tests/vision-tools.test.js tests/vision-tools-tools.test.js && node tests/settings-scope-compat.test.mjs"
8
+ "test": "node tests/preview-regex.test.mjs && node tests/preview-route.test.mjs && node tests/auto-image.test.mjs && node --test tests/vision-tools.test.js tests/vision-tools-tools.test.js && node --test tests/settings-scope-compat.test.mjs tests/settings-atomic-write.test.mjs tests/switch-behaviour.test.mjs"
9
9
  },
10
10
  "exports": {
11
11
  ".": "./index.js",
@@ -59,8 +59,8 @@
59
59
  },
60
60
  "peerDependencies": {
61
61
  "react": "^18.2.0",
62
- "@deepseek-ai/dsh-settings": "^0.1.0-rc.7 || ^0.1.1-rc.1 || ^0.1.2-rc.1",
63
- "@deepseek-ai/dsh-tools": "^0.1.0-rc.7 || ^0.1.1-rc.1 || ^0.1.2-rc.1"
62
+ "@deepseek-ai/dsh-settings": "^0.1.0-rc.7 || ^0.1.1-rc.1 || ^0.1.2-rc.1 || ^0.1.5-rc.1",
63
+ "@deepseek-ai/dsh-tools": "^0.1.0-rc.7 || ^0.1.1-rc.1 || ^0.1.2-rc.1 || ^0.1.5-rc.1"
64
64
  },
65
65
  "optionalDependencies": {
66
66
  "potrace": "^2.1.8",