@nimoxie/dsh-model-organizer 0.5.0 → 0.5.12
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 +140 -13
- package/LICENSE +21 -21
- package/README.md +35 -28
- package/cordis.patch.yml +5 -5
- package/lib/client.js +625 -38
- package/lib/index.js +9 -9
- package/package.json +2 -2
package/lib/client.js
CHANGED
|
@@ -124,6 +124,30 @@ window.__ModuleLoader__.load({
|
|
|
124
124
|
"page.displayNameOptional": "显示名称(可选)",
|
|
125
125
|
"page.baseUrlOptional": "API 地址(可稍后填写)",
|
|
126
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": "测一下这个模型现在能不能用",
|
|
143
|
+
"page.testOk": "可用",
|
|
144
|
+
"page.testMissing": "网关可达,但列表里没有这个 ID",
|
|
145
|
+
"page.testFailed": "测试失败。",
|
|
146
|
+
"page.testNoCatalog": "没有 API 地址,无法测试",
|
|
147
|
+
"page.deleteModel": "删除模型",
|
|
148
|
+
"page.testNeedsId": "先填模型 ID",
|
|
149
|
+
"page.deleteModelHint": "从配置里删掉这个模型(每条路由至少要留一个)",
|
|
150
|
+
"page.modelDeleted": "模型已删除",
|
|
127
151
|
"page.openOfficial": "官方模型页",
|
|
128
152
|
"page.openOfficialHint": "把「模型」这一行交还官方页(菜单不会变,仍然只有「模型」一行;随时可从官方页浮窗的「模型管理页」切回来)",
|
|
129
153
|
"page.switchFailed": "没找到设置导航,请在左侧直接点「模型」页。",
|
|
@@ -260,6 +284,30 @@ window.__ModuleLoader__.load({
|
|
|
260
284
|
"page.displayNameOptional": "Display name (optional)",
|
|
261
285
|
"page.baseUrlOptional": "Base URL (can be filled in later)",
|
|
262
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": "Check whether this model works right now",
|
|
303
|
+
"page.testOk": "Works",
|
|
304
|
+
"page.testMissing": "Gateway reachable, but this ID is not listed",
|
|
305
|
+
"page.testFailed": "The test failed.",
|
|
306
|
+
"page.testNoCatalog": "No API address — cannot test",
|
|
307
|
+
"page.deleteModel": "Delete model",
|
|
308
|
+
"page.testNeedsId": "Enter a model ID first",
|
|
309
|
+
"page.deleteModelHint": "Remove this model from the configuration (every route keeps at least one)",
|
|
310
|
+
"page.modelDeleted": "Model deleted",
|
|
263
311
|
"page.openOfficial": "Official Models page",
|
|
264
312
|
"page.openOfficialHint": "Hand the Models row back to the shipped page (the menu does not change; switch back any time from the panel)",
|
|
265
313
|
"page.switchFailed": "The settings nav was not found — pick the Models page on the left.",
|
|
@@ -329,7 +377,7 @@ window.__ModuleLoader__.load({
|
|
|
329
377
|
code changes, so a browser can keep running an old build until a hard reload.
|
|
330
378
|
Kept OFF the UI (it is not user-facing): read it as
|
|
331
379
|
`window.__dshModelOrganizerBuild` when a stale build is suspected. */
|
|
332
|
-
const BUILD = "
|
|
380
|
+
const BUILD = "b75";
|
|
333
381
|
|
|
334
382
|
let officialClasses = null;
|
|
335
383
|
let officialClassesSheets = -1;
|
|
@@ -422,6 +470,18 @@ window.__ModuleLoader__.load({
|
|
|
422
470
|
".dsh-mo-head:focus-visible{outline:none;background:var(--dsw-alias-interactive-bg-hover);}",
|
|
423
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;}",
|
|
424
472
|
".dsh-mo-panelhead.dsh-mo-panelhead:focus-visible{background:var(--dsw-alias-interactive-bg-hover);}",
|
|
473
|
+
/* Icon actions (row delete / model test): the kit's icons inherit
|
|
474
|
+
currentColor, so their colour state lives in these 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
|
+
|
|
425
485
|
".dsh-mo-chev{transition:transform .12s ease;}",
|
|
426
486
|
".dsh-mo-head[aria-expanded='true'] .dsh-mo-chev{transform:rotate(90deg);}",
|
|
427
487
|
".dsh-mo-row{border-width:1px;border-style:solid;border-color:transparent;}",
|
|
@@ -469,6 +529,35 @@ window.__ModuleLoader__.load({
|
|
|
469
529
|
".dsh-mo-model::before{content:'';position:absolute;left:15px;top:50%;width:5px;height:5px;margin-top:-2.5px;border-radius:50%;background:var(--dsw-alias-label-tertiary);opacity:.5;}",
|
|
470
530
|
".dsh-mo-model[aria-checked='true']::before{background:var(--dsw-alias-label-primary);opacity:.9;}"
|
|
471
531
|
];
|
|
532
|
+
|
|
533
|
+
/* Every button on this page, in its dialogs, and in the footer panel shares one
|
|
534
|
+
pointer state. The resting paint is inline (the S.* objects), so the hover
|
|
535
|
+
paint has to outrank it: `!important` is confined to hover/disabled, and
|
|
536
|
+
:where() keeps the scope selector at zero specificity so the variant rules
|
|
537
|
+
below still win. */
|
|
538
|
+
const BTN_SURFACES = [
|
|
539
|
+
'[data-mo="page"] button', '[data-mo="panel"] button',
|
|
540
|
+
'[data-mo="fetch-dialog"] button', '[data-mo="add-dialog"] button',
|
|
541
|
+
'[data-mo="model-dialog"] button', '[data-mo="provider-delete-dialog"] button'
|
|
542
|
+
];
|
|
543
|
+
const BTN = ":where(" + BTN_SURFACES.join(",") + ")";
|
|
544
|
+
const byMarker = function (markers) {
|
|
545
|
+
return ":where(" + markers.map(function (marker) { return "[data-mo='" + marker + "']"; }).join(",") + ")";
|
|
546
|
+
};
|
|
547
|
+
rules.push(BTN + "{transition:background .12s ease,border-color .12s ease,color .12s ease,filter .12s ease,box-shadow .12s ease;}");
|
|
548
|
+
rules.push(BTN + ":not([role='switch']):not(:disabled):hover{background:var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.20))!important;}");
|
|
549
|
+
rules.push(BTN + ":not(:disabled):active{filter:brightness(.96);}");
|
|
550
|
+
rules.push(BTN + ":disabled{cursor:not-allowed;opacity:.55;}");
|
|
551
|
+
/* A switch paints its own on/off fill: repainting it on hover would read as a
|
|
552
|
+
state change, so it gets a ring instead. */
|
|
553
|
+
rules.push(BTN + ":is([role='switch']):not(:disabled):hover{box-shadow:0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-accent, #4f8cff) 40%, transparent);}");
|
|
554
|
+
/* Primary actions keep their accent fill, destructive ones tint the danger
|
|
555
|
+
colour, and the two quiet cross-links accent their frame. */
|
|
556
|
+
rules.push(byMarker(["page-key-save", "fetch-adopt", "add-submit", "model-dialog-save"]) + ":not(:disabled):hover{background:var(--dsw-alias-state-accent, #4f8cff)!important;filter:brightness(1.12);}");
|
|
557
|
+
rules.push(byMarker(["page-model-delete", "page-provider-delete", "model-dialog-delete", "provider-delete-confirm"]) + ":not(:disabled):hover{background:color-mix(in srgb, var(--dsw-alias-state-error, #f85149) 18%, transparent)!important;border-color:var(--dsw-alias-state-error, #f85149)!important;}");
|
|
558
|
+
rules.push(byMarker(["page-open-official", "page-provider-add"]) + ":not(:disabled):hover{color:var(--dsw-alias-label-primary)!important;border-color:var(--dsw-alias-state-accent, #4f8cff)!important;}");
|
|
559
|
+
rules.push(BTN + "[role='radio'][aria-checked='true']:not(:disabled):hover{filter:brightness(1.12);}");
|
|
560
|
+
rules.push(BTN + "[data-mo$='-close']:not(:disabled):hover{color:var(--dsw-alias-label-primary)!important;}");
|
|
472
561
|
/* The shipped check alone is easy to miss: bold the selected row's name
|
|
473
562
|
wherever the official sheet is in use. Settings rows carry no
|
|
474
563
|
aria-checked, so the drag lists are untouched. */
|
|
@@ -495,6 +584,39 @@ window.__ModuleLoader__.load({
|
|
|
495
584
|
return h(P[name], props ? Object.assign({}, props) : {});
|
|
496
585
|
}
|
|
497
586
|
|
|
587
|
+
/* The shipped kit (94 icons) is reached through the primitives module, same as the
|
|
588
|
+
official pages do. Outside the app (headless harness, or a build without it) keep a
|
|
589
|
+
neutral glyph in the same box so nothing collapses. */
|
|
590
|
+
function kitIcon(name, glyph, size) {
|
|
591
|
+
const rendered = icon(name, { size: size });
|
|
592
|
+
if (rendered !== null) return rendered;
|
|
593
|
+
return h("span", { "aria-hidden": "true", style: { fontSize: size - 3, lineHeight: 1 } }, glyph);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/* One box for the three icon actions: the provider head's delete and each model
|
|
597
|
+
row's test / delete. Height is the switch's (17) on purpose — a taller icon
|
|
598
|
+
button beside the switch is what the page screenshot called out. Width is a
|
|
599
|
+
nudge above the proportional shrink (the same screenshot then called 20 a hair
|
|
600
|
+
too narrow); the height stays equal to the switch so the row never looks taller. */
|
|
601
|
+
const ACTION_BOX = {
|
|
602
|
+
boxSizing: "border-box", flex: "none", width: 19, height: 17, borderRadius: 6,
|
|
603
|
+
/* The detail head aligns its TEXT on the baseline; an icon-only box has no real
|
|
604
|
+
text baseline, so it must opt into center alignment or it floats a pixel or two
|
|
605
|
+
above the switch (the cropped screenshot that showed them out of line). */
|
|
606
|
+
alignSelf: "center",
|
|
607
|
+
display: "inline-flex", alignItems: "center", justifyContent: "center", padding: 0,
|
|
608
|
+
borderWidth: 1, borderStyle: "solid", borderColor: "var(--dsw-alias-border-l1)",
|
|
609
|
+
background: "transparent", cursor: "pointer"
|
|
610
|
+
};
|
|
611
|
+
/* The destructive sibling: identical geometry, danger frame. */
|
|
612
|
+
const DELETE_ACTION = Object.assign({}, ACTION_BOX, { borderColor: "var(--dsw-alias-state-error, #f85149)" });
|
|
613
|
+
/* The diagnostic button is a NORMAL button to the eye: same box as the two above,
|
|
614
|
+
but it wears the page buttons' tinted fill (S.pageButton) instead of a transparent
|
|
615
|
+
frame. Its verdict stays in the icon colour (.dsh-mo-act-ok/-miss/-danger). */
|
|
616
|
+
const TEST_ACTION = Object.assign({}, ACTION_BOX, {
|
|
617
|
+
background: "var(--dsw-alias-interactive-bg, rgba(127,127,127,.10))"
|
|
618
|
+
});
|
|
619
|
+
|
|
498
620
|
/* --------------------------------- styles --------------------------------- */
|
|
499
621
|
const S = {
|
|
500
622
|
chip: {
|
|
@@ -607,15 +729,6 @@ window.__ModuleLoader__.load({
|
|
|
607
729
|
background: "var(--dsw-alias-interactive-bg, rgba(127,127,127,.10))",
|
|
608
730
|
borderWidth: 1, borderStyle: "solid", borderColor: "var(--dsw-alias-border-l1)"
|
|
609
731
|
},
|
|
610
|
-
switchLabel: {
|
|
611
|
-
display: "inline-flex", alignItems: "center", gap: 6, fontSize: 11,
|
|
612
|
-
color: "var(--dsw-alias-label-secondary)", cursor: "pointer", userSelect: "none"
|
|
613
|
-
},
|
|
614
|
-
linkPillOn: {
|
|
615
|
-
color: "var(--dsw-alias-label-primary)",
|
|
616
|
-
background: "var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.20))",
|
|
617
|
-
borderColor: "var(--dsw-alias-state-accent, #4f8cff)"
|
|
618
|
-
},
|
|
619
732
|
pageTitle: { margin: 0, fontSize: 16, fontWeight: 600, color: "var(--dsw-alias-label-primary)" },
|
|
620
733
|
pageSubtitle: { margin: 0, fontSize: 12, lineHeight: "18px", color: "var(--dsw-alias-label-tertiary)" },
|
|
621
734
|
pageGrid: { display: "grid", gridTemplateColumns: "minmax(170px, 210px) minmax(0, 1fr)", gap: 14, alignItems: "start" },
|
|
@@ -637,21 +750,10 @@ window.__ModuleLoader__.load({
|
|
|
637
750
|
pageDetailHead: { display: "flex", alignItems: "baseline", gap: 8, flexWrap: "wrap" },
|
|
638
751
|
pageDetailTitle: { margin: 0, fontSize: 15, fontWeight: 600 },
|
|
639
752
|
pageDetailRoute: { fontSize: 11, color: "var(--dsw-alias-label-tertiary)" },
|
|
640
|
-
pageFacts: { display: "flex", flexDirection: "column", gap: 6 },
|
|
641
|
-
pageFact: { display: "grid", gridTemplateColumns: "84px minmax(0, 1fr)", gap: 10, alignItems: "baseline", fontSize: 12 },
|
|
642
|
-
pageFactLabel: { color: "var(--dsw-alias-label-tertiary)" },
|
|
643
|
-
pageFactValue: { color: "var(--dsw-alias-label-secondary)", wordBreak: "break-all" },
|
|
644
753
|
pageSectionHead: { display: "flex", alignItems: "baseline", justifyContent: "space-between", gap: 8 },
|
|
645
754
|
pageSectionLabel: { fontSize: 12, fontWeight: 600 },
|
|
646
755
|
pageHint: { fontSize: 10, color: "var(--dsw-alias-label-tertiary)" },
|
|
647
756
|
pageEmpty: { margin: 0, fontSize: 12, lineHeight: "18px", color: "var(--dsw-alias-label-tertiary)" },
|
|
648
|
-
pageModels: { display: "flex", flexDirection: "column", gap: 4 },
|
|
649
|
-
pageModelRow: {
|
|
650
|
-
display: "flex", alignItems: "center", gap: 8, minWidth: 0, padding: "5px 8px", borderRadius: 8,
|
|
651
|
-
fontSize: 12, background: "var(--dsw-alias-interactive-bg, rgba(127,127,127,.06))"
|
|
652
|
-
},
|
|
653
|
-
pageModelId: { minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" },
|
|
654
|
-
pageModelName: { minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", color: "var(--dsw-alias-label-tertiary)" },
|
|
655
757
|
pageSpacer: { flex: "1 1 auto", minWidth: 0 },
|
|
656
758
|
pageChip: { flex: "none", padding: "1px 6px", borderRadius: 6, fontSize: 10, border: "1px solid var(--dsw-alias-border-l1)", color: "var(--dsw-alias-label-tertiary)" },
|
|
657
759
|
pageLoading: { margin: 0, fontSize: 12, color: "var(--dsw-alias-label-tertiary)" },
|
|
@@ -704,8 +806,6 @@ window.__ModuleLoader__.load({
|
|
|
704
806
|
outline: "none"
|
|
705
807
|
},
|
|
706
808
|
pageInputMono: { flex: "1 1 auto", minWidth: 0, fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" },
|
|
707
|
-
pageModelTop: { display: "flex", alignItems: "center", gap: 6, minWidth: 0 },
|
|
708
|
-
pageModelFields: { display: "grid", gridTemplateColumns: "92px 92px auto", gap: 6, alignItems: "center", paddingLeft: 18 },
|
|
709
809
|
/* A native <select> popup is painted by the platform: in this theme its list came
|
|
710
810
|
out white-on-white. Three radio-style segments are painted by us, so the choice
|
|
711
811
|
is legible whatever the platform does. */
|
|
@@ -713,6 +813,9 @@ window.__ModuleLoader__.load({
|
|
|
713
813
|
"vision", but a switch reads as an on/off state at a glance. */
|
|
714
814
|
switchTrack: {
|
|
715
815
|
boxSizing: "border-box", flex: "none", width: 30, height: 17, padding: 2, borderRadius: 9,
|
|
816
|
+
/* Same reason as ACTION_BOX: the knob gives this flex box a baseline that is
|
|
817
|
+
NOT its box bottom, so baseline rows would offset it from the icon button. */
|
|
818
|
+
alignSelf: "center",
|
|
716
819
|
display: "inline-flex", alignItems: "center", justifyContent: "flex-start", cursor: "pointer",
|
|
717
820
|
background: "var(--dsw-alias-interactive-bg, rgba(127,127,127,.25))",
|
|
718
821
|
borderWidth: 1, borderStyle: "solid", borderColor: "var(--dsw-alias-border-l1)",
|
|
@@ -748,12 +851,6 @@ window.__ModuleLoader__.load({
|
|
|
748
851
|
background: "var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.20))",
|
|
749
852
|
borderColor: "var(--dsw-alias-state-accent, #4f8cff)"
|
|
750
853
|
},
|
|
751
|
-
pageSelect: {
|
|
752
|
-
boxSizing: "border-box", padding: "4px 8px", borderRadius: 7, fontSize: 12,
|
|
753
|
-
color: "var(--dsw-alias-label-primary)",
|
|
754
|
-
background: "var(--dsw-alias-bg-module, rgba(127,127,127,.08))",
|
|
755
|
-
borderWidth: 1, borderStyle: "solid", borderColor: "var(--dsw-alias-border-l1)"
|
|
756
|
-
},
|
|
757
854
|
pageButton: {
|
|
758
855
|
boxSizing: "border-box", flex: "none", padding: "4px 10px", borderRadius: 7, fontSize: 12, cursor: "pointer",
|
|
759
856
|
color: "var(--dsw-alias-label-secondary)",
|
|
@@ -766,6 +863,20 @@ window.__ModuleLoader__.load({
|
|
|
766
863
|
background: "var(--dsw-alias-state-accent, #4f8cff)",
|
|
767
864
|
borderWidth: 1, borderStyle: "solid", borderColor: "transparent"
|
|
768
865
|
},
|
|
866
|
+
/* Row-level actions and the one destructive action on this page. */
|
|
867
|
+
pageModelActions: { flex: "none", display: "flex", alignItems: "center", gap: 4 },
|
|
868
|
+
/* The diagnostic action: same box as the trash, but it wears the normal page
|
|
869
|
+
button's tinted fill; its verdict colour rides the icon (.dsh-mo-act-*), never
|
|
870
|
+
the frame. */
|
|
871
|
+
pageIconAction: TEST_ACTION,
|
|
872
|
+
/* The one trash box; the provider head and the model rows share this object. */
|
|
873
|
+
pageIconDelete: DELETE_ACTION,
|
|
874
|
+
|
|
875
|
+
pageButtonDanger: {
|
|
876
|
+
boxSizing: "border-box", flex: "none", padding: "4px 10px", borderRadius: 7, fontSize: 12, cursor: "pointer",
|
|
877
|
+
borderWidth: 1, borderStyle: "solid", borderColor: "var(--dsw-alias-state-error, #f85149)",
|
|
878
|
+
background: "transparent", color: "var(--dsw-alias-state-error, #f85149)"
|
|
879
|
+
},
|
|
769
880
|
pageIconButton: {
|
|
770
881
|
boxSizing: "border-box", flex: "none", width: 22, height: 22, lineHeight: "18px", padding: 0, borderRadius: 6, fontSize: 11,
|
|
771
882
|
cursor: "pointer", color: "var(--dsw-alias-label-tertiary)", background: "transparent",
|
|
@@ -773,7 +884,6 @@ window.__ModuleLoader__.load({
|
|
|
773
884
|
},
|
|
774
885
|
pageKeyRow: { display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap" },
|
|
775
886
|
pageActions: { display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" },
|
|
776
|
-
pageHandle: { flex: "none", color: "var(--dsw-alias-label-tertiary)", fontSize: 12, userSelect: "none" },
|
|
777
887
|
pageCheckLabel: { flex: "none", display: "inline-flex", alignItems: "center", gap: 4, fontSize: 11, color: "var(--dsw-alias-label-tertiary)", cursor: "pointer" },
|
|
778
888
|
/* Built-in rail rows: readable, obviously not ours to edit, and never draggable
|
|
779
889
|
(they sit outside the drag list for exactly that reason). */
|
|
@@ -2531,6 +2641,22 @@ window.__ModuleLoader__.load({
|
|
|
2531
2641
|
/* Draft rows keep what the user typed (text), and only the write parses. The
|
|
2532
2642
|
reasoning mode is three-valued because the schema is: absent inherits the catalog,
|
|
2533
2643
|
`false` declares a non-reasoning model, an object declares the levels. */
|
|
2644
|
+
/* Split a model-id list typed by a human ("glm-4.6, qwen-max" or one per line) into
|
|
2645
|
+
ids, dropping empties and duplicates. Used by the create dialog, because a declared
|
|
2646
|
+
route with no models cannot resolve at all (the host refuses it). */
|
|
2647
|
+
function parseModelIds(text) {
|
|
2648
|
+
const out = [];
|
|
2649
|
+
const seen = {};
|
|
2650
|
+
const parts = String(text === undefined || text === null ? "" : text).split(/[\s,,;;]+/);
|
|
2651
|
+
for (let i = 0; i < parts.length; i++) {
|
|
2652
|
+
const id = parts[i].trim();
|
|
2653
|
+
if (id === "" || seen[id] === true) continue;
|
|
2654
|
+
seen[id] = true;
|
|
2655
|
+
out.push(id);
|
|
2656
|
+
}
|
|
2657
|
+
return out;
|
|
2658
|
+
}
|
|
2659
|
+
|
|
2534
2660
|
/* A blank row for the "add model" dialog: the same shape modelDraftRows projects. */
|
|
2535
2661
|
function emptyModelRow() {
|
|
2536
2662
|
return { id: "", original: "", name: "", context: "", maxTokens: "", vision: false, reasoning: { mode: "inherit", levels: [] } };
|
|
@@ -2606,6 +2732,168 @@ window.__ModuleLoader__.load({
|
|
|
2606
2732
|
return { models: out };
|
|
2607
2733
|
}
|
|
2608
2734
|
|
|
2735
|
+
/* ---------------------------- modal focus shell ---------------------------- */
|
|
2736
|
+
/* Every dialog on this page is portalled to <body>, so its nodes sit AFTER the
|
|
2737
|
+
whole settings page in the document's tab order. Two things went wrong with the
|
|
2738
|
+
first attempt (a card-level keydown handler that only intervened at the edges):
|
|
2739
|
+
the host shell could still pull focus out, and any step that did not start or end
|
|
2740
|
+
exactly on a tracked element let the browser continue through the page behind the
|
|
2741
|
+
backdrop. This shell therefore OWNS Tab while it is open: it listens in the capture
|
|
2742
|
+
phase on the document, always takes the event over and moves focus itself, and a
|
|
2743
|
+
focusin guard drags focus back if anything (host shell, programmatic blur) moves it
|
|
2744
|
+
outside. Escape closes here too, so the page behind never sees it. */
|
|
2745
|
+
const FOCUSABLE_SELECTOR = [
|
|
2746
|
+
"a[href]",
|
|
2747
|
+
"button:not([disabled])",
|
|
2748
|
+
"input:not([disabled]):not([type='hidden'])",
|
|
2749
|
+
"select:not([disabled])",
|
|
2750
|
+
"textarea:not([disabled])",
|
|
2751
|
+
"[tabindex]:not([tabindex='-1'])"
|
|
2752
|
+
].join(",");
|
|
2753
|
+
|
|
2754
|
+
function focusableIn(root) {
|
|
2755
|
+
const out = [];
|
|
2756
|
+
if (root === null || root === undefined || typeof root.querySelectorAll !== "function") return out;
|
|
2757
|
+
let nodes = null;
|
|
2758
|
+
try { nodes = root.querySelectorAll(FOCUSABLE_SELECTOR); } catch (e) { return out; }
|
|
2759
|
+
if (nodes === null) return out;
|
|
2760
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
2761
|
+
const node = nodes[i];
|
|
2762
|
+
/* A hidden control cannot take focus: without this check the ring stops on an
|
|
2763
|
+
element the browser skips, and the next Tab starts from a stale position. */
|
|
2764
|
+
try {
|
|
2765
|
+
if (typeof node.getClientRects === "function" && node.getClientRects().length === 0) continue;
|
|
2766
|
+
} catch (e) {}
|
|
2767
|
+
out.push(node);
|
|
2768
|
+
}
|
|
2769
|
+
return out;
|
|
2770
|
+
}
|
|
2771
|
+
|
|
2772
|
+
/* One manual Tab step inside the dialog, wrapping at both ends. Always returns true:
|
|
2773
|
+
the caller must preventDefault, because the browser's own walk is exactly what
|
|
2774
|
+
escapes to the settings page. */
|
|
2775
|
+
function trapTab(event, card) {
|
|
2776
|
+
const list = focusableIn(card);
|
|
2777
|
+
if (list.length === 0) return true;
|
|
2778
|
+
let index = -1;
|
|
2779
|
+
try { index = list.indexOf(document.activeElement); } catch (e) {}
|
|
2780
|
+
const target = index === -1
|
|
2781
|
+
? (event.shiftKey === true ? list[list.length - 1] : list[0])
|
|
2782
|
+
: list[(index + (event.shiftKey === true ? -1 : 1) + list.length) % list.length];
|
|
2783
|
+
try { if (target !== undefined && typeof target.focus === "function") target.focus(); } catch (e) {}
|
|
2784
|
+
return true;
|
|
2785
|
+
}
|
|
2786
|
+
|
|
2787
|
+
function DialogShell(props) {
|
|
2788
|
+
const cardRef = useRef(null);
|
|
2789
|
+
const restoreRef = useRef(null);
|
|
2790
|
+
const closeRef = useRef(props.onClose);
|
|
2791
|
+
closeRef.current = props.onClose;
|
|
2792
|
+
|
|
2793
|
+
useEffect(function () {
|
|
2794
|
+
let previous = null;
|
|
2795
|
+
try { previous = document.activeElement; } catch (e) {}
|
|
2796
|
+
restoreRef.current = previous === undefined || previous === null || previous === document.body ? null : previous;
|
|
2797
|
+
const card = cardRef.current;
|
|
2798
|
+
if (card === null || card === undefined) return undefined;
|
|
2799
|
+
const firstControl = function () {
|
|
2800
|
+
let target = null;
|
|
2801
|
+
try { target = typeof card.querySelector === "function" ? card.querySelector("[data-mo-focus]") : null; } catch (e) {}
|
|
2802
|
+
if (target === null || target === undefined) target = focusableIn(card)[0];
|
|
2803
|
+
return target === undefined ? null : target;
|
|
2804
|
+
};
|
|
2805
|
+
const focusFirst = function () {
|
|
2806
|
+
const target = firstControl();
|
|
2807
|
+
try { if (target !== null && typeof target.focus === "function") target.focus(); } catch (e) {}
|
|
2808
|
+
return target;
|
|
2809
|
+
};
|
|
2810
|
+
focusFirst();
|
|
2811
|
+
/* A portal that was just inserted can lose the synchronous focus() to the host
|
|
2812
|
+
shell's own focus pass; re-assert on the next frame when focus did not stick. */
|
|
2813
|
+
let retry = null;
|
|
2814
|
+
try {
|
|
2815
|
+
if (typeof requestAnimationFrame === "function") {
|
|
2816
|
+
retry = requestAnimationFrame(function () {
|
|
2817
|
+
retry = null;
|
|
2818
|
+
let inside = false;
|
|
2819
|
+
try { inside = typeof card.contains === "function" && card.contains(document.activeElement); } catch (e) { inside = false; }
|
|
2820
|
+
if (!inside) focusFirst();
|
|
2821
|
+
});
|
|
2822
|
+
}
|
|
2823
|
+
} catch (e) { retry = null; }
|
|
2824
|
+
|
|
2825
|
+
const onKeyDown = function (event) {
|
|
2826
|
+
if (event.key === "Escape") {
|
|
2827
|
+
event.preventDefault();
|
|
2828
|
+
if (event.stopPropagation) event.stopPropagation();
|
|
2829
|
+
if (typeof closeRef.current === "function") closeRef.current();
|
|
2830
|
+
return;
|
|
2831
|
+
}
|
|
2832
|
+
if (event.key !== "Tab") return;
|
|
2833
|
+
if (event.preventDefault) event.preventDefault();
|
|
2834
|
+
if (event.stopPropagation) event.stopPropagation();
|
|
2835
|
+
trapTab(event, card);
|
|
2836
|
+
};
|
|
2837
|
+
/* The host shell reacts to focus changes; anything that lands outside the card
|
|
2838
|
+
(a stray click, a programmatic blur, another plugin's focus call) is pulled
|
|
2839
|
+
back to the dialog's first control. */
|
|
2840
|
+
const onFocusIn = function (event) {
|
|
2841
|
+
let inside = false;
|
|
2842
|
+
try { inside = typeof card.contains === "function" && card.contains(event.target); } catch (e) { inside = false; }
|
|
2843
|
+
if (inside) return;
|
|
2844
|
+
const list = focusableIn(card);
|
|
2845
|
+
const back = list.length > 0 ? list[0] : card;
|
|
2846
|
+
try { if (back !== undefined && typeof back.focus === "function") back.focus(); } catch (e) {}
|
|
2847
|
+
};
|
|
2848
|
+
|
|
2849
|
+
let wired = false;
|
|
2850
|
+
try {
|
|
2851
|
+
document.addEventListener("keydown", onKeyDown, true);
|
|
2852
|
+
document.addEventListener("focusin", onFocusIn, true);
|
|
2853
|
+
wired = true;
|
|
2854
|
+
} catch (e) { wired = false; }
|
|
2855
|
+
|
|
2856
|
+
return function () {
|
|
2857
|
+
if (retry !== null) { try { if (typeof cancelAnimationFrame === "function") cancelAnimationFrame(retry); } catch (e) {} retry = null; }
|
|
2858
|
+
if (wired) {
|
|
2859
|
+
try { document.removeEventListener("keydown", onKeyDown, true); } catch (e) {}
|
|
2860
|
+
try { document.removeEventListener("focusin", onFocusIn, true); } catch (e) {}
|
|
2861
|
+
}
|
|
2862
|
+
const restore = restoreRef.current;
|
|
2863
|
+
restoreRef.current = null;
|
|
2864
|
+
try {
|
|
2865
|
+
if (restore !== null && restore !== undefined && restore.isConnected !== false && typeof restore.focus === "function") restore.focus();
|
|
2866
|
+
} catch (e) {}
|
|
2867
|
+
};
|
|
2868
|
+
}, []);
|
|
2869
|
+
|
|
2870
|
+
/* Fallback path: environments where the document listeners above never attached
|
|
2871
|
+
(the headless test harness) still get the same manual step from the card. */
|
|
2872
|
+
function onKeyDown(event) {
|
|
2873
|
+
if (event.key === "Escape") {
|
|
2874
|
+
event.preventDefault();
|
|
2875
|
+
if (event.stopPropagation) event.stopPropagation();
|
|
2876
|
+
if (typeof props.onClose === "function") props.onClose();
|
|
2877
|
+
return;
|
|
2878
|
+
}
|
|
2879
|
+
if (event.key !== "Tab") return;
|
|
2880
|
+
if (event.preventDefault) event.preventDefault();
|
|
2881
|
+
if (event.stopPropagation) event.stopPropagation();
|
|
2882
|
+
trapTab(event, event.currentTarget);
|
|
2883
|
+
}
|
|
2884
|
+
|
|
2885
|
+
return h("div", {
|
|
2886
|
+
ref: cardRef,
|
|
2887
|
+
role: "dialog",
|
|
2888
|
+
"aria-modal": "true",
|
|
2889
|
+
"aria-label": props.label,
|
|
2890
|
+
"data-mo": props.marker,
|
|
2891
|
+
tabIndex: -1,
|
|
2892
|
+
onKeyDown: onKeyDown,
|
|
2893
|
+
style: S.pickCard
|
|
2894
|
+
}, props.children);
|
|
2895
|
+
}
|
|
2896
|
+
|
|
2609
2897
|
function ModelsPage(props) {
|
|
2610
2898
|
const C = resolveOfficialClasses();
|
|
2611
2899
|
ensureOwnStyles(C);
|
|
@@ -2648,6 +2936,16 @@ window.__ModuleLoader__.load({
|
|
|
2648
2936
|
const keyFactsState = useState(null);
|
|
2649
2937
|
const keyFacts = keyFactsState[0];
|
|
2650
2938
|
const setKeyFacts = keyFactsState[1];
|
|
2939
|
+
/* Deleting a route is destructive (its profile plus the credential this page
|
|
2940
|
+
derived), so it goes through a confirm dialog; deleting a model is one click and
|
|
2941
|
+
re-addable. Connection tests are per model id and never persisted. */
|
|
2942
|
+
const deleteState = useState(null);
|
|
2943
|
+
const deleteTarget = deleteState[0];
|
|
2944
|
+
const setDeleteTarget = deleteState[1];
|
|
2945
|
+
const testState = useState({});
|
|
2946
|
+
const modelTests = testState[0];
|
|
2947
|
+
const setModelTests = testState[1];
|
|
2948
|
+
|
|
2651
2949
|
/* The model details dialog: null, or one working copy of a model row plus where it
|
|
2652
2950
|
came from. There is no page-level draft any more — Save writes straight through. */
|
|
2653
2951
|
const modelState = useState(null);
|
|
@@ -2790,6 +3088,150 @@ window.__ModuleLoader__.load({
|
|
|
2790
3088
|
writeProvider({ [key]: next }, function () { setField(null); });
|
|
2791
3089
|
}
|
|
2792
3090
|
|
|
3091
|
+
function stopRowEvent(event) {
|
|
3092
|
+
if (event === null || event === undefined) return;
|
|
3093
|
+
if (typeof event.stopPropagation === "function") event.stopPropagation();
|
|
3094
|
+
}
|
|
3095
|
+
|
|
3096
|
+
function testMark(row) {
|
|
3097
|
+
return modelTests[current + "/" + String(row.id)];
|
|
3098
|
+
}
|
|
3099
|
+
|
|
3100
|
+
function testStateOf(row) {
|
|
3101
|
+
const mark = testMark(row);
|
|
3102
|
+
return mark === undefined ? "idle" : mark.state;
|
|
3103
|
+
}
|
|
3104
|
+
|
|
3105
|
+
/* One icon per verdict, all from the shipped kit and all the same size, so the button
|
|
3106
|
+
never changes shape when the answer arrives. */
|
|
3107
|
+
const TEST_ICONS = {
|
|
3108
|
+
idle: "IconLinkOutlineRegular",
|
|
3109
|
+
busy: "IconLoadingOutlineRegular",
|
|
3110
|
+
ok: "IconCheckOutlineRegular",
|
|
3111
|
+
miss: "IconWarningTriangleOutlineRegular",
|
|
3112
|
+
fail: "IconCloseOutlineRegular"
|
|
3113
|
+
};
|
|
3114
|
+
|
|
3115
|
+
/* A one-click connection check. The host exposes no "send a request" seam, so this
|
|
3116
|
+
asks the adapter the question it can answer: what models does this route serve?
|
|
3117
|
+
For a hand-declared gateway that is a real round-trip with the stored credential
|
|
3118
|
+
(GET {baseURL}/models); for a catalog route the adapter answers from its registry.
|
|
3119
|
+
The row's id is then checked against that answer. */
|
|
3120
|
+
/* A local gateway answers in tens of milliseconds, and React coalesces the busy paint
|
|
3121
|
+
with the verdict one — the spin then never reaches the screen. The busy state is
|
|
3122
|
+
therefore held for at least this long. */
|
|
3123
|
+
const TEST_SPIN_MS = 450;
|
|
3124
|
+
|
|
3125
|
+
function testModel(row) {
|
|
3126
|
+
const llm = bridge.llm;
|
|
3127
|
+
const id = String(row.id || "").trim();
|
|
3128
|
+
if (id === "") { setNote(t("page.testNeedsId")); return; }
|
|
3129
|
+
if (llm === undefined || llm === null) { setNote(t("page.fetchUnavailable")); return; }
|
|
3130
|
+
const key = current + "/" + id;
|
|
3131
|
+
const mark = function (state, message) {
|
|
3132
|
+
const next = Object.assign({}, modelTests);
|
|
3133
|
+
next[key] = { state: state, message: message };
|
|
3134
|
+
setModelTests(next);
|
|
3135
|
+
};
|
|
3136
|
+
const startedAt = Date.now();
|
|
3137
|
+
const settle = function (state, message) {
|
|
3138
|
+
const wait = Math.max(0, TEST_SPIN_MS - (Date.now() - startedAt));
|
|
3139
|
+
if (wait === 0) { mark(state, message); return; }
|
|
3140
|
+
setTimeout(function () { mark(state, message); }, wait);
|
|
3141
|
+
};
|
|
3142
|
+
mark("busy", t("page.testing"));
|
|
3143
|
+
/* The draft must carry the endpoint: for a route the catalog does not describe,
|
|
3144
|
+
discovery hits {baseURL}/models and refuses to run without one ("pi-ai ships no
|
|
3145
|
+
catalog for provider X"), which is exactly the route an operator wants to test. */
|
|
3146
|
+
const request = { provider: current };
|
|
3147
|
+
const liveBase = field !== null && field.baseURL !== undefined ? field.baseURL : profileString(profile, "baseURL");
|
|
3148
|
+
const liveApi = field !== null && field.api !== undefined ? field.api : profileString(profile, "api");
|
|
3149
|
+
if (liveBase !== "") request.baseURL = liveBase;
|
|
3150
|
+
if (liveApi !== "") request.api = liveApi;
|
|
3151
|
+
Promise.resolve(llm.discoverModels(namespace, request)).then(function (response) {
|
|
3152
|
+
if (!response || !response.ok) {
|
|
3153
|
+
const reason = (response && response.error && response.error.message) || "";
|
|
3154
|
+
settle("fail", /ships no catalog/i.test(reason) ? t("page.testNoCatalog") : (reason || t("page.testFailed")));
|
|
3155
|
+
return;
|
|
3156
|
+
}
|
|
3157
|
+
const found = Array.isArray(response.value) ? response.value : [];
|
|
3158
|
+
const hit = found.some(function (entry) {
|
|
3159
|
+
return entry !== null && entry !== undefined && String(entry.id) === id;
|
|
3160
|
+
});
|
|
3161
|
+
settle(hit ? "ok" : "miss", hit ? t("page.testOk") : t("page.testMissing"));
|
|
3162
|
+
}, function (error) {
|
|
3163
|
+
settle("fail", String((error && error.message) || error));
|
|
3164
|
+
});
|
|
3165
|
+
}
|
|
3166
|
+
|
|
3167
|
+
/* The animation must begin on the click itself. A class added by the React state
|
|
3168
|
+
update only animates once that commit is painted, which can be a frame later (and
|
|
3169
|
+
the whole request may finish inside that frame). Touching the DOM here starts the
|
|
3170
|
+
spin in the same task; React's own className keeps it for the rest of the run. */
|
|
3171
|
+
function startSpinNow(event) {
|
|
3172
|
+
try {
|
|
3173
|
+
const node = event && event.currentTarget;
|
|
3174
|
+
if (node === null || node === undefined) return;
|
|
3175
|
+
if (node.classList && typeof node.classList.add === "function") node.classList.add("dsh-mo-act-busy");
|
|
3176
|
+
const svg = typeof node.querySelector === "function" ? node.querySelector("svg") : null;
|
|
3177
|
+
if (svg !== null && svg !== undefined && svg.style) svg.style.animation = "dsh-mo-spin .9s linear infinite";
|
|
3178
|
+
} catch (e) {}
|
|
3179
|
+
}
|
|
3180
|
+
|
|
3181
|
+
function deleteModelRow(row) {
|
|
3182
|
+
if (!writable) { setNote(t("page.readonly")); return; }
|
|
3183
|
+
const target = row.original !== undefined ? row.original : row.id;
|
|
3184
|
+
writeModels(draftRows.filter(function (item) { return String(item.original) !== String(target); }), function (error) {
|
|
3185
|
+
if (error !== undefined) return;
|
|
3186
|
+
setNote(t("page.modelDeleted"));
|
|
3187
|
+
});
|
|
3188
|
+
}
|
|
3189
|
+
|
|
3190
|
+
/* Drop a hand-declared route: the credential this page derived first (a key the user
|
|
3191
|
+
configured elsewhere is not ours to remove, and a failed cleanup must not block the
|
|
3192
|
+
delete), then the profile itself. Official routes never reach here. */
|
|
3193
|
+
function confirmDeleteProvider() {
|
|
3194
|
+
const route = deleteTarget;
|
|
3195
|
+
if (route === null || route === undefined) return;
|
|
3196
|
+
if (!writable) { setDeleteTarget(null); setNote(t("page.readonly")); return; }
|
|
3197
|
+
setBusy("delete");
|
|
3198
|
+
const after = function () {
|
|
3199
|
+
const nextOrder = readProviderOrder().filter(function (id) { return providerKey(id) !== providerKey(route); });
|
|
3200
|
+
writeProviderOrder(nextOrder);
|
|
3201
|
+
const nextHidden = Object.assign({}, hidden.providers);
|
|
3202
|
+
delete nextHidden[providerKey(route)];
|
|
3203
|
+
writeHidden(HIDDEN_PROVIDERS_KEY, nextHidden);
|
|
3204
|
+
setHidden({ providers: nextHidden, models: hidden.models });
|
|
3205
|
+
setPicked(null);
|
|
3206
|
+
setField(null);
|
|
3207
|
+
setDeleteTarget(null);
|
|
3208
|
+
setBusy("");
|
|
3209
|
+
setNote("ok");
|
|
3210
|
+
};
|
|
3211
|
+
const drop = function () {
|
|
3212
|
+
Promise.resolve(mutate([{ op: "unset", path: ["providers", route] }], revision)).then(function (result) {
|
|
3213
|
+
if (!result || !result.ok) {
|
|
3214
|
+
setBusy("");
|
|
3215
|
+
setDeleteTarget(null);
|
|
3216
|
+
setNote((result && result.error && result.error.message) || t("page.deleteFailed"));
|
|
3217
|
+
return;
|
|
3218
|
+
}
|
|
3219
|
+
after();
|
|
3220
|
+
}, function (error) {
|
|
3221
|
+
setBusy("");
|
|
3222
|
+
setDeleteTarget(null);
|
|
3223
|
+
setNote(String((error && error.message) || error));
|
|
3224
|
+
});
|
|
3225
|
+
};
|
|
3226
|
+
const credentials = bridge.credentials;
|
|
3227
|
+
/* Clear the reference the profile actually names; a profile that named its own
|
|
3228
|
+
(e.g. an env-style key) must not leave the derived one as the target. */
|
|
3229
|
+
const named = route === current ? profileString(profile, "apiKeyEnv") : "";
|
|
3230
|
+
const ref = named !== "" ? named : deriveKeyRef(route);
|
|
3231
|
+
if (credentials === undefined || credentials === null || typeof credentials.unset !== "function") { drop(); return; }
|
|
3232
|
+
Promise.resolve(credentials.unset(ref)).then(drop, drop);
|
|
3233
|
+
}
|
|
3234
|
+
|
|
2793
3235
|
/* Every model change goes through here — the dialog's save, a drag reorder, the
|
|
2794
3236
|
fetch picker and a delete all rewrite the same array, so there is exactly one
|
|
2795
3237
|
writer and one revision source per pass (no draft to reconcile). */
|
|
@@ -2797,6 +3239,13 @@ window.__ModuleLoader__.load({
|
|
|
2797
3239
|
if (!writable) { setNote(t("page.readonly")); return; }
|
|
2798
3240
|
const built = modelsFromDraft(nextRows, t);
|
|
2799
3241
|
if (built.error !== undefined) { setNote(built.error); if (done !== undefined) done(built.error); return; }
|
|
3242
|
+
/* Emptying the list would leave the route unresolvable, which the host reports as a
|
|
3243
|
+
resolution error on the next reload — refuse here, with the reason. */
|
|
3244
|
+
if (built.models.length === 0) {
|
|
3245
|
+
setNote(t("page.modelsRequired"));
|
|
3246
|
+
if (done !== undefined) done(t("page.modelsRequired"));
|
|
3247
|
+
return;
|
|
3248
|
+
}
|
|
2800
3249
|
let pending;
|
|
2801
3250
|
try {
|
|
2802
3251
|
pending = mutate([{ op: "set", path: ["providers", current, "models"], value: built.models }], revision);
|
|
@@ -2861,6 +3310,37 @@ window.__ModuleLoader__.load({
|
|
|
2861
3310
|
}, function (error) { setBusy(""); setNote(String((error && error.message) || error)); });
|
|
2862
3311
|
}
|
|
2863
3312
|
|
|
3313
|
+
/* Discovery for a route that does not exist yet: the draft dialog carries the
|
|
3314
|
+
endpoint, protocol and one-shot credential, which is exactly what the discovery
|
|
3315
|
+
seam accepts (it never reads stored settings). Filling the ids here is what makes
|
|
3316
|
+
the create button able to satisfy "a declared route must list its models". */
|
|
3317
|
+
function fetchAddModels() {
|
|
3318
|
+
const llm = bridge.llm;
|
|
3319
|
+
if (llm === undefined || llm === null) { setAdd(Object.assign({}, addDialog, { error: t("page.fetchUnavailable") })); return; }
|
|
3320
|
+
const route = addDialog.route.trim();
|
|
3321
|
+
const baseURL = addDialog.baseURL.trim();
|
|
3322
|
+
if (baseURL === "") { setAdd(Object.assign({}, addDialog, { error: t("page.baseUrlRequired") })); return; }
|
|
3323
|
+
const request = { baseURL: baseURL, api: addDialog.api };
|
|
3324
|
+
if (route !== "") request.provider = route;
|
|
3325
|
+
const secret = addDialog.key.trim();
|
|
3326
|
+
if (secret !== "") request.apiKey = secret;
|
|
3327
|
+
setBusy("addfetch");
|
|
3328
|
+
Promise.resolve(llm.discoverModels(namespace, request)).then(function (response) {
|
|
3329
|
+
setBusy("");
|
|
3330
|
+
if (!response || !response.ok) {
|
|
3331
|
+
setAdd(Object.assign({}, addDialog, { error: (response && response.error && response.error.message) || "fetch failed" }));
|
|
3332
|
+
return;
|
|
3333
|
+
}
|
|
3334
|
+
const found = Array.isArray(response.value) ? response.value : [];
|
|
3335
|
+
const ids = parseModelIds(found.map(function (row) { return row !== null && row !== undefined && typeof row.id === "string" ? row.id : ""; }).join(","));
|
|
3336
|
+
if (ids.length === 0) { setAdd(Object.assign({}, addDialog, { error: t("page.fetchEmpty") })); return; }
|
|
3337
|
+
setAdd(Object.assign({}, addDialog, { models: ids.join(", "), error: "" }));
|
|
3338
|
+
}, function (error) {
|
|
3339
|
+
setBusy("");
|
|
3340
|
+
setAdd(Object.assign({}, addDialog, { error: String((error && error.message) || error) }));
|
|
3341
|
+
});
|
|
3342
|
+
}
|
|
3343
|
+
|
|
2864
3344
|
/* A brand-new route: the profile and the key reference go in ONE mutate (one
|
|
2865
3345
|
revision, so a concurrent edit cannot land between them), the secret goes to the
|
|
2866
3346
|
credentials service, and the page then selects what it just created. */
|
|
@@ -2871,7 +3351,12 @@ window.__ModuleLoader__.load({
|
|
|
2871
3351
|
if (providers[route] !== undefined) { setAdd(Object.assign({}, addDialog, { error: t("page.routeTaken") })); return; }
|
|
2872
3352
|
const displayName = addDialog.displayName.trim();
|
|
2873
3353
|
const baseURL = addDialog.baseURL.trim();
|
|
2874
|
-
|
|
3354
|
+
/* A hand-declared route has no catalog entry, so the host refuses a profile whose
|
|
3355
|
+
models list is empty ("resolves no models"). The dialog therefore requires the
|
|
3356
|
+
ids up front instead of creating a route that cannot resolve. */
|
|
3357
|
+
const modelIds = parseModelIds(addDialog.models);
|
|
3358
|
+
if (modelIds.length === 0) { setAdd(Object.assign({}, addDialog, { error: t("page.modelsRequired") })); return; }
|
|
3359
|
+
const profile = { api: addDialog.api, models: modelIds.map(function (id) { return { id: id }; }) };
|
|
2875
3360
|
if (displayName !== "") profile.displayName = displayName;
|
|
2876
3361
|
if (baseURL !== "") profile.baseURL = baseURL;
|
|
2877
3362
|
const secret = addDialog.key.trim();
|
|
@@ -3041,7 +3526,7 @@ window.__ModuleLoader__.load({
|
|
|
3041
3526
|
disabled: !writable,
|
|
3042
3527
|
title: t("page.addProvider"),
|
|
3043
3528
|
onClick: function () {
|
|
3044
|
-
setAdd({ route: "", displayName: "", baseURL: "", api: apiChoices[0], key: "", error: "" });
|
|
3529
|
+
setAdd({ route: "", displayName: "", baseURL: "", api: apiChoices[0], key: "", models: "", error: "" });
|
|
3045
3530
|
},
|
|
3046
3531
|
style: S.pageAddButton
|
|
3047
3532
|
}, "+ " + t("page.addProvider"))
|
|
@@ -3107,7 +3592,37 @@ window.__ModuleLoader__.load({
|
|
|
3107
3592
|
style: enabled ? S.pageDot : Object.assign({}, S.pageDot, S.pageDotMuted)
|
|
3108
3593
|
}),
|
|
3109
3594
|
h("span", { style: S.pageRowName }, row.name !== "" ? row.name : row.id),
|
|
3110
|
-
h("span", { style: S.pageRowCount }, meta)
|
|
3595
|
+
h("span", { style: S.pageRowCount }, meta),
|
|
3596
|
+
h("span", { style: S.pageModelActions },
|
|
3597
|
+
h("button", {
|
|
3598
|
+
"data-mo": "page-model-test",
|
|
3599
|
+
type: "button",
|
|
3600
|
+
"data-mo-id": row.id,
|
|
3601
|
+
"data-mo-state": testStateOf(row),
|
|
3602
|
+
className: cx(
|
|
3603
|
+
"dsh-mo-act",
|
|
3604
|
+
testStateOf(row) === "busy" ? "dsh-mo-act-busy" : "",
|
|
3605
|
+
testStateOf(row) === "ok" ? "dsh-mo-act-ok" : testStateOf(row) === "miss" ? "dsh-mo-act-miss" : testStateOf(row) === "fail" ? "dsh-mo-act-danger" : ""
|
|
3606
|
+
),
|
|
3607
|
+
"aria-label": t("page.test"),
|
|
3608
|
+
title: testMark(row) === undefined ? t("page.testHint") : testMark(row).message,
|
|
3609
|
+
disabled: busy === "models",
|
|
3610
|
+
onMouseDown: stopRowEvent,
|
|
3611
|
+
onClick: function (event) { stopRowEvent(event); startSpinNow(event); testModel(row); },
|
|
3612
|
+
style: S.pageIconAction
|
|
3613
|
+
}, kitIcon(TEST_ICONS[testStateOf(row)], "▶", 13)),
|
|
3614
|
+
h("button", {
|
|
3615
|
+
"data-mo": "page-model-delete",
|
|
3616
|
+
type: "button",
|
|
3617
|
+
"data-mo-id": row.id,
|
|
3618
|
+
"aria-label": t("page.deleteModel"),
|
|
3619
|
+
title: t("page.deleteModelHint"),
|
|
3620
|
+
disabled: !writable || busy === "models",
|
|
3621
|
+
onMouseDown: stopRowEvent,
|
|
3622
|
+
className: "dsh-mo-act dsh-mo-act-danger",
|
|
3623
|
+
onClick: function (event) { stopRowEvent(event); deleteModelRow(row); },
|
|
3624
|
+
style: S.pageIconDelete
|
|
3625
|
+
}, kitIcon("IconTrashOutlineRegular", "🗑", 13)))
|
|
3111
3626
|
);
|
|
3112
3627
|
}
|
|
3113
3628
|
});
|
|
@@ -3126,6 +3641,16 @@ window.__ModuleLoader__.load({
|
|
|
3126
3641
|
h("h3", { style: S.pageDetailTitle }, profile.displayName || current),
|
|
3127
3642
|
h("span", { style: S.pageDetailRoute }, current),
|
|
3128
3643
|
h("span", { style: S.pageSpacer }),
|
|
3644
|
+
h("button", {
|
|
3645
|
+
"data-mo": "page-provider-delete",
|
|
3646
|
+
type: "button",
|
|
3647
|
+
disabled: !writable,
|
|
3648
|
+
"aria-label": t("page.deleteProvider"),
|
|
3649
|
+
title: t("page.deleteProviderHint"),
|
|
3650
|
+
className: "dsh-mo-act dsh-mo-act-danger",
|
|
3651
|
+
onClick: function () { setDeleteTarget(current); },
|
|
3652
|
+
style: S.pageIconDelete
|
|
3653
|
+
}, kitIcon("IconTrashOutlineRegular", "🗑", 13)),
|
|
3129
3654
|
h(Switch, {
|
|
3130
3655
|
marker: "page-provider-switch",
|
|
3131
3656
|
id: current,
|
|
@@ -3252,7 +3777,7 @@ window.__ModuleLoader__.load({
|
|
|
3252
3777
|
style: S.pickBackdrop,
|
|
3253
3778
|
onMouseDown: function (event) { if (event.target === event.currentTarget) setPicker(null); }
|
|
3254
3779
|
},
|
|
3255
|
-
h(
|
|
3780
|
+
h(DialogShell, { label: t("page.fetchTitle"), onClose: function () { setPicker(null); } },
|
|
3256
3781
|
h("div", { style: S.pickHead },
|
|
3257
3782
|
h("h3", { style: S.pickTitle }, t("page.fetchTitle")),
|
|
3258
3783
|
h("button", {
|
|
@@ -3266,6 +3791,7 @@ window.__ModuleLoader__.load({
|
|
|
3266
3791
|
h("div", { style: S.pickTools },
|
|
3267
3792
|
h("input", {
|
|
3268
3793
|
"data-mo": "fetch-search",
|
|
3794
|
+
"data-mo-focus": "true",
|
|
3269
3795
|
value: picker.query,
|
|
3270
3796
|
placeholder: t("page.fetchSearch"),
|
|
3271
3797
|
spellCheck: false,
|
|
@@ -3335,7 +3861,7 @@ window.__ModuleLoader__.load({
|
|
|
3335
3861
|
style: S.pickBackdrop,
|
|
3336
3862
|
onMouseDown: function (event) { if (event.target === event.currentTarget) setAdd(null); }
|
|
3337
3863
|
},
|
|
3338
|
-
h(
|
|
3864
|
+
h(DialogShell, { label: t("page.newTitle"), onClose: function () { setAdd(null); } },
|
|
3339
3865
|
h("div", { style: S.pickHead },
|
|
3340
3866
|
h("h3", { style: S.pickTitle }, t("page.newTitle")),
|
|
3341
3867
|
h("button", {
|
|
@@ -3350,6 +3876,7 @@ window.__ModuleLoader__.load({
|
|
|
3350
3876
|
h("span", { style: S.pageFieldLabel }, t("page.route")),
|
|
3351
3877
|
h("input", {
|
|
3352
3878
|
"data-mo": "add-route",
|
|
3879
|
+
"data-mo-focus": "true",
|
|
3353
3880
|
value: addDialog.route,
|
|
3354
3881
|
placeholder: "my-gateway",
|
|
3355
3882
|
spellCheck: false,
|
|
@@ -3404,6 +3931,27 @@ window.__ModuleLoader__.load({
|
|
|
3404
3931
|
onKeyDown: function (event) { if (event.key === "Enter") createProvider(); },
|
|
3405
3932
|
style: S.pageInputWide
|
|
3406
3933
|
})),
|
|
3934
|
+
h("label", { style: S.pageField },
|
|
3935
|
+
h("span", { style: S.pageFieldLabel }, t("page.modelsField")),
|
|
3936
|
+
h("input", {
|
|
3937
|
+
"data-mo": "add-models",
|
|
3938
|
+
value: addDialog.models,
|
|
3939
|
+
placeholder: t("page.modelsFieldPlaceholder"),
|
|
3940
|
+
spellCheck: false,
|
|
3941
|
+
onChange: function (event) { setAdd(Object.assign({}, addDialog, { models: event.target.value, error: "" })); },
|
|
3942
|
+
onKeyDown: function (event) { if (event.key === "Enter") createProvider(); },
|
|
3943
|
+
style: S.pageInputWide
|
|
3944
|
+
}),
|
|
3945
|
+
h("div", { style: S.pageKeyRow },
|
|
3946
|
+
h("button", {
|
|
3947
|
+
"data-mo": "add-fetch",
|
|
3948
|
+
type: "button",
|
|
3949
|
+
disabled: busy === "addfetch",
|
|
3950
|
+
title: t("page.modelsFieldHint"),
|
|
3951
|
+
onClick: fetchAddModels,
|
|
3952
|
+
style: S.pageButton
|
|
3953
|
+
}, busy === "addfetch" ? t("page.fetching") : t("page.fetchFromGateway")),
|
|
3954
|
+
h("span", { style: S.pageHint }, t("page.modelsFieldHint")))),
|
|
3407
3955
|
addDialog.error === "" ? null : h("p", { "data-mo": "add-error", style: S.pageError }, addDialog.error),
|
|
3408
3956
|
h("div", { style: S.pickFoot },
|
|
3409
3957
|
h("button", {
|
|
@@ -3510,7 +4058,7 @@ window.__ModuleLoader__.load({
|
|
|
3510
4058
|
style: S.pickBackdrop,
|
|
3511
4059
|
onMouseDown: function (event) { if (event.target === event.currentTarget) closeModel(); }
|
|
3512
4060
|
},
|
|
3513
|
-
h(
|
|
4061
|
+
h(DialogShell, { label: t("page.modelDialogTitle"), marker: "model-dialog-card", onClose: closeModel },
|
|
3514
4062
|
h("div", { style: S.pickHead },
|
|
3515
4063
|
h("h3", { style: S.pickTitle }, modelDialog.mode === "new" ? t("page.modelDialogNew") : t("page.modelDialogTitle")),
|
|
3516
4064
|
h("button", {
|
|
@@ -3526,6 +4074,7 @@ window.__ModuleLoader__.load({
|
|
|
3526
4074
|
h("span", { style: S.pageFieldLabel }, t("page.modelId")),
|
|
3527
4075
|
h("input", {
|
|
3528
4076
|
"data-mo": "model-dialog-id",
|
|
4077
|
+
"data-mo-focus": "true",
|
|
3529
4078
|
value: row.id,
|
|
3530
4079
|
placeholder: t("page.modelId"),
|
|
3531
4080
|
spellCheck: false,
|
|
@@ -3656,6 +4205,43 @@ window.__ModuleLoader__.load({
|
|
|
3656
4205
|
document.body);
|
|
3657
4206
|
}
|
|
3658
4207
|
|
|
4208
|
+
/* ----------------------- the delete-provider confirm ----------------------- */
|
|
4209
|
+
let deleteNode = null;
|
|
4210
|
+
if (deleteTarget !== null) {
|
|
4211
|
+
deleteNode = ReactDOM.createPortal(
|
|
4212
|
+
h("div", {
|
|
4213
|
+
"data-mo": "provider-delete-dialog",
|
|
4214
|
+
style: S.pickBackdrop,
|
|
4215
|
+
onMouseDown: function (event) { if (event.target === event.currentTarget) setDeleteTarget(null); }
|
|
4216
|
+
},
|
|
4217
|
+
h(DialogShell, { label: t("page.deleteProvider"), onClose: function () { setDeleteTarget(null); } },
|
|
4218
|
+
h("div", { style: S.pickHead },
|
|
4219
|
+
h("h3", { style: S.pickTitle }, t("page.deleteProvider") + " · " + deleteTarget),
|
|
4220
|
+
h("button", {
|
|
4221
|
+
"data-mo": "provider-delete-close",
|
|
4222
|
+
type: "button",
|
|
4223
|
+
title: t("page.cancel"),
|
|
4224
|
+
onClick: function () { setDeleteTarget(null); },
|
|
4225
|
+
style: S.pageIconButton
|
|
4226
|
+
}, "✕")),
|
|
4227
|
+
h("p", { style: S.pickDesc }, t("page.deleteProviderBody")),
|
|
4228
|
+
h("div", { style: S.pickFoot },
|
|
4229
|
+
h("button", {
|
|
4230
|
+
"data-mo": "provider-delete-cancel",
|
|
4231
|
+
type: "button",
|
|
4232
|
+
onClick: function () { setDeleteTarget(null); },
|
|
4233
|
+
style: S.pageButton
|
|
4234
|
+
}, t("page.cancel")),
|
|
4235
|
+
h("button", {
|
|
4236
|
+
"data-mo": "provider-delete-confirm",
|
|
4237
|
+
type: "button",
|
|
4238
|
+
disabled: busy === "delete",
|
|
4239
|
+
onClick: confirmDeleteProvider,
|
|
4240
|
+
style: S.pageButtonDanger
|
|
4241
|
+
}, busy === "delete" ? t("page.working") : t("page.deleteProvider"))))),
|
|
4242
|
+
document.body);
|
|
4243
|
+
}
|
|
4244
|
+
|
|
3659
4245
|
return h("section", { "data-mo": "page", "data-mo-build": BUILD, style: S.pageRoot },
|
|
3660
4246
|
h("header", { style: S.pageHead },
|
|
3661
4247
|
h("div", { style: S.pageTitleRow },
|
|
@@ -3678,7 +4264,8 @@ window.__ModuleLoader__.load({
|
|
|
3678
4264
|
h("p", { style: S.pageFooterHint }, t("page.note")),
|
|
3679
4265
|
pickerNode,
|
|
3680
4266
|
addNode,
|
|
3681
|
-
modelNode
|
|
4267
|
+
modelNode,
|
|
4268
|
+
deleteNode
|
|
3682
4269
|
);
|
|
3683
4270
|
}
|
|
3684
4271
|
|
|
@@ -3943,7 +4530,7 @@ window.__ModuleLoader__.load({
|
|
|
3943
4530
|
}
|
|
3944
4531
|
|
|
3945
4532
|
/* `__test` is a test seam only: the loader reads name/apply/inject and ignores it. */
|
|
3946
|
-
module.exports = { name: "model-organizer", apply: apply, inject: inject, __test: { visibleGroupsFor: visibleGroupsFor } };
|
|
4533
|
+
module.exports = { name: "model-organizer", apply: apply, inject: inject, __test: { visibleGroupsFor: visibleGroupsFor, focusableIn: focusableIn } };
|
|
3947
4534
|
return module.exports;
|
|
3948
4535
|
}
|
|
3949
4536
|
});
|