dsh-tool-vision 0.10.0 → 0.10.2

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/client.js CHANGED
@@ -61,6 +61,37 @@ window.__ModuleLoader__.load({
61
61
  ".__tv_itemId{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}" +
62
62
  ".__tv_itemName{color:var(--dsw-alias-label-tertiary);font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:38%}" +
63
63
  ".__tv_unavailable{font-size:13px;color:var(--dsw-alias-label-tertiary)}";
64
+ // Scoped flat controls retain native keyboard and form behavior.
65
+ CSS += `
66
+ .dsh-flat.__tv_root{width:100%;max-width:720px;gap:14px;font-size:13px;line-height:1.65;color:var(--dsw-alias-label-primary);--flat-accent:var(--dsw-alias-state-business-primary,#3964fe);--flat-border:var(--dsw-alias-border-l2,#dce2eb)}
67
+ .dsh-flat.__tv_root *{box-sizing:border-box;min-width:0}
68
+ .dsh-flat.__tv_root p{margin:0}
69
+ .dsh-flat.__tv_root label[class$="_field"]{gap:7px}
70
+ .dsh-flat.__tv_root [class$="_label"]{font-size:13px;font-weight:500}
71
+ .dsh-flat.__tv_root [class$="_hint"]{font-size:12px;line-height:1.65}
72
+ .dsh-flat.__tv_root input:not([type=checkbox]),.dsh-flat.__tv_root select,.dsh-flat.__tv_root textarea{width:100%;border:1px solid var(--flat-border);border-radius:6px;background:var(--dsw-alias-bg-layer-3);color:inherit;font:inherit;padding:9px 12px;min-height:40px;box-shadow:none;transition:border-color .15s}
73
+ .dsh-flat.__tv_root input:hover:not(:disabled),.dsh-flat.__tv_root select:hover:not(:disabled),.dsh-flat.__tv_root textarea:hover:not(:disabled){border-color:var(--dsw-alias-label-tertiary)}
74
+ .dsh-flat.__tv_root select{appearance:none;padding-right:34px;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='m2 2 4 4 4-4' fill='none' stroke='%23778091' stroke-width='1.5'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 12px center}
75
+ .dsh-flat.__tv_root input[type=checkbox]{appearance:none;flex:none;width:30px;height:18px;margin:0;border:1px solid var(--flat-border);border-radius:12px;background:var(--dsw-alias-bg-layer-2);position:relative;cursor:pointer;transition:background .15s,border-color .15s}
76
+ .dsh-flat.__tv_root input[type=checkbox]::before{content:"";position:absolute;left:2px;top:2px;width:12px;height:12px;border-radius:50%;background:var(--dsw-alias-label-secondary);transition:transform .15s}
77
+ .dsh-flat.__tv_root input[type=checkbox]:checked{background:var(--flat-accent);border-color:var(--flat-accent)}
78
+ .dsh-flat.__tv_root input[type=checkbox]:checked::before{transform:translateX(12px);background:#fff}
79
+ .dsh-flat.__tv_root :is(input,select,textarea,button,summary,a):focus-visible{outline:2px solid var(--flat-accent);outline-offset:3px}
80
+ .dsh-flat.__tv_root :is(input,select,textarea,button):disabled{opacity:.5;cursor:default}
81
+ .dsh-flat.__tv_root [class$="_actions"]{flex-wrap:wrap;gap:10px;margin-top:4px;padding-top:16px;border-top:1px solid var(--flat-border)}
82
+ .dsh-flat.__tv_root details{border-top:1px solid var(--flat-border);padding:0}
83
+ .dsh-flat.__tv_root summary{list-style:none;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:14px 0;font-size:13px;font-weight:500;cursor:pointer;color:var(--dsw-alias-label-secondary)}
84
+ .dsh-flat.__tv_root summary::-webkit-details-marker{display:none}
85
+ .dsh-flat.__tv_root summary::after{content:"+";font-size:18px;font-weight:400;flex:none}
86
+ .dsh-flat.__tv_root details[open]>summary::after{content:"−"}
87
+ .dsh-flat.__tv_root details>div{padding-bottom:18px}
88
+ .dsh-flat.__tv_root .__tv_master{padding:0 0 16px;margin:0;border:0;border-bottom:1px solid var(--flat-border);border-radius:0;background:none}
89
+ .dsh-flat.__tv_root .__tv_catalog{padding:0;border:0;background:none;gap:8px}
90
+ @media(max-width:480px){.dsh-flat.__tv_root{gap:16px}.dsh-flat.__tv_root .__tv_master{flex-wrap:wrap}}
91
+ @media(prefers-reduced-motion:reduce){.dsh-flat.__tv_root *,.dsh-flat.__tv_root input[type=checkbox]::before{transition:none}}
92
+ .dsh-flat.__tv_root button:not(.__tv_link){border-radius:6px;min-height:34px;padding:7px 14px;font:inherit;font-size:12px;box-shadow:none}
93
+ .dsh-flat.__tv_root :is(h2,h3){margin:0;font-size:14px;font-weight:600}
94
+ `;
64
95
  var tagId = "dsh-tool-vision/main.css";
65
96
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
66
97
  var tag = document.createElement("style");
@@ -74,8 +105,16 @@ window.__ModuleLoader__.load({
74
105
  var NS = "toolVision";
75
106
  var inject = ["slots", "locale", "configForms"];
76
107
  var zh = {
108
+ imageMode: "图片处理模式",
109
+ imageModeAuto: "自动接图与预览(推荐)",
110
+ imageModeTools: "仅使用视觉工具",
111
+ imageModeCustom: "自定义组合",
112
+ imageModeHint: "自动模式为纯文本模型接收并桥接图片,同时显示缩略图;仅工具模式关闭自动接图、桥接和预览,仍可调用视觉工具。选择后点击保存。",
113
+ advanced: "高级设置:模型覆盖、桥接与请求参数",
114
+ connectionTitle: "外置视觉模型",
77
115
  nav: "视觉模型",
78
- intro: "外置视觉模型配置:Agent 通过 inspect_image 工具把图片发给该端点分析。修改后即时生效(settings.yaml 热重载)。",
116
+ imagePreview: "图片预览",
117
+ intro: "填写视觉模型连接信息,即可用工具分析图片。保存后即时生效;密钥留空保持原值。",
79
118
  apiKeyHint: "留空保持当前密钥。密钥只写不读,不会回显。",
80
119
  maxTokens: "最大输出 Tokens",
81
120
  timeoutMs: "请求超时(毫秒)",
@@ -163,8 +202,16 @@ window.__ModuleLoader__.load({
163
202
  notApplied: "写入未生效"
164
203
  };
165
204
  var en = {
205
+ imageMode: "Image handling",
206
+ imageModeAuto: "Automatic images and previews (recommended)",
207
+ imageModeTools: "Vision tools only",
208
+ imageModeCustom: "Custom combination",
209
+ imageModeHint: "Automatic accepts and bridges images for text-only models and shows thumbnails. Tools only disables automatic image admission, bridging and previews while keeping vision tools available. Click Save to apply your selection.",
210
+ advanced: "Advanced: model overrides, bridge and request options",
211
+ connectionTitle: "External vision model",
166
212
  nav: "Vision Model",
167
- intro: "External vision model config: the agent sends images to this endpoint via the inspect_image tool. Changes apply immediately (settings.yaml hot-reload).",
213
+ imagePreview: "Image preview",
214
+ intro: "Connect a vision model to analyze images with tools. Saved changes apply immediately; blank keys stay unchanged.",
168
215
  apiKeyHint: "Leave blank to keep the current key. The key is write-only and never echoed.",
169
216
  maxTokens: "Max output tokens",
170
217
  timeoutMs: "Request timeout (ms)",
@@ -280,6 +327,9 @@ window.__ModuleLoader__.load({
280
327
  { key: "sessionHeaderName", label: "fieldSessionHeaderName", type: "text" },
281
328
  { key: "sessionId", label: "fieldSessionId", type: "text" }
282
329
  ];
330
+ var BASIC_KEYS = ["baseURL", "apiKey", "model", "desktopScreenshot"];
331
+ var IMAGE_MODE_KEYS = ["bridgeTextOnly", "bridgeAutoImage", "bridgePreview"];
332
+ var IMAGE_MODES = { auto: [true, true, true], tools: [false, false, false] };
283
333
  var ZH_HINTS = {
284
334
  apiKey: "apiKeyHint",
285
335
  maxTokens: "maxTokens",
@@ -303,6 +353,7 @@ window.__ModuleLoader__.load({
303
353
 
304
354
  // ── component ─────────────────────────────────────────────────────────
305
355
  function VisionSection(props) {
356
+ useLocale(props.locale);
306
357
  var t = props.t;
307
358
  var scope = props.scope;
308
359
  var [snapshot, setSnapshot] = react.useState(function () { return scope.getSnapshot(); });
@@ -332,7 +383,7 @@ window.__ModuleLoader__.load({
332
383
  // so depending on snapshot.value would reset user input on every render
333
384
  // (typing appears dead).
334
385
  react.useEffect(function () {
335
- if (ready) setDraft(function (prev) { return Object.assign({}, prev, valueToDraft(snapshot.value)); });
386
+ if (ready) setDraft({});
336
387
  // eslint-disable-next-line react-hooks/exhaustive-deps
337
388
  }, [ready]);
338
389
 
@@ -367,6 +418,7 @@ window.__ModuleLoader__.load({
367
418
  var user = snapshot.user || {};
368
419
 
369
420
  function fieldDraft(f) {
421
+ if (f.secret) return draft[f.key] || "";
370
422
  if (f.type === "csv" || f.type === "modellist") return draft[f.key] !== void 0 ? draft[f.key] : draftToCsv(value[f.key]);
371
423
  if (f.type === "checkbox") return draft[f.key] !== void 0 ? draft[f.key] : Boolean(value[f.key]);
372
424
  return draft[f.key] !== void 0 ? draft[f.key] : String(value[f.key] ?? "");
@@ -380,6 +432,23 @@ window.__ModuleLoader__.load({
380
432
  setNotice(null);
381
433
  setError(null);
382
434
  }
435
+ function imageMode() {
436
+ var flags = IMAGE_MODE_KEYS.map(function (key) {
437
+ return Object.prototype.hasOwnProperty.call(draft, key) ? draft[key] : value[key] !== false;
438
+ });
439
+ return Object.keys(IMAGE_MODES).find(function (mode) {
440
+ return IMAGE_MODES[mode].every(function (flag, i) { return flag === flags[i]; });
441
+ }) || "custom";
442
+ }
443
+ function setImageMode(mode) {
444
+ if (!IMAGE_MODES[mode] || busy || !snapshot.writable) return;
445
+ setDraft(function (prev) {
446
+ var next = Object.assign({}, prev);
447
+ IMAGE_MODE_KEYS.forEach(function (key, i) { next[key] = IMAGE_MODES[mode][i]; });
448
+ return next;
449
+ });
450
+ setNotice(null); setError(null);
451
+ }
383
452
 
384
453
  // One atomic mutation carrying ONE revision fence.
385
454
  //
@@ -393,6 +462,7 @@ window.__ModuleLoader__.load({
393
462
  function buildOps() {
394
463
  var ops = [];
395
464
  FIELDS.forEach(function (f) {
465
+ if (!Object.prototype.hasOwnProperty.call(draft, f.key)) return;
396
466
  var d = fieldDraft(f);
397
467
  if (f.type === "csv" || f.type === "modellist") {
398
468
  var arr = String(d).split(",").map(function (s) { return s.trim(); }).filter(Boolean);
@@ -403,8 +473,7 @@ window.__ModuleLoader__.load({
403
473
  }
404
474
  if (f.type === "checkbox") {
405
475
  if (Boolean(d) === Boolean(value[f.key])) return;
406
- ops.push(Boolean(d) ? { op: "set", path: [f.key], value: true }
407
- : { op: "unset", path: [f.key] });
476
+ ops.push({ op: "set", path: [f.key], value: Boolean(d) });
408
477
  return;
409
478
  }
410
479
  if (f.type === "password") {
@@ -443,7 +512,8 @@ window.__ModuleLoader__.load({
443
512
  */
444
513
  function commit(ops) {
445
514
  var run;
446
- if (typeof scope.mutate === "function") {
515
+ var atomic = typeof scope.mutate === "function";
516
+ if (atomic) {
447
517
  run = scope.mutate(ops, scope.getSnapshot().revision);
448
518
  } else {
449
519
  run = ops.reduce(function (chain, op) {
@@ -452,7 +522,9 @@ window.__ModuleLoader__.load({
452
522
  });
453
523
  }, Promise.resolve());
454
524
  }
455
- return Promise.resolve(run).then(function () {
525
+ return Promise.resolve(run).then(function (accepted) {
526
+ if (atomic && accepted === false) return false;
527
+ if (atomic && accepted === true) return true;
456
528
  return opsApplied(ops, scope.getSnapshot());
457
529
  });
458
530
  }
@@ -460,61 +532,54 @@ window.__ModuleLoader__.load({
460
532
  function reportOutcome(ok, okMessage) {
461
533
  setBusy(false);
462
534
  if (ok) {
463
- setNotice(okMessage || t("saved"));
535
+ setNotice({ key: okMessage || "saved" });
464
536
  return;
465
537
  }
466
- setError(t("error") + ":" + t("notApplied"));
467
- reseedDraft();
538
+ setError({ key: "error", detailKey: "notApplied" });
539
+ setSnapshot(scope.getSnapshot());
468
540
  }
469
541
 
470
542
  function onSave() {
543
+ if (busy || !snapshot.writable) return;
471
544
  setBusy(true); setNotice(null); setError(null);
472
545
  var ops = buildOps();
473
- if (ops.length === 0) { setBusy(false); setNotice(t("saved")); return; }
546
+ if (ops.length === 0) { setBusy(false); setDraft({}); setNotice({ key: "saved" }); return; }
474
547
  commit(ops).then(function (ok) {
475
548
  reportOutcome(ok);
549
+ setSnapshot(scope.getSnapshot());
550
+ if (ok) setDraft({});
476
551
  }).catch(function (e) {
477
- setBusy(false); setError(t("error") + ":" + String(e && e.message || e));
552
+ setBusy(false); setError({ key: "error", detail: String(e && e.message || e) });
478
553
  });
479
554
  }
480
555
 
481
556
  // One-click master switch: the same field the checkbox edits, written on its
482
557
  // own so taking the plugin offline never depends on the rest of the form.
483
558
  function onToggleEnabled() {
559
+ if (busy || !snapshot.writable) return;
484
560
  setBusy(true); setNotice(null); setError(null);
485
561
  var next = value.enabled === false;
486
562
  commit([{ op: "set", path: ["enabled"], value: next }]).then(function (ok) {
487
- reportOutcome(ok, next ? t("enabledNotice") : t("disabledNotice"));
563
+ reportOutcome(ok, next ? "enabledNotice" : "disabledNotice");
564
+ setSnapshot(scope.getSnapshot());
488
565
  }).catch(function (e) {
489
- setBusy(false); setError(t("error") + ":" + String(e && e.message || e));
566
+ setBusy(false); setError({ key: "error", detail: String(e && e.message || e) });
490
567
  });
491
568
  }
492
569
 
493
- function reseedDraft() {
494
- // The mirror folds a write's answer in before the promise settles, so the
495
- // snapshot is already current; the timeout only covers a host that
496
- // answers late.
497
- var fresh = scope.getSnapshot();
498
- if (fresh.status === "ready" && fresh.value !== void 0) {
499
- setDraft(Object.assign({}, valueToDraft(fresh.value)));
500
- return;
501
- }
502
- setTimeout(function () {
503
- var later = scope.getSnapshot();
504
- if (later.status === "ready" && later.value !== void 0) setDraft(Object.assign({}, valueToDraft(later.value)));
505
- }, 120);
506
- }
507
-
508
570
  function onReset() {
571
+ if (busy || !snapshot.writable) return;
509
572
  setBusy(true); setNotice(null); setError(null);
510
573
  var ops = FIELDS
511
574
  .filter(function (f) { return f.key in user; })
512
575
  .map(function (f) { return { op: "unset", path: [f.key] }; });
513
- if (ops.length === 0) { setBusy(false); setNotice(t("saved")); return; }
576
+ if (ops.length === 0) { setBusy(false); setDraft({}); setNotice({ key: "saved" }); return; }
514
577
  commit(ops).then(function (ok) {
515
578
  reportOutcome(ok);
579
+ setSnapshot(scope.getSnapshot());
580
+ if (ok) setDraft({});
516
581
  }).catch(function (e) {
517
- setBusy(false); setError(t("error") + ":" + String(e && e.message || e));
582
+ setBusy(false); setError({ key: "error", detail: String(e && e.message || e) });
518
583
  });
519
584
  }
520
585
 
@@ -608,7 +673,7 @@ window.__ModuleLoader__.load({
608
673
  className: "__tv_check",
609
674
  type: "checkbox",
610
675
  checked: Boolean(entry.listed),
611
- disabled: !snapshot.writable,
676
+ disabled: busy || !snapshot.writable,
612
677
  onChange: function () { toggleModel(entry); }
613
678
  }),
614
679
  h("span", { className: "__tv_itemId", title: entry.id }, entry.id),
@@ -631,29 +696,7 @@ window.__ModuleLoader__.load({
631
696
  );
632
697
  }
633
698
 
634
- return h("div", { className: "__tv_root" },
635
- h("p", { className: "__tv_hint", style: { margin: "0 0 4px" } }, t("intro")),
636
- h("div", { className: "__tv_master" },
637
- h("span", { className: "__tv_masterLabel" },
638
- t("masterSwitch") + (value.enabled === false ? ":" + t("disabledNotice") : "")),
639
- h("button", {
640
- type: "button",
641
- className: "__tv_btn " + (value.enabled === false ? "__tv_btnPrimary" : "__tv_btnDanger"),
642
- onClick: onToggleEnabled,
643
- disabled: busy || !snapshot.writable
644
- }, value.enabled === false ? t("turnOn") : t("turnOff"))),
645
- h("div", { className: "__tv_catalog" },
646
- h("span", { className: "__tv_label" }, t("catalogTitle")),
647
- current.model
648
- ? h("span", null, (current.provider ? current.provider + " / " : "") + current.model)
649
- : h("span", null, catalogBusy ? t("catalogLoading") : t("catalogNoRoute")),
650
- current.model ? h("span", { className: "__tv_badge " + (current.direct ? "__tv_badgeOk" : "") },
651
- current.direct ? t("catalogDirect") : t("catalogBridged")) : null,
652
- current.model ? h("span", null, t("catalogSource") + ":" + sourceLabel(current.source, t)) : null,
653
- h("button", { type: "button", className: "__tv_link", onClick: loadCatalog, disabled: catalogBusy },
654
- catalogBusy ? t("catalogLoading") : t("catalogRefresh")),
655
- catalogError ? h("span", { className: "__tv_error" }, t("catalogFailed") + ":" + catalogError) : null),
656
- FIELDS.map(function (f) {
699
+ function renderField(f) {
657
700
  var overridden = f.key in user;
658
701
  var hint = f.key in ZH_HINTS ? h("span", { className: "__tv_hint" }, t(ZH_HINTS[f.key])) : null;
659
702
  var label = h("span", { className: "__tv_label" },
@@ -662,7 +705,7 @@ window.__ModuleLoader__.load({
662
705
  if (f.type === "checkbox") {
663
706
  return h("label", { key: f.key, className: "__tv_field" },
664
707
  h("span", { className: "__tv_row" },
665
- h("input", { className: "__tv_check", type: "checkbox", checked: Boolean(fieldDraft(f)), onChange: function (e) { setField(f, e.target.checked); } }),
708
+ h("input", { className: "__tv_check", type: "checkbox", disabled: busy || !snapshot.writable, checked: Boolean(fieldDraft(f)), onChange: function (e) { setField(f, e.target.checked); } }),
666
709
  h("span", { className: "__tv_label" }, labelOf(f, t)),
667
710
  overridden ? h("span", { className: "__tv_override" }, t("overridden")) : null
668
711
  ),
@@ -680,7 +723,7 @@ window.__ModuleLoader__.load({
680
723
  h("select", {
681
724
  className: "__tv_input",
682
725
  value: chosen,
683
- disabled: !snapshot.writable,
726
+ disabled: busy || !snapshot.writable,
684
727
  onChange: function (e) { setField(f, e.target.value); }
685
728
  }, spec.map(function (o) { return h("option", { key: o.value, value: o.value }, t(o.label)); })),
686
729
  hint
@@ -693,6 +736,7 @@ window.__ModuleLoader__.load({
693
736
  className: "__tv_input",
694
737
  type: "text",
695
738
  list: MODEL_OPTIONS_ID,
739
+ disabled: busy || !snapshot.writable,
696
740
  value: fieldDraft(f),
697
741
  placeholder: t("catalogPickHint"),
698
742
  onChange: function (e) { setField(f, e.target.value); }
@@ -707,31 +751,58 @@ window.__ModuleLoader__.load({
707
751
  className: "__tv_input",
708
752
  type: f.type === "password" ? "password" : f.type === "number" ? "number" : "text",
709
753
  value: fieldDraft(f),
754
+ disabled: busy || !snapshot.writable,
710
755
  placeholder: f.type === "password" ? (overridden ? "••••••••" : t("apiKeyHint")) : (f.placeholder || ""),
711
756
  onChange: function (e) { setField(f, e.target.value); }
712
757
  }),
713
758
  hint
714
759
  );
715
- }),
760
+ }
761
+ return h("div", { className: "__tv_root dsh-flat" },
762
+ h("p", { className: "__tv_hint", style: { margin: "0 0 4px" } }, t("intro")),
763
+ h("div", { className: "__tv_master" },
764
+ h("span", { className: "__tv_masterLabel" },
765
+ t("masterSwitch") + (value.enabled === false ? ":" + t("disabledNotice") : "")),
766
+ h("button", {
767
+ type: "button",
768
+ className: "__tv_btn " + (value.enabled === false ? "__tv_btnPrimary" : "__tv_btnDanger"),
769
+ onClick: onToggleEnabled,
770
+ disabled: busy || !snapshot.writable
771
+ }, value.enabled === false ? t("turnOn") : t("turnOff"))),
772
+ h("div", { className: "__tv_catalog" },
773
+ h("span", { className: "__tv_label" }, t("catalogTitle")),
774
+ current.model
775
+ ? h("span", null, (current.provider ? current.provider + " / " : "") + current.model)
776
+ : h("span", null, catalogBusy ? t("catalogLoading") : t("catalogNoRoute")),
777
+ current.model ? h("span", { className: "__tv_badge " + (current.direct ? "__tv_badgeOk" : "") },
778
+ current.direct ? t("catalogDirect") : t("catalogBridged")) : null,
779
+ current.model ? h("span", null, t("catalogSource") + ":" + sourceLabel(current.source, t)) : null,
780
+ h("button", { type: "button", className: "__tv_link", onClick: loadCatalog, disabled: catalogBusy },
781
+ catalogBusy ? t("catalogLoading") : t("catalogRefresh")),
782
+ catalogError ? h("span", { className: "__tv_error" }, t("catalogFailed") + ":" + catalogError) : null),
783
+ h("label", { className: "__tv_field" },
784
+ h("span", { className: "__tv_label" }, t("imageMode")),
785
+ h("select", { className: "__tv_input", "aria-label": t("imageMode"), value: imageMode(), disabled: busy || !snapshot.writable,
786
+ onChange: function (e) { setImageMode(e.target.value); } },
787
+ h("option", { value: "auto" }, t("imageModeAuto")), h("option", { value: "tools" }, t("imageModeTools")),
788
+ h("option", { value: "custom", disabled: true }, t("imageModeCustom"))),
789
+ h("span", { className: "__tv_hint" }, t("imageModeHint"))),
790
+ h("h3", { className: "__tv_label" }, t("connectionTitle")),
791
+ FIELDS.filter(function (f) { return BASIC_KEYS.indexOf(f.key) !== -1; }).map(renderField),
792
+ h("details", null, h("summary", { style: { cursor: "pointer" } }, t("advanced")),
793
+ h("div", { style: { display: "flex", flexDirection: "column", gap: 10, paddingTop: 12 } },
794
+ FIELDS.filter(function (f) { return f.key !== "enabled" && BASIC_KEYS.indexOf(f.key) === -1; }).map(renderField))),
716
795
  h("datalist", { id: MODEL_OPTIONS_ID }, catalogOptions),
717
796
  h("div", { className: "__tv_actions" },
718
797
  h("button", { type: "button", className: "__tv_btn __tv_btnPrimary", onClick: onSave, disabled: busy || !snapshot.writable }, t("save")),
719
798
  h("button", { type: "button", className: "__tv_btn", onClick: onReset, disabled: busy || !snapshot.writable }, t("reset")),
720
- notice ? h("span", { className: "__tv_status" }, notice) : null,
799
+ notice ? h("span", { className: "__tv_status" }, messageText(t, notice)) : null,
721
800
  busy ? h("span", { className: "__tv_status" }, t("saving")) : null,
722
- error ? h("span", { className: "__tv_error" }, error) : null
801
+ error ? h("span", { className: "__tv_error" }, messageText(t, error)) : null
723
802
  )
724
803
  );
725
804
  }
726
805
 
727
- function valueToDraft(value) {
728
- var out = {};
729
- for (var i = 0; i < FIELDS.length; i += 1) {
730
- var f = FIELDS[i];
731
- out[f.key] = f.type === "csv" || f.type === "modellist" ? draftToCsv(value[f.key]) : f.type === "checkbox" ? Boolean(value[f.key]) : String(value[f.key] ?? "");
732
- }
733
- return out;
734
- }
735
806
  function draftToCsv(arr) {
736
807
  return Array.isArray(arr) ? arr.join(", ") : String(arr ?? "");
737
808
  }
@@ -831,7 +902,8 @@ window.__ModuleLoader__.load({
831
902
  overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,0.82);display:flex;align-items:center;justify-content:center;z-index:2147483000;cursor:zoom-out;";
832
903
  var big = document.createElement("img");
833
904
  big.src = src;
834
- big.alt = alt || "图片预览";
905
+ big.alt = alt;
906
+ big.setAttribute(PREVIEW_ATTR, "1");
835
907
  big.style.cssText = "max-width:92vw;max-height:92vh;object-fit:contain;border-radius:4px;box-shadow:0 8px 40px rgba(0,0,0,0.5);";
836
908
  var close = function () {
837
909
  overlay.remove();
@@ -849,6 +921,7 @@ window.__ModuleLoader__.load({
849
921
 
850
922
  function attachBridgePreview(ctx, scope) {
851
923
  var cfg = previewConfigOf(scope);
924
+ var t = ctx.locale.bind(NS);
852
925
  var pendingTimer = null;
853
926
  var intervalTimer = null;
854
927
  var observer = null;
@@ -867,7 +940,7 @@ window.__ModuleLoader__.load({
867
940
  var img = document.createElement("img");
868
941
  img.setAttribute(PREVIEW_ATTR, "1");
869
942
  img.src = PREVIEW_ROUTE + "?p=" + encodeURIComponent(path);
870
- img.alt = "图片预览";
943
+ img.alt = t("imagePreview");
871
944
  img.style.cssText = "display:block;margin-left:auto;margin-right:0;max-width:min(360px,100%);max-height:420px;border-radius:8px;margin-top:4px;margin-bottom:6px;object-fit:contain;cursor:zoom-in;";
872
945
  img.addEventListener("click", function () { openLightbox(img.src, img.alt); });
873
946
  img.addEventListener("load", function () {
@@ -920,6 +993,9 @@ window.__ModuleLoader__.load({
920
993
  break;
921
994
  }
922
995
  });
996
+ var offLocale = typeof ctx.locale.subscribe === "function" ? ctx.locale.subscribe(function () {
997
+ document.querySelectorAll("img[" + PREVIEW_ATTR + "]").forEach(function (img) { img.alt = t("imagePreview"); });
998
+ }) : null;
923
999
  observer.observe(document.body, { childList: true, subtree: true, characterData: true });
924
1000
  armInterval();
925
1001
  var un = typeof scope.subscribe === "function" ? scope.subscribe(function () {
@@ -927,6 +1003,7 @@ window.__ModuleLoader__.load({
927
1003
  armInterval();
928
1004
  }) : null;
929
1005
  return function () {
1006
+ if (offLocale) offLocale();
930
1007
  if (observer) observer.disconnect();
931
1008
  if (pendingTimer !== null) { clearTimeout(pendingTimer); pendingTimer = null; }
932
1009
  if (intervalTimer !== null) { clearInterval(intervalTimer); intervalTimer = null; }
@@ -936,6 +1013,21 @@ window.__ModuleLoader__.load({
936
1013
  }
937
1014
 
938
1015
  // ── plugin ────────────────────────────────────────────────────────────
1016
+
1017
+ // Follow the host language without remounting the form or losing drafts.
1018
+ function useLocale(locale) {
1019
+ var refresh = react.useState(0)[1];
1020
+ react.useEffect(function () {
1021
+ if (!locale || typeof locale.subscribe !== "function") return;
1022
+ return locale.subscribe(function () { refresh(function (revision) { return revision + 1; }); });
1023
+ }, [locale]);
1024
+ }
1025
+ // Keep translation keys in state so feedback follows later language changes.
1026
+ function messageText(t, message) {
1027
+ if (!message) return "";
1028
+ return t(message.key) + (message.detailKey ? ": " + t(message.detailKey) : message.detail ? ": " + message.detail : "");
1029
+ }
1030
+
939
1031
  function apply(ctx) {
940
1032
  var t = ctx.locale.bind(NS);
941
1033
  ctx.effect(function () { return ctx.locale.register(NS, { zh: zh, en: en }); }, "dsh-tool-vision: dictionaries");
@@ -947,7 +1039,7 @@ window.__ModuleLoader__.load({
947
1039
  key: "dsh-tool-vision",
948
1040
  locale: NS
949
1041
  }, function (props) {
950
- return h(VisionSection, Object.assign({}, props, { scope: scope }));
1042
+ return h(VisionSection, Object.assign({}, props, { scope: scope, t: t, locale: ctx.locale }));
951
1043
  });
952
1044
  });
953
1045
  }
package/cordis.patch.yml CHANGED
@@ -1,8 +1,8 @@
1
- # dsh-tool-vision — bundle patch layer
2
- #
3
- # Declares the plugin as a Cordis loader entry, making the package a standard
4
- # profile bundle: `dsh plugin --profile <p> add dsh-tool-vision` installs and
5
- # mounts it without manual cordis.patch.yml edits.
6
- - insert:
7
- - id: tool-vision
8
- name: 'dsh-tool-vision'
1
+ # dsh-tool-vision — bundle patch layer
2
+ #
3
+ # Declares the plugin as a Cordis loader entry, making the package a standard
4
+ # profile bundle: `dsh plugin --profile <p> add dsh-tool-vision` installs and
5
+ # mounts it without manual cordis.patch.yml edits.
6
+ - insert:
7
+ - id: tool-vision
8
+ name: 'dsh-tool-vision'
package/index.js CHANGED
@@ -221,7 +221,7 @@ const Config = z.object({
221
221
  /** Fixed session id override for callers without a dsh session context.
222
222
  * Empty = auto (per-process stable id). */
223
223
  sessionId: z.string().default(""),
224
- }).volatile();
224
+ }).volatile();
225
225
 
226
226
  const MIME_BY_EXT = {
227
227
  ".png": "image/png",
@@ -1165,7 +1165,7 @@ function apply(ctx, config) {
1165
1165
  // would be undefined (apiKey included).
1166
1166
  let current = config;
1167
1167
  let sourceGetter = null;
1168
- const getConfig = () => (sourceGetter ? sourceGetter() : typeof current.get === "function" ? current.get() : current);
1168
+ const getConfig = () => (sourceGetter ? sourceGetter() : typeof current.get === "function" ? current.get() : current);
1169
1169
  // The registered settings scope, kept so tools can PERSIST what they measure.
1170
1170
  // Null whenever no settings provider is mounted (composition-entry-only runs),
1171
1171
  // in which case a probe still reports its verdict but cannot record it.
@@ -1435,12 +1435,12 @@ function apply(ctx, config) {
1435
1435
  // `installSettingsSection` export (the provider now lives at ctx.settings).
1436
1436
  // Inline the same logic via ctx.inject(["settings"]) — works on both
1437
1437
  // 0.1.1 (module export wrapper) and 0.1.2 (ctx.settings) hosts.
1438
- sourceGetter = () => typeof config.get === "function" ? config.get() : config;
1439
- settingsScope = { update: (patch) => ctx.settings.update(NS, patch) };
1440
- ctx.on("settings/document-updated", (id) => {
1441
- if (id === NS) syncFeatures();
1442
- });
1443
- ctx.effect(() => () => uninstallFeatures());
1438
+ sourceGetter = () => typeof config.get === "function" ? config.get() : config;
1439
+ settingsScope = { update: (patch) => ctx.settings.update(NS, patch) };
1440
+ ctx.on("settings/document-updated", (id) => {
1441
+ if (id === NS) syncFeatures();
1442
+ });
1443
+ ctx.effect(() => () => uninstallFeatures());
1444
1444
 
1445
1445
  // ── model catalog route: settings support, so it lives on THIS fiber ───────
1446
1446
  // The master switch disposes the feature fiber on purpose (tools, bridge,
@@ -134,12 +134,34 @@ function readAnswer(body) {
134
134
  return reasoning;
135
135
  }
136
136
 
137
- /**
138
- * One `chat/completions` request against a route's own endpoint.
139
- *
140
- * @returns {Promise<{ok: true, answer: string} | {ok: false, status: number, message: string}>}
141
- * `ok: false` with a status of 0 for a transport failure.
142
- */
137
+ /** Read real response streams with a byte limit before buffering the body. */
138
+ async function readProbeBody(response) {
139
+ const reader = response.body?.getReader?.();
140
+ if (!reader) {
141
+ const text = await response.text();
142
+ if (Buffer.byteLength(text, "utf8") > PROBE_MAX_BODY_BYTES) throw new Error("probe response body too large");
143
+ return text;
144
+ }
145
+ const chunks = [];
146
+ let size = 0;
147
+ try {
148
+ while (true) {
149
+ const { done, value } = await reader.read();
150
+ if (done) break;
151
+ size += value.byteLength;
152
+ if (size > PROBE_MAX_BODY_BYTES) throw new Error("probe response body too large");
153
+ chunks.push(Buffer.from(value));
154
+ }
155
+ return Buffer.concat(chunks, size).toString("utf8");
156
+ } catch (error) {
157
+ await reader.cancel().catch(() => {});
158
+ throw error;
159
+ } finally {
160
+ reader.releaseLock();
161
+ }
162
+ }
163
+
164
+ /** One chat/completions request; transport failures use status 0. */
143
165
  async function probeRequest(target, content, fetchImpl, signal) {
144
166
  const base = target.baseURL.endsWith("/") ? target.baseURL : `${target.baseURL}/`;
145
167
  const endpoint = new URL("chat/completions", base);
@@ -151,6 +173,7 @@ async function probeRequest(target, content, fetchImpl, signal) {
151
173
  else signal.addEventListener("abort", onAbort, { once: true });
152
174
  }
153
175
  try {
176
+ controller.signal.throwIfAborted();
154
177
  const response = await fetchImpl(endpoint, {
155
178
  method: "POST",
156
179
  headers: {
@@ -165,10 +188,7 @@ async function probeRequest(target, content, fetchImpl, signal) {
165
188
  }),
166
189
  signal: controller.signal,
167
190
  });
168
- const text = await response.text();
169
- if (text.length > PROBE_MAX_BODY_BYTES) {
170
- return { ok: false, status: response.status, message: "probe response body too large" };
171
- }
191
+ const text = await readProbeBody(response);
172
192
  let body = null;
173
193
  try {
174
194
  body = JSON.parse(text);
@@ -203,6 +223,11 @@ async function probeRequest(target, content, fetchImpl, signal) {
203
223
  * @returns {Promise<{support: 'yes'|'no'|'unknown', detail: string, evidence: object}>}
204
224
  */
205
225
  export async function probeModelCapability(target, signal) {
226
+ target = {
227
+ ...target,
228
+ timeoutMs: Number.isFinite(target.timeoutMs) && target.timeoutMs > 0 ? target.timeoutMs : PROBE_TIMEOUT_MS,
229
+ maxTokens: Number.isFinite(target.maxTokens) && target.maxTokens > 0 ? target.maxTokens : PROBE_MAX_TOKENS,
230
+ };
206
231
  const fetchImpl = target.fetchImpl ?? fetch;
207
232
  const api = target.api;
208
233
  if (api !== undefined && api !== null && api !== "openai-completions") {
@@ -217,6 +242,12 @@ export async function probeModelCapability(target, signal) {
217
242
  if (typeof target.baseURL !== "string" || target.baseURL.length === 0) {
218
243
  return { support: "unknown", detail: "the route has no baseURL", evidence: {} };
219
244
  }
245
+ try {
246
+ const url = new URL(target.baseURL);
247
+ if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("unsupported protocol");
248
+ } catch {
249
+ return { support: "unknown", detail: "the route has an invalid HTTP(S) baseURL", evidence: {} };
250
+ }
220
251
 
221
252
  const control = await probeRequest(target, PROBE_CONTROL_QUESTION, fetchImpl, signal);
222
253
  const controlAnswer = control.ok ? control.answer : "";
@@ -240,7 +271,7 @@ export async function probeModelCapability(target, signal) {
240
271
  { type: "image_url", image_url: { url: solidColorDataUrl(spec.rgb) } },
241
272
  ], fetchImpl, signal);
242
273
  if (!result.ok) {
243
- if (UNSUPPORTED_IMAGE_RE.test(result.message)) {
274
+ if ([400, 415, 422].includes(result.status) && UNSUPPORTED_IMAGE_RE.test(result.message)) {
244
275
  return {
245
276
  support: "no",
246
277
  detail: `the endpoint rejected the image part (${result.status}): ${result.message}`,
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Vision tools",
4
+ "description": "Connect an external vision model to analyze images, with image bridging and previews for text models."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "视觉工具",
4
+ "description": "连接外置视觉模型,分析图片,并为文本模型提供图片桥接与预览。"
5
+ }
6
+ }