dsh-tool-vision 0.3.9 → 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.
Files changed (6) hide show
  1. package/LICENSE +23 -21
  2. package/README.md +186 -156
  3. package/README.zh.md +124 -108
  4. package/client.js +498 -296
  5. package/index.js +641 -432
  6. package/package.json +7 -4
package/client.js CHANGED
@@ -1,296 +1,498 @@
1
- /**
2
- * dsh-tool-vision — browser half.
3
- *
4
- * A "视觉模型" section inside the Web UI settings page: edits the
5
- * `tool-vision` settings namespace (API endpoint, key, model, bridge
6
- * options) through the settings scope transport. Changes hot-apply via the
7
- * host settings provider — no restart needed.
8
- *
9
- * Hand-written ModuleLoader bundle — no build step required.
10
- */
11
- window.__ModuleLoader__.load({
12
- id: "dsh-tool-vision",
13
- factory: (require) => {
14
- var module = { exports: {} };
15
- var exports = module.exports;
16
- Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
17
- var react = require("react");
18
- var h = react.createElement;
19
-
20
- // ── CSS (theme tokens) ────────────────────────────────────────────────
21
- var CSS = ".__tv_root{max-width:640px;display:flex;flex-direction:column;gap:10px}" +
22
- ".__tv_field{display:flex;flex-direction:column;gap:4px}" +
23
- ".__tv_label{font-size:12px;font-weight:600;color:var(--dsw-alias-label-primary);display:flex;align-items:center;gap:6px}" +
24
- ".__tv_override{font-size:10px;color:var(--dsw-alias-state-business-primary);border:1px solid var(--dsw-alias-border-l2);border-radius:4px;padding:0 4px}" +
25
- ".__tv_hint{font-size:11px;color:var(--dsw-alias-label-tertiary)}" +
26
- ".__tv_input{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:6px 10px;font-size:13px;box-sizing:border-box;width:100%}" +
27
- ".__tv_row{display:flex;align-items:center;gap:8px}" +
28
- ".__tv_check{accent-color:var(--dsw-alias-state-business-primary)}" +
29
- ".__tv_actions{display:flex;gap:8px;align-items:center;margin-top:4px}" +
30
- ".__tv_btn{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);border-radius:8px;padding:6px 14px;font:inherit;font-size:13px;cursor:pointer}" +
31
- ".__tv_btn:hover:not(:disabled){border-color:var(--dsw-alias-state-business-primary)}" +
32
- ".__tv_btn:disabled{opacity:.5;cursor:default}" +
33
- ".__tv_btnPrimary{border-color:var(--dsw-alias-state-business-primary, #3964fe);background:var(--dsw-alias-state-business-primary, #3964fe);color:#fff}" +
34
- ".__tv_status{font-size:12px;color:var(--dsw-alias-label-tertiary)}" +
35
- ".__tv_error{font-size:12px;color:var(--dsw-alias-state-error-primary)}" +
36
- ".__tv_unavailable{font-size:13px;color:var(--dsw-alias-label-tertiary)}";
37
- var tagId = "dsh-tool-vision/main.css";
38
- if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
39
- var tag = document.createElement("style");
40
- tag.dataset.plugin = "dsh-tool-vision";
41
- tag.dataset.pluginCss = tagId;
42
- tag.textContent = CSS;
43
- document.head.appendChild(tag);
44
- }
45
-
46
- // ── locale ────────────────────────────────────────────────────────────
47
- var NS = "toolVision";
48
- var inject = ["slots", "locale", "settingsScope"];
49
- var zh = {
50
- nav: "视觉模型",
51
- intro: "外置视觉模型配置:Agent 通过 inspect_image 工具把图片发给该端点分析。修改后即时生效(settings.yaml 热重载)。",
52
- apiKeyHint: "留空保持当前密钥。密钥只写不读,不会回显。",
53
- maxTokens: "最大输出 Tokens",
54
- timeoutMs: "请求超时(毫秒)",
55
- maxImageBytes: "本地图片大小上限(字节)",
56
- bridgeTextOnly: "图片桥接(文本模型贴图自动转 inspect_image 指引)",
57
- bridgeExportDir: "桥接图片导出目录(空 = 系统临时目录)",
58
- multimodalModels: "多模态白名单(逗号分隔,这些模型直收图片块)",
59
- save: "保存",
60
- reset: "恢复默认",
61
- saved: "已保存",
62
- saving: "保存中…",
63
- error: "保存失败",
64
- unavailable: "设置命名空间不可用(服务端未注册 tool-vision 命名空间?)",
65
- overridden: "已覆盖",
66
- loading: "加载中…"
67
- };
68
- var en = {
69
- nav: "Vision Model",
70
- intro: "External vision model config: the agent sends images to this endpoint via the inspect_image tool. Changes apply immediately (settings.yaml hot-reload).",
71
- apiKeyHint: "Leave blank to keep the current key. The key is write-only and never echoed.",
72
- maxTokens: "Max output tokens",
73
- timeoutMs: "Request timeout (ms)",
74
- maxImageBytes: "Max local image size (bytes)",
75
- bridgeTextOnly: "Image bridge (pasted images on text-only models become inspect_image hints)",
76
- bridgeExportDir: "Bridge export dir (empty = system temp)",
77
- multimodalModels: "Multimodal whitelist (comma-separated; these models receive image blocks directly)",
78
- save: "Save",
79
- reset: "Reset",
80
- saved: "Saved",
81
- saving: "Saving…",
82
- error: "Save failed",
83
- unavailable: "Settings namespace unavailable (tool-vision namespace not registered server-side?)",
84
- overridden: "overridden",
85
- loading: "Loading…"
86
- };
87
-
88
- // ── field spec ────────────────────────────────────────────────────────
89
- var FIELDS = [
90
- { key: "baseURL", label: "API Base URL", type: "text", placeholder: "https://api.openai.com/v1" },
91
- { key: "apiKey", label: "API Key", type: "password", secret: true },
92
- { key: "apiKeyEnv", label: "API Key 环境变量(apiKey 为空时读取)", type: "text" },
93
- { key: "model", label: "视觉模型", type: "text", placeholder: "gpt-4o-mini" },
94
- { key: "maxTokens", label: "最大输出 Tokens", type: "number" },
95
- { key: "timeoutMs", label: "请求超时(毫秒)", type: "number" },
96
- { key: "maxImageBytes", label: "图片大小上限(字节)", type: "number" },
97
- { key: "bridgeTextOnly", label: "图片桥接开关", type: "checkbox" },
98
- { key: "bridgeExportDir", label: "桥接导出目录", type: "text" },
99
- { key: "multimodalModels", label: "多模态白名单(逗号分隔)", type: "csv" }
100
- ];
101
- var ZH_HINTS = {
102
- apiKey: "apiKeyHint",
103
- maxTokens: "maxTokens",
104
- timeoutMs: "timeoutMs",
105
- maxImageBytes: "maxImageBytes",
106
- bridgeTextOnly: "bridgeTextOnly",
107
- bridgeExportDir: "bridgeExportDir",
108
- multimodalModels: "multimodalModels"
109
- };
110
-
111
- function labelOf(f) {
112
- return f.label;
113
- }
114
-
115
- // ── component ─────────────────────────────────────────────────────────
116
- function VisionSection(props) {
117
- var t = props.t;
118
- var scope = props.scope;
119
- var [snapshot, setSnapshot] = react.useState(function () { return scope.getSnapshot(); });
120
- var ready = snapshot.status === "ready" && snapshot.value !== void 0;
121
- var [draft, setDraft] = react.useState({});
122
- var [busy, setBusy] = react.useState(false);
123
- var [notice, setNotice] = react.useState(null);
124
- var [error, setError] = react.useState(null);
125
-
126
- react.useEffect(function () {
127
- scope.load();
128
- var alive = true;
129
- var sync = function () { if (alive) setSnapshot(scope.getSnapshot()); };
130
- var un = typeof scope.subscribe === "function" ? scope.subscribe(sync) : null;
131
- return function () { alive = false; if (un) un(); if (scope.dispose) scope.dispose(); };
132
- }, [scope]);
133
- // Seed the draft ONLY when the snapshot becomes ready — never on value
134
- // churn. settingsScope.getSnapshot() returns a fresh object per call,
135
- // so depending on snapshot.value would reset user input on every render
136
- // (typing appears dead).
137
- react.useEffect(function () {
138
- if (ready) setDraft(function (prev) { return Object.assign({}, prev, valueToDraft(snapshot.value)); });
139
- // eslint-disable-next-line react-hooks/exhaustive-deps
140
- }, [ready]);
141
-
142
- if (snapshot.status === "unavailable") {
143
- return h("p", { className: "__tv_unavailable" }, t("unavailable"));
144
- }
145
- if (!ready) return h("p", { className: "__tv_status" }, t("loading"));
146
-
147
- var value = snapshot.value;
148
- var user = snapshot.user || {};
149
-
150
- function fieldDraft(f) {
151
- if (f.type === "csv") return draft[f.key] !== void 0 ? draft[f.key] : draftToCsv(value[f.key]);
152
- if (f.type === "checkbox") return draft[f.key] !== void 0 ? draft[f.key] : Boolean(value[f.key]);
153
- return draft[f.key] !== void 0 ? draft[f.key] : String(value[f.key] ?? "");
154
- }
155
- function setField(f, v) {
156
- setDraft(function (prev) {
157
- var next = Object.assign({}, prev);
158
- next[f.key] = v;
159
- return next;
160
- });
161
- setNotice(null);
162
- setError(null);
163
- }
164
-
165
- function onSave() {
166
- setBusy(true); setNotice(null); setError(null);
167
- var writes = FIELDS.map(function (f) {
168
- var d = fieldDraft(f);
169
- if (f.type === "csv") {
170
- var arr = String(d).split(",").map(function (s) { return s.trim(); }).filter(Boolean);
171
- var cur = value[f.key] || [];
172
- if (arr.length === cur.length && arr.every(function (x, i) { return x === cur[i]; })) return Promise.resolve();
173
- return scope.set(f.key, arr);
174
- }
175
- if (f.type === "checkbox") {
176
- if (Boolean(d) === Boolean(value[f.key])) return Promise.resolve();
177
- return Boolean(d) ? scope.set(f.key, true) : scope.unset(f.key);
178
- }
179
- if (f.type === "password") {
180
- if (!d) return Promise.resolve(); // blank keeps the current key
181
- if (d === String(value[f.key] ?? "")) return Promise.resolve();
182
- return scope.set(f.key, d);
183
- }
184
- if (String(d) === String(value[f.key] ?? "")) return Promise.resolve();
185
- if (String(d).trim() === "" && !(f.key in user)) return Promise.resolve();
186
- return String(d).trim() === "" ? scope.unset(f.key) : scope.set(f.key, f.type === "number" ? Number(d) : d);
187
- });
188
- Promise.all(writes).then(function () {
189
- setBusy(false); setNotice(t("saved"));
190
- if (scope.load) scope.load();
191
- }).catch(function (e) {
192
- setBusy(false); setError(t("error") + ": " + String(e && e.message || e));
193
- });
194
- }
195
-
196
- function reseedDraft() {
197
- if (typeof scope.load === "function") {
198
- var p = scope.load();
199
- if (p && typeof p.then === "function") {
200
- p.then(function () {
201
- var fresh = scope.getSnapshot();
202
- if (fresh.status === "ready" && fresh.value !== void 0) setDraft(Object.assign({}, valueToDraft(fresh.value)));
203
- }).catch(function () {});
204
- return;
205
- }
206
- }
207
- setTimeout(function () {
208
- var fresh = scope.getSnapshot();
209
- if (fresh.status === "ready" && fresh.value !== void 0) setDraft(Object.assign({}, valueToDraft(fresh.value)));
210
- }, 120);
211
- }
212
-
213
- function onReset() {
214
- setBusy(true); setNotice(null); setError(null);
215
- Promise.all(FIELDS.map(function (f) { return scope.unset(f.key); })).then(function () {
216
- setBusy(false); setNotice(t("saved"));
217
- reseedDraft();
218
- }).catch(function (e) {
219
- setBusy(false); setError(t("error") + ": " + String(e && e.message || e));
220
- });
221
- }
222
-
223
- return h("div", { className: "__tv_root" },
224
- h("p", { className: "__tv_hint", style: { margin: "0 0 4px" } }, t("intro")),
225
- FIELDS.map(function (f) {
226
- var overridden = f.key in user;
227
- if (f.type === "checkbox") {
228
- return h("label", { key: f.key, className: "__tv_field" },
229
- h("span", { className: "__tv_row" },
230
- h("input", { className: "__tv_check", type: "checkbox", checked: Boolean(fieldDraft(f)), onChange: function (e) { setField(f, e.target.checked); } }),
231
- h("span", { className: "__tv_label" }, labelOf(f)),
232
- overridden ? h("span", { className: "__tv_override" }, t("overridden")) : null
233
- ),
234
- f.key in ZH_HINTS ? h("span", { className: "__tv_hint" }, t(ZH_HINTS[f.key])) : null
235
- );
236
- }
237
- return h("label", { key: f.key, className: "__tv_field" },
238
- h("span", { className: "__tv_label" },
239
- labelOf(f),
240
- overridden ? h("span", { className: "__tv_override" }, t("overridden")) : null
241
- ),
242
- h("input", {
243
- className: "__tv_input",
244
- type: f.type === "password" ? "password" : f.type === "number" ? "number" : "text",
245
- value: fieldDraft(f),
246
- placeholder: f.type === "password" ? (overridden ? "••••••••" : t("apiKeyHint")) : (f.placeholder || ""),
247
- onChange: function (e) { setField(f, e.target.value); }
248
- }),
249
- f.key in ZH_HINTS ? h("span", { className: "__tv_hint" }, t(ZH_HINTS[f.key])) : null
250
- );
251
- }),
252
- h("div", { className: "__tv_actions" },
253
- h("button", { type: "button", className: "__tv_btn __tv_btnPrimary", onClick: onSave, disabled: busy || !snapshot.writable }, t("save")),
254
- h("button", { type: "button", className: "__tv_btn", onClick: onReset, disabled: busy || !snapshot.writable }, t("reset")),
255
- notice ? h("span", { className: "__tv_status" }, notice) : null,
256
- busy ? h("span", { className: "__tv_status" }, t("saving")) : null,
257
- error ? h("span", { className: "__tv_error" }, error) : null
258
- )
259
- );
260
- }
261
-
262
- function valueToDraft(value) {
263
- var out = {};
264
- for (var i = 0; i < FIELDS.length; i += 1) {
265
- var f = FIELDS[i];
266
- out[f.key] = f.type === "csv" ? draftToCsv(value[f.key]) : f.type === "checkbox" ? Boolean(value[f.key]) : String(value[f.key] ?? "");
267
- }
268
- return out;
269
- }
270
- function draftToCsv(arr) {
271
- return Array.isArray(arr) ? arr.join(", ") : String(arr ?? "");
272
- }
273
-
274
- // ── plugin ────────────────────────────────────────────────────────────
275
- function apply(ctx) {
276
- var t = ctx.locale.bind(NS);
277
- ctx.effect(function () { return ctx.locale.register(NS, { zh: zh, en: en }); }, "dsh-tool-vision: dictionaries");
278
- var scope = ctx.settingsScope.bind({ namespace: "tool-vision" });
279
- ctx.slots.inject("settings.section", function () {
280
- return ctx.slots.register({
281
- name: "settings.section",
282
- id: "tool-vision",
283
- order: 25,
284
- label: function () { return t("nav"); },
285
- locale: NS
286
- }, function (props) {
287
- return h(VisionSection, Object.assign({}, props, { scope: scope }));
288
- });
289
- });
290
- }
291
-
292
- exports.apply = apply;
293
- exports.inject = inject;
294
- return module.exports;
295
- }
296
- });
1
+ /**
2
+ * dsh-tool-vision — browser half.
3
+ *
4
+ * A "视觉模型" section inside the Web UI settings page: edits the
5
+ * `tool-vision` settings namespace (API endpoint, key, model, bridge
6
+ * options) through the settings scope transport. Changes hot-apply via the
7
+ * host settings provider — no restart needed.
8
+ *
9
+ * Bridge image preview (v0.4.0, contributed by xing666173 from
10
+ * dsh-bridge-preview, MIT © 2026 xing666173): scans user bubbles for bridge
11
+ * hint text blocks stamped with the invisible `BRIDGE_MARKER` (`\u200b[bridge]`)
12
+ * and renders an inline thumbnail (click to zoom, lightbox). Pure display
13
+ * layer — persisted messages, the transcript and the model-facing text are
14
+ * untouched. Robustness: per-block dedup, MutationObserver + configurable
15
+ * fallback interval, silent degradation on load failure, and optional
16
+ * hint-text hiding once the image has loaded (P2).
17
+ *
18
+ * Hand-written ModuleLoader bundle — no build step required.
19
+ */
20
+ window.__ModuleLoader__.load({
21
+ id: "dsh-tool-vision",
22
+ factory: (require) => {
23
+ var module = { exports: {} };
24
+ var exports = module.exports;
25
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
26
+ var react = require("react");
27
+ var h = react.createElement;
28
+
29
+ // ── CSS (theme tokens) ────────────────────────────────────────────────
30
+ var CSS = ".__tv_root{max-width:640px;display:flex;flex-direction:column;gap:10px}" +
31
+ ".__tv_field{display:flex;flex-direction:column;gap:4px}" +
32
+ ".__tv_label{font-size:12px;font-weight:600;color:var(--dsw-alias-label-primary);display:flex;align-items:center;gap:6px}" +
33
+ ".__tv_override{font-size:10px;color:var(--dsw-alias-state-business-primary);border:1px solid var(--dsw-alias-border-l2);border-radius:4px;padding:0 4px}" +
34
+ ".__tv_hint{font-size:11px;color:var(--dsw-alias-label-tertiary)}" +
35
+ ".__tv_input{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:6px 10px;font-size:13px;box-sizing:border-box;width:100%}" +
36
+ ".__tv_row{display:flex;align-items:center;gap:8px}" +
37
+ ".__tv_check{accent-color:var(--dsw-alias-state-business-primary)}" +
38
+ ".__tv_actions{display:flex;gap:8px;align-items:center;margin-top:4px}" +
39
+ ".__tv_btn{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);border-radius:8px;padding:6px 14px;font:inherit;font-size:13px;cursor:pointer}" +
40
+ ".__tv_btn:hover:not(:disabled){border-color:var(--dsw-alias-state-business-primary)}" +
41
+ ".__tv_btn:disabled{opacity:.5;cursor:default}" +
42
+ ".__tv_btnPrimary{border-color:var(--dsw-alias-state-business-primary, #3964fe);background:var(--dsw-alias-state-business-primary, #3964fe);color:#fff}" +
43
+ ".__tv_status{font-size:12px;color:var(--dsw-alias-label-tertiary)}" +
44
+ ".__tv_error{font-size:12px;color:var(--dsw-alias-state-error-primary)}" +
45
+ ".__tv_unavailable{font-size:13px;color:var(--dsw-alias-label-tertiary)}";
46
+ var tagId = "dsh-tool-vision/main.css";
47
+ if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
48
+ var tag = document.createElement("style");
49
+ tag.dataset.plugin = "dsh-tool-vision";
50
+ tag.dataset.pluginCss = tagId;
51
+ tag.textContent = CSS;
52
+ document.head.appendChild(tag);
53
+ }
54
+
55
+ // ── locale ────────────────────────────────────────────────────────────
56
+ var NS = "toolVision";
57
+ var inject = ["slots", "locale", "settingsScope"];
58
+ var zh = {
59
+ nav: "视觉模型",
60
+ intro: "外置视觉模型配置:Agent 通过 inspect_image 工具把图片发给该端点分析。修改后即时生效(settings.yaml 热重载)。",
61
+ apiKeyHint: "留空保持当前密钥。密钥只写不读,不会回显。",
62
+ maxTokens: "最大输出 Tokens",
63
+ timeoutMs: "请求超时(毫秒)",
64
+ maxImageBytes: "本地图片大小上限(字节)",
65
+ bridgeTextOnly: "图片桥接(文本模型贴图自动转 inspect_image 指引)",
66
+ bridgeExportDir: "桥接图片导出目录(空 = 系统临时目录)",
67
+ multimodalModels: "多模态白名单(逗号分隔,这些模型直收图片块)",
68
+ fieldBaseUrl: "API Base URL",
69
+ fieldApiKey: "API Key",
70
+ fieldApiKeyEnv: "API Key 环境变量(apiKey 为空时读取)",
71
+ fieldModel: "视觉模型",
72
+ fieldMaxTokens: "最大输出 Tokens",
73
+ fieldTimeoutMs: "请求超时(毫秒)",
74
+ fieldMaxImageBytes: "图片大小上限(字节)",
75
+ fieldBridgeTextOnly: "图片桥接开关",
76
+ fieldBridgeExportDir: "桥接导出目录",
77
+ fieldMultimodalModels: "多模态白名单(逗号分隔)",
78
+ fieldBridgePreview: "桥接图片内联预览(气泡内缩略图,点击放大)",
79
+ fieldBridgePreviewScanIntervalMs: "预览兜底扫描间隔(毫秒,0 = 关闭兜底)",
80
+ fieldBridgePreviewHideHint: "图片加载成功后隐藏桥接提示文本",
81
+ fieldBridgeAutoImage: "自动声明图片能力(纯文本模型也能粘贴图片)",
82
+ hintBridgePreview: "纯展示层:不影响模型侧文本与 inspect_image 调用。",
83
+ hintBridgePreviewScanIntervalMs: "默认 2000ms;越小响应越快,越大越省资源。",
84
+ hintBridgePreviewHideHint: "加载失败时保留文本(安全降级,绝不出现既无图又无字)。",
85
+ hintBridgeAutoImage: "桥接开启时向宿主报告所有模型支持图片,绕过“模型不支持图片”的发送拦截;实际仍走桥接转文本。",
86
+ save: "保存",
87
+ reset: "恢复默认",
88
+ saved: "已保存",
89
+ saving: "保存中…",
90
+ error: "保存失败",
91
+ unavailable: "设置命名空间不可用(服务端未注册 tool-vision 命名空间?)",
92
+ overridden: "已覆盖",
93
+ loading: "加载中…"
94
+ };
95
+ var en = {
96
+ nav: "Vision Model",
97
+ intro: "External vision model config: the agent sends images to this endpoint via the inspect_image tool. Changes apply immediately (settings.yaml hot-reload).",
98
+ apiKeyHint: "Leave blank to keep the current key. The key is write-only and never echoed.",
99
+ maxTokens: "Max output tokens",
100
+ timeoutMs: "Request timeout (ms)",
101
+ maxImageBytes: "Max local image size (bytes)",
102
+ bridgeTextOnly: "Image bridge (pasted images on text-only models become inspect_image hints)",
103
+ bridgeExportDir: "Bridge export dir (empty = system temp)",
104
+ multimodalModels: "Multimodal whitelist (comma-separated; these models receive image blocks directly)",
105
+ fieldBaseUrl: "API Base URL",
106
+ fieldApiKey: "API Key",
107
+ fieldApiKeyEnv: "API key env var (read when apiKey is empty)",
108
+ fieldModel: "Vision model",
109
+ fieldMaxTokens: "Max output tokens",
110
+ fieldTimeoutMs: "Request timeout (ms)",
111
+ fieldMaxImageBytes: "Max image size (bytes)",
112
+ fieldBridgeTextOnly: "Image bridge",
113
+ fieldBridgeExportDir: "Bridge export dir",
114
+ fieldMultimodalModels: "Multimodal whitelist (comma-separated)",
115
+ fieldBridgePreview: "Bridge image preview (inline thumbnail in the bubble, click to zoom)",
116
+ fieldBridgePreviewScanIntervalMs: "Preview fallback scan interval (ms, 0 = disable)",
117
+ fieldBridgePreviewHideHint: "Hide the bridged hint text once the image has loaded",
118
+ fieldBridgeAutoImage: "Auto-declare image capability (paste images on text-only models)",
119
+ hintBridgePreview: "Pure display layer: the model-facing text and the inspect_image chain are untouched.",
120
+ hintBridgePreviewScanIntervalMs: "Default 2000ms; lower is snappier, higher is cheaper.",
121
+ hintBridgePreviewHideHint: "Text is kept on load failure (safe degradation, never no image AND no text).",
122
+ hintBridgeAutoImage: "While the bridge is on, report image support for every model to bypass the host image-send gate; images still travel as bridged text hints.",
123
+ save: "Save",
124
+ reset: "Reset",
125
+ saved: "Saved",
126
+ saving: "Saving…",
127
+ error: "Save failed",
128
+ unavailable: "Settings namespace unavailable (tool-vision namespace not registered server-side?)",
129
+ overridden: "overridden",
130
+ loading: "Loading…"
131
+ };
132
+
133
+ // ── field spec ────────────────────────────────────────────────────────
134
+ var FIELDS = [
135
+ { key: "baseURL", label: "fieldBaseUrl", type: "text", placeholder: "https://api.openai.com/v1" },
136
+ { key: "apiKey", label: "fieldApiKey", type: "password", secret: true },
137
+ { key: "apiKeyEnv", label: "fieldApiKeyEnv", type: "text" },
138
+ { key: "model", label: "fieldModel", type: "text", placeholder: "gpt-4o-mini" },
139
+ { key: "maxTokens", label: "fieldMaxTokens", type: "number" },
140
+ { key: "timeoutMs", label: "fieldTimeoutMs", type: "number" },
141
+ { key: "maxImageBytes", label: "fieldMaxImageBytes", type: "number" },
142
+ { key: "bridgeTextOnly", label: "fieldBridgeTextOnly", type: "checkbox" },
143
+ { key: "bridgeExportDir", label: "fieldBridgeExportDir", type: "text" },
144
+ { key: "multimodalModels", label: "fieldMultimodalModels", type: "csv" },
145
+ { key: "bridgePreview", label: "fieldBridgePreview", type: "checkbox" },
146
+ { key: "bridgePreviewScanIntervalMs", label: "fieldBridgePreviewScanIntervalMs", type: "number" },
147
+ { key: "bridgePreviewHideHint", label: "fieldBridgePreviewHideHint", type: "checkbox" },
148
+ { key: "bridgeAutoImage", label: "fieldBridgeAutoImage", type: "checkbox" }
149
+ ];
150
+ var ZH_HINTS = {
151
+ apiKey: "apiKeyHint",
152
+ maxTokens: "maxTokens",
153
+ timeoutMs: "timeoutMs",
154
+ maxImageBytes: "maxImageBytes",
155
+ bridgeTextOnly: "bridgeTextOnly",
156
+ bridgeExportDir: "bridgeExportDir",
157
+ multimodalModels: "multimodalModels",
158
+ bridgePreview: "hintBridgePreview",
159
+ bridgePreviewScanIntervalMs: "hintBridgePreviewScanIntervalMs",
160
+ bridgePreviewHideHint: "hintBridgePreviewHideHint",
161
+ bridgeAutoImage: "hintBridgeAutoImage"
162
+ };
163
+
164
+ function labelOf(f, t) {
165
+ return t(f.label);
166
+ }
167
+
168
+ // ── component ─────────────────────────────────────────────────────────
169
+ function VisionSection(props) {
170
+ var t = props.t;
171
+ var scope = props.scope;
172
+ var [snapshot, setSnapshot] = react.useState(function () { return scope.getSnapshot(); });
173
+ var ready = snapshot.status === "ready" && snapshot.value !== void 0;
174
+ var [draft, setDraft] = react.useState({});
175
+ var [busy, setBusy] = react.useState(false);
176
+ var [notice, setNotice] = react.useState(null);
177
+ var [error, setError] = react.useState(null);
178
+
179
+ react.useEffect(function () {
180
+ scope.load();
181
+ var alive = true;
182
+ var sync = function () { if (alive) setSnapshot(scope.getSnapshot()); };
183
+ var un = typeof scope.subscribe === "function" ? scope.subscribe(sync) : null;
184
+ return function () { alive = false; if (un) un(); if (scope.dispose) scope.dispose(); };
185
+ }, [scope]);
186
+ // Seed the draft ONLY when the snapshot becomes ready — never on value
187
+ // churn. settingsScope.getSnapshot() returns a fresh object per call,
188
+ // so depending on snapshot.value would reset user input on every render
189
+ // (typing appears dead).
190
+ react.useEffect(function () {
191
+ if (ready) setDraft(function (prev) { return Object.assign({}, prev, valueToDraft(snapshot.value)); });
192
+ // eslint-disable-next-line react-hooks/exhaustive-deps
193
+ }, [ready]);
194
+
195
+ if (snapshot.status === "unavailable") {
196
+ return h("p", { className: "__tv_unavailable" }, t("unavailable"));
197
+ }
198
+ if (!ready) return h("p", { className: "__tv_status" }, t("loading"));
199
+
200
+ var value = snapshot.value;
201
+ var user = snapshot.user || {};
202
+
203
+ function fieldDraft(f) {
204
+ if (f.type === "csv") return draft[f.key] !== void 0 ? draft[f.key] : draftToCsv(value[f.key]);
205
+ if (f.type === "checkbox") return draft[f.key] !== void 0 ? draft[f.key] : Boolean(value[f.key]);
206
+ return draft[f.key] !== void 0 ? draft[f.key] : String(value[f.key] ?? "");
207
+ }
208
+ function setField(f, v) {
209
+ setDraft(function (prev) {
210
+ var next = Object.assign({}, prev);
211
+ next[f.key] = v;
212
+ return next;
213
+ });
214
+ setNotice(null);
215
+ setError(null);
216
+ }
217
+
218
+ function onSave() {
219
+ setBusy(true); setNotice(null); setError(null);
220
+ var writes = FIELDS.map(function (f) {
221
+ var d = fieldDraft(f);
222
+ if (f.type === "csv") {
223
+ var arr = String(d).split(",").map(function (s) { return s.trim(); }).filter(Boolean);
224
+ var cur = value[f.key] || [];
225
+ if (arr.length === cur.length && arr.every(function (x, i) { return x === cur[i]; })) return Promise.resolve();
226
+ return scope.set(f.key, arr);
227
+ }
228
+ if (f.type === "checkbox") {
229
+ if (Boolean(d) === Boolean(value[f.key])) return Promise.resolve();
230
+ return Boolean(d) ? scope.set(f.key, true) : scope.unset(f.key);
231
+ }
232
+ if (f.type === "password") {
233
+ if (!d) return Promise.resolve(); // blank keeps the current key
234
+ if (d === String(value[f.key] ?? "")) return Promise.resolve();
235
+ return scope.set(f.key, d);
236
+ }
237
+ if (String(d) === String(value[f.key] ?? "")) return Promise.resolve();
238
+ if (String(d).trim() === "" && !(f.key in user)) return Promise.resolve();
239
+ return String(d).trim() === "" ? scope.unset(f.key) : scope.set(f.key, f.type === "number" ? Number(d) : d);
240
+ });
241
+ Promise.all(writes).then(function () {
242
+ setBusy(false); setNotice(t("saved"));
243
+ if (scope.load) scope.load();
244
+ }).catch(function (e) {
245
+ setBusy(false); setError(t("error") + ": " + String(e && e.message || e));
246
+ });
247
+ }
248
+
249
+ function reseedDraft() {
250
+ if (typeof scope.load === "function") {
251
+ var p = scope.load();
252
+ if (p && typeof p.then === "function") {
253
+ p.then(function () {
254
+ var fresh = scope.getSnapshot();
255
+ if (fresh.status === "ready" && fresh.value !== void 0) setDraft(Object.assign({}, valueToDraft(fresh.value)));
256
+ }).catch(function () {});
257
+ return;
258
+ }
259
+ }
260
+ setTimeout(function () {
261
+ var fresh = scope.getSnapshot();
262
+ if (fresh.status === "ready" && fresh.value !== void 0) setDraft(Object.assign({}, valueToDraft(fresh.value)));
263
+ }, 120);
264
+ }
265
+
266
+ function onReset() {
267
+ setBusy(true); setNotice(null); setError(null);
268
+ Promise.all(FIELDS.map(function (f) { return scope.unset(f.key); })).then(function () {
269
+ setBusy(false); setNotice(t("saved"));
270
+ reseedDraft();
271
+ }).catch(function (e) {
272
+ setBusy(false); setError(t("error") + ": " + String(e && e.message || e));
273
+ });
274
+ }
275
+
276
+ return h("div", { className: "__tv_root" },
277
+ h("p", { className: "__tv_hint", style: { margin: "0 0 4px" } }, t("intro")),
278
+ FIELDS.map(function (f) {
279
+ var overridden = f.key in user;
280
+ if (f.type === "checkbox") {
281
+ return h("label", { key: f.key, className: "__tv_field" },
282
+ h("span", { className: "__tv_row" },
283
+ h("input", { className: "__tv_check", type: "checkbox", checked: Boolean(fieldDraft(f)), onChange: function (e) { setField(f, e.target.checked); } }),
284
+ h("span", { className: "__tv_label" }, labelOf(f, t)),
285
+ overridden ? h("span", { className: "__tv_override" }, t("overridden")) : null
286
+ ),
287
+ f.key in ZH_HINTS ? h("span", { className: "__tv_hint" }, t(ZH_HINTS[f.key])) : null
288
+ );
289
+ }
290
+ return h("label", { key: f.key, className: "__tv_field" },
291
+ h("span", { className: "__tv_label" },
292
+ labelOf(f, t),
293
+ overridden ? h("span", { className: "__tv_override" }, t("overridden")) : null
294
+ ),
295
+ h("input", {
296
+ className: "__tv_input",
297
+ type: f.type === "password" ? "password" : f.type === "number" ? "number" : "text",
298
+ value: fieldDraft(f),
299
+ placeholder: f.type === "password" ? (overridden ? "••••••••" : t("apiKeyHint")) : (f.placeholder || ""),
300
+ onChange: function (e) { setField(f, e.target.value); }
301
+ }),
302
+ f.key in ZH_HINTS ? h("span", { className: "__tv_hint" }, t(ZH_HINTS[f.key])) : null
303
+ );
304
+ }),
305
+ h("div", { className: "__tv_actions" },
306
+ h("button", { type: "button", className: "__tv_btn __tv_btnPrimary", onClick: onSave, disabled: busy || !snapshot.writable }, t("save")),
307
+ h("button", { type: "button", className: "__tv_btn", onClick: onReset, disabled: busy || !snapshot.writable }, t("reset")),
308
+ notice ? h("span", { className: "__tv_status" }, notice) : null,
309
+ busy ? h("span", { className: "__tv_status" }, t("saving")) : null,
310
+ error ? h("span", { className: "__tv_error" }, error) : null
311
+ )
312
+ );
313
+ }
314
+
315
+ function valueToDraft(value) {
316
+ var out = {};
317
+ for (var i = 0; i < FIELDS.length; i += 1) {
318
+ var f = FIELDS[i];
319
+ out[f.key] = f.type === "csv" ? draftToCsv(value[f.key]) : f.type === "checkbox" ? Boolean(value[f.key]) : String(value[f.key] ?? "");
320
+ }
321
+ return out;
322
+ }
323
+ function draftToCsv(arr) {
324
+ return Array.isArray(arr) ? arr.join(", ") : String(arr ?? "");
325
+ }
326
+
327
+ // ── bridge image preview (v0.4.0, xing666173 / dsh-bridge-preview) ────
328
+ var PREVIEW_MARK = "\u200b[bridge]";
329
+ var PREVIEW_ATTR = "data-tv-preview";
330
+ var PREVIEW_ROUTE = "/plugins/dsh-tool-vision/image";
331
+ var PREVIEW_PATH_RE = /exported to:\s*("[^"]+"|'[^']+'|[A-Za-z]:[\\/][^\s\]]+?\.(?:png|jpe?g|webp|gif|avif|bmp))/gi;
332
+ // 完整桥接提示段(从 \u200b[bridge] 到 see its content.]),用于精准剔除桥接文本、
333
+ // 保留用户自己的文字(dsh 会把用户消息的多段文本合并渲染到同一容器)。
334
+ var PREVIEW_TEXT_RE = /\u200b\[bridge\]\[User sent an image[\s\S]*?see its content\.\]/g;
335
+
336
+ function previewConfigOf(scope) {
337
+ var cfg = { enabled: true, intervalMs: 2000, hideHint: true };
338
+ try {
339
+ var snap = scope.getSnapshot();
340
+ if (snap.status === "ready" && snap.value) {
341
+ cfg.enabled = snap.value.bridgePreview !== false;
342
+ var iv = Number(snap.value.bridgePreviewScanIntervalMs);
343
+ cfg.intervalMs = Number.isFinite(iv) && iv > 0 ? iv : 0;
344
+ cfg.hideHint = snap.value.bridgePreviewHideHint !== false;
345
+ }
346
+ } catch (e) { /* keep defaults */ }
347
+ return cfg;
348
+ }
349
+
350
+ function previewPathOf(data) {
351
+ var m;
352
+ PREVIEW_PATH_RE.lastIndex = 0;
353
+ while ((m = PREVIEW_PATH_RE.exec(data)) !== null) {
354
+ var s = m[1];
355
+ if (s.length >= 2) {
356
+ var first = s[0];
357
+ var last = s[s.length - 1];
358
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) s = s.slice(1, -1);
359
+ }
360
+ return s;
361
+ }
362
+ return null;
363
+ }
364
+
365
+ function openLightbox(src, alt) {
366
+ var overlay = document.createElement("div");
367
+ 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;";
368
+ var big = document.createElement("img");
369
+ big.src = src;
370
+ big.alt = alt || "图片预览";
371
+ 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);";
372
+ var close = function () {
373
+ overlay.remove();
374
+ document.removeEventListener("keydown", onKey, true);
375
+ };
376
+ var onKey = function (e) {
377
+ if (e.key === "Escape") close();
378
+ };
379
+ big.addEventListener("error", close);
380
+ overlay.addEventListener("click", close);
381
+ overlay.appendChild(big);
382
+ document.body.appendChild(overlay);
383
+ document.addEventListener("keydown", onKey, true);
384
+ }
385
+
386
+ function attachBridgePreview(ctx, scope) {
387
+ var cfg = previewConfigOf(scope);
388
+ var pendingTimer = null;
389
+ var intervalTimer = null;
390
+ var observer = null;
391
+
392
+ function processTextNode(node) {
393
+ var data = node.data;
394
+ if (typeof data !== "string" || data.indexOf(PREVIEW_MARK) === -1) return;
395
+ var block = node.parentElement;
396
+ if (!block || block.hasAttribute(PREVIEW_ATTR)) return;
397
+ var path = previewPathOf(data);
398
+ if (!path) return;
399
+ // 只插到消息行内的文本容器;绝不插进列表/body(拖图消息形态的防御)
400
+ var container = block.parentElement;
401
+ if (!container || container === document.body || container.childElementCount > 3) return;
402
+ block.setAttribute(PREVIEW_ATTR, "1");
403
+ var img = document.createElement("img");
404
+ img.setAttribute(PREVIEW_ATTR, "1");
405
+ img.src = PREVIEW_ROUTE + "?p=" + encodeURIComponent(path);
406
+ img.alt = "图片预览";
407
+ 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;";
408
+ img.addEventListener("click", function () { openLightbox(img.src, img.alt); });
409
+ img.addEventListener("load", function () {
410
+ // P2:图片加载成功后隐藏桥接提示文本;失败时保留(安全降级)。
411
+ // 不能直接隐藏整块:dsh 把用户消息的多段文本 join 后渲染到同一容器,
412
+ // 隐藏整块会把用户自己打的字(如"测试")一起藏掉。这里先精准剔除
413
+ // 桥接文本段,仅当容器已无其他内容时才隐藏整个容器。
414
+ if (cfg.hideHint) {
415
+ var cleaned = node.data ? node.data.replace(PREVIEW_TEXT_RE, "") : "";
416
+ if (cleaned !== node.data) node.data = cleaned;
417
+ if ((block.textContent || "").replace(/\s/g, "") === "") block.style.display = "none";
418
+ }
419
+ });
420
+ img.addEventListener("error", function () {
421
+ img.remove(); // 静默降级;块标记保留,避免无限重试
422
+ });
423
+ container.insertBefore(img, block);
424
+ }
425
+
426
+ function scan() {
427
+ if (!cfg.enabled) return;
428
+ var walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {
429
+ acceptNode: function (node) {
430
+ if (!node.data || node.data.indexOf(PREVIEW_MARK) === -1) return NodeFilter.FILTER_REJECT;
431
+ return NodeFilter.FILTER_ACCEPT;
432
+ }
433
+ });
434
+ var nodes = [];
435
+ while (walker.nextNode()) nodes.push(walker.currentNode);
436
+ for (var i = 0; i < nodes.length; i++) processTextNode(nodes[i]);
437
+ }
438
+
439
+ function armInterval() {
440
+ if (intervalTimer !== null) { clearInterval(intervalTimer); intervalTimer = null; }
441
+ if (cfg.intervalMs > 0) intervalTimer = setInterval(scan, cfg.intervalMs);
442
+ }
443
+
444
+ ctx.effect(function () {
445
+ scan();
446
+ observer = new MutationObserver(function (records) {
447
+ // 忽略本插件自己插入的图片节点,避免自我触发
448
+ for (var i = 0; i < records.length; i++) {
449
+ var t = records[i].target;
450
+ if (t && t.nodeType === 1 && t.hasAttribute && t.hasAttribute(PREVIEW_ATTR)) continue;
451
+ if (pendingTimer !== null) return;
452
+ pendingTimer = setTimeout(function () {
453
+ pendingTimer = null;
454
+ scan();
455
+ }, 300);
456
+ break;
457
+ }
458
+ });
459
+ observer.observe(document.body, { childList: true, subtree: true, characterData: true });
460
+ armInterval();
461
+ var un = typeof scope.subscribe === "function" ? scope.subscribe(function () {
462
+ cfg = previewConfigOf(scope);
463
+ armInterval();
464
+ }) : null;
465
+ return function () {
466
+ if (observer) observer.disconnect();
467
+ if (pendingTimer !== null) { clearTimeout(pendingTimer); pendingTimer = null; }
468
+ if (intervalTimer !== null) { clearInterval(intervalTimer); intervalTimer = null; }
469
+ if (un) un();
470
+ };
471
+ }, "dsh-tool-vision: bridge preview scanner");
472
+ }
473
+
474
+ // ── plugin ────────────────────────────────────────────────────────────
475
+ function apply(ctx) {
476
+ var t = ctx.locale.bind(NS);
477
+ ctx.effect(function () { return ctx.locale.register(NS, { zh: zh, en: en }); }, "dsh-tool-vision: dictionaries");
478
+ var scope = ctx.settingsScope.bind({ namespace: "tool-vision" });
479
+ scope.load();
480
+ attachBridgePreview(ctx, scope);
481
+ ctx.slots.inject("settings.section", function () {
482
+ return ctx.slots.register({
483
+ name: "settings.section",
484
+ id: "tool-vision",
485
+ order: 25,
486
+ label: function () { return t("nav"); },
487
+ locale: NS
488
+ }, function (props) {
489
+ return h(VisionSection, Object.assign({}, props, { scope: scope }));
490
+ });
491
+ });
492
+ }
493
+
494
+ exports.apply = apply;
495
+ exports.inject = inject;
496
+ return module.exports;
497
+ }
498
+ });