@nimoxie/dsh-model-organizer 0.3.6 → 0.5.11

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 (4) hide show
  1. package/DEVELOPMENT.md +385 -75
  2. package/README.md +94 -73
  3. package/lib/client.js +2679 -18
  4. package/package.json +63 -60
package/lib/client.js CHANGED
@@ -64,6 +64,136 @@ window.__ModuleLoader__.load({
64
64
  "providers.expand": "展开顺序面板",
65
65
  "providers.collapse": "收起顺序面板",
66
66
  "providers.saved": "已保存 ✓",
67
+ "providers.onlyOne": "当前只有 0–1 个可排序的供应商,没有顺序需要调整;添上第二个之后这里就能拖动排序。",
68
+ "nav": "模型管理页",
69
+ "page.title": "模型管理页",
70
+ "page.subtitle": "总览已配置的供应商与它们提供的模型。",
71
+ "page.providers": "已配置的供应商",
72
+ "page.catalog": "官方目录",
73
+ "page.custom": "自定义供应商",
74
+ "page.baseUrl": "API 地址",
75
+ "page.api": "API 格式",
76
+ "page.apiKey": "API 密钥",
77
+ "page.keyUnset": "未配置",
78
+ "page.models": "模型列表",
79
+ "page.empty": "还没有配置任何供应商。在官方「模型」页添加后,这里会列出来。",
80
+ "page.loading": "正在读取配置…",
81
+ "page.readonly": "当前设置不可写(只读环境)。",
82
+ "page.vision": "视觉",
83
+ "page.reasoning": "推理等级",
84
+ "page.keySet": "已配置",
85
+ "page.keyPlaceholder": "粘贴密钥后点「保存密钥」",
86
+ "page.keySave": "保存密钥",
87
+ "page.keyClear": "清除",
88
+ "page.keyUnavailable": "当前环境不提供凭据服务,密钥请在官方「模型」页设置。",
89
+ "page.addModel": "添加模型",
90
+ "page.saving": "保存中…",
91
+ "page.saved": "已保存 ✓",
92
+ "page.show": "显示",
93
+ "page.hide": "隐藏",
94
+ "page.modelId": "模型 ID",
95
+ "page.modelContext": "上下文",
96
+ "page.modelMax": "最大输出",
97
+ "page.modelsEmptyEdit": "还没有模型,点下面的「添加模型」。",
98
+ "page.modelIdRequired": "模型 ID 不能为空。",
99
+ "page.modelIdDuplicate": "模型 ID 不能重复。",
100
+ "page.capacityInvalid": "容量要写正数,可以用 K / M 后缀(如 256K、1M)。",
101
+ "page.fetchModels": "获取可用模型",
102
+ "page.fetching": "获取中…",
103
+ "page.fetchNone": "这个路由的模型都已经在列表里了。",
104
+ "page.fetchTitle": "选择要添加的模型",
105
+ "page.fetchDescription": "以下是模型提供商的可用模型,勾选要添加的模型。",
106
+ "page.fetchSearch": "搜索模型",
107
+ "page.fetchNoMatches": "没有匹配的模型。",
108
+ "page.fetchEmpty": "该提供商没有列出任何模型。",
109
+ "page.selectAll": "全选",
110
+ "page.deselectAll": "取消全选",
111
+ "page.cancel": "取消",
112
+ "page.adopt": "添加所选",
113
+ "page.switchOn": "已启用(点击停用)",
114
+ "page.switchOff": "已停用(点击启用)",
115
+ "page.switchHint": "开关只影响本插件的模型选择菜单:停用的供应商/模型不会再出现在聊天框的模型菜单里;DSH 配置原样保留。",
116
+ "page.addProvider": "添加模型提供商",
117
+ "page.newTitle": "添加模型提供商",
118
+ "page.newDescription": "新建一条供应商路由。路由 ID 是配置里的键,创建后可继续在右栏调整并补充模型。",
119
+ "page.route": "路由 ID",
120
+ "page.routeHint": "字母、数字、点、下划线、连字符;创建后不建议改名。",
121
+ "page.routeRequired": "路由 ID 不能为空。",
122
+ "page.routeInvalid": "路由 ID 只能包含字母、数字、点、下划线和连字符。",
123
+ "page.routeTaken": "该路由已存在,请换一个 ID。",
124
+ "page.displayNameOptional": "显示名称(可选)",
125
+ "page.baseUrlOptional": "API 地址(可稍后填写)",
126
+ "page.keyOptional": "API 密钥(可留空,稍后在右栏填写)",
127
+ "page.modelsField": "模型 ID(至少一个)",
128
+ "page.modelsFieldPlaceholder": "例如 glm-4.6, qwen-max(多个用逗号或空格分隔)",
129
+ "page.modelsFieldHint": "这个路由不在 DSH 的目录里,配置里必须先列出模型,否则它会解析不出模型。填好 API 地址与密钥后可点「从网关获取」自动填。",
130
+ "page.modelsRequired": "至少要填一个模型 ID:这个路由不在 DSH 的目录里,没有模型它无法解析。",
131
+ "page.fetchFromGateway": "从网关获取",
132
+ "page.baseUrlRequired": "「从网关获取」需要先填 API 地址。",
133
+ "page.deleteProvider": "删除供应商",
134
+ "page.deleteProviderHint": "删除这条自建路由:它的配置和本页派生的密钥引用会一起移除,无法撤销。官方路由不能在这里删除。",
135
+ "page.deleteProviderBody": "删除后无法撤销:这条供应商的配置会被移除,本页为它派生的密钥引用也会一并清理(别处配置的密钥不会被删)。",
136
+ "page.deleteFailed": "删除失败。",
137
+ "page.test": "测试",
138
+ "page.testing": "测试中…",
139
+ "page.testOkShort": "✓ 可用",
140
+ "page.testMissingShort": "⚠ 不在列表",
141
+ "page.testFailShort": "✗ 失败",
142
+ "page.testHint": "让宿主向该供应商询问模型列表,并检查这个模型 ID 是否在其中。自建路由会真的访问网关(用已保存的密钥),目录已描述的路由直接读目录。",
143
+ "page.testOk": "网关可达,且它返回的模型列表里有这个 ID。",
144
+ "page.testMissing": "网关可达,但它返回的模型列表里没有这个 ID(可能拼错,或网关没暴露该模型)。",
145
+ "page.testFailed": "测试失败。",
146
+ "page.testNoCatalog": "这条路由没有目录、也没有填 API 地址,无法询问网关:先在「API 地址」里填上它的地址再测。",
147
+ "page.deleteModel": "删除模型",
148
+ "page.testNeedsId": "先填模型 ID 再测试。",
149
+ "page.deleteModelHint": "从配置里删掉这个模型(每条路由至少要留一个)",
150
+ "page.modelDeleted": "模型已删除",
151
+ "page.openOfficial": "官方模型页",
152
+ "page.openOfficialHint": "把「模型」这一行交还官方页(菜单不会变,仍然只有「模型」一行;随时可从官方页浮窗的「模型管理页」切回来)",
153
+ "page.switchFailed": "没找到设置导航,请在左侧直接点「模型」页。",
154
+ "panel.openOrganizer": "模型管理页",
155
+ "panel.openOrganizerHint": "接替「模型」这一行:点菜单里的「模型」打开的就是本页(菜单看起来不变)",
156
+ "page.create": "创建",
157
+ "page.working": "处理中…",
158
+ "page.createFailed": "创建失败。",
159
+ "page.fetchUnavailable": "当前环境不提供模型发现服务,请在官方「模型」页获取。",
160
+ "page.note": "左栏拖一下就能改顺序;API 地址、协议、供应商与模型改动都是即时保存,密钥只写入凭据库、页面读不回明文。官方路由是只读的,看到「官方」标记就说明要回官方模型页改。",
161
+ "page.officialGroup": "官方(内置)",
162
+ "page.officialBadge": "官方",
163
+ "page.officialHint": "由 DSH 本体提供、设置在自己的页面里;点击这一行会切回官方模型页。",
164
+ "page.officialRowTitle": "官方路由 · 请到官方模型页编辑(点击前往)",
165
+ "page.dotOn": "已启用:会出现在本插件的模型菜单里",
166
+ "page.dotOff": "已停用:不会出现在本插件的模型菜单里",
167
+ "page.dotHint": "只影响本插件的模型菜单,DSH 配置原样保留",
168
+ "page.modelOpenHint": "点击编辑模型详情,按住拖动排序",
169
+ "page.modelDialogTitle": "模型详情",
170
+ "page.modelDialogNew": "添加模型",
171
+ "page.modelDialogHint": "保存后立即写回 DSH 配置。",
172
+ "page.modelDisplayName": "显示名(可选,留空就用 ID)",
173
+ "page.modelInput": "输入模态",
174
+ "page.modelText": "文本",
175
+ "page.modelImage": "图片",
176
+ "page.modelInputHint": "至少选一个;不勾「图片」的模型在菜单里不会被当成视觉模型。",
177
+ "page.modelCapacityHint": "支持 128K / 128000 两种写法;留空表示不写进配置。",
178
+ "page.reasoningMode": "推理等级",
179
+ "page.reasoningInherit": "继承目录",
180
+ "page.reasoningNone": "不支持推理",
181
+ "page.reasoningCustom": "自定义等级",
182
+ "page.reasoningHint": "只有「自定义等级」会写进配置:左边是菜单里显示的等级名,右边是选中时发给模型的参数值(可留空)。",
183
+ "page.reasoningLevel": "等级名",
184
+ "page.reasoningValue": "发送值",
185
+ "page.reasoningAdd": "添加等级",
186
+ "page.reasoningRemove": "删除等级",
187
+ "page.reasoningRequired": "自定义等级至少要有一个等级名。",
188
+ "page.reasoningDuplicate": "等级名不能重复。",
189
+ "page.modelEnable": "启用",
190
+ "page.modelDelete": "删除此模型",
191
+ "page.modelDeleteHint": "删除后立即写回配置,无法撤销。",
192
+ "page.saveModel": "保存模型",
193
+ "page.cancelModel": "取消",
194
+ "protocol.completions": "Chat Completions (/chat/completions)",
195
+ "protocol.responses": "Responses (/responses)",
196
+ "protocol.messages": "Anthropic Messages (/v1/messages)",
67
197
  };
68
198
  const en = {
69
199
  "composer.title": "Select model",
@@ -94,15 +224,147 @@ window.__ModuleLoader__.load({
94
224
  "providers.expand": "Expand the order panel",
95
225
  "providers.collapse": "Collapse the order panel",
96
226
  "providers.saved": "Saved ✓",
227
+ "providers.onlyOne": "Nothing to order yet — with 0–1 sortable providers the list is empty; a second one turns this into a drag surface.",
228
+ "nav": "Model manager",
229
+ "page.title": "Model manager",
230
+ "page.subtitle": "An overview of the configured providers and the models they serve.",
231
+ "page.providers": "Configured providers",
232
+ "page.catalog": "Catalog providers",
233
+ "page.custom": "Custom providers",
234
+ "page.baseUrl": "Base URL",
235
+ "page.api": "API format",
236
+ "page.apiKey": "API key",
237
+ "page.keyUnset": "Not configured",
238
+ "page.models": "Models",
239
+ "page.empty": "No providers are configured yet. Add one on the official Models page and it shows up here.",
240
+ "page.loading": "Reading configuration…",
241
+ "page.readonly": "This settings document is read-only here.",
242
+ "page.vision": "vision",
243
+ "page.reasoning": "reasoning",
244
+ "page.keySet": "Configured",
245
+ "page.keyPlaceholder": "Paste the key, then Save key",
246
+ "page.keySave": "Save key",
247
+ "page.keyClear": "Clear",
248
+ "page.keyUnavailable": "No credential service in this build; set the key on the official Models page.",
249
+ "page.addModel": "Add model",
250
+ "page.saving": "Saving…",
251
+ "page.saved": "Saved ✓",
252
+ "page.show": "Show",
253
+ "page.hide": "Hide",
254
+ "page.modelId": "Model ID",
255
+ "page.modelContext": "Context",
256
+ "page.modelMax": "Max output",
257
+ "page.modelsEmptyEdit": "No models yet — use Add model below.",
258
+ "page.modelIdRequired": "A model ID cannot be empty.",
259
+ "page.modelIdDuplicate": "Model IDs must be unique.",
260
+ "page.capacityInvalid": "Capacities must be positive numbers, optionally with a K or M suffix.",
261
+ "page.fetchModels": "Fetch models",
262
+ "page.fetching": "Fetching…",
263
+ "page.fetchNone": "Every listed model is already in this route.",
264
+ "page.fetchTitle": "Choose models to add",
265
+ "page.fetchDescription": "These are the models this provider lists. Pick the ones to add.",
266
+ "page.fetchSearch": "Search models",
267
+ "page.fetchNoMatches": "No matching models.",
268
+ "page.fetchEmpty": "This provider listed no models.",
269
+ "page.selectAll": "Select all",
270
+ "page.deselectAll": "Deselect all",
271
+ "page.cancel": "Cancel",
272
+ "page.adopt": "Add selected",
273
+ "page.switchOn": "Enabled (click to disable)",
274
+ "page.switchOff": "Disabled (click to enable)",
275
+ "page.switchHint": "These switches only affect this plugin's pickers: a disabled route or model stops appearing in the composer's model menu. The DSH config is left untouched.",
276
+ "page.addProvider": "Add model provider",
277
+ "page.newTitle": "Add model provider",
278
+ "page.newDescription": "Create a new provider route. The route ID is the config key; you can adjust it and add models on the right afterwards.",
279
+ "page.route": "Route ID",
280
+ "page.routeHint": "Letters, digits, dot, underscore, hyphen; renaming later is not recommended.",
281
+ "page.routeRequired": "The route ID cannot be empty.",
282
+ "page.routeInvalid": "A route ID may only contain letters, digits, dot, underscore and hyphen.",
283
+ "page.routeTaken": "That route already exists — pick another ID.",
284
+ "page.displayNameOptional": "Display name (optional)",
285
+ "page.baseUrlOptional": "Base URL (can be filled in later)",
286
+ "page.keyOptional": "API key (leave empty, fill it on the right later)",
287
+ "page.modelsField": "Model IDs (at least one)",
288
+ "page.modelsFieldPlaceholder": "e.g. glm-4.6, qwen-max (comma or space separated)",
289
+ "page.modelsFieldHint": "DSH's catalog does not describe this route, so the config must list its models — otherwise the route resolves no models. Fill the API address and key, then use Fetch from gateway.",
290
+ "page.modelsRequired": "List at least one model ID: the catalog does not describe this route, so it cannot resolve without models.",
291
+ "page.fetchFromGateway": "Fetch from gateway",
292
+ "page.baseUrlRequired": "Fetch from gateway needs the API address first.",
293
+ "page.deleteProvider": "Delete provider",
294
+ "page.deleteProviderHint": "Delete this hand-declared route: its profile and the credential reference this page derived go with it, and it cannot be undone. Built-in routes cannot be deleted here.",
295
+ "page.deleteProviderBody": "This cannot be undone: the provider's profile is removed and the credential reference this page derived is cleaned up (a key configured elsewhere is left alone).",
296
+ "page.deleteFailed": "Deleting failed.",
297
+ "page.test": "Test",
298
+ "page.testing": "Testing…",
299
+ "page.testOkShort": "✓ reachable",
300
+ "page.testMissingShort": "⚠ not listed",
301
+ "page.testFailShort": "✗ failed",
302
+ "page.testHint": "Asks the host which models this provider serves and checks whether this model ID is among them. A hand-declared route really calls the gateway (with the stored key); a catalog route answers from the catalog.",
303
+ "page.testOk": "The gateway answered and lists this ID.",
304
+ "page.testMissing": "The gateway answered, but its model list does not contain this ID (typo, or the gateway does not expose it).",
305
+ "page.testFailed": "The test failed.",
306
+ "page.testNoCatalog": "This route has no catalog and no API address, so the gateway cannot be asked: fill in the API address first.",
307
+ "page.deleteModel": "Delete model",
308
+ "page.testNeedsId": "Give the model an ID before testing it.",
309
+ "page.deleteModelHint": "Remove this model from the configuration (every route keeps at least one)",
310
+ "page.modelDeleted": "Model deleted",
311
+ "page.openOfficial": "Official Models page",
312
+ "page.openOfficialHint": "Hand the Models row back to the shipped page (the menu does not change; switch back any time from the panel)",
313
+ "page.switchFailed": "The settings nav was not found — pick the Models page on the left.",
314
+ "panel.openOrganizer": "Model manager",
315
+ "panel.openOrganizerHint": "Take the Models row over: the menu entry opens this page instead (the menu itself does not change)",
316
+ "page.create": "Create",
317
+ "page.working": "Working…",
318
+ "page.createFailed": "Creating the route failed.",
319
+ "page.fetchUnavailable": "No model-discovery service in this build; use the official Models page.",
320
+ "page.note": "Drag the left rail to reorder; the API address, protocol, provider and model edits all save immediately; the key goes to the credential store and is never read back. Built-in routes are read-only — an \"official\" tag means the shipped Models page owns them.",
321
+ "page.officialGroup": "Official (built-in)",
322
+ "page.officialBadge": "Official",
323
+ "page.officialHint": "Provided by DSH itself and configured on its own page; clicking this row switches back to the shipped Models page.",
324
+ "page.officialRowTitle": "Built-in route — edit it on the shipped Models page (click to go there)",
325
+ "page.dotOn": "Enabled: it appears in this plugin's model menu",
326
+ "page.dotOff": "Disabled: it stays out of this plugin's model menu",
327
+ "page.dotHint": "Only affects this plugin's model menu; the DSH config is untouched",
328
+ "page.modelOpenHint": "Click to edit this model, press and drag to reorder",
329
+ "page.modelDialogTitle": "Model details",
330
+ "page.modelDialogNew": "Add model",
331
+ "page.modelDialogHint": "Saving writes straight back to the DSH config.",
332
+ "page.modelDisplayName": "Display name (optional — the ID is used when empty)",
333
+ "page.modelInput": "Input modalities",
334
+ "page.modelText": "Text",
335
+ "page.modelImage": "Image",
336
+ "page.modelInputHint": "Pick at least one; a model without \"Image\" is not treated as a vision model.",
337
+ "page.modelCapacityHint": "Both 128K and 128000 work; an empty box writes nothing.",
338
+ "page.reasoningMode": "Reasoning levels",
339
+ "page.reasoningInherit": "Inherit from catalog",
340
+ "page.reasoningNone": "No reasoning",
341
+ "page.reasoningCustom": "Custom levels",
342
+ "page.reasoningHint": "Only \"Custom levels\" is written to the config: the left box is the level name shown in the menu, the right one is the value sent to the model (may be empty).",
343
+ "page.reasoningLevel": "Level",
344
+ "page.reasoningValue": "Sent value",
345
+ "page.reasoningAdd": "Add level",
346
+ "page.reasoningRemove": "Remove level",
347
+ "page.reasoningRequired": "A custom level list needs at least one level name.",
348
+ "page.reasoningDuplicate": "Level names must be unique.",
349
+ "page.modelEnable": "Enabled",
350
+ "page.modelDelete": "Delete this model",
351
+ "page.modelDeleteHint": "Deleting writes back immediately and cannot be undone.",
352
+ "page.saveModel": "Save model",
353
+ "page.cancelModel": "Cancel",
354
+ "protocol.completions": "Chat Completions (/chat/completions)",
355
+ "protocol.responses": "Responses (/responses)",
356
+ "protocol.messages": "Anthropic Messages (/v1/messages)",
97
357
  };
98
358
 
99
359
  /* --------------------------- official CSS classes -------------------------- */
100
360
  /* The shipped client-ui-model-selection bundle injects its CSS-module sheet and
101
361
  STILL loads (this plugin only shadows its seat), so reusing its class names
102
362
  reproduces the shipped look exactly. The hash is resolved from the live
103
- stylesheet so a dsh upgrade cannot silently drop the styling. */
363
+ stylesheet so a dsh upgrade cannot silently drop the styling. A class the
364
+ shipped sheet stops defining must be probed for at runtime (see
365
+ `hasGroupSection`); `groupTitle` left the sheet in DSH 0.2.1-rc.2. */
104
366
  const CLASS_SUFFIXES = ["root", "trigger", "triggerLabel", "triggerEffort", "triggerIcon", "chevron", "chevronOpen",
105
- "menu", "status", "empty", "error", "warning", "retry", "groups", "group", "groupTitle",
367
+ "menu", "status", "empty", "error", "warning", "retry", "groups", "group",
106
368
  "option", "selected", "optionCopy", "modelName", "check", "cell", "cellLabel", "cellValue", "cellChevron",
107
369
  "searchRow", "search", "searchWithQuery", "searchClear"];
108
370
 
@@ -115,7 +377,7 @@ window.__ModuleLoader__.load({
115
377
  code changes, so a browser can keep running an old build until a hard reload.
116
378
  Kept OFF the UI (it is not user-facing): read it as
117
379
  `window.__dshModelOrganizerBuild` when a stale build is suspected. */
118
- const BUILD = "b33";
380
+ const BUILD = "b65";
119
381
 
120
382
  let officialClasses = null;
121
383
  let officialClassesSheets = -1;
@@ -174,6 +436,14 @@ window.__ModuleLoader__.load({
174
436
  for (let i = 0; i < selectors.length; i++) {
175
437
  if (selectors[i].indexOf(searchMarker) !== -1) { out.hasSearchRow = true; break; }
176
438
  }
439
+ /* 0.2.1-rc.2 dropped the per-group SECTION class while keeping `groups`
440
+ (plural), so probe with a boundary: a bare `_group` match would also hit
441
+ `_groups` and claim a class the sheet never defines. */
442
+ const groupMarker = new RegExp("\\." + prefix + "_group(?![A-Za-z0-9_-])");
443
+ out.hasGroupSection = false;
444
+ for (let i = 0; i < selectors.length; i++) {
445
+ if (groupMarker.test(selectors[i])) { out.hasGroupSection = true; break; }
446
+ }
177
447
  officialClasses = out;
178
448
  return out;
179
449
  }
@@ -200,6 +470,18 @@ window.__ModuleLoader__.load({
200
470
  ".dsh-mo-head:focus-visible{outline:none;background:var(--dsw-alias-interactive-bg-hover);}",
201
471
  ".dsh-mo-list .dsh-mo-head.dsh-mo-head:focus-visible,.dsh-mo-list .dsh-mo-model.dsh-mo-model:focus-visible,.dsh-mo-list .dsh-mo-row.dsh-mo-row:focus-visible{background:transparent;}",
202
472
  ".dsh-mo-panelhead.dsh-mo-panelhead:focus-visible{background:var(--dsw-alias-interactive-bg-hover);}",
473
+ /* Ghost icon actions (row delete / model test): the shipped kit's icons inherit
474
+ currentColor, so the state lives entirely in these three rules. */
475
+ ".dsh-mo-act{color:var(--dsw-alias-label-secondary);transition:background .12s ease,color .12s ease;}",
476
+ ".dsh-mo-act:hover{background:var(--dsw-alias-interactive-bg-hover);}",
477
+ ".dsh-mo-act:disabled{opacity:.45;cursor:not-allowed;background:transparent;}",
478
+ ".dsh-mo-act-danger{color:var(--dsw-alias-state-error, #f85149);}",
479
+ ".dsh-mo-act-ok{color:var(--dsw-alias-state-success, #3fb950);}",
480
+ ".dsh-mo-act-miss{color:var(--dsw-alias-state-warn-label, #d29922);}",
481
+ /* IconLoadingOutlineRegular is a still glyph; the spin lives here. */
482
+ "@keyframes dsh-mo-spin{from{transform:rotate(0deg);}to{transform:rotate(360deg);}}",
483
+ ".dsh-mo-act-busy svg{animation:dsh-mo-spin .9s linear infinite;}",
484
+
203
485
  ".dsh-mo-chev{transition:transform .12s ease;}",
204
486
  ".dsh-mo-head[aria-expanded='true'] .dsh-mo-chev{transform:rotate(90deg);}",
205
487
  ".dsh-mo-row{border-width:1px;border-style:solid;border-color:transparent;}",
@@ -218,6 +500,13 @@ window.__ModuleLoader__.load({
218
500
  hover tint. Doubled selectors outrank the shipped `cell` :hover rule. */
219
501
  ".dsh-mo-panelhead.dsh-mo-panelhead{cursor:grab;}",
220
502
  ".dsh-mo-panelhead.dsh-mo-panelhead:hover{background:transparent;}",
503
+ /* Page rows the shipped sheet has no class for (the editable model rows):
504
+ same hover tint as the panel's rows, no cursor change — the grip does that. */
505
+ ".dsh-mo-pagerow{display:flex;flex-direction:column;gap:5px;min-width:0;padding:6px 8px;border-radius:8px;background:var(--dsw-alias-interactive-bg, rgba(127,127,127,.06));transition:background .12s ease;}",
506
+ ".dsh-mo-pagerow:hover{background:var(--dsw-alias-interactive-bg-hover);}",
507
+ ".dsh-mo-grip{cursor:grab;}",
508
+ ".dsh-mo-grip:active{cursor:grabbing;}",
509
+ ".dsh-mo-nohover.dsh-mo-nohover .dsh-mo-pagerow:hover{background:var(--dsw-alias-interactive-bg, rgba(127,127,127,.06));}",
221
510
  ".dsh-mo-list{display:flex;flex-direction:column;gap:2px;margin-top:2px;}",
222
511
  ".dsh-mo-head{touch-action:none;user-select:none;-webkit-user-select:none;}",
223
512
  /* Borrowed from the sidebar rows: without -webkit-user-drag the browser treats
@@ -266,6 +555,15 @@ window.__ModuleLoader__.load({
266
555
  return h(P[name], props ? Object.assign({}, props) : {});
267
556
  }
268
557
 
558
+ /* The shipped kit (94 icons) is reached through the primitives module, same as the
559
+ official pages do. Outside the app (headless harness, or a build without it) keep a
560
+ neutral glyph in the same box so nothing collapses. */
561
+ function kitIcon(name, glyph, size) {
562
+ const rendered = icon(name, { size: size });
563
+ if (rendered !== null) return rendered;
564
+ return h("span", { "aria-hidden": "true", style: { fontSize: size - 3, lineHeight: 1 } }, glyph);
565
+ }
566
+
269
567
  /* --------------------------------- styles --------------------------------- */
270
568
  const S = {
271
569
  chip: {
@@ -301,8 +599,10 @@ window.__ModuleLoader__.load({
301
599
  },
302
600
  fallbackCopy: { display: "flex", flexDirection: "column", flex: 1, minWidth: 0 },
303
601
  fallbackCheck: { flex: "0 0 18px", display: "grid", placeItems: "center" },
304
- /* the shipped groupTitle is a plain heading; the clickable variant only adds
305
- layout + affordance so the visuals keep coming from the shipped sheet. */
602
+ /* The shipped sheet dropped the per-group heading and section classes in
603
+ 0.2.1-rc.2, so the header rides the shipped `cell` row plus our own
604
+ dsh-mo-head, and the section falls back to fallbackGroup — which is a
605
+ STYLE object, never a class name. */
306
606
  groupChevron: { color: "var(--dsw-alias-label-tertiary)", flex: "none", transition: "transform .12s" },
307
607
  measureStyle: { visibility: "hidden", left: 0, top: 0 },
308
608
  groupCount: { fontSize: 10, color: "var(--dsw-alias-label-tertiary)", flex: "none" },
@@ -327,6 +627,8 @@ window.__ModuleLoader__.load({
327
627
  /* The shipped `cell` sets min-width:100%; the head must stay inside the panel. */
328
628
  panelHeadCell: { display: "flex", alignItems: "center", gap: 6, flex: "1 1 auto", minWidth: 0 },
329
629
  floatBody: { display: "flex", flexDirection: "column", gap: 2, overflowY: "auto", maxHeight: "52vh", padding: "2px 4px 4px" },
630
+ /* The cross-link rides in the head row, next to the title; it isolates the pointer
631
+ events so it neither drags the panel nor collapses it. */
330
632
 
331
633
  boxHead: { display: "flex", alignItems: "center", gap: 8, marginBottom: 6, flexWrap: "wrap" },
332
634
  boxTitle: { fontSize: 12, fontWeight: 600, color: "var(--dsw-alias-label-primary)" },
@@ -358,7 +660,238 @@ window.__ModuleLoader__.load({
358
660
  /* Right-aligned "N models" count on a provider row (fallback styling only). */
359
661
  rowMeta: { flex: "none", fontSize: 10, color: "var(--dsw-alias-label-tertiary)" },
360
662
  dirty: { fontSize: 11, color: "var(--dsw-alias-state-warn-label, #d29922)" },
361
- ok: { fontSize: 11, color: "var(--dsw-alias-state-success, #3fb950)" }
663
+ ok: { fontSize: 11, color: "var(--dsw-alias-state-success, #3fb950)" },
664
+
665
+ /* The organizer page (a `settings.section` entry): a two-column master/detail
666
+ reading the same mirror the footer panel does. Read-only by design — the
667
+ shipped page stays the editor, this one is the overview. */
668
+ pageRoot: { display: "flex", flexDirection: "column", gap: 12, width: "100%", minWidth: 0, color: "var(--dsw-alias-label-primary)" },
669
+ pageHead: { display: "flex", flexDirection: "column", gap: 3 },
670
+ pageTitleRow: { display: "flex", alignItems: "center", gap: 8, minWidth: 0 },
671
+ /* Both cross-links are pills next to a title, so they must not look like the
672
+ full-size action buttons in the body. */
673
+ linkPill: {
674
+ boxSizing: "border-box", flex: "none", padding: "1px 7px", borderRadius: 999, fontSize: 11,
675
+ lineHeight: "16px", cursor: "pointer", color: "var(--dsw-alias-label-secondary)",
676
+ background: "var(--dsw-alias-interactive-bg, rgba(127,127,127,.10))",
677
+ borderWidth: 1, borderStyle: "solid", borderColor: "var(--dsw-alias-border-l1)"
678
+ },
679
+ switchLabel: {
680
+ display: "inline-flex", alignItems: "center", gap: 6, fontSize: 11,
681
+ color: "var(--dsw-alias-label-secondary)", cursor: "pointer", userSelect: "none"
682
+ },
683
+ linkPillOn: {
684
+ color: "var(--dsw-alias-label-primary)",
685
+ background: "var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.20))",
686
+ borderColor: "var(--dsw-alias-state-accent, #4f8cff)"
687
+ },
688
+ pageTitle: { margin: 0, fontSize: 16, fontWeight: 600, color: "var(--dsw-alias-label-primary)" },
689
+ pageSubtitle: { margin: 0, fontSize: 12, lineHeight: "18px", color: "var(--dsw-alias-label-tertiary)" },
690
+ pageGrid: { display: "grid", gridTemplateColumns: "minmax(170px, 210px) minmax(0, 1fr)", gap: 14, alignItems: "start" },
691
+ pageList: { display: "flex", flexDirection: "column", gap: 2, minWidth: 0 },
692
+ pageGroup: { fontSize: 11, fontWeight: 600, padding: "2px 8px 4px", color: "var(--dsw-alias-label-tertiary)" },
693
+ /* The current provider is marked with an accent bar, never with an inline
694
+ background: an inline background would beat the shipped :hover rule and the
695
+ row would stop reacting to the pointer — the panel's rows had the same trap. */
696
+ pageRowCurrent: { boxShadow: "inset 2px 0 0 0 var(--dsw-alias-state-accent, #4f8cff)" },
697
+ pageDot: { flex: "0 0 6px", width: 6, height: 6, borderRadius: "50%", background: "var(--dsw-alias-state-success, #3fb950)" },
698
+ pageDotMuted: { background: "var(--dsw-alias-label-tertiary)" },
699
+ pageRowName: { flex: "1 1 auto", minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
700
+ pageRowCount: { flex: "none", fontSize: 10, color: "var(--dsw-alias-label-tertiary)" },
701
+ pageDetail: {
702
+ display: "flex", flexDirection: "column", gap: 10, minWidth: 0, padding: 14,
703
+ border: "1px solid var(--dsw-alias-border-l1)", borderRadius: 12,
704
+ background: "var(--dsw-alias-bg-module-secondary, transparent)"
705
+ },
706
+ pageDetailHead: { display: "flex", alignItems: "baseline", gap: 8, flexWrap: "wrap" },
707
+ pageDetailTitle: { margin: 0, fontSize: 15, fontWeight: 600 },
708
+ pageDetailRoute: { fontSize: 11, color: "var(--dsw-alias-label-tertiary)" },
709
+ pageFacts: { display: "flex", flexDirection: "column", gap: 6 },
710
+ pageFact: { display: "grid", gridTemplateColumns: "84px minmax(0, 1fr)", gap: 10, alignItems: "baseline", fontSize: 12 },
711
+ pageFactLabel: { color: "var(--dsw-alias-label-tertiary)" },
712
+ pageFactValue: { color: "var(--dsw-alias-label-secondary)", wordBreak: "break-all" },
713
+ pageSectionHead: { display: "flex", alignItems: "baseline", justifyContent: "space-between", gap: 8 },
714
+ pageSectionLabel: { fontSize: 12, fontWeight: 600 },
715
+ pageHint: { fontSize: 10, color: "var(--dsw-alias-label-tertiary)" },
716
+ pageEmpty: { margin: 0, fontSize: 12, lineHeight: "18px", color: "var(--dsw-alias-label-tertiary)" },
717
+ pageModels: { display: "flex", flexDirection: "column", gap: 4 },
718
+ pageModelRow: {
719
+ display: "flex", alignItems: "center", gap: 8, minWidth: 0, padding: "5px 8px", borderRadius: 8,
720
+ fontSize: 12, background: "var(--dsw-alias-interactive-bg, rgba(127,127,127,.06))"
721
+ },
722
+ pageModelId: { minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" },
723
+ pageModelName: { minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", color: "var(--dsw-alias-label-tertiary)" },
724
+ pageSpacer: { flex: "1 1 auto", minWidth: 0 },
725
+ pageChip: { flex: "none", padding: "1px 6px", borderRadius: 6, fontSize: 10, border: "1px solid var(--dsw-alias-border-l1)", color: "var(--dsw-alias-label-tertiary)" },
726
+ pageLoading: { margin: 0, fontSize: 12, color: "var(--dsw-alias-label-tertiary)" },
727
+ pageFooterHint: { margin: 0, fontSize: 10, lineHeight: "16px", color: "var(--dsw-alias-label-tertiary)" },
728
+ /* The fetch picker: portaled to document.body like the footer panel, because a
729
+ fixed card inside the settings subtree is trapped in its stacking context. */
730
+ pickBackdrop: {
731
+ position: "fixed", inset: 0, zIndex: 10000, display: "flex", alignItems: "center",
732
+ justifyContent: "center", padding: 16, background: "rgba(0,0,0,.45)"
733
+ },
734
+ pickCard: {
735
+ display: "flex", flexDirection: "column", gap: 10, width: "min(520px, 92vw)", maxHeight: "min(80vh, 640px)",
736
+ padding: 16, borderRadius: 14, color: "var(--dsw-alias-label-primary)",
737
+ /* Frost parity with the footer panel (S.floatPanel): the theme's menu token is
738
+ an opaque color-mix, and with today's translucent theme value the token alone
739
+ leaves the card see-through. Mix it back toward transparent and blur what sits
740
+ behind it, exactly like the panel does. */
741
+ background: "color-mix(in srgb, var(--dsw-specific-menu, #26262a) 50%, transparent)",
742
+ backdropFilter: "var(--dsw-menu-backdrop-filter, blur(28px) saturate(1.6))",
743
+ WebkitBackdropFilter: "var(--dsw-menu-backdrop-filter, blur(28px) saturate(1.6))",
744
+ borderWidth: 1, borderStyle: "solid", borderColor: "var(--dsw-alias-border-l1)",
745
+ boxShadow: "var(--dsw-elevation-prominent, 0 10px 30px rgba(0,0,0,.34))"
746
+ },
747
+ pickHead: { display: "flex", alignItems: "center", gap: 8 },
748
+ pickTitle: { margin: 0, fontSize: 15, fontWeight: 600, flex: "1 1 auto" },
749
+ pickDesc: { margin: 0, fontSize: 12, lineHeight: "18px", color: "var(--dsw-alias-label-tertiary)" },
750
+ pickTools: { display: "flex", alignItems: "center", gap: 8 },
751
+ pickList: { display: "flex", flexDirection: "column", gap: 2, overflowY: "auto", minHeight: 0, paddingRight: 2 },
752
+ pickRow: { display: "flex", alignItems: "center", gap: 8, padding: "3px 6px", borderRadius: 7, cursor: "pointer", fontSize: 12 },
753
+ pickId: { minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" },
754
+ pickFoot: { display: "flex", alignItems: "center", justifyContent: "flex-end", gap: 8 },
755
+ /* Editable surface: one grid row per field so the label column stays aligned,
756
+ and controls that inherit the theme instead of the browser defaults. */
757
+ /* Label above control, full-width control: inside the settings column (~700px)
758
+ a fixed label column squeezed every input and the rows ended up unreadable. */
759
+ pageField: { display: "flex", flexDirection: "column", gap: 4, minWidth: 0, fontSize: 12 },
760
+ pageFieldLabel: { color: "var(--dsw-alias-label-tertiary)", fontSize: 12 },
761
+ pageInput: {
762
+ boxSizing: "border-box", minWidth: 0, padding: "4px 8px", borderRadius: 7, fontSize: 12,
763
+ color: "var(--dsw-alias-label-primary)",
764
+ background: "var(--dsw-alias-bg-module, rgba(127,127,127,.08))",
765
+ borderWidth: 1, borderStyle: "solid", borderColor: "var(--dsw-alias-border-l1)",
766
+ outline: "none"
767
+ },
768
+ pageInputWide: {
769
+ boxSizing: "border-box", width: "100%", minWidth: 0, padding: "4px 8px", borderRadius: 7, fontSize: 12,
770
+ color: "var(--dsw-alias-label-primary)",
771
+ background: "var(--dsw-alias-bg-module, rgba(127,127,127,.08))",
772
+ borderWidth: 1, borderStyle: "solid", borderColor: "var(--dsw-alias-border-l1)",
773
+ outline: "none"
774
+ },
775
+ pageInputMono: { flex: "1 1 auto", minWidth: 0, fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" },
776
+ pageModelTop: { display: "flex", alignItems: "center", gap: 6, minWidth: 0 },
777
+ pageModelFields: { display: "grid", gridTemplateColumns: "92px 92px auto", gap: 6, alignItems: "center", paddingLeft: 18 },
778
+ /* A native <select> popup is painted by the platform: in this theme its list came
779
+ out white-on-white. Three radio-style segments are painted by us, so the choice
780
+ is legible whatever the platform does. */
781
+ /* Drawn by us: a native checkbox in this theme is what the row already uses for
782
+ "vision", but a switch reads as an on/off state at a glance. */
783
+ switchTrack: {
784
+ boxSizing: "border-box", flex: "none", width: 30, height: 17, padding: 2, borderRadius: 9,
785
+ display: "inline-flex", alignItems: "center", justifyContent: "flex-start", cursor: "pointer",
786
+ background: "var(--dsw-alias-interactive-bg, rgba(127,127,127,.25))",
787
+ borderWidth: 1, borderStyle: "solid", borderColor: "var(--dsw-alias-border-l1)",
788
+ transition: "background .12s ease, border-color .12s ease"
789
+ },
790
+ switchTrackOn: {
791
+ background: "var(--dsw-alias-state-accent, #4f8cff)",
792
+ borderColor: "var(--dsw-alias-state-accent, #4f8cff)",
793
+ justifyContent: "flex-end"
794
+ },
795
+ switchKnob: { width: 11, height: 11, borderRadius: "50%", background: "#fff", boxShadow: "0 1px 2px rgba(0,0,0,.35)" },
796
+ pageDim: { opacity: 0.5 },
797
+ /* Same affordance as the shipped Models page: a dashed full-width box under the
798
+ last row of the list. */
799
+ pageAddButton: {
800
+ boxSizing: "border-box", width: "100%", marginTop: 4, padding: "8px 10px", borderRadius: 10,
801
+ display: "flex", alignItems: "center", justifyContent: "center", gap: 6,
802
+ fontSize: 12, cursor: "pointer", color: "var(--dsw-alias-label-tertiary)",
803
+ background: "transparent",
804
+ borderWidth: 1, borderStyle: "dashed", borderColor: "var(--dsw-alias-border-l1)"
805
+ },
806
+ pageError: { margin: 0, fontSize: 11, lineHeight: "16px", color: "var(--dsw-alias-state-error, #f85149)" },
807
+ pageSegments: { display: "flex", gap: 6, minWidth: 0, flexWrap: "wrap" },
808
+ pageSegment: {
809
+ boxSizing: "border-box", flex: "1 1 auto", minWidth: 0, padding: "5px 8px", borderRadius: 7, fontSize: 12,
810
+ cursor: "pointer", textAlign: "center", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
811
+ color: "var(--dsw-alias-label-secondary)",
812
+ background: "var(--dsw-alias-interactive-bg, rgba(127,127,127,.10))",
813
+ borderWidth: 1, borderStyle: "solid", borderColor: "var(--dsw-alias-border-l1)"
814
+ },
815
+ pageSegmentActive: {
816
+ color: "var(--dsw-alias-label-primary)",
817
+ background: "var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.20))",
818
+ borderColor: "var(--dsw-alias-state-accent, #4f8cff)"
819
+ },
820
+ pageSelect: {
821
+ boxSizing: "border-box", padding: "4px 8px", borderRadius: 7, fontSize: 12,
822
+ color: "var(--dsw-alias-label-primary)",
823
+ background: "var(--dsw-alias-bg-module, rgba(127,127,127,.08))",
824
+ borderWidth: 1, borderStyle: "solid", borderColor: "var(--dsw-alias-border-l1)"
825
+ },
826
+ pageButton: {
827
+ boxSizing: "border-box", flex: "none", padding: "4px 10px", borderRadius: 7, fontSize: 12, cursor: "pointer",
828
+ color: "var(--dsw-alias-label-secondary)",
829
+ background: "var(--dsw-alias-interactive-bg, rgba(127,127,127,.10))",
830
+ borderWidth: 1, borderStyle: "solid", borderColor: "var(--dsw-alias-border-l1)"
831
+ },
832
+ pageButtonPrimary: {
833
+ flex: "none", padding: "4px 10px", borderRadius: 7, fontSize: 12, cursor: "pointer",
834
+ color: "var(--dsw-alias-label-primary)",
835
+ background: "var(--dsw-alias-state-accent, #4f8cff)",
836
+ borderWidth: 1, borderStyle: "solid", borderColor: "transparent"
837
+ },
838
+ /* Row-level actions and the one destructive action on this page. */
839
+ pageModelActions: { flex: "none", display: "flex", alignItems: "center", gap: 4 },
840
+ /* Row actions are the shipped ghost icon buttons: no border, one height, colour comes
841
+ from the icon's own state (see .dsh-mo-act-* in the injected sheet). */
842
+ pageIconAction: {
843
+ boxSizing: "border-box", flex: "none", width: 26, height: 22, borderRadius: 7,
844
+ display: "inline-flex", alignItems: "center", justifyContent: "center", padding: 0,
845
+ borderWidth: 1, borderStyle: "solid", borderColor: "var(--dsw-alias-border-l1)",
846
+ background: "transparent", cursor: "pointer"
847
+ },
848
+ pageIconActionHead: {
849
+ boxSizing: "border-box", flex: "none", width: 22, height: 17, borderRadius: 6,
850
+ display: "inline-flex", alignItems: "center", justifyContent: "center", padding: 0,
851
+ borderWidth: 1, borderStyle: "solid", borderColor: "var(--dsw-alias-border-l1)",
852
+ background: "transparent", cursor: "pointer"
853
+ },
854
+
855
+ pageButtonCompact: {
856
+ boxSizing: "border-box", flex: "none", padding: "1px 7px", borderRadius: 6, fontSize: 10, cursor: "pointer",
857
+ borderWidth: 1, borderStyle: "solid", borderColor: "var(--dsw-alias-border-l1)",
858
+ background: "transparent", color: "var(--dsw-alias-label-secondary)"
859
+ },
860
+ pageButtonOk: {
861
+ boxSizing: "border-box", flex: "none", padding: "1px 7px", borderRadius: 6, fontSize: 10, cursor: "pointer",
862
+ borderWidth: 1, borderStyle: "solid", borderColor: "var(--dsw-alias-state-success, #3fb950)",
863
+ background: "transparent", color: "var(--dsw-alias-state-success, #3fb950)"
864
+ },
865
+ pageButtonWarn: {
866
+ boxSizing: "border-box", flex: "none", padding: "1px 7px", borderRadius: 6, fontSize: 10, cursor: "pointer",
867
+ borderWidth: 1, borderStyle: "solid", borderColor: "var(--dsw-alias-state-warn-label, #d29922)",
868
+ background: "transparent", color: "var(--dsw-alias-state-warn-label, #d29922)"
869
+ },
870
+ pageButtonDanger: {
871
+ boxSizing: "border-box", flex: "none", padding: "4px 10px", borderRadius: 7, fontSize: 12, cursor: "pointer",
872
+ borderWidth: 1, borderStyle: "solid", borderColor: "var(--dsw-alias-state-error, #f85149)",
873
+ background: "transparent", color: "var(--dsw-alias-state-error, #f85149)"
874
+ },
875
+ pageIconButton: {
876
+ boxSizing: "border-box", flex: "none", width: 22, height: 22, lineHeight: "18px", padding: 0, borderRadius: 6, fontSize: 11,
877
+ cursor: "pointer", color: "var(--dsw-alias-label-tertiary)", background: "transparent",
878
+ borderWidth: 1, borderStyle: "solid", borderColor: "transparent"
879
+ },
880
+ pageKeyRow: { display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap" },
881
+ pageActions: { display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" },
882
+ pageHandle: { flex: "none", color: "var(--dsw-alias-label-tertiary)", fontSize: 12, userSelect: "none" },
883
+ pageCheckLabel: { flex: "none", display: "inline-flex", alignItems: "center", gap: 4, fontSize: 11, color: "var(--dsw-alias-label-tertiary)", cursor: "pointer" },
884
+ /* Built-in rail rows: readable, obviously not ours to edit, and never draggable
885
+ (they sit outside the drag list for exactly that reason). */
886
+ pageOfficialRow: { opacity: 0.66, cursor: "pointer" },
887
+ pageDotOfficial: { flex: "0 0 6px", width: 6, height: 6, borderRadius: "50%", background: "var(--dsw-alias-label-tertiary)" },
888
+ pageBadge: { flex: "none", padding: "0 5px", borderRadius: 5, fontSize: 10, borderWidth: 1, borderStyle: "solid", borderColor: "var(--dsw-alias-border-l1)", color: "var(--dsw-alias-label-tertiary)" },
889
+ /* The model details dialog: a label column plus the control, like the shipped
890
+ editor's folds, and a level list that lines up under the control column. */
891
+ pageDialogBody: { display: "flex", flexDirection: "column", gap: 8, overflowY: "auto", minHeight: 0 },
892
+ pageDialogRow: { display: "grid", gridTemplateColumns: "116px minmax(0, 1fr)", gap: 8, alignItems: "center", fontSize: 12 },
893
+ pageLevels: { display: "flex", flexDirection: "column", gap: 6, paddingLeft: 124 },
894
+ pageLevelRow: { display: "flex", alignItems: "center", gap: 6 },
362
895
  };
363
896
 
364
897
  /* --------------------------- drag reorder helper -------------------------- */
@@ -546,6 +1079,23 @@ window.__ModuleLoader__.load({
546
1079
  }
547
1080
 
548
1081
  /* ---------------- A. composer model selector, grouped by provider ---------- */
1082
+ /* A drawn switch. The config has no enabled/disabled field, so this is the
1083
+ plugin's own visibility list, and the page says so in the tooltip. */
1084
+ function Switch(props) {
1085
+ const on = props.on === true;
1086
+ return h("button", {
1087
+ type: "button",
1088
+ role: "switch",
1089
+ "aria-checked": on ? "true" : "false",
1090
+ "data-mo": props.marker,
1091
+ "data-mo-id": props.id,
1092
+ disabled: props.disabled === true,
1093
+ title: props.title,
1094
+ onClick: function (event) { event.stopPropagation(); props.onToggle(on !== true); },
1095
+ style: on ? Object.assign({}, S.switchTrack, S.switchTrackOn) : S.switchTrack
1096
+ }, h("span", { style: S.switchKnob }));
1097
+ }
1098
+
549
1099
  function ProviderGroupedModelSelect(props) {
550
1100
  const locked = props.locked;
551
1101
  const available = props.available;
@@ -605,10 +1155,15 @@ window.__ModuleLoader__.load({
605
1155
  /* Providers follow the plugin's own display preference (see below). Computed
606
1156
  per render — not memoised — so a reorder made in Settings shows up here the
607
1157
  next time the menu opens, without a page reload. */
1158
+ const visibleGroups = visibleGroupsFor(groups, current, {
1159
+ providers: readHidden(HIDDEN_PROVIDERS_KEY),
1160
+ models: readHidden(HIDDEN_MODELS_KEY)
1161
+ });
1162
+
608
1163
  const groupsOrdered = (function () {
609
1164
  const ids = [];
610
1165
  const byId = {};
611
- for (let i = 0; i < groups.length; i++) { ids.push(groups[i].id); byId[groups[i].id] = groups[i]; }
1166
+ for (let i = 0; i < visibleGroups.length; i++) { ids.push(visibleGroups[i].id); byId[visibleGroups[i].id] = visibleGroups[i]; }
612
1167
  const ordered = applyProviderOrder(ids, readProviderOrder(), true);
613
1168
  const out = [];
614
1169
  for (let i = 0; i < ordered.length; i++) if (byId[ordered[i]] !== undefined) out.push(byId[ordered[i]]);
@@ -941,7 +1496,8 @@ window.__ModuleLoader__.load({
941
1496
  key: group.id,
942
1497
  role: "group",
943
1498
  "aria-labelledby": headingId,
944
- className: C !== null ? C.group : S.fallbackGroup
1499
+ className: C !== null && C.hasGroupSection === true ? C.group : undefined,
1500
+ style: C !== null && C.hasGroupSection === true ? undefined : S.fallbackGroup
945
1501
  }, [head].concat(items));
946
1502
  });
947
1503
 
@@ -1302,6 +1858,66 @@ window.__ModuleLoader__.load({
1302
1858
  try { window.localStorage.setItem(ORDER_STORAGE_KEY, JSON.stringify(ids)); } catch (e) {}
1303
1859
  }
1304
1860
 
1861
+ /* The page's switches. DSH's pi-ai schema has no enabled/disabled field, so a
1862
+ switch cannot be written into the config without lying about it; these sets are
1863
+ the plugin's own "keep it out of my pickers" list, stored next to the order. */
1864
+ const HIDDEN_PROVIDERS_KEY = "dsh-model-organizer.hiddenProviders";
1865
+ const HIDDEN_MODELS_KEY = "dsh-model-organizer.hiddenModels";
1866
+
1867
+ function readHidden(key) {
1868
+ try {
1869
+ const raw = window.localStorage.getItem(key);
1870
+ if (raw === null) return {};
1871
+ const parsed = JSON.parse(raw);
1872
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return {};
1873
+ const out = {};
1874
+ for (const name in parsed) if (parsed[name] === true) out[name] = true;
1875
+ return out;
1876
+ } catch (e) { return {}; }
1877
+ }
1878
+
1879
+ function writeHidden(key, map) {
1880
+ try {
1881
+ const out = {};
1882
+ for (const name in map) if (map[name] === true) out[name] = true;
1883
+ window.localStorage.setItem(key, JSON.stringify(out));
1884
+ } catch (e) {}
1885
+ }
1886
+
1887
+ function hiddenModelKey(provider, id) { return providerKey(provider) + "/" + String(id); }
1888
+
1889
+ /* Which routes/models the pickers show, given the settings page's switches. Pure on
1890
+ purpose: the composer menu is host-side, and this is the only honest way to pin
1891
+ "the switch actually removes the row from the picker".
1892
+ A hidden entry never disappears from underneath the ACTIVE selection, and a list
1893
+ that would come out empty falls back to the full one (an empty menu is worse than
1894
+ an unfiltered one). */
1895
+ function visibleGroupsFor(groups, current, hidden) {
1896
+ const hiddenProviders = (hidden && hidden.providers) || {};
1897
+ const hiddenModels = (hidden && hidden.models) || {};
1898
+ const currentProvider = current !== null && current !== undefined && typeof current.provider === "string" ? providerKey(current.provider) : null;
1899
+ const currentModel = current !== null && current !== undefined ? (current.id !== undefined ? current.id : current.model) : undefined;
1900
+ const out = [];
1901
+ for (let i = 0; i < groups.length; i++) {
1902
+ const group = groups[i];
1903
+ const owner = providerKey(group.id);
1904
+ const holdsCurrent = currentProvider !== null && owner === currentProvider;
1905
+ if (hiddenProviders[owner] === true && !holdsCurrent) continue;
1906
+ const models = [];
1907
+ for (let k = 0; k < group.models.length; k++) {
1908
+ const model = group.models[k];
1909
+ if (hiddenModels[hiddenModelKey(group.id, model.id)] === true && !(holdsCurrent && model.id === currentModel)) continue;
1910
+ models.push(model);
1911
+ }
1912
+ if (models.length === 0) continue;
1913
+ const copy = {};
1914
+ for (const name in group) copy[name] = group[name];
1915
+ copy.models = models;
1916
+ out.push(copy);
1917
+ }
1918
+ return out.length === 0 ? groups : out;
1919
+ }
1920
+
1305
1921
  const PANEL_POS_KEY = "dsh-model-organizer.panelPos";
1306
1922
 
1307
1923
 
@@ -1330,6 +1946,15 @@ window.__ModuleLoader__.load({
1330
1946
  return cut === -1 ? text : text.slice(cut + 1);
1331
1947
  }
1332
1948
 
1949
+ /* The shipped page pins its two built-in DeepSeek routes first; the rail keeps that
1950
+ order so both pages read the same way. */
1951
+ function officialRank(id) {
1952
+ const text = String(id);
1953
+ if (text === "deepseek-account") return 0;
1954
+ if (text === "deepseek-official") return 1;
1955
+ return 2;
1956
+ }
1957
+
1333
1958
  /* Stored order first, then everything else in document order — or the other way
1334
1959
  round when `unknownFirst` is set. The composer menu wants unknownFirst: an id no
1335
1960
  stored preference mentions is a provider this plugin does not manage (the shipped
@@ -1349,6 +1974,208 @@ window.__ModuleLoader__.load({
1349
1974
  return unknownFirst === true ? rest.concat(known) : known.concat(rest);
1350
1975
  }
1351
1976
 
1977
+ function cellText(node) { return (node.textContent || "").trim(); }
1978
+
1979
+ /* The SHIPPED row's own label, from the RAW entries view (our shadow hides it from
1980
+ entriesOfSlot). Used only as a second anchor when deciding whether the settings
1981
+ panel is open: our own cell must never be the sole witness. */
1982
+ function shippedSectionLabel(slots, id, own) {
1983
+ try {
1984
+ const rows = slots.entries("settings.section");
1985
+ for (let i = 0; i < rows.length; i++) {
1986
+ const options = rows[i] && rows[i].options;
1987
+ if (options === undefined || options === null || options.id !== id) continue;
1988
+ if (typeof options.label !== "function") continue;
1989
+ const text = options.label();
1990
+ if (typeof text !== "string") continue;
1991
+ const trimmed = text.trim();
1992
+ if (trimmed !== "" && trimmed !== own) return trimmed;
1993
+ }
1994
+ } catch (e) {}
1995
+ return null;
1996
+ }
1997
+
1998
+ /* The SHIPPED entry's priority for a cell. Winning it means being the first live entry
1999
+ in priority order — ascending — so `shipped - 1` wins it and `shipped + 1` hands it
2000
+ back. Hard-coding -1 would silently lose the default the day the shipped entry is not
2001
+ registered at 0 (which is exactly why the default has to be relative). */
2002
+ function shippedSectionPriority(slots, id, own) {
2003
+ let best = null;
2004
+ try {
2005
+ const rows = slots.entries("settings.section");
2006
+ for (let i = 0; i < rows.length; i++) {
2007
+ const options = rows[i] && rows[i].options;
2008
+ if (options === undefined || options === null || options.id !== id) continue;
2009
+ if (typeof options.label !== "function") continue;
2010
+ const text = options.label();
2011
+ if (typeof text === "string" && text.trim() === own) continue; /* ours */
2012
+ const value = typeof options.priority === "number" ? options.priority : 0;
2013
+ if (best === null || value < best) best = value;
2014
+ }
2015
+ } catch (e) { return 0; }
2016
+ return best === null ? 0 : best;
2017
+ }
2018
+
2019
+ /* The settings nav: the only sibling-button group holding BOTH labels. Shared by the
2020
+ cross-links and by the takeover, which hides our own spare row. */
2021
+
2022
+ function navGroups() {
2023
+ const groups = new Map();
2024
+ try {
2025
+ if (typeof document === "undefined") return groups;
2026
+ const cells = document.querySelectorAll("button");
2027
+ for (let i = 0; i < cells.length; i++) {
2028
+ const parent = cells[i].parentElement;
2029
+ if (parent === undefined || parent === null) continue;
2030
+ const list = groups.get(parent);
2031
+ if (list === undefined) groups.set(parent, [cells[i]]);
2032
+ else list.push(cells[i]);
2033
+ }
2034
+ } catch (e) {}
2035
+ return groups;
2036
+ }
2037
+
2038
+ /* Cells WE hid, with the value to put back. A list, not a single node: both rows of the
2039
+ shadowed id carry the same React key, so React may reuse a node we hid for the
2040
+ SHIPPED row — the repair is to notice the label changed and restore that node. */
2041
+ let hiddenNavCells = [];
2042
+ /* The watcher runs for the entry's whole lifetime: our spare cell stays hidden in BOTH
2043
+ states, so there is no "stop watching" state any more. */
2044
+
2045
+ function showOwnNavCell() {
2046
+ for (let i = 0; i < hiddenNavCells.length; i++) {
2047
+ const entry = hiddenNavCells[i];
2048
+ try { entry.node.style.display = entry.display === "none" ? "" : entry.display; } catch (e) {}
2049
+ }
2050
+ hiddenNavCells = [];
2051
+ }
2052
+
2053
+ /* One invariant while the shadow is in place: the ONLY hidden nav cell is the one
2054
+ carrying our label. Repairs both directions — a node React reused for the shipped
2055
+ row is put back, and a rebuilt cell of ours is hidden again. */
2056
+ /* The settings nav: the list holding our own label — or, when React reused that node
2057
+ for the shipped row (both rows carry the same key), the list still holding a node we
2058
+ hid. Without that second clue the reused node would stay hidden forever, which is
2059
+ exactly the "the shipped row disappeared" bug seen in the wild. */
2060
+ function navGroupFor(label) {
2061
+ let byLabel = null;
2062
+ let byHidden = null;
2063
+ navGroups().forEach(function (nodes) {
2064
+ if (nodes.length < 4) return;
2065
+ for (let i = 0; i < nodes.length; i++) {
2066
+ if (byLabel === null && cellText(nodes[i]) === label) byLabel = nodes;
2067
+ if (byHidden === null) {
2068
+ for (let k = 0; k < hiddenNavCells.length; k++) {
2069
+ if (hiddenNavCells[k].node === nodes[i]) { byHidden = nodes; break; }
2070
+ }
2071
+ }
2072
+ }
2073
+ });
2074
+ return byLabel === null ? byHidden : byLabel;
2075
+ }
2076
+
2077
+ function hideOwnNavCell(label) {
2078
+ const group = navGroupFor(label);
2079
+ if (group === null) return false;
2080
+ const mine = [];
2081
+ for (let i = 0; i < group.length; i++) if (cellText(group[i]) === label) mine.push(group[i]);
2082
+ for (let i = hiddenNavCells.length - 1; i >= 0; i--) {
2083
+ const entry = hiddenNavCells[i];
2084
+ let stillOurs = false;
2085
+ for (let k = 0; k < mine.length; k++) if (mine[k] === entry.node) stillOurs = true;
2086
+ if (stillOurs) continue;
2087
+ try { entry.node.style.display = entry.display === "none" ? "" : entry.display; } catch (e) {}
2088
+ hiddenNavCells.splice(i, 1);
2089
+ }
2090
+ for (let i = 0; i < mine.length; i++) {
2091
+ if (mine[i].style.display === "none") continue;
2092
+ hiddenNavCells.push({ node: mine[i], display: mine[i].style.display });
2093
+ mine[i].style.display = "none";
2094
+ }
2095
+ return true;
2096
+ }
2097
+
2098
+ let navObserver = null;
2099
+
2100
+ /* The invariant, checked against the DOM rather than against what we did earlier:
2101
+ every cell carrying our label is hidden, and no other cell is. A node React reused
2102
+ for the shipped row breaks it, and so does a freshly rendered spare cell. */
2103
+ function ownNavCellHidden(label) {
2104
+ const group = navGroupFor(label);
2105
+ if (group === null) return false;
2106
+ let mine = 0;
2107
+ for (let i = 0; i < group.length; i++) {
2108
+ if (cellText(group[i]) === label) {
2109
+ if (group[i].style.display !== "none") return false;
2110
+ mine++;
2111
+ } else if (group[i].style.display === "none") {
2112
+ return false;
2113
+ }
2114
+ }
2115
+ return mine > 0;
2116
+ }
2117
+
2118
+ /* The nav does not exist while the settings panel is closed, and React may rebuild the
2119
+ cells (both rows share the id, hence the same key), so one-shot hiding is not
2120
+ enough: re-apply on every relevant DOM change until the switch goes off. */
2121
+ function ensureOwnNavCellHidden(label) {
2122
+ if (ownNavCellHidden(label)) return true;
2123
+ return hideOwnNavCell(label);
2124
+ }
2125
+
2126
+ /* Is the settings panel open? The nav only exists while it is, and it is recognised by
2127
+ containing a known section label — ours OR the shipped one, never ours alone (a
2128
+ re-render can drop our cell for a frame while React rebuilds it). */
2129
+ function settingsPanelOpen(slots, own) {
2130
+ const shipped = shippedSectionLabel(slots, "models", own);
2131
+ let open = false;
2132
+ navGroups().forEach(function (nodes) {
2133
+ if (open || nodes.length < 4) return;
2134
+ for (let i = 0; i < nodes.length; i++) {
2135
+ const text = cellText(nodes[i]);
2136
+ if (text === own || (shipped !== null && text === shipped)) { open = true; return; }
2137
+ }
2138
+ });
2139
+ return open;
2140
+ }
2141
+
2142
+ let navWasOpen = false;
2143
+
2144
+ /* Two jobs on every relevant DOM change:
2145
+ 1. keep our spare cell hidden (the invariant);
2146
+ 2. notice the panel CLOSING, and hand the row back to this plugin then — the
2147
+ hand-back is a decision for one visit, so re-opening settings shows this page
2148
+ again without any persistence. */
2149
+ function onNavChanged(label, slots, onClosed) {
2150
+ const open = settingsPanelOpen(slots, label);
2151
+ if (open) ensureOwnNavCellHidden(label);
2152
+ else if (navWasOpen === true && typeof onClosed === "function") onClosed();
2153
+ navWasOpen = open;
2154
+ }
2155
+
2156
+ function watchOwnNavCell(label, slots, onClosed) {
2157
+ if (navObserver !== null) return;
2158
+ try {
2159
+ if (typeof MutationObserver !== "function" || typeof document === "undefined" || document.body === null) return;
2160
+ navObserver = new MutationObserver(function () { onNavChanged(label, slots, onClosed); });
2161
+ navObserver.observe(document.body, { childList: true, subtree: true });
2162
+ } catch (e) { navObserver = null; }
2163
+ }
2164
+
2165
+ function unwatchOwnNavCell() {
2166
+ if (navObserver === null) return;
2167
+ try { navObserver.disconnect(); } catch (e) {}
2168
+ navObserver = null;
2169
+ }
2170
+
2171
+ /* The ledger bump that follows a registration re-renders the nav, hence a frame. */
2172
+ function nextFrame(fn) {
2173
+ try {
2174
+ if (typeof requestAnimationFrame === "function") { requestAnimationFrame(fn); return; }
2175
+ } catch (e) {}
2176
+ try { setTimeout(fn, 0); } catch (e) {}
2177
+ }
2178
+
1352
2179
  /* A seat entry that throws is silently abdicated, so "my panel is missing" and
1353
2180
  "my panel threw" look identical from the page. This boundary turns the throw
1354
2181
  into one visible line, so the failure can be reported instead of guessed. */
@@ -1519,9 +2346,9 @@ window.__ModuleLoader__.load({
1519
2346
 
1520
2347
  const ids = order === null ? baseIds : order;
1521
2348
 
1522
- /* The shipped provider cards are left exactly as React renders them. Ordering used
1523
- to be mirrored onto them with inline `order`; that is gone — this plugin adds a
1524
- floating panel to the Models page and nothing else. */
2349
+ /* Committing an order here writes the plugin's own preference list. The shipped
2350
+ cards are mirrored separately (mirrorCards above) by writing an inline `order`
2351
+ onto them, which is what makes this panel's order visible on that page too. */
1525
2352
  function commit(next) {
1526
2353
  if (next.join("|") === baseIds.join("|")) { setOrder(null); return; }
1527
2354
  writeProviderOrder(next);
@@ -1531,7 +2358,11 @@ window.__ModuleLoader__.load({
1531
2358
  setTimeout(function () { setNote(null); }, 2200);
1532
2359
  }
1533
2360
 
1534
- if (baseIds.length < 2) return null;
2361
+ /* The header is the collapse control AND the way back to this plugin's page, so it
2362
+ must render even when there is nothing to sort. Returning null for 0–1 configured
2363
+ providers used to take that way back with it, and the shipped page then had no
2364
+ entry point to the organizer page at all. */
2365
+ const sortable = baseIds.length >= 2;
1535
2366
 
1536
2367
  const notes = [];
1537
2368
  if (note === "ok") notes.push(h("span", { key: "ok", style: S.ok }, t("providers.saved")));
@@ -1540,7 +2371,25 @@ window.__ModuleLoader__.load({
1540
2371
  /* Same shipped-cell header as every other row in this page: the title, the
1541
2372
  hint, then any status note on the right edge. */
1542
2373
  const headChildren = [
1543
- h("span", { key: "l", className: C !== null ? C.cellLabel : undefined, style: C === null ? S.boxTitle : undefined }, t("providers.title"))
2374
+ h("span", { key: "l", className: C !== null ? C.cellLabel : undefined, style: C === null ? S.boxTitle : undefined }, t("providers.title")),
2375
+ /* Sits right of the title, inside the head row. The head is both the collapse
2376
+ control and the drag surface, so every pointer event stops here. */
2377
+ h("button", {
2378
+ key: "link",
2379
+ "data-mo": "panel-open-organizer",
2380
+ type: "button",
2381
+ title: t("panel.openOrganizerHint"),
2382
+ onPointerDown: function (event) { event.stopPropagation(); },
2383
+ onPointerUp: function (event) { event.stopPropagation(); },
2384
+ /* One button, one meaning: going to this plugin's page IS taking the shipped
2385
+ row over — no separate switch, no second state to explain. */
2386
+ onClick: function (event) {
2387
+ event.stopPropagation();
2388
+ const shared = props.bridge;
2389
+ if (shared !== undefined && shared !== null && typeof shared.setTakeover === "function") shared.setTakeover(true);
2390
+ },
2391
+ style: S.linkPill
2392
+ }, t("panel.openOrganizer")),
1544
2393
  ];
1545
2394
  /* Status notes sit immediately after the title; the hint keeps the right edge. */
1546
2395
  for (let i = 0; i < notes.length; i++) headChildren.push(notes[i]);
@@ -1705,13 +2554,1721 @@ window.__ModuleLoader__.load({
1705
2554
  ReactDOM.createPortal(
1706
2555
  h("div", { ref: panelRef, style: panelStyle, "data-mo": "panel" },
1707
2556
  head,
1708
- panelOpen ? h("div", { style: S.floatBody }, list) : null
2557
+ panelOpen && sortable ? h("div", { style: S.floatBody }, list) : null,
2558
+ panelOpen && !sortable ? h("div", { style: S.boxHint }, t("providers.onlyOne")) : null
1709
2559
  ),
1710
2560
  document.body
1711
2561
  )
1712
2562
  );
1713
2563
  }
1714
2564
 
2565
+ /* --------------------------- C: the organizer page --------------------------- */
2566
+ /* A real settings page (`settings.section`, kind list) placed right after the
2567
+ shipped Models page. It is the plugin's own surface: providers and their
2568
+ models are edited here, dragged into the saved order, and their API key is
2569
+ written through the credentials service. The shipped page is NOT shadowed —
2570
+ both entries stay reachable from the nav, so this stays additive. The key
2571
+ itself never comes back: the credentials service only moves values inward. */
2572
+
2573
+ /* Capacity spelling for the model rows: 1000000 → "1M", 262144 → "256K". */
2574
+ function formatCapacity(value) {
2575
+ if (typeof value !== "number" || !isFinite(value) || value <= 0) return "";
2576
+ if (value >= 1000000) return Math.round(value / 100000) / 10 + "M";
2577
+ if (value >= 1000) return Math.round(value / 1000) + "K";
2578
+ return String(value);
2579
+ }
2580
+
2581
+ /* The shipped editor accepts "256K"/"1M" (K = 1000, M = 1000000) — the same
2582
+ vocabulary its own capacity fields document. Anything else is an error. */
2583
+ function parseCapacity(text) {
2584
+ if (text === undefined || text === null) return undefined;
2585
+ const bare = String(text).trim();
2586
+ if (bare === "") return undefined;
2587
+ const match = /^([0-9]+(?:\.[0-9]+)?)\s*([KkMm])?$/.exec(bare);
2588
+ if (match === null) return NaN;
2589
+ const scale = match[2] === undefined ? 1 : (match[2].toLowerCase() === "k" ? 1000 : 1000000);
2590
+ const value = Math.round(Number(match[1]) * scale);
2591
+ return Number.isSafeInteger(value) && value > 0 ? value : NaN;
2592
+ }
2593
+
2594
+ /* One profile field the page may read but never guesses. */
2595
+ function profileString(profile, key) {
2596
+ const value = profile === null || profile === undefined ? undefined : profile[key];
2597
+ return typeof value === "string" ? value : "";
2598
+ }
2599
+
2600
+ /* The shipped editor spells one label per protocol; an unknown value keeps its
2601
+ raw wire spelling instead of inventing a name for it. */
2602
+ const API_LABEL_KEYS = {
2603
+ "openai-completions": "protocol.completions",
2604
+ "openai-responses": "protocol.responses",
2605
+ "anthropic-messages": "protocol.messages"
2606
+ };
2607
+
2608
+ /* The protocol ids the adapter accepts, in its own preference order. */
2609
+ const PROTOCOL_IDS = ["openai-completions", "openai-responses", "anthropic-messages"];
2610
+
2611
+ function apiLabel(api, t) {
2612
+ if (typeof api !== "string" || api.length === 0) return "";
2613
+ const key = API_LABEL_KEYS[api];
2614
+ return key === undefined ? api : t(key);
2615
+ }
2616
+
2617
+ /* The segment shows the adapter name only; the endpoint stays visible in the hint
2618
+ under the row, so three segments fit the settings column. */
2619
+ function apiShort(api, t) {
2620
+ const label = apiLabel(api, t);
2621
+ const at = label.indexOf(" (");
2622
+ return at === -1 ? label : label.slice(0, at);
2623
+ }
2624
+
2625
+ /* A route the page may write a key for: the shipped derivation, so a key this
2626
+ page stores is the key the shipped page would read back. */
2627
+ function deriveKeyRef(provider) {
2628
+ return String(provider).toUpperCase().replace(/[^A-Z0-9]+/g, "_") + "_API_KEY";
2629
+ }
2630
+
2631
+ /* The dot is a reading aid, never a claim: a profile that names a credential or a
2632
+ base URL is configured enough to be worth trying. */
2633
+ function profileConfigured(profile) {
2634
+ return !!(profile && (profile.apiKeyEnv || profile.baseURL));
2635
+ }
2636
+
2637
+ /* Draft rows keep what the user typed (text), and only the write parses. The
2638
+ reasoning mode is three-valued because the schema is: absent inherits the catalog,
2639
+ `false` declares a non-reasoning model, an object declares the levels. */
2640
+ /* Split a model-id list typed by a human ("glm-4.6, qwen-max" or one per line) into
2641
+ ids, dropping empties and duplicates. Used by the create dialog, because a declared
2642
+ route with no models cannot resolve at all (the host refuses it). */
2643
+ function parseModelIds(text) {
2644
+ const out = [];
2645
+ const seen = {};
2646
+ const parts = String(text === undefined || text === null ? "" : text).split(/[\s,,;;]+/);
2647
+ for (let i = 0; i < parts.length; i++) {
2648
+ const id = parts[i].trim();
2649
+ if (id === "" || seen[id] === true) continue;
2650
+ seen[id] = true;
2651
+ out.push(id);
2652
+ }
2653
+ return out;
2654
+ }
2655
+
2656
+ /* A blank row for the "add model" dialog: the same shape modelDraftRows projects. */
2657
+ function emptyModelRow() {
2658
+ return { id: "", original: "", name: "", context: "", maxTokens: "", vision: false, reasoning: { mode: "inherit", levels: [] } };
2659
+ }
2660
+
2661
+ function modelDraftRows(models) {
2662
+ return (models || []).map(function (model) {
2663
+ const efforts = model.reasoningEfforts;
2664
+ let reasoning = { mode: "inherit", levels: [] };
2665
+ if (efforts === false) reasoning = { mode: "none", levels: [] };
2666
+ else if (efforts !== null && efforts !== undefined && typeof efforts === "object") {
2667
+ const levels = [];
2668
+ for (const level in efforts) {
2669
+ const value = efforts[level];
2670
+ levels.push({ level: level, value: value === null || value === undefined ? "" : String(value) });
2671
+ }
2672
+ reasoning = { mode: "custom", levels: levels };
2673
+ }
2674
+ return {
2675
+ id: typeof model.id === "string" ? model.id : "",
2676
+ /* The id as it is stored now: renaming a model is the one edit that has to
2677
+ chase a key held elsewhere (the plugin's own hidden list). */
2678
+ original: typeof model.id === "string" ? model.id : "",
2679
+ name: typeof model.name === "string" ? model.name : "",
2680
+ context: model.contextWindow === undefined ? "" : formatCapacity(model.contextWindow),
2681
+ maxTokens: model.maxTokens === undefined ? "" : formatCapacity(model.maxTokens),
2682
+ vision: Array.isArray(model.input) && model.input.indexOf("image") !== -1,
2683
+ reasoning: reasoning
2684
+ };
2685
+ });
2686
+ }
2687
+
2688
+ /* Draft rows back to wire models, or an error message when a row is not writable
2689
+ (an empty or repeated id, a bad capacity, a broken level list). */
2690
+ function modelsFromDraft(rows, t) {
2691
+ const seen = {};
2692
+ const out = [];
2693
+ for (let i = 0; i < rows.length; i++) {
2694
+ const row = rows[i];
2695
+ const id = String(row.id || "").trim();
2696
+ if (id === "") return { error: t("page.modelIdRequired") };
2697
+ if (seen[id] === true) return { error: t("page.modelIdDuplicate") };
2698
+ seen[id] = true;
2699
+ const contextWindow = parseCapacity(row.context);
2700
+ const maxTokens = parseCapacity(row.maxTokens);
2701
+ if (Number.isNaN(contextWindow) || Number.isNaN(maxTokens)) return { error: t("page.capacityInvalid") };
2702
+ /* The id is the display name a model falls back to; `name` is written only when
2703
+ the user typed one, so an empty box keeps the document as it was. */
2704
+ const entry = { id: id, input: row.vision === true ? ["text", "image"] : ["text"] };
2705
+ const name = String(row.name || "").trim();
2706
+ if (name !== "") entry.name = name;
2707
+ if (contextWindow !== undefined) entry.contextWindow = contextWindow;
2708
+ if (maxTokens !== undefined) entry.maxTokens = maxTokens;
2709
+ const reasoning = row.reasoning || { mode: "inherit", levels: [] };
2710
+ if (reasoning.mode === "none") entry.reasoningEfforts = false;
2711
+ else if (reasoning.mode === "custom") {
2712
+ const levels = reasoning.levels || [];
2713
+ const write = {};
2714
+ let named = 0;
2715
+ for (let k = 0; k < levels.length; k++) {
2716
+ const level = String(levels[k].level || "").trim();
2717
+ if (level === "") continue;
2718
+ if (write[level] !== undefined) return { error: t("page.reasoningDuplicate") };
2719
+ const value = String(levels[k].value || "").trim();
2720
+ write[level] = value === "" ? null : value;
2721
+ named++;
2722
+ }
2723
+ if (named === 0) return { error: t("page.reasoningRequired") };
2724
+ entry.reasoningEfforts = write;
2725
+ }
2726
+ out.push(entry);
2727
+ }
2728
+ return { models: out };
2729
+ }
2730
+
2731
+ /* ---------------------------- modal focus shell ---------------------------- */
2732
+ /* Every dialog on this page is portalled to <body>, so its nodes sit AFTER the
2733
+ whole settings page in the document's tab order. Two things went wrong with the
2734
+ first attempt (a card-level keydown handler that only intervened at the edges):
2735
+ the host shell could still pull focus out, and any step that did not start or end
2736
+ exactly on a tracked element let the browser continue through the page behind the
2737
+ backdrop. This shell therefore OWNS Tab while it is open: it listens in the capture
2738
+ phase on the document, always takes the event over and moves focus itself, and a
2739
+ focusin guard drags focus back if anything (host shell, programmatic blur) moves it
2740
+ outside. Escape closes here too, so the page behind never sees it. */
2741
+ const FOCUSABLE_SELECTOR = [
2742
+ "a[href]",
2743
+ "button:not([disabled])",
2744
+ "input:not([disabled]):not([type='hidden'])",
2745
+ "select:not([disabled])",
2746
+ "textarea:not([disabled])",
2747
+ "[tabindex]:not([tabindex='-1'])"
2748
+ ].join(",");
2749
+
2750
+ function focusableIn(root) {
2751
+ const out = [];
2752
+ if (root === null || root === undefined || typeof root.querySelectorAll !== "function") return out;
2753
+ let nodes = null;
2754
+ try { nodes = root.querySelectorAll(FOCUSABLE_SELECTOR); } catch (e) { return out; }
2755
+ if (nodes === null) return out;
2756
+ for (let i = 0; i < nodes.length; i++) {
2757
+ const node = nodes[i];
2758
+ /* A hidden control cannot take focus: without this check the ring stops on an
2759
+ element the browser skips, and the next Tab starts from a stale position. */
2760
+ try {
2761
+ if (typeof node.getClientRects === "function" && node.getClientRects().length === 0) continue;
2762
+ } catch (e) {}
2763
+ out.push(node);
2764
+ }
2765
+ return out;
2766
+ }
2767
+
2768
+ /* One manual Tab step inside the dialog, wrapping at both ends. Always returns true:
2769
+ the caller must preventDefault, because the browser's own walk is exactly what
2770
+ escapes to the settings page. */
2771
+ function trapTab(event, card) {
2772
+ const list = focusableIn(card);
2773
+ if (list.length === 0) return true;
2774
+ let index = -1;
2775
+ try { index = list.indexOf(document.activeElement); } catch (e) {}
2776
+ const target = index === -1
2777
+ ? (event.shiftKey === true ? list[list.length - 1] : list[0])
2778
+ : list[(index + (event.shiftKey === true ? -1 : 1) + list.length) % list.length];
2779
+ try { if (target !== undefined && typeof target.focus === "function") target.focus(); } catch (e) {}
2780
+ return true;
2781
+ }
2782
+
2783
+ function DialogShell(props) {
2784
+ const cardRef = useRef(null);
2785
+ const restoreRef = useRef(null);
2786
+ const closeRef = useRef(props.onClose);
2787
+ closeRef.current = props.onClose;
2788
+
2789
+ useEffect(function () {
2790
+ let previous = null;
2791
+ try { previous = document.activeElement; } catch (e) {}
2792
+ restoreRef.current = previous === undefined || previous === null || previous === document.body ? null : previous;
2793
+ const card = cardRef.current;
2794
+ if (card === null || card === undefined) return undefined;
2795
+ const firstControl = function () {
2796
+ let target = null;
2797
+ try { target = typeof card.querySelector === "function" ? card.querySelector("[data-mo-focus]") : null; } catch (e) {}
2798
+ if (target === null || target === undefined) target = focusableIn(card)[0];
2799
+ return target === undefined ? null : target;
2800
+ };
2801
+ const focusFirst = function () {
2802
+ const target = firstControl();
2803
+ try { if (target !== null && typeof target.focus === "function") target.focus(); } catch (e) {}
2804
+ return target;
2805
+ };
2806
+ focusFirst();
2807
+ /* A portal that was just inserted can lose the synchronous focus() to the host
2808
+ shell's own focus pass; re-assert on the next frame when focus did not stick. */
2809
+ let retry = null;
2810
+ try {
2811
+ if (typeof requestAnimationFrame === "function") {
2812
+ retry = requestAnimationFrame(function () {
2813
+ retry = null;
2814
+ let inside = false;
2815
+ try { inside = typeof card.contains === "function" && card.contains(document.activeElement); } catch (e) { inside = false; }
2816
+ if (!inside) focusFirst();
2817
+ });
2818
+ }
2819
+ } catch (e) { retry = null; }
2820
+
2821
+ const onKeyDown = function (event) {
2822
+ if (event.key === "Escape") {
2823
+ event.preventDefault();
2824
+ if (event.stopPropagation) event.stopPropagation();
2825
+ if (typeof closeRef.current === "function") closeRef.current();
2826
+ return;
2827
+ }
2828
+ if (event.key !== "Tab") return;
2829
+ if (event.preventDefault) event.preventDefault();
2830
+ if (event.stopPropagation) event.stopPropagation();
2831
+ trapTab(event, card);
2832
+ };
2833
+ /* The host shell reacts to focus changes; anything that lands outside the card
2834
+ (a stray click, a programmatic blur, another plugin's focus call) is pulled
2835
+ back to the dialog's first control. */
2836
+ const onFocusIn = function (event) {
2837
+ let inside = false;
2838
+ try { inside = typeof card.contains === "function" && card.contains(event.target); } catch (e) { inside = false; }
2839
+ if (inside) return;
2840
+ const list = focusableIn(card);
2841
+ const back = list.length > 0 ? list[0] : card;
2842
+ try { if (back !== undefined && typeof back.focus === "function") back.focus(); } catch (e) {}
2843
+ };
2844
+
2845
+ let wired = false;
2846
+ try {
2847
+ document.addEventListener("keydown", onKeyDown, true);
2848
+ document.addEventListener("focusin", onFocusIn, true);
2849
+ wired = true;
2850
+ } catch (e) { wired = false; }
2851
+
2852
+ return function () {
2853
+ if (retry !== null) { try { if (typeof cancelAnimationFrame === "function") cancelAnimationFrame(retry); } catch (e) {} retry = null; }
2854
+ if (wired) {
2855
+ try { document.removeEventListener("keydown", onKeyDown, true); } catch (e) {}
2856
+ try { document.removeEventListener("focusin", onFocusIn, true); } catch (e) {}
2857
+ }
2858
+ const restore = restoreRef.current;
2859
+ restoreRef.current = null;
2860
+ try {
2861
+ if (restore !== null && restore !== undefined && restore.isConnected !== false && typeof restore.focus === "function") restore.focus();
2862
+ } catch (e) {}
2863
+ };
2864
+ }, []);
2865
+
2866
+ /* Fallback path: environments where the document listeners above never attached
2867
+ (the headless test harness) still get the same manual step from the card. */
2868
+ function onKeyDown(event) {
2869
+ if (event.key === "Escape") {
2870
+ event.preventDefault();
2871
+ if (event.stopPropagation) event.stopPropagation();
2872
+ if (typeof props.onClose === "function") props.onClose();
2873
+ return;
2874
+ }
2875
+ if (event.key !== "Tab") return;
2876
+ if (event.preventDefault) event.preventDefault();
2877
+ if (event.stopPropagation) event.stopPropagation();
2878
+ trapTab(event, event.currentTarget);
2879
+ }
2880
+
2881
+ return h("div", {
2882
+ ref: cardRef,
2883
+ role: "dialog",
2884
+ "aria-modal": "true",
2885
+ "aria-label": props.label,
2886
+ "data-mo": props.marker,
2887
+ tabIndex: -1,
2888
+ onKeyDown: onKeyDown,
2889
+ style: S.pickCard
2890
+ }, props.children);
2891
+ }
2892
+
2893
+ function ModelsPage(props) {
2894
+ const C = resolveOfficialClasses();
2895
+ ensureOwnStyles(C);
2896
+ const store = props.store;
2897
+ const namespace = props.namespace;
2898
+ const mutate = props.mutate;
2899
+ const bridge = props.bridge || {};
2900
+ const t = props.t;
2901
+
2902
+ /* The third argument keeps this component renderable outside a live DOM (the
2903
+ plugin's smoke harness uses it); the browser never reads it. */
2904
+ const snap = useSyncExternalStore(
2905
+ function (fn) { return store.subscribe(fn); },
2906
+ function () { return store.getSnapshot(); },
2907
+ function () { return store.getSnapshot(); }
2908
+ );
2909
+ const view = snap ? snap.view : undefined;
2910
+ const writable = !!(view && view.writable);
2911
+ const nsView = namespaceView(view, namespace);
2912
+ const revision = nsView ? nsView.revision : undefined;
2913
+ const providers = orderLayer(nsView) || {};
2914
+ const providerIds = applyProviderOrder(Object.keys(providers), readProviderOrder());
2915
+ const sourceKey = providerIds.join("|");
2916
+
2917
+ const pickState = useState(null);
2918
+ const picked = pickState[0];
2919
+ const setPicked = pickState[1];
2920
+ const orderState = useState(null);
2921
+ const order = orderState[0];
2922
+ const setOrder = orderState[1];
2923
+ const fieldState = useState(null);
2924
+ const field = fieldState[0];
2925
+ const setField = fieldState[1];
2926
+ const keyState = useState("");
2927
+ const keyDraft = keyState[0];
2928
+ const setKeyDraft = keyState[1];
2929
+ const keyShownState = useState(false);
2930
+ const keyShown = keyShownState[0];
2931
+ const setKeyShown = keyShownState[1];
2932
+ const keyFactsState = useState(null);
2933
+ const keyFacts = keyFactsState[0];
2934
+ const setKeyFacts = keyFactsState[1];
2935
+ /* Deleting a route is destructive (its profile plus the credential this page
2936
+ derived), so it goes through a confirm dialog; deleting a model is one click and
2937
+ re-addable. Connection tests are per model id and never persisted. */
2938
+ const deleteState = useState(null);
2939
+ const deleteTarget = deleteState[0];
2940
+ const setDeleteTarget = deleteState[1];
2941
+ const testState = useState({});
2942
+ const modelTests = testState[0];
2943
+ const setModelTests = testState[1];
2944
+
2945
+ /* The model details dialog: null, or one working copy of a model row plus where it
2946
+ came from. There is no page-level draft any more — Save writes straight through. */
2947
+ const modelState = useState(null);
2948
+ const modelDialog = modelState[0];
2949
+ const setModel = modelState[1];
2950
+ const busyState = useState("");
2951
+ const busy = busyState[0];
2952
+ const setBusy = busyState[1];
2953
+ /* The fetch picker's own state — the provider selection above already owns
2954
+ `pickState`, so this one keeps a distinct name. */
2955
+ const fetchPickState = useState(null);
2956
+ const picker = fetchPickState[0];
2957
+ const setPicker = fetchPickState[1];
2958
+ /* The provider directory (every route any adapter declares, built-ins included).
2959
+ The settings document alone only carries the llm-pi-ai routes, which is exactly
2960
+ how the built-in DeepSeek rows used to go missing from this page. */
2961
+ const directoryState = useState(null);
2962
+ const directory = directoryState[0];
2963
+ const setDirectory = directoryState[1];
2964
+ /* The switch lives in the mount layer: flipping it re-registers this section under
2965
+ a different id, so the component must not hold that state itself. */
2966
+ const takeover = props.takeover === true;
2967
+ const setTakeover = props.setTakeover;
2968
+
2969
+ const addState = useState(null);
2970
+ const addDialog = addState[0];
2971
+ const setAdd = addState[1];
2972
+ const hiddenState = useState(function () {
2973
+ return { providers: readHidden(HIDDEN_PROVIDERS_KEY), models: readHidden(HIDDEN_MODELS_KEY) };
2974
+ });
2975
+ const hidden = hiddenState[0];
2976
+ const setHidden = hiddenState[1];
2977
+
2978
+ function toggleHidden(kind, key) {
2979
+ const next = Object.assign({}, kind === "provider" ? hidden.providers : hidden.models);
2980
+ if (next[key] === true) delete next[key]; else next[key] = true;
2981
+ writeHidden(kind === "provider" ? HIDDEN_PROVIDERS_KEY : HIDDEN_MODELS_KEY, next);
2982
+ setHidden(kind === "provider" ? { providers: next, models: hidden.models } : { providers: hidden.providers, models: next });
2983
+ }
2984
+ const notePair = useNote();
2985
+ const note = notePair[0];
2986
+ const setNote = notePair[1];
2987
+
2988
+ /* A provider removed in the shipped page must not leave this page pointing at a
2989
+ route the configuration no longer holds; the same reset drops every draft. */
2990
+ useEffect(function () {
2991
+ setPicked(null); setOrder(null); setField(null); setModel(null); setKeyDraft(""); setKeyFacts(null);
2992
+ }, [sourceKey]);
2993
+
2994
+ const ids = order === null ? providerIds : order;
2995
+ const current = picked !== null && providers[picked] !== undefined ? picked : (ids.length > 0 ? ids[0] : null);
2996
+ const profile = current === null ? null : providers[current] || {};
2997
+ const storedModels = profile === null ? [] : profile.models || [];
2998
+ /* A pure projection of the stored models: with no inline editor left, nothing else
2999
+ keeps a half-typed copy of a row. */
3000
+ const draftRows = modelDraftRows(storedModels);
3001
+ const currentKeyRef = current === null ? "" : (profileString(profile, "apiKeyEnv") || deriveKeyRef(current));
3002
+
3003
+ /* Built-in (namespace-level) routes: read-only here. Their settings live at the
3004
+ namespace root and their editors are the shipped ones, so the rail lists them to
3005
+ tell the truth about what exists, and a click hands the Models row back to the
3006
+ shipped page instead of pretending this page could edit them. */
3007
+ const officialRows = [];
3008
+ if (directory !== null) {
3009
+ for (let i = 0; i < directory.length; i++) {
3010
+ const entry = directory[i];
3011
+ if (entry === null || typeof entry !== "object") continue;
3012
+ if (typeof entry.provider !== "string") continue;
3013
+ if (Array.isArray(entry.settingsPath) && entry.settingsPath.length > 0) continue;
3014
+ if (providers[entry.provider] !== undefined) continue;
3015
+ officialRows.push(entry);
3016
+ }
3017
+ officialRows.sort(function (a, b) { return officialRank(a.provider) - officialRank(b.provider); });
3018
+ }
3019
+
3020
+ /* One credential read per selection: the service only answers whether a value
3021
+ exists and whether it may be written, never the value. */
3022
+ useEffect(function () {
3023
+ const credentials = bridge.credentials;
3024
+ if (credentials === undefined || credentials === null || current === null) { setKeyFacts(null); return; }
3025
+ let live = true;
3026
+ try {
3027
+ Promise.resolve(credentials.describe([currentKeyRef])).then(function (response) {
3028
+ if (!live) return;
3029
+ const described = response && response.ok ? response.value[currentKeyRef] : undefined;
3030
+ setKeyFacts(described && typeof described === "object" ? described : { configured: false });
3031
+ }, function () { if (live) setKeyFacts(null); });
3032
+ } catch (e) { setKeyFacts(null); }
3033
+ return function () { live = false; };
3034
+ }, [current, currentKeyRef, bridge.credentials]);
3035
+
3036
+ /* Which rows the shipped catalog owns. The directory answers it; a build without
3037
+ that namespace simply keeps one ungrouped list. */
3038
+ useEffect(function () {
3039
+ const llm = bridge.llm;
3040
+ if (llm === undefined || llm === null) return undefined;
3041
+ let live = true;
3042
+ try {
3043
+ Promise.resolve(llm.listConfigurableProviders()).then(function (response) {
3044
+ if (!live) return;
3045
+ const rows = response && response.ok && Array.isArray(response.value) ? response.value : [];
3046
+ setDirectory(rows);
3047
+ }, function () {});
3048
+ } catch (e) {}
3049
+ return function () { live = false; };
3050
+ }, [bridge.llm]);
3051
+
3052
+ if (view === undefined || view === null) {
3053
+ return h("section", { "data-mo": "page", "data-mo-build": BUILD, style: S.pageRoot },
3054
+ h("p", { style: S.pageLoading }, t("page.loading")));
3055
+ }
3056
+
3057
+ /* Nothing configured is no longer a dead end: the rail still renders (built-in
3058
+ rows and the add button), and the detail pane carries the explanation. */
3059
+
3060
+ /* ------------------------------- writes ------------------------------- */
3061
+ function writeProvider(patch, done) {
3062
+ if (!writable) { setNote(t("page.readonly")); return; }
3063
+ const ops = [];
3064
+ for (const key in patch) {
3065
+ const value = patch[key];
3066
+ ops.push(value === undefined || value === ""
3067
+ ? { op: "unset", path: ["providers", current, key] }
3068
+ : { op: "set", path: ["providers", current, key], value: value });
3069
+ }
3070
+ let pending;
3071
+ try { pending = mutate(ops, revision); } catch (e) { setNote(String((e && e.message) || e)); return; }
3072
+ setBusy("field");
3073
+ Promise.resolve(pending).then(function (result) {
3074
+ setBusy("");
3075
+ if (result && result.ok) { setNote("ok"); if (done !== undefined) done(); }
3076
+ else setNote((result && result.error && result.error.message) || "save failed");
3077
+ }, function (error) { setBusy(""); setNote(String((error && error.message) || error)); });
3078
+ }
3079
+
3080
+ function saveField(key) {
3081
+ const next = field === null ? null : field[key];
3082
+ const before = profileString(profile, key);
3083
+ if (next === null || next === undefined || next === before) { setField(null); return; }
3084
+ writeProvider({ [key]: next }, function () { setField(null); });
3085
+ }
3086
+
3087
+ function stopRowEvent(event) {
3088
+ if (event === null || event === undefined) return;
3089
+ if (typeof event.stopPropagation === "function") event.stopPropagation();
3090
+ }
3091
+
3092
+ function testMark(row) {
3093
+ return modelTests[current + "/" + String(row.id)];
3094
+ }
3095
+
3096
+ function testStateOf(row) {
3097
+ const mark = testMark(row);
3098
+ return mark === undefined ? "idle" : mark.state;
3099
+ }
3100
+
3101
+ /* One icon per verdict, all from the shipped kit and all the same size, so the button
3102
+ never changes shape when the answer arrives. */
3103
+ const TEST_ICONS = {
3104
+ idle: "IconLinkOutlineRegular",
3105
+ busy: "IconLoadingOutlineRegular",
3106
+ ok: "IconCheckOutlineRegular",
3107
+ miss: "IconWarningTriangleOutlineRegular",
3108
+ fail: "IconCloseOutlineRegular"
3109
+ };
3110
+
3111
+ /* A one-click connection check. The host exposes no "send a request" seam, so this
3112
+ asks the adapter the question it can answer: what models does this route serve?
3113
+ For a hand-declared gateway that is a real round-trip with the stored credential
3114
+ (GET {baseURL}/models); for a catalog route the adapter answers from its registry.
3115
+ The row's id is then checked against that answer. */
3116
+ /* A local gateway answers in tens of milliseconds, and React coalesces the busy paint
3117
+ with the verdict one — the spin then never reaches the screen. The busy state is
3118
+ therefore held for at least this long. */
3119
+ const TEST_SPIN_MS = 450;
3120
+
3121
+ function testModel(row) {
3122
+ const llm = bridge.llm;
3123
+ const id = String(row.id || "").trim();
3124
+ if (id === "") { setNote(t("page.testNeedsId")); return; }
3125
+ if (llm === undefined || llm === null) { setNote(t("page.fetchUnavailable")); return; }
3126
+ const key = current + "/" + id;
3127
+ const mark = function (state, message) {
3128
+ const next = Object.assign({}, modelTests);
3129
+ next[key] = { state: state, message: message };
3130
+ setModelTests(next);
3131
+ };
3132
+ const startedAt = Date.now();
3133
+ const settle = function (state, message) {
3134
+ const wait = Math.max(0, TEST_SPIN_MS - (Date.now() - startedAt));
3135
+ if (wait === 0) { mark(state, message); return; }
3136
+ setTimeout(function () { mark(state, message); }, wait);
3137
+ };
3138
+ mark("busy", t("page.testing"));
3139
+ /* The draft must carry the endpoint: for a route the catalog does not describe,
3140
+ discovery hits {baseURL}/models and refuses to run without one ("pi-ai ships no
3141
+ catalog for provider X"), which is exactly the route an operator wants to test. */
3142
+ const request = { provider: current };
3143
+ const liveBase = field !== null && field.baseURL !== undefined ? field.baseURL : profileString(profile, "baseURL");
3144
+ const liveApi = field !== null && field.api !== undefined ? field.api : profileString(profile, "api");
3145
+ if (liveBase !== "") request.baseURL = liveBase;
3146
+ if (liveApi !== "") request.api = liveApi;
3147
+ Promise.resolve(llm.discoverModels(namespace, request)).then(function (response) {
3148
+ if (!response || !response.ok) {
3149
+ const reason = (response && response.error && response.error.message) || "";
3150
+ settle("fail", /ships no catalog/i.test(reason) ? t("page.testNoCatalog") : (reason || t("page.testFailed")));
3151
+ return;
3152
+ }
3153
+ const found = Array.isArray(response.value) ? response.value : [];
3154
+ const hit = found.some(function (entry) {
3155
+ return entry !== null && entry !== undefined && String(entry.id) === id;
3156
+ });
3157
+ settle(hit ? "ok" : "miss", hit ? t("page.testOk") : t("page.testMissing"));
3158
+ }, function (error) {
3159
+ settle("fail", String((error && error.message) || error));
3160
+ });
3161
+ }
3162
+
3163
+ /* The animation must begin on the click itself. A class added by the React state
3164
+ update only animates once that commit is painted, which can be a frame later (and
3165
+ the whole request may finish inside that frame). Touching the DOM here starts the
3166
+ spin in the same task; React's own className keeps it for the rest of the run. */
3167
+ function startSpinNow(event) {
3168
+ try {
3169
+ const node = event && event.currentTarget;
3170
+ if (node === null || node === undefined) return;
3171
+ if (node.classList && typeof node.classList.add === "function") node.classList.add("dsh-mo-act-busy");
3172
+ const svg = typeof node.querySelector === "function" ? node.querySelector("svg") : null;
3173
+ if (svg !== null && svg !== undefined && svg.style) svg.style.animation = "dsh-mo-spin .9s linear infinite";
3174
+ } catch (e) {}
3175
+ }
3176
+
3177
+ function deleteModelRow(row) {
3178
+ if (!writable) { setNote(t("page.readonly")); return; }
3179
+ const target = row.original !== undefined ? row.original : row.id;
3180
+ writeModels(draftRows.filter(function (item) { return String(item.original) !== String(target); }), function (error) {
3181
+ if (error !== undefined) return;
3182
+ setNote(t("page.modelDeleted"));
3183
+ });
3184
+ }
3185
+
3186
+ /* Drop a hand-declared route: the credential this page derived first (a key the user
3187
+ configured elsewhere is not ours to remove, and a failed cleanup must not block the
3188
+ delete), then the profile itself. Official routes never reach here. */
3189
+ function confirmDeleteProvider() {
3190
+ const route = deleteTarget;
3191
+ if (route === null || route === undefined) return;
3192
+ if (!writable) { setDeleteTarget(null); setNote(t("page.readonly")); return; }
3193
+ setBusy("delete");
3194
+ const after = function () {
3195
+ const nextOrder = readProviderOrder().filter(function (id) { return providerKey(id) !== providerKey(route); });
3196
+ writeProviderOrder(nextOrder);
3197
+ const nextHidden = Object.assign({}, hidden.providers);
3198
+ delete nextHidden[providerKey(route)];
3199
+ writeHidden(HIDDEN_PROVIDERS_KEY, nextHidden);
3200
+ setHidden({ providers: nextHidden, models: hidden.models });
3201
+ setPicked(null);
3202
+ setField(null);
3203
+ setDeleteTarget(null);
3204
+ setBusy("");
3205
+ setNote("ok");
3206
+ };
3207
+ const drop = function () {
3208
+ Promise.resolve(mutate([{ op: "unset", path: ["providers", route] }], revision)).then(function (result) {
3209
+ if (!result || !result.ok) {
3210
+ setBusy("");
3211
+ setDeleteTarget(null);
3212
+ setNote((result && result.error && result.error.message) || t("page.deleteFailed"));
3213
+ return;
3214
+ }
3215
+ after();
3216
+ }, function (error) {
3217
+ setBusy("");
3218
+ setDeleteTarget(null);
3219
+ setNote(String((error && error.message) || error));
3220
+ });
3221
+ };
3222
+ const credentials = bridge.credentials;
3223
+ /* Clear the reference the profile actually names; a profile that named its own
3224
+ (e.g. an env-style key) must not leave the derived one as the target. */
3225
+ const named = route === current ? profileString(profile, "apiKeyEnv") : "";
3226
+ const ref = named !== "" ? named : deriveKeyRef(route);
3227
+ if (credentials === undefined || credentials === null || typeof credentials.unset !== "function") { drop(); return; }
3228
+ Promise.resolve(credentials.unset(ref)).then(drop, drop);
3229
+ }
3230
+
3231
+ /* Every model change goes through here — the dialog's save, a drag reorder, the
3232
+ fetch picker and a delete all rewrite the same array, so there is exactly one
3233
+ writer and one revision source per pass (no draft to reconcile). */
3234
+ function writeModels(nextRows, done) {
3235
+ if (!writable) { setNote(t("page.readonly")); return; }
3236
+ const built = modelsFromDraft(nextRows, t);
3237
+ if (built.error !== undefined) { setNote(built.error); if (done !== undefined) done(built.error); return; }
3238
+ /* Emptying the list would leave the route unresolvable, which the host reports as a
3239
+ resolution error on the next reload — refuse here, with the reason. */
3240
+ if (built.models.length === 0) {
3241
+ setNote(t("page.modelsRequired"));
3242
+ if (done !== undefined) done(t("page.modelsRequired"));
3243
+ return;
3244
+ }
3245
+ let pending;
3246
+ try {
3247
+ pending = mutate([{ op: "set", path: ["providers", current, "models"], value: built.models }], revision);
3248
+ } catch (e) { setNote(String((e && e.message) || e)); if (done !== undefined) done(String((e && e.message) || e)); return; }
3249
+ setBusy("models");
3250
+ Promise.resolve(pending).then(function (result) {
3251
+ setBusy("");
3252
+ if (result && result.ok) { setNote("ok"); if (done !== undefined) done(undefined); }
3253
+ else {
3254
+ const message = (result && result.error && result.error.message) || "save failed";
3255
+ setNote(message);
3256
+ if (done !== undefined) done(message);
3257
+ }
3258
+ }, function (error) {
3259
+ setBusy("");
3260
+ const message = String((error && error.message) || error);
3261
+ setNote(message);
3262
+ if (done !== undefined) done(message);
3263
+ });
3264
+ }
3265
+
3266
+ /* The shipped discovery, reached exactly like the official page does. Candidates
3267
+ go into a picker (the official interaction): nothing is written until
3268
+ 「添加所选」, which is what keeps a wide listing from being a cleanup chore —
3269
+ and what it writes then goes straight into the config (no draft to save). */
3270
+ function fetchModels() {
3271
+ const llm = bridge.llm;
3272
+ if (llm === undefined || llm === null) { setNote(t("page.fetchUnavailable")); return; }
3273
+ const request = { provider: current };
3274
+ const baseURL = profileString(profile, "baseURL");
3275
+ const api = profileString(profile, "api");
3276
+ if (baseURL !== "") request.baseURL = baseURL;
3277
+ if (api !== "") request.api = api;
3278
+ setBusy("fetch");
3279
+ Promise.resolve(llm.discoverModels(namespace, request)).then(function (response) {
3280
+ setBusy("");
3281
+ if (!response || !response.ok) {
3282
+ setNote((response && response.error && response.error.message) || "fetch failed");
3283
+ return;
3284
+ }
3285
+ const found = Array.isArray(response.value) ? response.value : [];
3286
+ const known = {};
3287
+ for (let i = 0; i < draftRows.length; i++) known[String(draftRows[i].id).trim()] = true;
3288
+ const candidates = [];
3289
+ const picked = {};
3290
+ for (let i = 0; i < found.length; i++) {
3291
+ const model = found[i];
3292
+ if (model === null || typeof model !== "object") continue;
3293
+ const id = typeof model.id === "string" ? model.id.trim() : "";
3294
+ if (id === "" || known[id] === true) continue;
3295
+ known[id] = true;
3296
+ candidates.push({
3297
+ id: id,
3298
+ context: model.contextWindow === undefined ? "" : formatCapacity(model.contextWindow),
3299
+ maxTokens: model.maxTokens === undefined ? "" : formatCapacity(model.maxTokens),
3300
+ vision: Array.isArray(model.inputModalities) && model.inputModalities.indexOf("image") !== -1
3301
+ });
3302
+ picked[id] = true;
3303
+ }
3304
+ if (candidates.length === 0) { setNote(t("page.fetchNone")); return; }
3305
+ setPicker({ rows: candidates, picked: picked, query: "" });
3306
+ }, function (error) { setBusy(""); setNote(String((error && error.message) || error)); });
3307
+ }
3308
+
3309
+ /* Discovery for a route that does not exist yet: the draft dialog carries the
3310
+ endpoint, protocol and one-shot credential, which is exactly what the discovery
3311
+ seam accepts (it never reads stored settings). Filling the ids here is what makes
3312
+ the create button able to satisfy "a declared route must list its models". */
3313
+ function fetchAddModels() {
3314
+ const llm = bridge.llm;
3315
+ if (llm === undefined || llm === null) { setAdd(Object.assign({}, addDialog, { error: t("page.fetchUnavailable") })); return; }
3316
+ const route = addDialog.route.trim();
3317
+ const baseURL = addDialog.baseURL.trim();
3318
+ if (baseURL === "") { setAdd(Object.assign({}, addDialog, { error: t("page.baseUrlRequired") })); return; }
3319
+ const request = { baseURL: baseURL, api: addDialog.api };
3320
+ if (route !== "") request.provider = route;
3321
+ const secret = addDialog.key.trim();
3322
+ if (secret !== "") request.apiKey = secret;
3323
+ setBusy("addfetch");
3324
+ Promise.resolve(llm.discoverModels(namespace, request)).then(function (response) {
3325
+ setBusy("");
3326
+ if (!response || !response.ok) {
3327
+ setAdd(Object.assign({}, addDialog, { error: (response && response.error && response.error.message) || "fetch failed" }));
3328
+ return;
3329
+ }
3330
+ const found = Array.isArray(response.value) ? response.value : [];
3331
+ const ids = parseModelIds(found.map(function (row) { return row !== null && row !== undefined && typeof row.id === "string" ? row.id : ""; }).join(","));
3332
+ if (ids.length === 0) { setAdd(Object.assign({}, addDialog, { error: t("page.fetchEmpty") })); return; }
3333
+ setAdd(Object.assign({}, addDialog, { models: ids.join(", "), error: "" }));
3334
+ }, function (error) {
3335
+ setBusy("");
3336
+ setAdd(Object.assign({}, addDialog, { error: String((error && error.message) || error) }));
3337
+ });
3338
+ }
3339
+
3340
+ /* A brand-new route: the profile and the key reference go in ONE mutate (one
3341
+ revision, so a concurrent edit cannot land between them), the secret goes to the
3342
+ credentials service, and the page then selects what it just created. */
3343
+ function createProvider() {
3344
+ const route = addDialog.route.trim();
3345
+ if (route === "") { setAdd(Object.assign({}, addDialog, { error: t("page.routeRequired") })); return; }
3346
+ if (!/^[A-Za-z0-9._-]+$/.test(route)) { setAdd(Object.assign({}, addDialog, { error: t("page.routeInvalid") })); return; }
3347
+ if (providers[route] !== undefined) { setAdd(Object.assign({}, addDialog, { error: t("page.routeTaken") })); return; }
3348
+ const displayName = addDialog.displayName.trim();
3349
+ const baseURL = addDialog.baseURL.trim();
3350
+ /* A hand-declared route has no catalog entry, so the host refuses a profile whose
3351
+ models list is empty ("resolves no models"). The dialog therefore requires the
3352
+ ids up front instead of creating a route that cannot resolve. */
3353
+ const modelIds = parseModelIds(addDialog.models);
3354
+ if (modelIds.length === 0) { setAdd(Object.assign({}, addDialog, { error: t("page.modelsRequired") })); return; }
3355
+ const profile = { api: addDialog.api, models: modelIds.map(function (id) { return { id: id }; }) };
3356
+ if (displayName !== "") profile.displayName = displayName;
3357
+ if (baseURL !== "") profile.baseURL = baseURL;
3358
+ const secret = addDialog.key.trim();
3359
+ const ref = deriveKeyRef(route);
3360
+ const ops = [{ op: "set", path: ["providers", route], value: profile }];
3361
+ if (secret !== "") ops.push({ op: "set", path: ["providers", route, "apiKeyEnv"], value: ref });
3362
+ setBusy("create");
3363
+ const failed = function (error) {
3364
+ setBusy("");
3365
+ setAdd(Object.assign({}, addDialog, { error: String((error && error.message) || error || t("page.createFailed")) }));
3366
+ };
3367
+ Promise.resolve(mutate(ops, revision)).then(function (result) {
3368
+ if (!result || !result.ok) { failed((result && result.error && result.error.message) || t("page.createFailed")); return; }
3369
+ const done = function () { setBusy(""); setAdd(null); setPicked(route); setField(null); };
3370
+ if (secret === "" || bridge.credentials === null || bridge.credentials === undefined) { done(); return; }
3371
+ Promise.resolve(bridge.credentials.set(ref, secret)).then(done, function (error) {
3372
+ setBusy("");
3373
+ setAdd(Object.assign({}, addDialog, { error: String((error && error.message) || error) }));
3374
+ });
3375
+ }, failed);
3376
+ }
3377
+
3378
+ function saveKey() {
3379
+ const credentials = bridge.credentials;
3380
+ const secret = keyDraft.trim();
3381
+ if (secret === "") { setNote(t("page.keyPlaceholder")); return; }
3382
+ if (credentials === undefined || credentials === null) { setNote(t("page.keyUnavailable")); return; }
3383
+ setBusy("key");
3384
+ Promise.resolve(credentials.set(currentKeyRef, secret)).then(function (response) {
3385
+ if (!response || !response.ok) {
3386
+ setBusy("");
3387
+ setNote((response && response.error && response.error.message) || "save failed");
3388
+ return;
3389
+ }
3390
+ setKeyDraft("");
3391
+ /* A route that names no credential would never resolve the value we just
3392
+ stored, so the first save also points the profile at that reference. */
3393
+ const named = profileString(profile, "apiKeyEnv");
3394
+ if (named === "" ) writeProvider({ apiKeyEnv: currentKeyRef }, function () { setBusy(""); setKeyFacts({ configured: true }); });
3395
+ else { setBusy(""); setNote("ok"); setKeyFacts({ configured: true }); }
3396
+ }, function (error) { setBusy(""); setNote(String((error && error.message) || error)); });
3397
+ }
3398
+
3399
+ function clearKey() {
3400
+ const credentials = bridge.credentials;
3401
+ if (credentials === undefined || credentials === null) { setNote(t("page.keyUnavailable")); return; }
3402
+ setBusy("key");
3403
+ Promise.resolve(credentials.unset(currentKeyRef)).then(function (response) {
3404
+ setBusy("");
3405
+ if (response && response.ok) { setNote("ok"); setKeyFacts({ configured: false }); }
3406
+ else setNote((response && response.error && response.error.message) || "save failed");
3407
+ }, function (error) { setBusy(""); setNote(String((error && error.message) || error)); });
3408
+ }
3409
+
3410
+ /* ------------------------------ rail rows ----------------------------- */
3411
+ function sectionOf(id) {
3412
+ if (directory === null) return "all";
3413
+ for (let i = 0; i < directory.length; i++) {
3414
+ const entry = directory[i];
3415
+ if (entry !== null && entry !== undefined && entry.provider === id) return entry.declared === true ? "custom" : "catalog";
3416
+ }
3417
+ return "custom";
3418
+ }
3419
+ /* Built-in routes: listed, read-only, one click away from the page that owns them.
3420
+ They are deliberately NOT part of the drag list — their order is the shipped one,
3421
+ and a drag would store a preference this plugin could never apply to them. */
3422
+ const goOfficial = function () {
3423
+ if (typeof setTakeover === "function") setTakeover(false);
3424
+ };
3425
+ const officialBlock = officialRows.length === 0 ? null : [
3426
+ h("div", { key: "official-group", style: S.pageGroup, title: t("page.officialHint") }, t("page.officialGroup")),
3427
+ officialRows.map(function (entry) {
3428
+ return h("div", {
3429
+ key: "official-" + entry.provider,
3430
+ "data-mo": "page-provider",
3431
+ "data-mo-official": "true",
3432
+ "data-mo-id": entry.provider,
3433
+ role: "button",
3434
+ tabIndex: 0,
3435
+ title: t("page.officialRowTitle"),
3436
+ onClick: goOfficial,
3437
+ onKeyDown: function (event) {
3438
+ if (event.key !== "Enter" && event.key !== " ") return;
3439
+ event.preventDefault();
3440
+ goOfficial();
3441
+ },
3442
+ className: C !== null ? cx(C.cell, "dsh-mo-head") : "dsh-mo-head",
3443
+ style: C !== null ? S.pageOfficialRow : Object.assign({}, S.row, S.pageOfficialRow)
3444
+ },
3445
+ h("span", { style: S.pageDotOfficial }),
3446
+ h("span", { style: S.pageRowName }, entry.displayName || entry.provider),
3447
+ h("span", { style: S.pageBadge }, t("page.officialBadge")),
3448
+ h("span", { style: S.pageRowCount }, "↗"));
3449
+ })
3450
+ ];
3451
+
3452
+ const rail = h("aside", { style: S.pageList, "aria-label": t("page.providers") },
3453
+ officialBlock,
3454
+ h(DragList, {
3455
+ ids: ids,
3456
+ disabled: false,
3457
+ onCommit: function (next) {
3458
+ setOrder(null);
3459
+ writeProviderOrder(next);
3460
+ },
3461
+ render: function (id, index, dragProps) {
3462
+ const entry = providers[id] || {};
3463
+ const count = (entry.models || []).length;
3464
+ const active = id === current;
3465
+ const off = hidden.providers[providerKey(id)] === true;
3466
+ const section = sectionOf(id);
3467
+ const opensSection = index === 0 || sectionOf(ids[index - 1]) !== section;
3468
+ const header = opensSection
3469
+ ? h("div", { key: "group", style: S.pageGroup }, t(section === "catalog" ? "page.catalog" : (section === "custom" ? "page.custom" : "page.providers")))
3470
+ : null;
3471
+ const row = h("div", {
3472
+ key: id,
3473
+ "data-mo": "page-provider",
3474
+ "data-mo-id": id,
3475
+ role: "button",
3476
+ tabIndex: 0,
3477
+ "aria-current": active ? "true" : undefined,
3478
+ title: entry.displayName || id,
3479
+ draggable: dragProps.draggable,
3480
+ onDragStart: dragProps.onDragStart,
3481
+ onDragOver: dragProps.onDragOver,
3482
+ onDragEnd: dragProps.onDragEnd,
3483
+ onDrop: dragProps.onDrop,
3484
+ onClick: function () { setPicked(id); setField(null); },
3485
+ onKeyDown: function (event) {
3486
+ if (event.key !== "Enter" && event.key !== " ") return;
3487
+ event.preventDefault();
3488
+ setPicked(id); setField(null);
3489
+ },
3490
+ className: C !== null ? cx(C.cell, "dsh-mo-head") : "dsh-mo-head",
3491
+ /* Shipped classes resolved → NO inline background/cursor at all, so the
3492
+ sheet's :hover tint and .dsh-mo-list .dsh-mo-head grab cursor apply
3493
+ (exactly what the footer panel does). Only the current row marks
3494
+ itself, and only with a box-shadow. */
3495
+ style: dragProps.dragging
3496
+ ? (C !== null ? S.rowDraggingOnly : Object.assign({}, S.row, S.rowDragging))
3497
+ : (function () {
3498
+ const off = hidden.providers[providerKey(id)] === true;
3499
+ const mark = active ? S.pageRowCurrent : undefined;
3500
+ if (C !== null) return off ? Object.assign({}, mark === undefined ? {} : mark, S.pageDim) : mark;
3501
+ return Object.assign({}, S.row, mark === undefined ? {} : mark, off ? S.pageDim : {});
3502
+ })()
3503
+ },
3504
+ /* The dot is this plugin's own enable state now (it used to mean
3505
+ "configured enough to try"); the tooltip keeps that fact available. */
3506
+ h("span", {
3507
+ "data-mo": "page-provider-dot",
3508
+ "data-mo-on": off ? "false" : "true",
3509
+ title: (off ? t("page.dotOff") : t("page.dotOn")) + " — " + t("page.dotHint")
3510
+ + (profileConfigured(entry) ? "" : " · " + t("page.keyUnset")),
3511
+ style: off ? Object.assign({}, S.pageDot, S.pageDotMuted) : S.pageDot
3512
+ }),
3513
+ h("span", { style: S.pageRowName }, entry.displayName || id),
3514
+ h("span", { style: S.pageRowCount }, count + " " + t("composer.count"))
3515
+ );
3516
+ return h(React.Fragment, { key: id }, header, row);
3517
+ }
3518
+ }),
3519
+ h("button", {
3520
+ "data-mo": "page-provider-add",
3521
+ type: "button",
3522
+ disabled: !writable,
3523
+ title: t("page.addProvider"),
3524
+ onClick: function () {
3525
+ setAdd({ route: "", displayName: "", baseURL: "", api: apiChoices[0], key: "", models: "", error: "" });
3526
+ },
3527
+ style: S.pageAddButton
3528
+ }, "+ " + t("page.addProvider"))
3529
+ );
3530
+
3531
+ /* ------------------------------ model rows ---------------------------- */
3532
+ /* One line per model, exactly like the provider rows above: the dot is this
3533
+ plugin's enable state, the row itself is the drag surface, and a click opens the
3534
+ details dialog. Nothing is edited inline any more — a row used to carry two rows
3535
+ of inputs and needed the footer's Save button to land. */
3536
+ function modelMeta(row) {
3537
+ const chips = [];
3538
+ if (row.context !== "") chips.push(row.context);
3539
+ if (row.maxTokens !== "") chips.push("↑" + row.maxTokens);
3540
+ if (row.vision === true) chips.push(t("page.modelImage"));
3541
+ if (row.reasoning !== undefined && row.reasoning.mode === "none") chips.push(t("page.reasoningNone"));
3542
+ else if (row.reasoning !== undefined && row.reasoning.mode === "custom") chips.push(t("page.reasoning"));
3543
+ return chips.join(" · ");
3544
+ }
3545
+
3546
+ const modelList = h(DragList, {
3547
+ ids: draftRows.map(function (row, index) { return "row-" + String(index); }),
3548
+ disabled: false,
3549
+ onCommit: function (next) {
3550
+ const positions = next.map(function (key) { return Number(String(key).slice(4)); });
3551
+ writeModels(positions.map(function (at) { return draftRows[at]; }));
3552
+ },
3553
+ render: function (key, index, dragProps) {
3554
+ const at = Number(String(key).slice(4));
3555
+ const row = draftRows[at];
3556
+ if (row === undefined) return null;
3557
+ const enabled = hidden.models[hiddenModelKey(current, row.id)] !== true;
3558
+ const meta = modelMeta(row);
3559
+ return h("div", {
3560
+ key: key,
3561
+ "data-mo": "page-model",
3562
+ "data-mo-id": row.id,
3563
+ role: "button",
3564
+ tabIndex: 0,
3565
+ title: t("page.modelOpenHint"),
3566
+ draggable: dragProps.draggable,
3567
+ onDragStart: dragProps.onDragStart,
3568
+ onDragOver: dragProps.onDragOver,
3569
+ onDragEnd: dragProps.onDragEnd,
3570
+ onDrop: dragProps.onDrop,
3571
+ onClick: function () { setModel({ mode: "edit", at: at, row: row, error: "" }); },
3572
+ onKeyDown: function (event) {
3573
+ if (event.key !== "Enter" && event.key !== " ") return;
3574
+ event.preventDefault();
3575
+ setModel({ mode: "edit", at: at, row: row, error: "" });
3576
+ },
3577
+ className: C !== null ? cx(C.cell, "dsh-mo-head") : "dsh-mo-head",
3578
+ style: dragProps.dragging
3579
+ ? (C !== null ? S.rowDraggingOnly : Object.assign({}, S.row, S.rowDragging))
3580
+ : (enabled
3581
+ ? (C !== null ? undefined : S.row)
3582
+ : Object.assign({}, C !== null ? {} : S.row, S.pageDim))
3583
+ },
3584
+ h("span", {
3585
+ "data-mo": "page-model-dot",
3586
+ "data-mo-on": enabled ? "true" : "false",
3587
+ title: (enabled ? t("page.dotOn") : t("page.dotOff")) + " — " + t("page.dotHint"),
3588
+ style: enabled ? S.pageDot : Object.assign({}, S.pageDot, S.pageDotMuted)
3589
+ }),
3590
+ h("span", { style: S.pageRowName }, row.name !== "" ? row.name : row.id),
3591
+ h("span", { style: S.pageRowCount }, meta),
3592
+ h("span", { style: S.pageModelActions },
3593
+ h("button", {
3594
+ "data-mo": "page-model-test",
3595
+ type: "button",
3596
+ "data-mo-id": row.id,
3597
+ "data-mo-state": testStateOf(row),
3598
+ className: cx(
3599
+ "dsh-mo-act",
3600
+ testStateOf(row) === "busy" ? "dsh-mo-act-busy" : "",
3601
+ testStateOf(row) === "ok" ? "dsh-mo-act-ok" : testStateOf(row) === "miss" ? "dsh-mo-act-miss" : testStateOf(row) === "fail" ? "dsh-mo-act-danger" : ""
3602
+ ),
3603
+ "aria-label": t("page.test"),
3604
+ title: testMark(row) === undefined ? t("page.testHint") : testMark(row).message,
3605
+ disabled: busy === "models",
3606
+ onMouseDown: stopRowEvent,
3607
+ onClick: function (event) { stopRowEvent(event); startSpinNow(event); testModel(row); },
3608
+ style: S.pageIconAction
3609
+ }, kitIcon(TEST_ICONS[testStateOf(row)], "▶", 14)),
3610
+ h("button", {
3611
+ "data-mo": "page-model-delete",
3612
+ type: "button",
3613
+ "data-mo-id": row.id,
3614
+ "aria-label": t("page.deleteModel"),
3615
+ title: t("page.deleteModelHint"),
3616
+ disabled: !writable || busy === "models",
3617
+ onMouseDown: stopRowEvent,
3618
+ className: "dsh-mo-act dsh-mo-act-danger",
3619
+ onClick: function (event) { stopRowEvent(event); deleteModelRow(row); },
3620
+ style: S.pageIconAction
3621
+ }, kitIcon("IconTrashOutlineRegular", "🗑", 14)))
3622
+ );
3623
+ }
3624
+ });
3625
+
3626
+ /* ------------------------------- detail ------------------------------- */
3627
+ const apiValue = field !== null && field.api !== undefined ? field.api : profileString(profile, "api");
3628
+ const apiChoices = PROTOCOL_IDS.indexOf(apiValue) === -1 && apiValue !== "" ? PROTOCOL_IDS.concat([apiValue]) : PROTOCOL_IDS;
3629
+ const baseValue = field !== null && field.baseURL !== undefined ? field.baseURL : profileString(profile, "baseURL");
3630
+ const keyConfigured = keyFacts !== null && keyFacts.configured === true;
3631
+ /* No page-level model draft: every change writes through immediately. */
3632
+
3633
+ const detail = current === null
3634
+ ? h("p", { key: "empty", style: S.pageEmpty }, t("page.empty"))
3635
+ : h("section", { style: S.pageDetail, "data-mo": "page-detail" },
3636
+ h("div", { style: S.pageDetailHead },
3637
+ h("h3", { style: S.pageDetailTitle }, profile.displayName || current),
3638
+ h("span", { style: S.pageDetailRoute }, current),
3639
+ h("span", { style: S.pageSpacer }),
3640
+ h("button", {
3641
+ "data-mo": "page-provider-delete",
3642
+ type: "button",
3643
+ disabled: !writable,
3644
+ "aria-label": t("page.deleteProvider"),
3645
+ title: t("page.deleteProviderHint"),
3646
+ className: "dsh-mo-act dsh-mo-act-danger",
3647
+ onClick: function () { setDeleteTarget(current); },
3648
+ style: S.pageIconActionHead
3649
+ }, kitIcon("IconTrashOutlineRegular", "🗑", 13)),
3650
+ h(Switch, {
3651
+ marker: "page-provider-switch",
3652
+ id: current,
3653
+ on: hidden.providers[providerKey(current)] !== true,
3654
+ title: (hidden.providers[providerKey(current)] !== true ? t("page.switchOn") : t("page.switchOff")) + " — " + t("page.switchHint"),
3655
+ onToggle: function () { toggleHidden("provider", providerKey(current)); }
3656
+ })),
3657
+ h("label", { style: S.pageField },
3658
+ h("span", { style: S.pageFieldLabel }, t("page.baseUrl")),
3659
+ h("input", {
3660
+ "data-mo": "page-base-url",
3661
+ value: baseValue,
3662
+ placeholder: "https://api.example.com/v1",
3663
+ spellCheck: false,
3664
+ disabled: !writable,
3665
+ onChange: function (event) {
3666
+ setField(Object.assign({}, field === null ? {} : field, { baseURL: event.target.value }));
3667
+ },
3668
+ onBlur: function () { saveField("baseURL"); },
3669
+ onKeyDown: function (event) { if (event.key === "Enter") saveField("baseURL"); },
3670
+ style: S.pageInputWide
3671
+ })),
3672
+ h("div", { style: S.pageField },
3673
+ h("span", { style: S.pageFieldLabel }, t("page.api")),
3674
+ h("div", {
3675
+ "data-mo": "page-api",
3676
+ role: "radiogroup",
3677
+ "aria-label": t("page.api"),
3678
+ style: S.pageSegments
3679
+ }, apiChoices.map(function (choice) {
3680
+ const active = choice === apiValue;
3681
+ return h("button", {
3682
+ key: choice,
3683
+ "data-mo": "page-api-option",
3684
+ "data-mo-id": choice,
3685
+ type: "button",
3686
+ role: "radio",
3687
+ "aria-checked": active ? "true" : "false",
3688
+ disabled: !writable,
3689
+ title: apiLabel(choice, t),
3690
+ onClick: function () {
3691
+ setField(Object.assign({}, field === null ? {} : field, { api: choice }));
3692
+ writeProvider({ api: choice }, function () { setField(null); });
3693
+ },
3694
+ style: active ? Object.assign({}, S.pageSegment, S.pageSegmentActive) : S.pageSegment
3695
+ }, apiShort(choice, t));
3696
+ })),
3697
+ h("span", { style: S.pageHint }, apiLabel(apiValue, t))),
3698
+ h("div", { style: S.pageField },
3699
+ h("span", { style: S.pageFieldLabel }, t("page.apiKey")),
3700
+ h("input", {
3701
+ "data-mo": "page-api-key",
3702
+ type: keyShown ? "text" : "password",
3703
+ value: keyDraft,
3704
+ placeholder: keyConfigured ? t("page.keySet") : t("page.keyPlaceholder"),
3705
+ disabled: !writable || bridge.credentials === null || bridge.credentials === undefined,
3706
+ onChange: function (event) { setKeyDraft(event.target.value); },
3707
+ onKeyDown: function (event) { if (event.key === "Enter") saveKey(); },
3708
+ style: S.pageInputWide
3709
+ }),
3710
+ h("div", { style: S.pageKeyRow },
3711
+ h("button", {
3712
+ "data-mo": "page-key-toggle",
3713
+ type: "button",
3714
+ onClick: function () { setKeyShown(!keyShown); },
3715
+ style: S.pageButton
3716
+ }, keyShown ? t("page.hide") : t("page.show")),
3717
+ h("button", {
3718
+ "data-mo": "page-key-save",
3719
+ type: "button",
3720
+ disabled: busy === "key",
3721
+ onClick: saveKey,
3722
+ style: S.pageButtonPrimary
3723
+ }, busy === "key" ? t("page.saving") : t("page.keySave")),
3724
+ keyConfigured ? h("button", {
3725
+ "data-mo": "page-key-clear",
3726
+ type: "button",
3727
+ disabled: busy === "key",
3728
+ onClick: clearKey,
3729
+ style: S.pageButton
3730
+ }, t("page.keyClear")) : null),
3731
+ h("span", { style: S.pageHint },
3732
+ bridge.credentials === null || bridge.credentials === undefined
3733
+ ? t("page.keyUnavailable")
3734
+ : (keyConfigured ? t("page.keySet") : t("page.keyUnset")) + " · " + currentKeyRef)),
3735
+ h("div", { style: S.pageSectionHead },
3736
+ h("span", { style: S.pageSectionLabel }, t("page.models")),
3737
+ h("span", { style: S.pageSpacer }),
3738
+ h("span", { style: S.pageHint }, busy === "models" ? t("page.saving") : ""),
3739
+ h("button", {
3740
+ "data-mo": "page-models-fetch",
3741
+ type: "button",
3742
+ disabled: !writable || busy === "fetch" || bridge.llm === null || bridge.llm === undefined,
3743
+ title: bridge.llm === null || bridge.llm === undefined ? t("page.fetchUnavailable") : t("page.fetchModels"),
3744
+ onClick: fetchModels,
3745
+ style: S.pageButton
3746
+ }, busy === "fetch" ? t("page.fetching") : t("page.fetchModels"))),
3747
+ draftRows.length === 0 ? h("p", { style: S.pageEmpty }, t("page.modelsEmptyEdit")) : modelList,
3748
+ h("div", { style: S.pageActions },
3749
+ h("button", {
3750
+ "data-mo": "page-model-add",
3751
+ type: "button",
3752
+ disabled: !writable,
3753
+ onClick: function () { setModel({ mode: "new", at: -1, row: emptyModelRow(), error: "" }); },
3754
+ style: S.pageButton
3755
+ }, "+ " + t("page.addModel"))),
3756
+ h("div", { style: S.pageActions },
3757
+ h("span", { style: S.pageSpacer }),
3758
+ note !== null && note !== "ok" ? h("span", { style: S.dirty }, note) : null,
3759
+ note === "ok" ? h("span", { style: S.ok }, t("page.saved")) : null)
3760
+ );
3761
+
3762
+ /* ------------------------- the fetch picker ------------------------- */
3763
+ let pickerNode = null;
3764
+ if (picker !== null) {
3765
+ const query = String(picker.query || "").trim().toLowerCase();
3766
+ const visible = query === "" ? picker.rows : picker.rows.filter(function (row) {
3767
+ return row.id.toLowerCase().indexOf(query) !== -1;
3768
+ });
3769
+ const allPicked = visible.length > 0 && visible.every(function (row) { return picker.picked[row.id] === true; });
3770
+ pickerNode = ReactDOM.createPortal(
3771
+ h("div", {
3772
+ "data-mo": "fetch-dialog",
3773
+ style: S.pickBackdrop,
3774
+ onMouseDown: function (event) { if (event.target === event.currentTarget) setPicker(null); }
3775
+ },
3776
+ h(DialogShell, { label: t("page.fetchTitle"), onClose: function () { setPicker(null); } },
3777
+ h("div", { style: S.pickHead },
3778
+ h("h3", { style: S.pickTitle }, t("page.fetchTitle")),
3779
+ h("button", {
3780
+ "data-mo": "fetch-close",
3781
+ type: "button",
3782
+ title: t("page.cancel"),
3783
+ onClick: function () { setPicker(null); },
3784
+ style: S.pageIconButton
3785
+ }, "✕")),
3786
+ h("p", { style: S.pickDesc }, t("page.fetchDescription")),
3787
+ h("div", { style: S.pickTools },
3788
+ h("input", {
3789
+ "data-mo": "fetch-search",
3790
+ "data-mo-focus": "true",
3791
+ value: picker.query,
3792
+ placeholder: t("page.fetchSearch"),
3793
+ spellCheck: false,
3794
+ onChange: function (event) { setPicker({ rows: picker.rows, picked: picker.picked, query: event.target.value }); },
3795
+ style: S.pageInputWide
3796
+ }),
3797
+ h("button", {
3798
+ "data-mo": "fetch-all",
3799
+ type: "button",
3800
+ onClick: function () {
3801
+ const next = Object.assign({}, picker.picked);
3802
+ for (let i = 0; i < visible.length; i++) next[visible[i].id] = allPicked ? undefined : true;
3803
+ setPicker({ rows: picker.rows, picked: next, query: picker.query });
3804
+ },
3805
+ style: S.pageButton
3806
+ }, allPicked ? t("page.deselectAll") : t("page.selectAll"))),
3807
+ visible.length === 0
3808
+ ? h("p", { style: S.pageEmpty }, picker.rows.length === 0 ? t("page.fetchEmpty") : t("page.fetchNoMatches"))
3809
+ : h("div", { style: S.pickList }, visible.map(function (row) {
3810
+ const chips = [];
3811
+ if (row.context !== "") chips.push(h("span", { key: "c", style: S.pageChip }, row.context));
3812
+ if (row.vision === true) chips.push(h("span", { key: "v", style: S.pageChip }, t("page.vision")));
3813
+ return h("label", { key: row.id, style: S.pickRow, "data-mo": "fetch-item", "data-mo-id": row.id },
3814
+ h("input", {
3815
+ type: "checkbox",
3816
+ checked: picker.picked[row.id] === true,
3817
+ onChange: function (event) {
3818
+ const next = Object.assign({}, picker.picked);
3819
+ next[row.id] = event.target.checked ? true : undefined;
3820
+ setPicker({ rows: picker.rows, picked: next, query: picker.query });
3821
+ }
3822
+ }),
3823
+ h("span", { style: S.pickId }, row.id),
3824
+ h("span", { style: S.pageSpacer }),
3825
+ chips);
3826
+ })),
3827
+ h("div", { style: S.pickFoot },
3828
+ h("button", {
3829
+ "data-mo": "fetch-cancel",
3830
+ type: "button",
3831
+ onClick: function () { setPicker(null); },
3832
+ style: S.pageButton
3833
+ }, t("page.cancel")),
3834
+ h("button", {
3835
+ "data-mo": "fetch-adopt",
3836
+ type: "button",
3837
+ onClick: function () {
3838
+ const chosen = picker.rows.filter(function (row) { return picker.picked[row.id] === true; });
3839
+ setPicker(null);
3840
+ if (chosen.length === 0) return;
3841
+ /* Adopting is a write, not a draft: the picked rows join the config
3842
+ immediately, which is also what keeps the list honest. */
3843
+ writeModels(draftRows.concat(chosen));
3844
+ },
3845
+ style: S.pageButtonPrimary
3846
+ }, t("page.adopt"))))),
3847
+ document.body);
3848
+ }
3849
+
3850
+ /* ------------------------ the add-provider dialog ------------------------ */
3851
+ let addNode = null;
3852
+ if (addDialog !== null) {
3853
+ const addApi = addDialog.api;
3854
+ addNode = ReactDOM.createPortal(
3855
+ h("div", {
3856
+ "data-mo": "add-dialog",
3857
+ style: S.pickBackdrop,
3858
+ onMouseDown: function (event) { if (event.target === event.currentTarget) setAdd(null); }
3859
+ },
3860
+ h(DialogShell, { label: t("page.newTitle"), onClose: function () { setAdd(null); } },
3861
+ h("div", { style: S.pickHead },
3862
+ h("h3", { style: S.pickTitle }, t("page.newTitle")),
3863
+ h("button", {
3864
+ "data-mo": "add-close",
3865
+ type: "button",
3866
+ title: t("page.cancel"),
3867
+ onClick: function () { setAdd(null); },
3868
+ style: S.pageIconButton
3869
+ }, "✕")),
3870
+ h("p", { style: S.pickDesc }, t("page.newDescription")),
3871
+ h("label", { style: S.pageField },
3872
+ h("span", { style: S.pageFieldLabel }, t("page.route")),
3873
+ h("input", {
3874
+ "data-mo": "add-route",
3875
+ "data-mo-focus": "true",
3876
+ value: addDialog.route,
3877
+ placeholder: "my-gateway",
3878
+ spellCheck: false,
3879
+ onChange: function (event) { setAdd(Object.assign({}, addDialog, { route: event.target.value, error: "" })); },
3880
+ style: Object.assign({}, S.pageInputWide, S.pageInputMono)
3881
+ }),
3882
+ h("span", { style: S.pageHint }, t("page.routeHint"))),
3883
+ h("label", { style: S.pageField },
3884
+ h("span", { style: S.pageFieldLabel }, t("page.displayNameOptional")),
3885
+ h("input", {
3886
+ "data-mo": "add-display-name",
3887
+ value: addDialog.displayName,
3888
+ spellCheck: false,
3889
+ onChange: function (event) { setAdd(Object.assign({}, addDialog, { displayName: event.target.value, error: "" })); },
3890
+ style: S.pageInputWide
3891
+ })),
3892
+ h("label", { style: S.pageField },
3893
+ h("span", { style: S.pageFieldLabel }, t("page.baseUrlOptional")),
3894
+ h("input", {
3895
+ "data-mo": "add-base-url",
3896
+ value: addDialog.baseURL,
3897
+ placeholder: "https://api.example.com/v1",
3898
+ spellCheck: false,
3899
+ onChange: function (event) { setAdd(Object.assign({}, addDialog, { baseURL: event.target.value, error: "" })); },
3900
+ style: S.pageInputWide
3901
+ })),
3902
+ h("div", { style: S.pageField },
3903
+ h("span", { style: S.pageFieldLabel }, t("page.api")),
3904
+ h("div", { "data-mo": "add-api", role: "radiogroup", "aria-label": t("page.api"), style: S.pageSegments },
3905
+ apiChoices.map(function (choice) {
3906
+ const active = choice === addApi;
3907
+ return h("button", {
3908
+ key: choice,
3909
+ "data-mo": "add-api-option",
3910
+ "data-mo-id": choice,
3911
+ type: "button",
3912
+ role: "radio",
3913
+ "aria-checked": active ? "true" : "false",
3914
+ title: apiLabel(choice, t),
3915
+ onClick: function () { setAdd(Object.assign({}, addDialog, { api: choice, error: "" })); },
3916
+ style: active ? Object.assign({}, S.pageSegment, S.pageSegmentActive) : S.pageSegment
3917
+ }, apiShort(choice, t));
3918
+ })),
3919
+ h("span", { style: S.pageHint }, apiLabel(addApi, t))),
3920
+ h("label", { style: S.pageField },
3921
+ h("span", { style: S.pageFieldLabel }, t("page.keyOptional")),
3922
+ h("input", {
3923
+ "data-mo": "add-key",
3924
+ type: "password",
3925
+ value: addDialog.key,
3926
+ onChange: function (event) { setAdd(Object.assign({}, addDialog, { key: event.target.value, error: "" })); },
3927
+ onKeyDown: function (event) { if (event.key === "Enter") createProvider(); },
3928
+ style: S.pageInputWide
3929
+ })),
3930
+ h("label", { style: S.pageField },
3931
+ h("span", { style: S.pageFieldLabel }, t("page.modelsField")),
3932
+ h("input", {
3933
+ "data-mo": "add-models",
3934
+ value: addDialog.models,
3935
+ placeholder: t("page.modelsFieldPlaceholder"),
3936
+ spellCheck: false,
3937
+ onChange: function (event) { setAdd(Object.assign({}, addDialog, { models: event.target.value, error: "" })); },
3938
+ onKeyDown: function (event) { if (event.key === "Enter") createProvider(); },
3939
+ style: S.pageInputWide
3940
+ }),
3941
+ h("div", { style: S.pageKeyRow },
3942
+ h("button", {
3943
+ "data-mo": "add-fetch",
3944
+ type: "button",
3945
+ disabled: busy === "addfetch",
3946
+ title: t("page.modelsFieldHint"),
3947
+ onClick: fetchAddModels,
3948
+ style: S.pageButton
3949
+ }, busy === "addfetch" ? t("page.fetching") : t("page.fetchFromGateway")),
3950
+ h("span", { style: S.pageHint }, t("page.modelsFieldHint")))),
3951
+ addDialog.error === "" ? null : h("p", { "data-mo": "add-error", style: S.pageError }, addDialog.error),
3952
+ h("div", { style: S.pickFoot },
3953
+ h("button", {
3954
+ "data-mo": "add-cancel",
3955
+ type: "button",
3956
+ onClick: function () { setAdd(null); },
3957
+ style: S.pageButton
3958
+ }, t("page.cancel")),
3959
+ h("button", {
3960
+ "data-mo": "add-submit",
3961
+ type: "button",
3962
+ disabled: busy === "create",
3963
+ onClick: createProvider,
3964
+ style: S.pageButtonPrimary
3965
+ }, busy === "create" ? t("page.working") : t("page.create"))))),
3966
+ document.body);
3967
+ }
3968
+
3969
+ /* ------------------------- the model details dialog ------------------------ */
3970
+ /* Everything about ONE model lives here, so the list row can stay a single line.
3971
+ The enable switch rides the plugin's own hidden list (DSH has no enabled field),
3972
+ and the reasoning editor is the three-valued shape the schema actually has. */
3973
+ function closeModel() { setModel(null); }
3974
+ function patchModel(patch) {
3975
+ if (modelDialog === null) return;
3976
+ setModel(Object.assign({}, modelDialog, { row: Object.assign({}, modelDialog.row, patch), error: "" }));
3977
+ }
3978
+ function patchReasoning(patch) {
3979
+ if (modelDialog === null) return;
3980
+ patchModel({ reasoning: Object.assign({}, modelDialog.row.reasoning, patch) });
3981
+ }
3982
+ function patchLevel(at, patch) {
3983
+ if (modelDialog === null) return;
3984
+ const levels = modelDialog.row.reasoning.levels.map(function (item, position) {
3985
+ return position === at ? Object.assign({}, item, patch) : item;
3986
+ });
3987
+ patchReasoning({ levels: levels });
3988
+ }
3989
+ function toggleModelEnabled() {
3990
+ if (modelDialog === null) return;
3991
+ toggleHidden("model", hiddenModelKey(current, modelDialog.row.original));
3992
+ }
3993
+ /* The enable list is keyed by route/model id, so a rename has to carry the key
3994
+ over; otherwise the new id would silently come back enabled. */
3995
+ function moveHiddenOnRename(row) {
3996
+ const before = hiddenModelKey(current, row.original);
3997
+ const after = hiddenModelKey(current, row.id);
3998
+ if (before === after) return;
3999
+ if (hidden.models[before] !== true) return;
4000
+ const next = Object.assign({}, hidden.models);
4001
+ delete next[before];
4002
+ next[after] = true;
4003
+ writeHidden(HIDDEN_MODELS_KEY, next);
4004
+ setHidden({ providers: hidden.providers, models: next });
4005
+ }
4006
+ function saveModelDialog() {
4007
+ if (modelDialog === null || busy === "models") return;
4008
+ const row = modelDialog.row;
4009
+ const at = modelDialog.at;
4010
+ const next = modelDialog.mode === "new"
4011
+ ? draftRows.concat([row])
4012
+ : draftRows.map(function (item, position) { return position === at ? row : item; });
4013
+ writeModels(next, function (error) {
4014
+ if (error !== undefined) { setModel(Object.assign({}, modelDialog, { error: error })); return; }
4015
+ moveHiddenOnRename(row);
4016
+ setModel(null);
4017
+ setNote("ok");
4018
+ });
4019
+ }
4020
+ function deleteModelDialog() {
4021
+ if (modelDialog === null || busy === "models") return;
4022
+ const at = modelDialog.at;
4023
+ const id = modelDialog.row.original;
4024
+ writeModels(draftRows.filter(function (item, position) { return position !== at; }), function (error) {
4025
+ if (error !== undefined) { setModel(Object.assign({}, modelDialog, { error: error })); return; }
4026
+ const key = hiddenModelKey(current, id);
4027
+ if (hidden.models[key] === true) toggleHidden("model", key);
4028
+ setModel(null);
4029
+ setNote("ok");
4030
+ });
4031
+ }
4032
+
4033
+ let modelNode = null;
4034
+ if (modelDialog !== null) {
4035
+ const row = modelDialog.row;
4036
+ const enabled = hidden.models[hiddenModelKey(current, row.original)] !== true;
4037
+ const reasoning = row.reasoning || { mode: "inherit", levels: [] };
4038
+ const modeButton = function (name, label, mode) {
4039
+ const active = reasoning.mode === mode;
4040
+ return h("button", {
4041
+ key: name,
4042
+ "data-mo": "model-dialog-reasoning-option",
4043
+ "data-mo-id": mode,
4044
+ type: "button",
4045
+ role: "radio",
4046
+ "aria-checked": active ? "true" : "false",
4047
+ onClick: function () { patchReasoning({ mode: mode }); },
4048
+ style: active ? Object.assign({}, S.pageSegment, S.pageSegmentActive) : S.pageSegment
4049
+ }, label);
4050
+ };
4051
+ modelNode = ReactDOM.createPortal(
4052
+ h("div", {
4053
+ "data-mo": "model-dialog",
4054
+ style: S.pickBackdrop,
4055
+ onMouseDown: function (event) { if (event.target === event.currentTarget) closeModel(); }
4056
+ },
4057
+ h(DialogShell, { label: t("page.modelDialogTitle"), marker: "model-dialog-card", onClose: closeModel },
4058
+ h("div", { style: S.pickHead },
4059
+ h("h3", { style: S.pickTitle }, modelDialog.mode === "new" ? t("page.modelDialogNew") : t("page.modelDialogTitle")),
4060
+ h("button", {
4061
+ "data-mo": "model-dialog-close",
4062
+ type: "button",
4063
+ title: t("page.cancelModel"),
4064
+ onClick: closeModel,
4065
+ style: S.pageIconButton
4066
+ }, "✕")),
4067
+ h("p", { style: S.pickDesc }, t("page.modelDialogHint")),
4068
+ h("div", { style: S.pageDialogBody },
4069
+ h("label", { style: S.pageDialogRow },
4070
+ h("span", { style: S.pageFieldLabel }, t("page.modelId")),
4071
+ h("input", {
4072
+ "data-mo": "model-dialog-id",
4073
+ "data-mo-focus": "true",
4074
+ value: row.id,
4075
+ placeholder: t("page.modelId"),
4076
+ spellCheck: false,
4077
+ onChange: function (event) { patchModel({ id: event.target.value }); },
4078
+ style: Object.assign({}, S.pageInputWide, S.pageInputMono)
4079
+ })),
4080
+ h("label", { style: S.pageDialogRow },
4081
+ h("span", { style: S.pageFieldLabel }, t("page.modelDisplayName")),
4082
+ h("input", {
4083
+ "data-mo": "model-dialog-name",
4084
+ value: row.name,
4085
+ spellCheck: false,
4086
+ onChange: function (event) { patchModel({ name: event.target.value }); },
4087
+ style: S.pageInputWide
4088
+ })),
4089
+ h("label", { style: S.pageDialogRow },
4090
+ h("span", { style: S.pageFieldLabel }, t("page.modelContext")),
4091
+ h("input", {
4092
+ "data-mo": "model-dialog-context",
4093
+ value: row.context,
4094
+ placeholder: "256K",
4095
+ title: t("page.modelCapacityHint"),
4096
+ spellCheck: false,
4097
+ onChange: function (event) { patchModel({ context: event.target.value }); },
4098
+ style: S.pageInputWide
4099
+ })),
4100
+ h("label", { style: S.pageDialogRow },
4101
+ h("span", { style: S.pageFieldLabel }, t("page.modelMax")),
4102
+ h("input", {
4103
+ "data-mo": "model-dialog-max",
4104
+ value: row.maxTokens,
4105
+ placeholder: "32K",
4106
+ title: t("page.modelCapacityHint"),
4107
+ spellCheck: false,
4108
+ onChange: function (event) { patchModel({ maxTokens: event.target.value }); },
4109
+ style: S.pageInputWide
4110
+ })),
4111
+ h("div", { style: S.pageDialogRow },
4112
+ h("span", { style: S.pageFieldLabel }, t("page.modelInput")),
4113
+ h("span", { style: S.pageKeyRow },
4114
+ h("label", { style: S.pageCheckLabel, title: t("page.modelText") },
4115
+ h("input", { "data-mo": "model-dialog-text", type: "checkbox", checked: true, disabled: true, readOnly: true }),
4116
+ h("span", null, t("page.modelText"))),
4117
+ h("label", { style: S.pageCheckLabel, title: t("page.modelInputHint") },
4118
+ h("input", {
4119
+ "data-mo": "model-dialog-image",
4120
+ type: "checkbox",
4121
+ checked: row.vision === true,
4122
+ onChange: function (event) { patchModel({ vision: event.target.checked }); }
4123
+ }),
4124
+ h("span", null, t("page.modelImage"))))),
4125
+ h("div", { style: S.pageDialogRow },
4126
+ h("span", { style: S.pageFieldLabel }, t("page.reasoningMode")),
4127
+ h("div", { style: S.pageSegments, role: "radiogroup", "aria-label": t("page.reasoningMode"), title: t("page.reasoningHint") },
4128
+ modeButton("inherit", t("page.reasoningInherit"), "inherit"),
4129
+ modeButton("none", t("page.reasoningNone"), "none"),
4130
+ modeButton("custom", t("page.reasoningCustom"), "custom"))),
4131
+ reasoning.mode === "custom" ? h("div", { key: "levels", style: S.pageLevels },
4132
+ reasoning.levels.map(function (level, at) {
4133
+ return h("div", { key: "level-" + String(at), style: S.pageLevelRow },
4134
+ h("input", {
4135
+ "data-mo": "model-dialog-level",
4136
+ value: level.level,
4137
+ placeholder: t("page.reasoningLevel"),
4138
+ spellCheck: false,
4139
+ onChange: function (event) { patchLevel(at, { level: event.target.value }); },
4140
+ style: S.pageInput
4141
+ }),
4142
+ h("input", {
4143
+ "data-mo": "model-dialog-level-value",
4144
+ value: level.value,
4145
+ placeholder: t("page.reasoningValue"),
4146
+ spellCheck: false,
4147
+ onChange: function (event) { patchLevel(at, { value: event.target.value }); },
4148
+ style: S.pageInput
4149
+ }),
4150
+ h("button", {
4151
+ "data-mo": "model-dialog-level-remove",
4152
+ type: "button",
4153
+ title: t("page.reasoningRemove"),
4154
+ onClick: function () {
4155
+ patchReasoning({ levels: reasoning.levels.filter(function (item, position) { return position !== at; }) });
4156
+ },
4157
+ style: S.pageIconButton
4158
+ }, "✕"));
4159
+ }),
4160
+ h("button", {
4161
+ "data-mo": "model-dialog-level-add",
4162
+ type: "button",
4163
+ onClick: function () { patchReasoning({ levels: reasoning.levels.concat([{ level: "", value: "" }]) }); },
4164
+ style: S.pageButton
4165
+ }, "+ " + t("page.reasoningAdd"))) : null,
4166
+ h("div", { style: S.pageDialogRow },
4167
+ h("span", { style: S.pageFieldLabel }, t("page.modelEnable")),
4168
+ h("span", { style: S.pageKeyRow },
4169
+ h(Switch, {
4170
+ marker: "model-dialog-switch",
4171
+ id: row.id,
4172
+ on: enabled,
4173
+ title: (enabled ? t("page.dotOn") : t("page.dotOff")) + " — " + t("page.dotHint"),
4174
+ onToggle: toggleModelEnabled
4175
+ }),
4176
+ h("span", { style: S.pageHint }, t("page.dotHint"))))),
4177
+ modelDialog.error !== "" ? h("p", { "data-mo": "model-dialog-error", style: S.pageError }, modelDialog.error) : null,
4178
+ h("div", { style: S.pickFoot },
4179
+ modelDialog.mode === "edit" ? h("button", {
4180
+ "data-mo": "model-dialog-delete",
4181
+ type: "button",
4182
+ title: t("page.modelDeleteHint"),
4183
+ disabled: busy === "models",
4184
+ onClick: deleteModelDialog,
4185
+ style: S.pageButton
4186
+ }, t("page.modelDelete")) : null,
4187
+ h("span", { style: S.pageSpacer }),
4188
+ h("button", {
4189
+ "data-mo": "model-dialog-cancel",
4190
+ type: "button",
4191
+ onClick: closeModel,
4192
+ style: S.pageButton
4193
+ }, t("page.cancelModel")),
4194
+ h("button", {
4195
+ "data-mo": "model-dialog-save",
4196
+ type: "button",
4197
+ disabled: busy === "models",
4198
+ onClick: saveModelDialog,
4199
+ style: S.pageButtonPrimary
4200
+ }, busy === "models" ? t("page.saving") : t("page.saveModel"))))),
4201
+ document.body);
4202
+ }
4203
+
4204
+ /* ----------------------- the delete-provider confirm ----------------------- */
4205
+ let deleteNode = null;
4206
+ if (deleteTarget !== null) {
4207
+ deleteNode = ReactDOM.createPortal(
4208
+ h("div", {
4209
+ "data-mo": "provider-delete-dialog",
4210
+ style: S.pickBackdrop,
4211
+ onMouseDown: function (event) { if (event.target === event.currentTarget) setDeleteTarget(null); }
4212
+ },
4213
+ h(DialogShell, { label: t("page.deleteProvider"), onClose: function () { setDeleteTarget(null); } },
4214
+ h("div", { style: S.pickHead },
4215
+ h("h3", { style: S.pickTitle }, t("page.deleteProvider") + " · " + deleteTarget),
4216
+ h("button", {
4217
+ "data-mo": "provider-delete-close",
4218
+ type: "button",
4219
+ title: t("page.cancel"),
4220
+ onClick: function () { setDeleteTarget(null); },
4221
+ style: S.pageIconButton
4222
+ }, "✕")),
4223
+ h("p", { style: S.pickDesc }, t("page.deleteProviderBody")),
4224
+ h("div", { style: S.pickFoot },
4225
+ h("button", {
4226
+ "data-mo": "provider-delete-cancel",
4227
+ type: "button",
4228
+ onClick: function () { setDeleteTarget(null); },
4229
+ style: S.pageButton
4230
+ }, t("page.cancel")),
4231
+ h("button", {
4232
+ "data-mo": "provider-delete-confirm",
4233
+ type: "button",
4234
+ disabled: busy === "delete",
4235
+ onClick: confirmDeleteProvider,
4236
+ style: S.pageButtonDanger
4237
+ }, busy === "delete" ? t("page.working") : t("page.deleteProvider"))))),
4238
+ document.body);
4239
+ }
4240
+
4241
+ return h("section", { "data-mo": "page", "data-mo-build": BUILD, style: S.pageRoot },
4242
+ h("header", { style: S.pageHead },
4243
+ h("div", { style: S.pageTitleRow },
4244
+ h("h2", { style: S.pageTitle }, t("page.title")),
4245
+ h("button", {
4246
+ "data-mo": "page-open-official",
4247
+ type: "button",
4248
+ title: t("page.openOfficialHint"),
4249
+ /* Going to the shipped page IS giving the row back: no separate switch, and
4250
+ no second state to explain. */
4251
+ onClick: function () {
4252
+ /* Nothing to navigate: the cell is won or lost by registration priority, so
4253
+ the menu row never moves. */
4254
+ if (typeof setTakeover === "function") setTakeover(false);
4255
+ },
4256
+ style: S.linkPill
4257
+ }, t("page.openOfficial"))),
4258
+ h("p", { style: S.pageSubtitle }, t("page.subtitle"))),
4259
+ h("div", { style: S.pageGrid }, rail, detail),
4260
+ h("p", { style: S.pageFooterHint }, t("page.note")),
4261
+ pickerNode,
4262
+ addNode,
4263
+ modelNode,
4264
+ deleteNode
4265
+ );
4266
+ }
4267
+
4268
+ function ModelsPageGuarded(props) {
4269
+ return h(PanelBoundary, null, h(ModelsPage, props));
4270
+ }
4271
+
1715
4272
  /* -------------------------------- helpers --------------------------------- */
1716
4273
 
1717
4274
  function namespaceView(view, namespace) {
@@ -1834,8 +4391,9 @@ window.__ModuleLoader__.load({
1834
4391
  mirror.ensure();
1835
4392
  const mutate = function (ops, revision) { return scope.remote.settings.mutate(PI_AI_NS, ops, revision); };
1836
4393
 
4394
+ bridge.slots = scope.slots;
1837
4395
  const face = function () {
1838
- return { store: mirror, namespace: PI_AI_NS, mutate: mutate, t: t };
4396
+ return { store: mirror, namespace: PI_AI_NS, mutate: mutate, t: t, slots: scope.slots, bridge: bridge };
1839
4397
  };
1840
4398
 
1841
4399
  /* NOTE: `settings.models.provider-card` is deliberately NOT taken. The owner
@@ -1857,15 +4415,118 @@ window.__ModuleLoader__.load({
1857
4415
  });
1858
4416
  }
1859
4417
 
4418
+ /* The organizer page registers from the SAME mirror the footer panel uses, and
4419
+ only once: the two `configForms`/`settingsScope` callbacks race, and a second
4420
+ entry would put a duplicate row in the nav (list seats key on id). The entry
4421
+ uses its OWN id — never "models" — so the shipped section keeps rendering. */
4422
+ /* Two more namespaces, requested separately: a build without them keeps the page
4423
+ mounted and reports the key as unavailable instead of losing the whole seat. */
4424
+ const bridge = { credentials: null, llm: null };
4425
+ ctx.inject(["slots", "remote.credentials"], function (scope) { bridge.credentials = scope.remote.credentials; });
4426
+ ctx.inject(["slots", "remote.llm"], function (scope) { bridge.llm = scope.remote.llm; });
4427
+
4428
+ let modelsPageMounted = false;
4429
+
4430
+ function mountModelsPage(scope, mirror) {
4431
+ if (modelsPageMounted) return;
4432
+ modelsPageMounted = true;
4433
+ mirror.ensure();
4434
+ try {
4435
+ if (document.body !== null && document.querySelector('[data-mo="page-mounted"]') === null) {
4436
+ const mark = document.createElement("div");
4437
+ mark.setAttribute("data-mo", "page-mounted");
4438
+ mark.hidden = true;
4439
+ document.body.appendChild(mark);
4440
+ }
4441
+ } catch (e) {}
4442
+ const mutate = function (ops, revision) { return scope.remote.settings.mutate(PI_AI_NS, ops, revision); };
4443
+ bridge.slots = scope.slots;
4444
+ let sectionDispose = null;
4445
+
4446
+ /* ONE registration for the whole lifetime, always under the shipped id:
4447
+ - the nav reads the RAW entry list (no dedup), so it always holds our cell next
4448
+ to the shipped one. Our cell is kept hidden, so the settings menu always looks
4449
+ like the original single row — and that visible row carries the SHIPPED label,
4450
+ because it is the shipped cell;
4451
+ - WHO wins that cell is decided by priority: entries stay sorted ascending by
4452
+ priority and `entriesOfSlot` takes the first per id. So -1 wins it for this
4453
+ page ("taken over") and a huge number lets the shipped page win ("handed
4454
+ back") — with no unregistration, no id change and no navigation at all.
4455
+ That is what makes the two states indistinguishable in the menu. */
4456
+ function registerSection(on) {
4457
+ try {
4458
+ if (sectionDispose !== null) { sectionDispose(); sectionDispose = null; }
4459
+ } catch (e) {}
4460
+ const face = function () {
4461
+ return {
4462
+ store: mirror, namespace: PI_AI_NS, mutate: mutate, bridge: bridge, t: t,
4463
+ takeover: on === true, setTakeover: switchTo
4464
+ };
4465
+ };
4466
+ sectionDispose = guard(scope, "settings.section", function () {
4467
+ return scope.slots.register({
4468
+ name: "settings.section",
4469
+ id: "models",
4470
+ order: 10,
4471
+ /* One below the shipped entry wins the cell for this page; one above hands
4472
+ it back. Relative, so the default survives whatever priority the shipped
4473
+ entry was given. */
4474
+ priority: shippedSectionPriority(scope.slots, "models", t("nav")) + (on === true ? -1 : 1),
4475
+ label: function () { return t("nav"); },
4476
+ locale: NS,
4477
+ inject: face
4478
+ }, ModelsPageGuarded);
4479
+ });
4480
+ }
4481
+
4482
+ /* Our cell stays hidden whichever page wins: the menu must never show two rows for
4483
+ one section, and the visible row (the shipped cell) is what opens the cell —
4484
+ whoever ends up winning it. */
4485
+ function paintOwnNavCell() {
4486
+ const slots = scope.slots;
4487
+ const onClosed = function () {
4488
+ /* Settings was closed: the next time it opens, the row is this page again. */
4489
+ if (bridge.takeover !== true) switchTo(true);
4490
+ };
4491
+ onNavChanged(t("nav"), slots, onClosed);
4492
+ nextFrame(function () { onNavChanged(t("nav"), slots, onClosed); });
4493
+ watchOwnNavCell(t("nav"), slots, onClosed);
4494
+ }
4495
+
4496
+ function switchTo(on) {
4497
+ const next = on === true;
4498
+ bridge.takeover = next;
4499
+ registerSection(next);
4500
+ paintOwnNavCell();
4501
+ }
4502
+
4503
+ bridge.setTakeover = switchTo;
4504
+ bridge.takeover = true;
4505
+ scope.slots.inject("settings.section", function () {
4506
+ registerSection(bridge.takeover);
4507
+ paintOwnNavCell();
4508
+ return function () {
4509
+ if (sectionDispose !== null) { sectionDispose(); sectionDispose = null; }
4510
+ unwatchOwnNavCell();
4511
+ showOwnNavCell();
4512
+ };
4513
+ });
4514
+ }
4515
+
1860
4516
  ctx.inject(["slots", "configForms", "remote", "remote.settings"], function (scope) {
1861
- mountOrderPanel(scope, scope.configForms.describe());
4517
+ const mirror = scope.configForms.describe();
4518
+ mountOrderPanel(scope, mirror);
4519
+ mountModelsPage(scope, mirror);
1862
4520
  });
1863
4521
  ctx.inject(["slots", "settingsScope", "remote", "remote.settings"], function (scope) {
1864
- mountOrderPanel(scope, scope.settingsScope.describe());
4522
+ const mirror = scope.settingsScope.describe();
4523
+ mountOrderPanel(scope, mirror);
4524
+ mountModelsPage(scope, mirror);
1865
4525
  });
1866
4526
  }
1867
4527
 
1868
- module.exports = { name: "model-organizer", apply: apply, inject: inject };
4528
+ /* `__test` is a test seam only: the loader reads name/apply/inject and ignores it. */
4529
+ module.exports = { name: "model-organizer", apply: apply, inject: inject, __test: { visibleGroupsFor: visibleGroupsFor, focusableIn: focusableIn } };
1869
4530
  return module.exports;
1870
4531
  }
1871
4532
  });