@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.
- package/DEVELOPMENT.md +385 -75
- package/README.md +94 -73
- package/lib/client.js +2679 -18
- 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",
|
|
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 = "
|
|
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
|
-
/*
|
|
305
|
-
|
|
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 <
|
|
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 :
|
|
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
|
-
/*
|
|
1523
|
-
|
|
1524
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4522
|
+
const mirror = scope.settingsScope.describe();
|
|
4523
|
+
mountOrderPanel(scope, mirror);
|
|
4524
|
+
mountModelsPage(scope, mirror);
|
|
1865
4525
|
});
|
|
1866
4526
|
}
|
|
1867
4527
|
|
|
1868
|
-
|
|
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
|
});
|