llm-switcher 1.2.2 → 1.2.4

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/ui.html CHANGED
@@ -177,6 +177,14 @@
177
177
  letter-spacing: 0.02em;
178
178
  }
179
179
 
180
+ .update-notice {
181
+ display: grid; gap: 0.4rem; margin: 0 0.25rem 0.75rem; padding: 0.6rem 0.65rem;
182
+ border: 1px solid var(--warn); border-radius: 8px; background: var(--warn-soft); font-size: 0.75rem; color: var(--fg);
183
+ }
184
+ .update-notice[hidden] { display: none; }
185
+ .update-notice b { color: var(--warn); font-weight: 600; }
186
+ .update-notice code { font-family: var(--font-mono); font-size: 0.72rem; overflow-wrap: anywhere; color: var(--muted); }
187
+
180
188
  aside nav { display: grid; gap: 0.25rem; }
181
189
  aside a.nav-link {
182
190
  display: flex;
@@ -520,6 +528,44 @@
520
528
  .search svg { position: absolute; left: 0.65rem; top: 50%; transform: translateY(-50%); color: var(--faint); width: 15px; height: 15px; pointer-events: none; }
521
529
  .search input { padding-left: 2rem; min-height: 32px; }
522
530
 
531
+ /* Model slot combobox: one listbox per slot, provider prefix dimmed, match in the accent. */
532
+ .combo { position: relative; }
533
+ .slot-1m-note { font-size: 0.72rem; color: var(--faint); }
534
+ /* Kept in the page when empty: a live region added later is often not read. */
535
+ .slot-1m-note:empty { margin-top: -0.25rem; }
536
+ .slot-1m-note.warn { color: var(--warn); }
537
+ .slot-1m-note.err { color: var(--err); }
538
+ .combo > input { padding-right: 2.25rem; font-family: var(--font-mono); font-size: 0.8rem; }
539
+ .combo-toggle {
540
+ position: absolute; right: 4px; top: 4px; width: 28px; height: 28px;
541
+ display: grid; place-items: center; padding: 0; min-height: 0;
542
+ border: 0; border-radius: 6px; background: transparent; color: var(--faint); cursor: pointer;
543
+ }
544
+ .combo-toggle:hover { color: var(--fg); background: var(--card2); }
545
+ .combo-toggle svg { width: 14px; height: 14px; transition: transform 0.15s ease; }
546
+ .combo[data-open] .combo-toggle svg { transform: rotate(180deg); }
547
+ .combo-list {
548
+ position: absolute; z-index: 30; left: 0; right: 0; top: calc(100% + 4px);
549
+ max-height: 15rem; overflow-y: auto; margin: 0; padding: 0.25rem; list-style: none;
550
+ background: var(--card2); border: 1px solid var(--line); border-radius: 8px; box-shadow: var(--shadow);
551
+ }
552
+ .combo[data-up] .combo-list { top: auto; bottom: calc(100% + 4px); }
553
+ .combo-opt {
554
+ display: flex; align-items: baseline; gap: 0.5rem; padding: 0.4rem 0.55rem;
555
+ border-left: 2px solid transparent; border-radius: 6px; cursor: pointer;
556
+ font-family: var(--font-mono); font-size: 0.8rem; color: var(--fg);
557
+ }
558
+ .combo-opt .id { overflow-wrap: anywhere; }
559
+ .combo-opt .pfx { color: var(--muted); } /* --faint fails 4.5:1 on the selected row in light */
560
+ .combo-opt mark { background: transparent; color: var(--accent); font-weight: 600; }
561
+ .combo-opt:hover { background: var(--bg); }
562
+ .combo-opt[aria-selected="true"] { background: var(--accent-soft); border-left-color: var(--accent); }
563
+ .combo-opt .cur { margin-left: auto; flex: none; color: var(--accent); font-size: 0.7rem; }
564
+ .combo-empty { padding: 0.5rem 0.55rem; font-size: 0.78rem; color: var(--faint); }
565
+ .models-status { display: flex; align-items: center; gap: 0.5rem; font-size: 0.75rem; color: var(--faint); }
566
+ .models-status[data-state="error"] { color: var(--err); }
567
+ @media (prefers-reduced-motion: reduce) { .combo-toggle svg { transition: none; } }
568
+
523
569
  /* Switch */
524
570
  .switch { position: relative; width: 36px; height: 20px; flex: none; display: inline-block; }
525
571
  .switch input { position: absolute; opacity: 0; inset: 0; margin: 0; cursor: pointer; min-height: 0; }
@@ -716,7 +762,7 @@
716
762
  <body>
717
763
  <!-- Sidebar -->
718
764
  <aside>
719
- <a class="brand" href="#/routes" aria-label="LLM Switcher home">
765
+ <a class="brand" href="#/routes">
720
766
  <span class="logo">
721
767
  <svg viewBox="0 0 32 32" aria-hidden="true">
722
768
  <defs>
@@ -732,9 +778,16 @@
732
778
  <div>
733
779
  <b>LLM Switcher</b>
734
780
  <small>Edge Gateway · :<span class="gateway-port">3456</span></small>
781
+ <small id="app-version"></small>
735
782
  </div>
736
783
  </a>
737
784
 
785
+ <div id="update-notice" class="update-notice" role="status" hidden>
786
+ <b>Update available: v<span id="update-latest"></span></b>
787
+ <code id="update-command"></code>
788
+ <button type="button" class="btn btn-outline btn-sm" onclick="copyUpdateCommand()">Copy command</button>
789
+ </div>
790
+
738
791
  <nav id="sideNav">
739
792
  <a class="nav-link" href="#/routes" data-nav="routes">
740
793
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 12h16M12 4v16"/><circle cx="12" cy="12" r="8"/></svg>
@@ -802,7 +855,7 @@
802
855
  <div class="meta">Native Anthropic Messages · <code>/v1/messages</code></div>
803
856
  </div>
804
857
  <div style="min-width:14rem;">
805
- <select id="select-slot-claude" onchange="switchCliTarget('claude', this.value)">
858
+ <select id="select-slot-claude" aria-label="Claude Code route" onchange="switchCliTarget('claude', this.value)">
806
859
  <option value="">Official endpoint</option>
807
860
  </select>
808
861
  </div>
@@ -817,7 +870,7 @@
817
870
  <div class="meta">Responses WebSocket · <code>/v1/responses</code></div>
818
871
  </div>
819
872
  <div style="min-width:14rem;">
820
- <select id="select-slot-codex" onchange="switchCliTarget('codex', this.value)">
873
+ <select id="select-slot-codex" aria-label="Codex route" onchange="switchCliTarget('codex', this.value)">
821
874
  <option value="">Official endpoint</option>
822
875
  </select>
823
876
  </div>
@@ -936,7 +989,7 @@
936
989
  </div>
937
990
 
938
991
  <!-- Profile Modal Editor -->
939
- <div id="profile-modal-overlay" class="overlay" style="display:none;" onclick="if(event.target===this) closeProfileModal()">
992
+ <div id="profile-modal-overlay" class="overlay" style="display:none;" onmousedown="overlayPressed = event.target === this" onclick="if (overlayPressed && event.target === this) closeProfileModal()">
940
993
  <div class="modal" role="dialog" aria-modal="true" aria-labelledby="form-title">
941
994
  <div class="mh">
942
995
  <div>
@@ -946,7 +999,7 @@
946
999
  <button class="icon" type="button" onclick="closeProfileModal()" aria-label="Close dialog">✕</button>
947
1000
  </div>
948
1001
 
949
- <form id="profile-form" onsubmit="saveProfile(event)" style="display:flex; flex-direction:column; overflow:hidden;">
1002
+ <form id="profile-form" onsubmit="saveProfile(event)" onkeydown="blockImplicitSubmit(event)" style="display:flex; flex-direction:column; overflow:hidden;">
950
1003
  <!-- Modal Inset Tabs -->
951
1004
  <div style="padding:0.85rem 1.25rem 0.5rem; background:var(--card2);">
952
1005
  <div class="tabs">
@@ -1000,6 +1053,7 @@
1000
1053
  </label>
1001
1054
  <label class="f">Outbound Format
1002
1055
  <select id="p-outformat">
1056
+ <option value="">Auto (detect from the model)</option>
1003
1057
  <option value="openai-chat">OpenAI Chat (/v1/chat/completions)</option>
1004
1058
  <option value="anthropic">Anthropic Messages (/v1/messages)</option>
1005
1059
  <option value="vertex">Google Vertex AI (/models/...)</option>
@@ -1031,20 +1085,12 @@
1031
1085
  <span id="warn-1m-icon">⚠️</span> <span id="warn-1m-text">1M context flag is enabled</span>
1032
1086
  </div>
1033
1087
 
1034
- <div style="display:flex; justify-content:space-between; align-items:center;">
1088
+ <div style="display:flex; justify-content:space-between; align-items:center; gap:0.75rem;">
1035
1089
  <h3 id="model-slot-heading" style="font-size:0.92rem; font-weight:600;">Model Slots Mapping</h3>
1036
- <button type="button" id="btn-fetch-models" class="btn btn-outline btn-sm" onclick="fetchModelList()">Browse Models</button>
1090
+ <div id="models-status" class="models-status" aria-live="polite"></div>
1037
1091
  </div>
1038
1092
 
1039
1093
  <div id="slot-fields" style="display:grid; gap:0.75rem;"></div>
1040
-
1041
- <div id="model-browser" style="display:none; background:var(--card2); padding:0.85rem; border-radius:8px; border:1px solid var(--line);">
1042
- <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:0.5rem;">
1043
- <span id="model-count-label" class="hint">Loaded models</span>
1044
- <input type="text" id="model-filter" placeholder="Search..." oninput="filterModels()" style="width:12rem; min-height:28px;">
1045
- </div>
1046
- <div id="model-items" style="max-height:8rem; overflow-y:auto; display:flex; flex-wrap:wrap; gap:0.35rem;"></div>
1047
- </div>
1048
1094
  </div>
1049
1095
 
1050
1096
  <!-- Test Connection Box -->
@@ -1072,7 +1118,6 @@
1072
1118
  </div>
1073
1119
 
1074
1120
  <div id="toast" class="toast"></div>
1075
- <datalist id="model-options"></datalist>
1076
1121
 
1077
1122
  <script>
1078
1123
  // State & Globals
@@ -1098,6 +1143,7 @@
1098
1143
  let formLoadedKey = null;
1099
1144
  let formDirty = false;
1100
1145
  let loadedModels = [];
1146
+ let loadedLimits = {}; // model id -> { context, input, output }, as the gateway lists them
1101
1147
  let currentProfileBuffer = null;
1102
1148
  let renderedSlotFormat = 'auto';
1103
1149
  let logRefreshTimer = null;
@@ -1163,9 +1209,17 @@
1163
1209
  if (modal) modal.style.display = 'none';
1164
1210
  }
