dsh-model-input-toggle 0.0.0-stage → 1.0.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/lib/client.js ADDED
@@ -0,0 +1,731 @@
1
+ // dsh-model-input-toggle — browser half (client plugin bundle).
2
+ //
3
+ // Loaded by dsh-client-modules and executed through the vendored cordis Loader's
4
+ // lazy-CJS module table (window.__ModuleLoader__.load). The factory body is plain
5
+ // CJS: require() resolves against the shell's module table, so React comes from
6
+ // the host page rather than a bundled copy.
7
+ //
8
+ // WHAT THIS RENDERS
9
+ // -----------------
10
+ // The official Models page exposes only id/name/contextWindow/maxTokens. The
11
+ // adapter's modality declaration (`input` for llm-pi-ai, `inputModalities` for
12
+ // llm-deepseek) has no UI, so a model added by a third-party provider is treated
13
+ // as text-only and DSH intercepts image messages before they leave the machine.
14
+ //
15
+ // This half adds that missing control. It mounts into the
16
+ // `settings.models.provider-card` keyed slot — one cell per settings namespace,
17
+ // dispatched on every provider card — and renders one image-input checkbox per
18
+ // model of the card's own provider.
19
+ //
20
+ // SLOT KEY SEMANTICS (why one cell serves many cards)
21
+ // ---------------------------------------------------
22
+ // The slot is keyed by `settingsNs`, and llm-pi-ai dispatches the SAME key for
23
+ // every route it serves. A keyed cell therefore cannot be registered per model
24
+ // or per route: a single registration for "llm-pi-ai" is invoked once on each
25
+ // pi-ai card, and the card's own route arrives as `props.provider.provider`.
26
+ // That is what makes every card independent without colliding.
27
+ //
28
+ // THE ONE THING THIS UI MUST NOT LET A USER BELIEVE
29
+ // -------------------------------------------------
30
+ // `input: ["text","image"]` declares an intent; it does not create a capability.
31
+ // Ticking the box moves the failure from "DSH blocked it" to "the endpoint
32
+ // returned 400". Every row therefore carries a 验证端点 action that sends a real
33
+ // image-bearing request, and the first-time confirmation says so plainly.
34
+
35
+ window.__ModuleLoader__.load({
36
+ id: "dsh-model-input-toggle",
37
+ factory: (require) => {
38
+ var module = { exports: {} };
39
+ var exports = module.exports;
40
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
41
+ var React = require("react");
42
+
43
+ exports.inject = ["slots"];
44
+ exports.apply = function apply(ctx) {
45
+ // ── constants ────────────────────────────────────────────────────
46
+ var API_URL = "/dsh-model-input-toggle/api";
47
+ /** Host reply budget; past this the page reports a connection failure. */
48
+ var REQUEST_TIMEOUT_MS = 10000;
49
+ /** How long a success/error marker stays beside the checkbox. */
50
+ var FEEDBACK_MS = 2600;
51
+ /** Namespaces this plugin can extend, and the slot cell key each needs. */
52
+ var SUPPORTED = {
53
+ "llm-pi-ai": true,
54
+ "llm-deepseek": true
55
+ };
56
+ /** Confirmation is per browser, not per model: it explains the risk once. */
57
+ var CONFIRM_KEY = "dsh-model-input-toggle:acknowledged";
58
+ /** Exact warning shown before the first enable. */
59
+ var CONFIRM_TEXT = "开启图片输入声明后,DSH 将不再拦截发往该模型的图片消息。"
60
+ + "如果该模型的 API 端点实际不支持图片,请求会返回 400 错误。"
61
+ + "请确认该模型确实支持视觉输入。";
62
+ /** Hover explanation for every checkbox. */
63
+ var TOOLTIP = "勾选后该模型可接收图片消息,取消勾选则仅支持文本。"
64
+ + "修改后建议用带图片的请求验证端点实际能力。";
65
+
66
+ // ── styles ───────────────────────────────────────────────────────
67
+ // React inline styles cannot express :hover or keyframes, so the few
68
+ // rules that need them live in one owned stylesheet. It is registered
69
+ // as an effect, so stopping the plugin removes it with the UI.
70
+ var CSS = [
71
+ // The panel is COLLAPSED BY DEFAULT: the slot renders on every saved
72
+ // provider card unconditionally, so an always-open block would add a
73
+ // wall of rows to a page the user is usually just scanning. Collapsed,
74
+ // it costs one line and still answers "is image input on for anything
75
+ // here?" through the count in the summary.
76
+ ".dsh-imit-panel{display:flex;flex-direction:column;gap:8px;margin:6px 0 2px;}",
77
+ ".dsh-imit-panel[data-expanded='1']{gap:8px;padding:10px 12px;border-radius:8px;",
78
+ "border:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,0.28));",
79
+ "background:var(--dsw-alias-bg-layer-2,rgba(128,128,128,0.06));}",
80
+ ".dsh-imit-toggle{display:flex;align-items:center;gap:7px;width:100%;margin:0;padding:2px 0;",
81
+ "border:0;background:transparent;cursor:pointer;font:inherit;text-align:left;",
82
+ "color:var(--dsw-alias-label-primary,inherit);}",
83
+ ".dsh-imit-toggle:hover .dsh-imit-title{color:var(--dsw-alias-brand-primary,#4a7dff);}",
84
+ ".dsh-imit-toggle:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#4a7dff);",
85
+ "outline-offset:2px;border-radius:4px;}",
86
+ ".dsh-imit-chevron{display:inline-block;flex:none;width:9px;font-size:9px;line-height:1;",
87
+ "color:var(--dsw-alias-label-secondary,rgba(128,128,128,0.9));transition:transform 0.15s ease;}",
88
+ ".dsh-imit-chevron[data-open='1']{transform:rotate(90deg);}",
89
+ ".dsh-imit-count{font-size:11px;color:var(--dsw-alias-label-secondary,rgba(128,128,128,0.9));}",
90
+ ".dsh-imit-title{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary,inherit);}",
91
+ ".dsh-imit-hint{font-size:11px;color:var(--dsw-alias-label-secondary,rgba(128,128,128,0.9));line-height:1.45;}",
92
+ ".dsh-imit-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-height:24px;}",
93
+ ".dsh-imit-label{display:flex;align-items:center;gap:7px;cursor:pointer;font-size:13px;",
94
+ "color:var(--dsw-alias-label-primary,inherit);}",
95
+ ".dsh-imit-label[data-disabled='1']{cursor:not-allowed;opacity:0.75;}",
96
+ ".dsh-imit-id{font-size:11px;color:var(--dsw-alias-label-secondary,rgba(128,128,128,0.9));",
97
+ "font-family:ui-monospace,SFMono-Regular,Menlo,monospace;overflow-wrap:anywhere;}",
98
+ ".dsh-imit-cb{width:15px;height:15px;margin:0;accent-color:var(--dsw-alias-brand-primary,#4a7dff);cursor:inherit;}",
99
+ ".dsh-imit-cb:disabled{cursor:not-allowed;}",
100
+ ".dsh-imit-btn{font:inherit;font-size:11px;padding:3px 8px;border-radius:6px;cursor:pointer;",
101
+ "border:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,0.3));",
102
+ "background:var(--dsw-alias-bg-layer-1,transparent);color:var(--dsw-alias-label-secondary,inherit);}",
103
+ ".dsh-imit-btn:hover:not(:disabled){border-color:var(--dsw-alias-border-l2,rgba(128,128,128,0.5));}",
104
+ ".dsh-imit-btn:disabled{opacity:0.55;cursor:not-allowed;}",
105
+ ".dsh-imit-ok{font-size:11px;color:var(--dsw-alias-state-success-primary,#2f9e44);}",
106
+ ".dsh-imit-err{font-size:11px;color:var(--dsw-alias-state-error-primary,#e03131);overflow-wrap:anywhere;}",
107
+ ".dsh-imit-warn{font-size:11px;color:var(--dsw-alias-state-warn-primary,#e8590c);overflow-wrap:anywhere;}",
108
+ ".dsh-imit-locked{font-size:11px;color:var(--dsw-alias-label-secondary,rgba(128,128,128,0.9));}",
109
+ ".dsh-imit-spin{display:inline-block;width:11px;height:11px;border-radius:50%;vertical-align:-1px;",
110
+ "border:2px solid var(--dsw-alias-border-l2,rgba(128,128,128,0.45));border-top-color:transparent;",
111
+ "animation:dsh-imit-spin 0.7s linear infinite;}",
112
+ "@keyframes dsh-imit-spin{to{transform:rotate(360deg);}}",
113
+ ".dsh-imit-overlay{position:fixed;inset:0;z-index:2147483000;display:flex;align-items:center;",
114
+ "justify-content:center;padding:24px;background:rgba(0,0,0,0.42);}",
115
+ ".dsh-imit-dialog{width:min(460px,100%);padding:18px 20px;border-radius:10px;display:flex;",
116
+ "flex-direction:column;gap:12px;background:var(--dsw-alias-bg-overlay,#fff);",
117
+ "border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,0.35));",
118
+ "box-shadow:0 12px 32px rgba(0,0,0,0.28);}",
119
+ ".dsh-imit-dialog h3{margin:0;font-size:14px;color:var(--dsw-alias-label-primary,inherit);}",
120
+ ".dsh-imit-dialog p{margin:0;font-size:12.5px;line-height:1.6;",
121
+ "color:var(--dsw-alias-label-secondary,rgba(90,90,90,0.95));}",
122
+ ".dsh-imit-actions{display:flex;justify-content:flex-end;gap:8px;}",
123
+ ".dsh-imit-actions button{font:inherit;font-size:12.5px;padding:5px 14px;border-radius:6px;cursor:pointer;}",
124
+ ".dsh-imit-cancel{border:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,0.3));",
125
+ "background:transparent;color:var(--dsw-alias-label-primary,inherit);}",
126
+ ".dsh-imit-confirm{border:1px solid transparent;color:#fff;",
127
+ "background:var(--dsw-alias-brand-primary,#4a7dff);}"
128
+ ].join("");
129
+
130
+ ctx.effect(function() {
131
+ var style = document.createElement("style");
132
+ style.setAttribute("data-plugin", "dsh-model-input-toggle");
133
+ style.textContent = CSS;
134
+ document.head.appendChild(style);
135
+ return function() {
136
+ if (style.parentNode) style.parentNode.removeChild(style);
137
+ };
138
+ }, "dsh-model-input-toggle: stylesheet");
139
+
140
+ // ── host bridge ──────────────────────────────────────────────────
141
+ /**
142
+ * POST one JSON request to the host half, bounded by a hard timeout.
143
+ *
144
+ * The timeout is not cosmetic: a host that never answers would leave
145
+ * a checkbox spinning forever, which reads as "still working" rather
146
+ * than "nothing is listening".
147
+ */
148
+ function callHost(payload) {
149
+ return new Promise(function(resolve) {
150
+ var controller = typeof AbortController === "function" ? new AbortController() : undefined;
151
+ var settled = false;
152
+ var timer = setTimeout(function() {
153
+ if (settled) return;
154
+ settled = true;
155
+ if (controller) controller.abort();
156
+ resolve({ ok: false, code: "timeout", message: "连接超时,请检查 DSH 是否正常运行。" });
157
+ }, REQUEST_TIMEOUT_MS);
158
+ var finish = function(value) {
159
+ if (settled) return;
160
+ settled = true;
161
+ clearTimeout(timer);
162
+ resolve(value);
163
+ };
164
+ try {
165
+ fetch(API_URL, {
166
+ method: "POST",
167
+ headers: { "content-type": "application/json" },
168
+ body: JSON.stringify(payload),
169
+ ...(controller === undefined ? {} : { signal: controller.signal })
170
+ }).then(function(response) {
171
+ return response.json().then(function(body) {
172
+ return { status: response.status, body: body };
173
+ }, function() {
174
+ return { status: response.status, body: undefined };
175
+ });
176
+ }).then(function(result) {
177
+ var body = result.body;
178
+ if (body !== null && typeof body === "object" && typeof body.ok === "boolean") {
179
+ finish(body);
180
+ return;
181
+ }
182
+ finish({
183
+ ok: false,
184
+ code: "bad-reply",
185
+ message: "DSH 返回了无法解析的响应(HTTP " + result.status + ")。"
186
+ });
187
+ }, function(error) {
188
+ if (error && error.name === "AbortError") return; // the timeout settles it
189
+ finish({
190
+ ok: false,
191
+ code: "network",
192
+ message: "无法连接 DSH 主机:" + String((error && error.message) || error)
193
+ });
194
+ });
195
+ } catch (error) {
196
+ finish({
197
+ ok: false,
198
+ code: "network",
199
+ message: "无法连接 DSH 主机:" + String((error && error.message) || error)
200
+ });
201
+ }
202
+ });
203
+ }
204
+
205
+ // ── shared inventory store ───────────────────────────────────────
206
+ // Every provider card renders its own cell, so a naive per-cell fetch
207
+ // would issue one full inventory request per card. One shared store
208
+ // keeps that to a single request and lets a write refresh every card.
209
+ var store = {
210
+ providers: null,
211
+ error: null,
212
+ loading: false,
213
+ inflight: null,
214
+ listeners: new Set()
215
+ };
216
+
217
+ function notify() {
218
+ store.listeners.forEach(function(listener) {
219
+ try {
220
+ listener();
221
+ } catch (error) {
222
+ console.error("[dsh-model-input-toggle] listener failed", error);
223
+ }
224
+ });
225
+ }
226
+
227
+ /** Fetch the inventory once; concurrent callers share one request. */
228
+ function loadInventory(force) {
229
+ if (store.inflight !== null && !force) return store.inflight;
230
+ store.loading = true;
231
+ notify();
232
+ var request = callHost({ method: "list" }).then(function(reply) {
233
+ store.loading = false;
234
+ if (store.inflight === request) store.inflight = null;
235
+ if (reply.ok === true) {
236
+ store.providers = Array.isArray(reply.providers) ? reply.providers : [];
237
+ store.error = null;
238
+ } else {
239
+ store.error = reply.message || "无法读取模型配置。";
240
+ }
241
+ notify();
242
+ return reply;
243
+ });
244
+ store.inflight = request;
245
+ return request;
246
+ }
247
+
248
+ function useInventory() {
249
+ var state = React.useState(0);
250
+ var bump = state[1];
251
+ React.useEffect(function() {
252
+ var listener = function() {
253
+ bump(function(n) {
254
+ return n + 1;
255
+ });
256
+ };
257
+ store.listeners.add(listener);
258
+ if (store.providers === null && store.inflight === null) loadInventory(false);
259
+ return function() {
260
+ store.listeners.delete(listener);
261
+ };
262
+ }, []);
263
+ return { providers: store.providers, error: store.error, loading: store.loading };
264
+ }
265
+
266
+ // ── confirmation dialog ──────────────────────────────────────────
267
+ /** Whether the one-time risk acknowledgement has been given. */
268
+ function isAcknowledged() {
269
+ try {
270
+ return window.localStorage.getItem(CONFIRM_KEY) === "1";
271
+ } catch {
272
+ return false;
273
+ }
274
+ }
275
+
276
+ /** Remember the acknowledgement; a blocked store simply re-prompts. */
277
+ function acknowledge() {
278
+ try {
279
+ window.localStorage.setItem(CONFIRM_KEY, "1");
280
+ } catch {
281
+ // storage unavailable (private mode): the dialog reappears, which
282
+ // is the safe direction for a warning.
283
+ }
284
+ }
285
+
286
+ /**
287
+ * The first-enable warning. Reused for every model, because the risk it
288
+ * describes is a property of the declaration itself, not of one model.
289
+ */
290
+ function ConfirmDialog(props) {
291
+ React.useEffect(function() {
292
+ var onKey = function(event) {
293
+ if (event.key === "Escape") props.onCancel();
294
+ };
295
+ document.addEventListener("keydown", onKey);
296
+ return function() {
297
+ document.removeEventListener("keydown", onKey);
298
+ };
299
+ }, []);
300
+ return React.createElement(
301
+ "div",
302
+ {
303
+ className: "dsh-imit-overlay",
304
+ role: "presentation",
305
+ onClick: function(event) {
306
+ if (event.target === event.currentTarget) props.onCancel();
307
+ }
308
+ },
309
+ React.createElement(
310
+ "div",
311
+ {
312
+ className: "dsh-imit-dialog",
313
+ role: "alertdialog",
314
+ "aria-modal": "true",
315
+ "aria-label": "开启图片输入确认"
316
+ },
317
+ React.createElement("h3", null, "开启图片输入声明"),
318
+ React.createElement("p", null, CONFIRM_TEXT),
319
+ React.createElement(
320
+ "div",
321
+ { className: "dsh-imit-actions" },
322
+ React.createElement(
323
+ "button",
324
+ { type: "button", className: "dsh-imit-cancel", onClick: props.onCancel },
325
+ "取消"
326
+ ),
327
+ React.createElement(
328
+ "button",
329
+ { type: "button", className: "dsh-imit-confirm", onClick: props.onConfirm },
330
+ "确认开启"
331
+ )
332
+ )
333
+ )
334
+ );
335
+ }
336
+
337
+ // ── one model row ────────────────────────────────────────────────
338
+ /**
339
+ * One model's image-input control.
340
+ *
341
+ * The checkbox is optimistic: it flips immediately, then rolls back if
342
+ * the host refuses. That keeps a fast local write from feeling laggy
343
+ * while guaranteeing the displayed state matches what was persisted.
344
+ */
345
+ function ModelRow(props) {
346
+ var model = props.model;
347
+ var provider = props.provider;
348
+ var namespace = props.namespace;
349
+ var disabledByLock = model.locked === true;
350
+ var fromModel = Array.isArray(model.effective) && model.effective.indexOf("image") >= 0;
351
+
352
+ // `override` is the row's optimistic local truth: while it is set it
353
+ // wins over the inventory, which is what prevents the freshly written
354
+ // value from flickering back to the stale inventory value between the
355
+ // write landing and the inventory refresh arriving. It is cleared the
356
+ // moment the inventory agrees, so the adapter stays the authority.
357
+ var overrideState = React.useState(null);
358
+ var override = overrideState[0];
359
+ var setOverride = overrideState[1];
360
+ var checked = override === null ? fromModel : override;
361
+ var phaseState = React.useState("idle");
362
+ var phase = phaseState[0];
363
+ var setPhase = phaseState[1];
364
+ var noteState = React.useState("");
365
+ var note = noteState[0];
366
+ var setNote = noteState[1];
367
+ var verifyState = React.useState(null);
368
+ var verify = verifyState[0];
369
+ var setVerify = verifyState[1];
370
+ var confirmState = React.useState(false);
371
+ var confirming = confirmState[0];
372
+ var setConfirming = confirmState[1];
373
+
374
+ // Hand authority back to the inventory once it reflects the write.
375
+ React.useEffect(function() {
376
+ if (override !== null && fromModel === override) setOverride(null);
377
+ }, [fromModel, override]);
378
+
379
+ // Retire transient markers so the row returns to a resting state.
380
+ React.useEffect(function() {
381
+ if (phase !== "ok" && phase !== "error") return;
382
+ var timer = setTimeout(function() {
383
+ setPhase("idle");
384
+ setNote("");
385
+ }, FEEDBACK_MS);
386
+ return function() {
387
+ clearTimeout(timer);
388
+ };
389
+ }, [phase]);
390
+
391
+ function commit(next) {
392
+ var previous = checked;
393
+ setOverride(next);
394
+ setPhase("saving");
395
+ setNote("");
396
+ setVerify(null);
397
+ callHost({
398
+ method: "set",
399
+ namespace: namespace,
400
+ provider: provider,
401
+ modelId: model.id,
402
+ supportsImage: next
403
+ }).then(function(reply) {
404
+ if (reply.ok === true) {
405
+ setPhase("ok");
406
+ // The adapter is the authority: report what it resolved to,
407
+ // and correct the box if it disagrees with the request.
408
+ var declared = Array.isArray(reply.declared) ? reply.declared : null;
409
+ if (declared !== null) {
410
+ var effective = declared.indexOf("image") >= 0;
411
+ setOverride(effective);
412
+ if (effective !== next) {
413
+ setPhase("error");
414
+ setNote(
415
+ "配置已写入,但适配器回报该模型的可输入模态为 ["
416
+ + declared.join(", ") + "],与请求不一致。"
417
+ );
418
+ loadInventory(true);
419
+ return;
420
+ }
421
+ }
422
+ setNote(reply.verified === false && reply.verifiedReason
423
+ ? "已保存。" + reply.verifiedReason
424
+ : "已保存");
425
+ loadInventory(true);
426
+ } else {
427
+ // Rollback is the whole point of tracking `previous`.
428
+ setOverride(previous);
429
+ setPhase("error");
430
+ setNote(reply.message || "写入失败。");
431
+ }
432
+ });
433
+ }
434
+
435
+ function onToggle(event) {
436
+ var next = event.target.checked;
437
+ // Forgetting the acknowledgement is not a state this UI invents:
438
+ // enabling always warns once, disabling never needs to.
439
+ if (next && !isAcknowledged()) {
440
+ setConfirming(true);
441
+ return;
442
+ }
443
+ commit(next);
444
+ }
445
+
446
+ function onVerify() {
447
+ setVerify({ phase: "running" });
448
+ callHost({
449
+ method: "verify",
450
+ namespace: namespace,
451
+ provider: provider,
452
+ modelId: model.id
453
+ }).then(function(reply) {
454
+ if (reply.ok === true && reply.verdict === "supported") {
455
+ setVerify({ phase: "supported", message: reply.message });
456
+ } else if (reply.ok === true && reply.verdict === "rejected") {
457
+ setVerify({ phase: "rejected", message: reply.message });
458
+ } else if (reply.verdict === "inconclusive") {
459
+ // The endpoint refused the request itself, so the image was
460
+ // never actually judged. Saying "does not support images"
461
+ // here would be a false accusation.
462
+ setVerify({ phase: "inconclusive", message: reply.message });
463
+ } else {
464
+ setVerify({ phase: "failed", message: reply.message || "验证失败。" });
465
+ }
466
+ });
467
+ }
468
+
469
+ var busy = phase === "saving";
470
+ var rowDisabled = busy || disabledByLock;
471
+ var feedback;
472
+ if (busy) {
473
+ feedback = React.createElement(
474
+ "span",
475
+ { className: "dsh-imit-hint", role: "status" },
476
+ React.createElement("span", { className: "dsh-imit-spin", "aria-hidden": "true" }),
477
+ " 正在写入…"
478
+ );
479
+ } else if (phase === "ok") {
480
+ feedback = React.createElement("span", { className: "dsh-imit-ok", role: "status" }, "✓ ", note);
481
+ } else if (phase === "error") {
482
+ feedback = React.createElement("span", { className: "dsh-imit-err", role: "alert" }, "✕ ", note);
483
+ } else if (disabledByLock) {
484
+ feedback = React.createElement("span", { className: "dsh-imit-locked" }, "该模型由官方目录声明");
485
+ } else {
486
+ feedback = null;
487
+ }
488
+
489
+ var verifyNode = null;
490
+ if (verify !== null) {
491
+ if (verify.phase === "running") {
492
+ verifyNode = React.createElement(
493
+ "span",
494
+ { className: "dsh-imit-hint", role: "status" },
495
+ React.createElement("span", { className: "dsh-imit-spin", "aria-hidden": "true" }),
496
+ " 正在向端点发送测试图片…"
497
+ );
498
+ } else if (verify.phase === "supported") {
499
+ verifyNode = React.createElement("span", { className: "dsh-imit-ok", role: "status" }, "✓ ", verify.message);
500
+ } else if (verify.phase === "rejected") {
501
+ verifyNode = React.createElement("span", { className: "dsh-imit-err", role: "alert" }, "✕ ", verify.message);
502
+ } else if (verify.phase === "inconclusive") {
503
+ // Amber, not red: this is "could not tell", not "does not work".
504
+ verifyNode = React.createElement("span", { className: "dsh-imit-warn", role: "status" }, "? ", verify.message);
505
+ } else {
506
+ verifyNode = React.createElement("span", { className: "dsh-imit-warn", role: "alert" }, "! ", verify.message);
507
+ }
508
+ }
509
+
510
+ return React.createElement(
511
+ "div",
512
+ { style: { display: "flex", flexDirection: "column", gap: "4px" } },
513
+ React.createElement(
514
+ "div",
515
+ { className: "dsh-imit-row" },
516
+ React.createElement(
517
+ "label",
518
+ {
519
+ className: "dsh-imit-label",
520
+ title: disabledByLock
521
+ ? "该模型的可输入模态由官方目录声明,无需也无法在此修改。"
522
+ : TOOLTIP,
523
+ "data-disabled": rowDisabled ? "1" : "0"
524
+ },
525
+ React.createElement("input", {
526
+ type: "checkbox",
527
+ className: "dsh-imit-cb",
528
+ checked: checked,
529
+ disabled: rowDisabled,
530
+ "aria-label": model.id + " 支持图片输入",
531
+ onChange: onToggle
532
+ }),
533
+ React.createElement("span", null, model.name || model.id),
534
+ model.name && model.name !== model.id
535
+ ? React.createElement("span", { className: "dsh-imit-id" }, model.id)
536
+ : null
537
+ ),
538
+ React.createElement(
539
+ "button",
540
+ {
541
+ type: "button",
542
+ className: "dsh-imit-btn",
543
+ title: "向该端点发送一条带 image_url 的测试请求,验证它是否真的接受图片。",
544
+ disabled: verify !== null && verify.phase === "running",
545
+ onClick: onVerify
546
+ },
547
+ "验证端点"
548
+ ),
549
+ feedback
550
+ ),
551
+ verifyNode === null ? null : React.createElement("div", { className: "dsh-imit-row" }, verifyNode),
552
+ confirming
553
+ ? React.createElement(ConfirmDialog, {
554
+ onCancel: function() {
555
+ setConfirming(false);
556
+ },
557
+ onConfirm: function() {
558
+ setConfirming(false);
559
+ acknowledge();
560
+ commit(true);
561
+ }
562
+ })
563
+ : null
564
+ );
565
+ }
566
+
567
+ // ── provider panel ───────────────────────────────────────────────
568
+ /**
569
+ * The panel rendered in one provider card.
570
+ *
571
+ * Two independent gates decide whether anything appears:
572
+ * - the card must carry a settings namespace this plugin supports
573
+ * (a route with no namespace has no configuration to extend), and
574
+ * - the provider must actually be configured (a not-yet-saved draft
575
+ * card has no persisted route to write to).
576
+ */
577
+ function ProviderPanel(props) {
578
+ var entry = props.provider || {};
579
+ var namespace = typeof entry.settingsNs === "string" ? entry.settingsNs : "";
580
+ var route = typeof entry.provider === "string" ? entry.provider : "";
581
+ var inventory = useInventory();
582
+ // Collapsed unless the user opens it. Not persisted on purpose: a
583
+ // fresh page load should present the quiet view again.
584
+ var expandedState = React.useState(false);
585
+ var expanded = expandedState[0];
586
+ var setExpanded = expandedState[1];
587
+
588
+ if (SUPPORTED[namespace] !== true || route.length === 0) return null;
589
+ if (props.configured === false) return null;
590
+
591
+ /**
592
+ * The one-line header. Always rendered, so the section is
593
+ * discoverable without occupying space. `summary` carries the count
594
+ * so a collapsed card still reports whether anything is enabled.
595
+ * Any further arguments are rendered beneath the header line.
596
+ */
597
+ function header(summary) {
598
+ var body = Array.prototype.slice.call(arguments, 1);
599
+ return React.createElement(
600
+ "div",
601
+ { className: "dsh-imit-panel", "data-expanded": expanded ? "1" : "0" },
602
+ React.createElement(
603
+ "button",
604
+ {
605
+ type: "button",
606
+ className: "dsh-imit-toggle",
607
+ "aria-expanded": expanded ? "true" : "false",
608
+ onClick: function() {
609
+ setExpanded(!expanded);
610
+ }
611
+ },
612
+ React.createElement(
613
+ "span",
614
+ { className: "dsh-imit-chevron", "data-open": expanded ? "1" : "0", "aria-hidden": "true" },
615
+ "▶"
616
+ ),
617
+ React.createElement("span", { className: "dsh-imit-title" }, "图片输入"),
618
+ summary === null ? null : React.createElement("span", { className: "dsh-imit-count" }, summary)
619
+ ),
620
+ body
621
+ );
622
+ }
623
+
624
+ if (inventory.error !== null) {
625
+ return header(
626
+ null,
627
+ React.createElement(
628
+ "div",
629
+ { className: "dsh-imit-row" },
630
+ React.createElement("span", { className: "dsh-imit-err", role: "alert" }, inventory.error),
631
+ React.createElement(
632
+ "button",
633
+ {
634
+ type: "button",
635
+ className: "dsh-imit-btn",
636
+ onClick: function() {
637
+ loadInventory(true);
638
+ }
639
+ },
640
+ "重试"
641
+ )
642
+ )
643
+ );
644
+ }
645
+
646
+ var provider = null;
647
+ var list = inventory.providers || [];
648
+ for (var i = 0; i < list.length; i++) {
649
+ // Match on namespace AND route: the same route id can exist under
650
+ // different adapters, and each has its own config entry.
651
+ if (list[i].namespace === namespace && list[i].provider === route) {
652
+ provider = list[i];
653
+ break;
654
+ }
655
+ }
656
+
657
+ if (provider === null) {
658
+ if (inventory.loading || inventory.providers === null) {
659
+ return header(
660
+ null,
661
+ React.createElement(
662
+ "div",
663
+ { className: "dsh-imit-hint", role: "status" },
664
+ React.createElement("span", { className: "dsh-imit-spin", "aria-hidden": "true" }),
665
+ " 正在读取模型配置…"
666
+ )
667
+ );
668
+ }
669
+ // No models resolved for this route: nothing meaningful to toggle.
670
+ return null;
671
+ }
672
+
673
+ if (provider.models.length === 0) return null;
674
+
675
+ // The collapsed summary: how many of this route's models accept
676
+ // images, and how many are locked to the installed catalog.
677
+ var enabled = 0;
678
+ var locked = 0;
679
+ provider.models.forEach(function(model) {
680
+ if (Array.isArray(model.effective) && model.effective.indexOf("image") >= 0) enabled += 1;
681
+ if (model.locked === true) locked += 1;
682
+ });
683
+ var summary = enabled === 0
684
+ ? "全部仅文本"
685
+ : enabled + "/" + provider.models.length + " 已开启";
686
+ if (locked > 0) summary += " · " + locked + " 项由官方目录声明";
687
+
688
+ if (!expanded) return header(summary, null);
689
+
690
+ return header(
691
+ summary,
692
+ React.createElement(
693
+ "div",
694
+ { className: "dsh-imit-hint" },
695
+ "勾选即刻写入配置,无需保存。注意:这里只是声明模型可接收图片,"
696
+ + "DSH 因此不再拦截图片消息;若端点实际不支持图片,请求会返回 400。"
697
+ + "请用「验证端点」确认实际能力。"
698
+ ),
699
+ React.createElement(
700
+ "div",
701
+ { style: { display: "flex", flexDirection: "column", gap: "6px" } },
702
+ provider.models.map(function(model) {
703
+ return React.createElement(ModelRow, {
704
+ key: model.id,
705
+ model: model,
706
+ provider: provider.provider,
707
+ namespace: namespace
708
+ });
709
+ })
710
+ )
711
+ );
712
+ }
713
+
714
+ // ── registration ─────────────────────────────────────────────────
715
+ // One cell per supported namespace. The key is the settings namespace
716
+ // because that is exactly what the owner dispatches, so a pi-ai route
717
+ // and the DeepSeek route are served by different cells and can never
718
+ // collide on a single card.
719
+ Object.keys(SUPPORTED).forEach(function(namespace) {
720
+ ctx.slots.inject("settings.models.provider-card", function() {
721
+ return ctx.slots.register(
722
+ { name: "settings.models.provider-card", key: namespace },
723
+ ProviderPanel
724
+ );
725
+ });
726
+ });
727
+ };
728
+
729
+ return module.exports;
730
+ }
731
+ });