@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/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 = "b54";
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
- const profile = { api: addDialog.api };
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("div", { style: S.pickCard, role: "dialog", "aria-label": t("page.fetchTitle") },
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("div", { style: S.pickCard, role: "dialog", "aria-label": t("page.newTitle") },
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("div", { style: S.pickCard, role: "dialog", "aria-label": t("page.modelDialogTitle"), "data-mo": "model-dialog-card" },
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
  });