1165
1211
 
1166
- // Close modal on Escape
1212
+ // A text selection that starts in a field and ends on the overlay also clicks the overlay.
1213
+ let overlayPressed = false;
1214
+
1215
+ // Enter in a field would submit the form and save a half-typed value. Only Save Profile saves.
1216
+ function blockImplicitSubmit(e) {
1217
+ if (e.key === 'Enter' && e.target.tagName === 'INPUT' && e.target.type !== 'submit') e.preventDefault();
1218
+ }
1219
+
1220
+ // Close modal on Escape. Inside a field, Escape belongs to the field (it closes the autocomplete list).
1167
1221
  window.addEventListener('keydown', (e) => {
1168
- if (e.key === 'Escape') {
1222
+ if (e.key === 'Escape' && !e.target.closest?.('input, select, textarea')) {
1169
1223
  const modal = document.getElementById('profile-modal-overlay');
1170
1224
  if (modal && modal.style.display !== 'none') {
1171
1225
  closeProfileModal();
@@ -1265,6 +1319,7 @@
1265
1319
  target.classList.add('active');
1266
1320
  target.style.display = 'grid';
1267
1321
  }
1322
+ if (tabId === 'tab-models') ensureModelsLoaded();
1268
1323
  }
1269
1324
 
1270
1325
  // Slot Configs
@@ -1387,13 +1442,21 @@
1387
1442
  <div style="display:flex; justify-content:space-between; align-items:center;">
1388
1443
  <label for="p-${s.id}" style="font-size:0.82rem; font-weight:500;">${escapeHtml(s.name)}</label>
1389
1444
  <label style="display:flex; align-items:center; gap:0.35rem; font-size:0.75rem; color:var(--accent); cursor:pointer;">
1390
- <input type="checkbox" id="p-${s.id}-1m" style="width:13px; height:13px; min-height:0;" onchange="update1mWarnings()" ${checked ? 'checked' : ''}>
1445
+ <input type="checkbox" id="p-${s.id}-1m" aria-label="1M context for ${escapeHtml(s.name)}" aria-describedby="p-${s.id}-1m-note" style="width:13px; height:13px; min-height:0;" onchange="update1mWarnings()" ${checked ? 'checked' : ''}>
1391
1446
  1M context
1392
1447
  </label>
1393
1448
  </div>
1394
- <input type="text" id="p-${s.id}" list="model-options" placeholder="${escapeHtml(s.placeholder)}" value="${escapeHtml(val)}">
1449
+ <div class="combo">
1450
+ <input type="text" id="p-${s.id}" oninput="update1mWarnings()" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="p-${s.id}-list" autocomplete="off" spellcheck="false" placeholder="${escapeHtml(s.placeholder)}" value="${escapeHtml(val)}">
1451
+ <button type="button" class="combo-toggle" tabindex="-1" aria-label="Show models for ${escapeHtml(s.name)}">
1452
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 6l4 4 4-4"/></svg>
1453
+ </button>
1454
+ <ul class="combo-list" id="p-${s.id}-list" role="listbox" aria-label="${escapeHtml(s.name)} models" hidden></ul>
1455
+ </div>
1456
+ <div class="slot-1m-note" id="p-${s.id}-1m-note" role="status" aria-live="polite"></div>
1395
1457
  `;
1396
1458
  container.appendChild(slotRow);
1459
+ wireCombo(slotRow.querySelector('.combo'));
1397
1460
  }
1398
1461
  update1mWarnings();
1399
1462
  }
@@ -1426,12 +1489,43 @@
1426
1489
  return { defaultModels, model1M };
1427
1490
  }
1428
1491
 
1492
+ // A window known to be under 1M locks the box; an unknown one only warns, since many
1493
+ // gateways list no limits at all.
1494
+ function contextVerdict(l) {
1495
+ const window = (l && (l.context || l.input)) || 0;
1496
+ if (!window) return { kind: 'unknown', window: 0 };
1497
+ return { kind: window >= 1000000 ? 'ok' : 'small', window };
1498
+ }
1499
+
1500
+ // 128000 reads as 128K and 262144 as 256K: a round decimal count keeps base 1000.
1501
+ function fmtTokens(n) {
1502
+ const b = n % 1000 === 0 ? 1000 : 1024;
1503
+ return n >= b * b ? +(n / b / b).toFixed(1) + 'M' : n >= b ? Math.round(n / b) + 'K' : String(n);
1504
+ }
1505
+
1429
1506
  function update1mWarnings() {
1430
1507
  const cfg = getActiveSlotConfig(renderedSlotFormat);
1431
1508
  let any1m = false;
1432
1509
  for (const s of cfg.slots) {
1433
1510
  const chk = document.getElementById('p-' + s.id + '-1m');
1434
- if (chk && chk.checked) any1m = true;
1511
+ const note = document.getElementById('p-' + s.id + '-1m-note');
1512
+ const model = document.getElementById('p-' + s.id)?.value.trim() || '';
1513
+ if (!chk) continue;
1514
+ const v = contextVerdict(model ? loadedLimits[model] : null);
1515
+ const locked = v.kind === 'small';
1516
+ if (locked) chk.checked = false;
1517
+ chk.disabled = locked;
1518
+ chk.closest('label').style.opacity = locked ? '0.5' : '';
1519
+ chk.closest('label').style.cursor = locked ? 'not-allowed' : 'pointer';
1520
+ if (note) {
1521
+ let text = '', cls = '';
1522
+ if (locked) { text = `Context ${fmtTokens(v.window)} is under 1M, so 1M context is off for this model.`; cls = 'err'; }
1523
+ else if (v.kind === 'ok') text = `Context ${fmtTokens(v.window)}.`;
1524
+ else if (chk.checked && model) { text = 'The gateway does not list this model\'s context. Make sure it takes 1M tokens.'; cls = 'warn'; }
1525
+ note.textContent = text;
1526
+ note.className = 'slot-1m-note' + (cls ? ' ' + cls : '');
1527
+ }
1528
+ if (chk.checked) any1m = true;
1435
1529
  }
1436
1530
  const warnCallout = document.getElementById('warn-1m-callout');
1437
1531
  if (warnCallout) warnCallout.style.display = any1m ? 'inline-flex' : 'none';
@@ -1591,7 +1685,7 @@
1591
1685
  </div>
1592
1686
  </div>
1593
1687
  <label class="switch" onclick="event.stopPropagation()">
1594
- <input type="checkbox" ${isActiveAny ? 'checked' : ''} onchange="toggleProfileAllCli('${escapeHtml(key)}', this.checked)">
1688
+ <input type="checkbox" aria-label="Use ${escapeHtml(p.name)} (${escapeHtml(key)}) for every compatible CLI" ${isActiveAny ? 'checked' : ''} onchange="toggleProfileAllCli('${escapeHtml(key)}', this.checked)">
1595
1689
  <span></span>
1596
1690
  </label>
1597
1691
  </div>
@@ -1633,7 +1727,7 @@
1633
1727
  body: JSON.stringify({ target, profile: profileKey || null })
1634
1728
  });
1635
1729
  const data = await res.json();
1636
- if (data.ok) {
1730
+ if (data.success) {
1637
1731
  showToast('Updated ' + target + ' routing');
1638
1732
  await loadStatus();
1639
1733
  } else {
@@ -1653,7 +1747,7 @@
1653
1747
  body: JSON.stringify({ profile: key, deactivate: !enable })
1654
1748
  });
1655
1749
  const data = await res.json();
1656
- if (data.ok) {
1750
+ if (data.success) {
1657
1751
  showToast(key + (enable ? ' activated' : ' deactivated'));
1658
1752
  await loadStatus();
1659
1753
  } else {
@@ -1678,7 +1772,7 @@
1678
1772
  document.getElementById('p-key-val').value = p.apiKey ? MASKED_KEY : '';
1679
1773
  const toolVal = p.tool || 'auto';
1680
1774
  document.getElementById('p-tool').value = toolVal;
1681
- document.getElementById('p-outformat').value = p.outFormat || 'openai-chat';
1775
+ document.getElementById('p-outformat').value = p.outFormat || '';
1682
1776
  document.getElementById('p-mode').value = p.mode || 'convert';
1683
1777
  document.getElementById('p-thinking-mode').value = p.thinkingMode || 'auto';
1684
1778
  document.getElementById('btn-delete').style.display = 'inline-flex';
@@ -1801,7 +1895,7 @@
1801
1895
  body: JSON.stringify({ enabled: !isAnyActive })
1802
1896
  });
1803
1897
  const data = await res.json();
1804
- if (data.ok) {
1898
+ if (data.success) {
1805
1899
  showToast(isAnyActive ? 'Using official endpoints' : 'Gateway activated');
1806
1900
  await loadStatus();
1807
1901
  } else {
@@ -1851,7 +1945,7 @@
1851
1945
  body: JSON.stringify({ key, profile: profilePayload })
1852
1946
  });
1853
1947
  const data = await res.json();
1854
- if (data.ok) {
1948
+ if (data.success) {
1855
1949
  showToast('Profile "' + key + '" saved successfully');
1856
1950
  closeProfileModal();
1857
1951
  await loadStatus();
@@ -1875,7 +1969,7 @@
1875
1969
  body: JSON.stringify({ key })
1876
1970
  });
1877
1971
  const data = await res.json();
1878
- if (data.ok) {
1972
+ if (data.success) {
1879
1973
  showToast('Profile "' + key + '" deleted');
1880
1974
  closeProfileModal();
1881
1975
  await loadStatus();
@@ -1949,89 +2043,220 @@
1949
2043
  }
1950
2044
  }
1951
2045
 
2046
+ // Provider models for the slot comboboxes. The Model Slots tab loads them once per source.
2047
+ let modelsLoadedFor = '';
2048
+ let modelsRequest = 0;
2049
+
2050
+ function modelsSourceKey() {
2051
+ return [document.getElementById('p-url').value.trim(), formLoadedKey || '', document.getElementById('p-key-val').value.trim()].join('\n');
2052
+ }
2053
+
2054
+ function setModelsStatus(stateName, text, action) {
2055
+ const el = document.getElementById('models-status');
2056
+ if (!el) return;
2057
+ el.dataset.state = stateName;
2058
+ el.innerHTML = `<span>${escapeHtml(text)}</span>`
2059
+ + (action ? `<button type="button" class="btn btn-outline btn-sm" onclick="ensureModelsLoaded(true)">${action}</button>` : '');
2060
+ }
2061
+
2062
+ // The upstream error body is often JSON; show its message, not the raw text.
2063
+ function modelsError(raw) {
2064
+ let text = String(raw || 'no models returned');
2065
+ try { const j = JSON.parse(text); text = j?.error?.message || j?.error || j?.message || text; } catch {}
2066
+ return String(text).slice(0, 140);
2067
+ }
2068
+
2069
+ async function ensureModelsLoaded(force = false) {
2070
+ if (!force && modelsSourceKey() === modelsLoadedFor) return;
2071
+ await fetchModelList();
2072
+ }
2073
+
1952
2074
  async function fetchModelList() {
1953
- const btn = document.getElementById('btn-fetch-models');
1954
- const browser = document.getElementById('model-browser');
1955
2075
  const baseURL = document.getElementById('p-url').value.trim();
1956
2076
  const apiKeyVal = document.getElementById('p-key-val').value.trim();
1957
2077
  const apiKey = (apiKeyVal === MASKED_KEY) ? (state.config?.profiles?.[formLoadedKey]?.apiKey || '') : apiKeyVal;
2078
+ const source = modelsSourceKey();
2079
+ const request = ++modelsRequest;
1958
2080
 
1959
2081
  if (!baseURL) {
1960
- showToast('Please enter a Base URL first', true);
2082
+ loadedModels = [];
2083
+ modelsLoadedFor = '';
2084
+ setModelsStatus('idle', 'Enter a Base URL on the General tab to list models');
1961
2085
  return;
1962
2086
  }
1963
2087
 
1964
- btn.disabled = true;
1965
- btn.innerText = 'Fetching...';
2088
+ setModelsStatus('loading', 'Loading models...');
1966
2089
  try {
1967
2090
  const res = await api('/api/fetch-models', {
1968
2091
  method: 'POST',
1969
2092
  headers: { 'Content-Type': 'application/json' },
1970
- body: JSON.stringify({ baseURL, apiKey })
2093
+ body: JSON.stringify({ baseURL, apiKey, key: formLoadedKey })
1971
2094
  });
1972
2095
  const data = await res.json();
2096
+ if (request !== modelsRequest) return; // a newer load started while this one waited
1973
2097
  if (data.ok && Array.isArray(data.models)) {
1974
2098
  loadedModels = data.models;
1975
- populateModelOptions(loadedModels);
1976
- renderModelBrowser(loadedModels);
1977
- browser.style.display = 'block';
1978
- showToast('Loaded ' + loadedModels.length + ' models');
2099
+ loadedLimits = data.limits || {};
2100
+ modelsLoadedFor = source;
2101
+ setModelsStatus('ready', loadedModels.length + (loadedModels.length === 1 ? ' model' : ' models'), 'Reload');
1979
2102
  } else {
1980
- showToast(data.error || 'No models returned from provider', true);
2103
+ loadedModels = [];
2104
+ modelsLoadedFor = '';
2105
+ setModelsStatus('error', 'Models not loaded: ' + modelsError(data.error), 'Retry');
1981
2106
  }
1982
2107
  } catch (err) {
1983
- showToast('Error fetching models: ' + err.message, true);
1984
- } finally {
1985
- btn.disabled = false;
1986
- btn.innerText = 'Browse Models';
2108
+ if (request !== modelsRequest) return;
2109
+ loadedModels = [];
2110
+ modelsLoadedFor = '';
2111
+ setModelsStatus('error', 'Models not loaded: ' + err.message, 'Retry');
1987
2112
  }
2113
+ document.querySelectorAll('.combo[data-open]').forEach(renderComboOptions);
2114
+ update1mWarnings();
1988
2115
  }
1989
2116
 
1990
- function populateModelOptions(models) {
1991
- const dl = document.getElementById('model-options');
1992
- if (!dl) return;
1993
- dl.innerHTML = '';
1994
- for (const m of models) {
1995
- const opt = document.createElement('option');
1996
- opt.value = m;
1997
- dl.appendChild(opt);
1998
- }
2117
+ // Slot combobox (ARIA combobox with a listbox). Typing filters; the arrow opens the full list.
2118
+ // Free text stays valid: a model the provider does not list is typed and kept.
2119
+ function wireCombo(combo) {
2120
+ const input = combo.querySelector('input');
2121
+ combo.activeIndex = -1;
2122
+ input.addEventListener('input', () => { combo.dataset.filter = '1'; combo.activeIndex = -1; openCombo(combo); });
2123
+ input.addEventListener('keydown', onComboKeydown);
2124
+ input.addEventListener('blur', () => closeCombo(combo));
2125
+ // Keep the focus in the field while the list or the arrow is used.
2126
+ combo.querySelector('.combo-list').addEventListener('mousedown', e => e.preventDefault());
2127
+ const toggle = combo.querySelector('.combo-toggle');
2128
+ toggle.addEventListener('mousedown', e => e.preventDefault());
2129
+ toggle.addEventListener('click', () => {
2130
+ if (combo.hasAttribute('data-open')) return closeCombo(combo);
2131
+ input.focus();
2132
+ input.select(); // typing then replaces the current model instead of joining it
2133
+ delete combo.dataset.filter;
2134
+ openCombo(combo);
2135
+ });
1999
2136
  }
2000
2137
 
2001
- function renderModelBrowser(models) {
2002
- const container = document.getElementById('model-items');
2003
- const label = document.getElementById('model-count-label');
2004
- if (!container) return;
2005
- container.innerHTML = '';
2006
- if (label) label.textContent = models.length + ' models found';
2007
-
2008
- for (const m of models) {
2009
- const btn = document.createElement('button');
2010
- btn.type = 'button';
2011
- btn.className = 'btn btn-outline btn-sm';
2012
- btn.style.cssText = 'font-size:0.75rem; padding:0.15rem 0.5rem;';
2013
- btn.textContent = m;
2014
- btn.onclick = () => selectModel(m);
2015
- container.appendChild(btn);
2138
+ function openCombo(combo) {
2139
+ combo.setAttribute('data-open', '');
2140
+ combo.querySelector('input').setAttribute('aria-expanded', 'true');
2141
+ combo.querySelector('.combo-list').hidden = false;
2142
+ renderComboOptions(combo);
2143
+ placeCombo(combo);
2144
+ setComboActive(combo, combo.activeIndex); // again, now that the list has its final height
2145
+ }
2146
+
2147
+ function closeCombo(combo) {
2148
+ if (!combo.hasAttribute('data-open')) return;
2149
+ const input = combo.querySelector('input');
2150
+ combo.removeAttribute('data-open');
2151
+ combo.removeAttribute('data-up');
2152
+ delete combo.dataset.filter;
2153
+ combo.activeIndex = -1;
2154
+ input.setAttribute('aria-expanded', 'false');
2155
+ input.removeAttribute('aria-activedescendant');
2156
+ combo.querySelector('.combo-list').hidden = true;
2157
+ }
2158
+
2159
+ // The modal body scrolls and clips the list: open toward the larger room and fit the list into it.
2160
+ function placeCombo(combo) {
2161
+ const list = combo.querySelector('.combo-list');
2162
+ const scroller = combo.closest('.mb');
2163
+ combo.removeAttribute('data-up');
2164
+ list.style.maxHeight = '';
2165
+ if (!scroller) return;
2166
+ const box = scroller.getBoundingClientRect();
2167
+ const field = combo.getBoundingClientRect();
2168
+ const below = box.bottom - field.bottom - 8;
2169
+ const above = field.top - box.top - 8;
2170
+ const up = below < list.offsetHeight && above > below;
2171
+ if (up) combo.setAttribute('data-up', '');
2172
+ list.style.maxHeight = Math.max(120, Math.min(240, up ? above : below)) + 'px';
2173
+ }
2174
+
2175
+ function markMatch(text, q) {
2176
+ const at = q ? text.toLowerCase().indexOf(q) : -1;
2177
+ if (at < 0) return escapeHtml(text);
2178
+ return escapeHtml(text.slice(0, at)) + '<mark>' + escapeHtml(text.slice(at, at + q.length)) + '</mark>' + escapeHtml(text.slice(at + q.length));
2179
+ }
2180
+
2181
+ function renderComboOptions(combo) {
2182
+ const input = combo.querySelector('input');
2183
+ const list = combo.querySelector('.combo-list');
2184
+ const q = combo.dataset.filter ? input.value.trim().toLowerCase() : '';
2185
+ const current = input.value.trim();
2186
+ const matches = loadedModels.filter(m => !q || m.toLowerCase().includes(q)).slice(0, 200);
2187
+ combo.matches = matches;
2188
+ if (!loadedModels.length) {
2189
+ const status = document.getElementById('models-status')?.textContent || '';
2190
+ list.innerHTML = `<li class="combo-empty">${escapeHtml(status || 'No models loaded')}</li>`;
2191
+ return;
2192
+ }
2193
+ if (!matches.length) {
2194
+ list.innerHTML = '<li class="combo-empty">No listed model matches. The typed name is kept as it is.</li>';
2195
+ return;
2196
+ }
2197
+ if (!(combo.activeIndex >= 0 && combo.activeIndex < matches.length)) combo.activeIndex = matches.indexOf(current);
2198
+ list.innerHTML = matches.map((m, i) => {
2199
+ const cut = m.lastIndexOf('/') + 1;
2200
+ const prefix = cut ? `<span class="pfx">${markMatch(m.slice(0, cut), q)}</span>` : '';
2201
+ return `<li class="combo-opt" role="option" id="${input.id}-opt-${i}" data-i="${i}" aria-selected="${i === combo.activeIndex}"`
2202
+ + ` onmousedown="event.preventDefault(); chooseComboOption(this)">`
2203
+ + `<span class="id">${prefix}${markMatch(m.slice(cut), q)}</span>`
2204
+ + (m === current ? '<span class="cur">current</span>' : '') + '</li>';
2205
+ }).join('');
2206
+ setComboActive(combo, combo.activeIndex);
2207
+ }
2208
+
2209
+ function setComboActive(combo, i) {
2210
+ const input = combo.querySelector('input');
2211
+ combo.activeIndex = i;
2212
+ combo.querySelectorAll('.combo-opt').forEach(li => li.setAttribute('aria-selected', String(Number(li.dataset.i) === i)));
2213
+ const active = i >= 0 ? document.getElementById(`${input.id}-opt-${i}`) : null;
2214
+ if (active) {
2215
+ input.setAttribute('aria-activedescendant', active.id);
2216
+ // Scroll the list only. The browser's scroll-into-view also scrolls the dialog body.
2217
+ const list = combo.querySelector('.combo-list');
2218
+ if (active.offsetTop < list.scrollTop) list.scrollTop = active.offsetTop - 4;
2219
+ else if (active.offsetTop + active.offsetHeight > list.scrollTop + list.clientHeight) list.scrollTop = active.offsetTop + active.offsetHeight - list.clientHeight + 4;
2220
+ } else {
2221
+ input.removeAttribute('aria-activedescendant');
2016
2222
  }
2017
2223
  }
2018
2224
 
2019
- function filterModels() {
2020
- const q = (document.getElementById('model-filter')?.value || '').toLowerCase();
2021
- const filtered = loadedModels.filter(m => m.toLowerCase().includes(q));
2022
- renderModelBrowser(filtered);
2225
+ function chooseComboOption(li) {
2226
+ const combo = li.closest('.combo');
2227
+ const model = combo?.matches?.[Number(li.dataset.i)];
2228
+ if (!model) return;
2229
+ combo.querySelector('input').value = model;
2230
+ closeCombo(combo);
2231
+ update1mWarnings();
2023
2232
  }
2024
2233
 
2025
- function selectModel(modelId) {
2026
- const activeTab = document.querySelector('.tab-content.active');
2027
- const focused = activeTab?.querySelector('input[type="text"]:focus');
2028
- if (focused && focused.id.startsWith('p-')) {
2029
- focused.value = modelId;
2030
- } else {
2031
- const sonnetInput = document.getElementById('p-sonnet') || document.getElementById('p-default');
2032
- if (sonnetInput) sonnetInput.value = modelId;
2234
+ function onComboKeydown(e) {
2235
+ const combo = e.currentTarget.closest('.combo');
2236
+ const open = combo.hasAttribute('data-open');
2237
+ const count = combo.matches?.length || 0;
2238
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
2239
+ e.preventDefault();
2240
+ if (!open) return openCombo(combo);
2241
+ if (!count) return;
2242
+ const from = combo.activeIndex;
2243
+ const next = e.key === 'ArrowDown' ? (from < 0 ? 0 : (from + 1) % count) : (from <= 0 ? count - 1 : from - 1);
2244
+ setComboActive(combo, next);
2245
+ } else if (e.key === 'Enter') {
2246
+ // Never submit from a slot. Enter picks the highlighted model, or keeps the typed text.
2247
+ e.preventDefault();
2248
+ e.stopPropagation();
2249
+ if (open && combo.activeIndex >= 0) chooseComboOption(document.getElementById(`${e.currentTarget.id}-opt-${combo.activeIndex}`));
2250
+ else closeCombo(combo);
2251
+ } else if (e.key === 'Escape') {
2252
+ if (open) {
2253
+ e.preventDefault();
2254
+ e.stopPropagation();
2255
+ closeCombo(combo);
2256
+ }
2257
+ } else if (e.key === 'Tab') {
2258
+ closeCombo(combo);
2033
2259
  }
2034
- showToast('Selected: ' + modelId);
2035
2260
  }
2036
2261
 
2037
2262
  // Dynamic Model Catalog View
@@ -2104,7 +2329,7 @@
2104
2329
  try {
2105
2330
  const res = await api('/api/catalog/refresh', { method: 'POST' });
2106
2331
  const data = await res.json();
2107
- if (data.ok) {
2332
+ if (data.success) {
2108
2333
  showToast('Model catalog synchronized');
2109
2334
  await renderCatalogView();
2110
2335
  } else {
@@ -2181,7 +2406,7 @@
2181
2406
  try {
2182
2407
  const res = await api('/api/logs/clear', { method: 'POST' });
2183
2408
  const data = await res.json();
2184
- if (data.ok) {
2409
+ if (data.success) {
2185
2410
  showToast('Request inspector logs cleared');
2186
2411
  await loadInspectorLogs();
2187
2412
  }
@@ -2206,6 +2431,23 @@
2206
2431
  }
2207
2432
 
2208
2433
  // Initial Bootstrap
2434
+ // The version check never blocks the dashboard: without an answer there is no notice.
2435
+ async function loadVersion() {
2436
+ try {
2437
+ const v = await (await api('/api/version')).json();
2438
+ document.getElementById('app-version').textContent = 'v' + v.current;
2439
+ if (!v.updateAvailable) return;
2440
+ document.getElementById('update-latest').textContent = v.latest;
2441
+ document.getElementById('update-command').textContent = v.updateCommand;
2442
+ document.getElementById('update-notice').hidden = false;
2443
+ } catch {}
2444
+ }
2445
+
2446
+ function copyUpdateCommand() {
2447
+ const cmd = document.getElementById('update-command').textContent;
2448
+ navigator.clipboard.writeText(cmd).then(() => showToast('Copied: ' + cmd), () => showToast('Copy failed. Select the command and copy it by hand.', true));
2449
+ }
2450
+
2209
2451
  document.addEventListener('DOMContentLoaded', () => {
2210
2452
  // Setup initial theme buttons state
2211
2453
  const currentTheme = document.documentElement.dataset.theme || 'dark';
@@ -2220,6 +2462,7 @@
2220
2462
 
2221
2463
  // Load initial state from edge gateway
2222
2464
  loadStatus();
2465
+ loadVersion();
2223
2466
  });
2224
2467
  </script>
2225
2468
  </body>