llm-switcher 1.2.2 → 1.2.3

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,39 @@
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
+ .combo > input { padding-right: 2.25rem; font-family: var(--font-mono); font-size: 0.8rem; }
534
+ .combo-toggle {
535
+ position: absolute; right: 4px; top: 4px; width: 28px; height: 28px;
536
+ display: grid; place-items: center; padding: 0; min-height: 0;
537
+ border: 0; border-radius: 6px; background: transparent; color: var(--faint); cursor: pointer;
538
+ }
539
+ .combo-toggle:hover { color: var(--fg); background: var(--card2); }
540
+ .combo-toggle svg { width: 14px; height: 14px; transition: transform 0.15s ease; }
541
+ .combo[data-open] .combo-toggle svg { transform: rotate(180deg); }
542
+ .combo-list {
543
+ position: absolute; z-index: 30; left: 0; right: 0; top: calc(100% + 4px);
544
+ max-height: 15rem; overflow-y: auto; margin: 0; padding: 0.25rem; list-style: none;
545
+ background: var(--card2); border: 1px solid var(--line); border-radius: 8px; box-shadow: var(--shadow);
546
+ }
547
+ .combo[data-up] .combo-list { top: auto; bottom: calc(100% + 4px); }
548
+ .combo-opt {
549
+ display: flex; align-items: baseline; gap: 0.5rem; padding: 0.4rem 0.55rem;
550
+ border-left: 2px solid transparent; border-radius: 6px; cursor: pointer;
551
+ font-family: var(--font-mono); font-size: 0.8rem; color: var(--fg);
552
+ }
553
+ .combo-opt .id { overflow-wrap: anywhere; }
554
+ .combo-opt .pfx { color: var(--muted); } /* --faint fails 4.5:1 on the selected row in light */
555
+ .combo-opt mark { background: transparent; color: var(--accent); font-weight: 600; }
556
+ .combo-opt:hover { background: var(--bg); }
557
+ .combo-opt[aria-selected="true"] { background: var(--accent-soft); border-left-color: var(--accent); }
558
+ .combo-opt .cur { margin-left: auto; flex: none; color: var(--accent); font-size: 0.7rem; }
559
+ .combo-empty { padding: 0.5rem 0.55rem; font-size: 0.78rem; color: var(--faint); }
560
+ .models-status { display: flex; align-items: center; gap: 0.5rem; font-size: 0.75rem; color: var(--faint); }
561
+ .models-status[data-state="error"] { color: var(--err); }
562
+ @media (prefers-reduced-motion: reduce) { .combo-toggle svg { transition: none; } }
563
+
523
564
  /* Switch */
524
565
  .switch { position: relative; width: 36px; height: 20px; flex: none; display: inline-block; }
525
566
  .switch input { position: absolute; opacity: 0; inset: 0; margin: 0; cursor: pointer; min-height: 0; }
@@ -716,7 +757,7 @@
716
757
  <body>
717
758
  <!-- Sidebar -->
718
759
  <aside>
719
- <a class="brand" href="#/routes" aria-label="LLM Switcher home">
760
+ <a class="brand" href="#/routes">
720
761
  <span class="logo">
721
762
  <svg viewBox="0 0 32 32" aria-hidden="true">
722
763
  <defs>
@@ -732,9 +773,16 @@
732
773
  <div>
733
774
  <b>LLM Switcher</b>
734
775
  <small>Edge Gateway · :<span class="gateway-port">3456</span></small>
776
+ <small id="app-version"></small>
735
777
  </div>
736
778
  </a>
737
779
 
780
+ <div id="update-notice" class="update-notice" role="status" hidden>
781
+ <b>Update available: v<span id="update-latest"></span></b>
782
+ <code id="update-command"></code>
783
+ <button type="button" class="btn btn-outline btn-sm" onclick="copyUpdateCommand()">Copy command</button>
784
+ </div>
785
+
738
786
  <nav id="sideNav">
739
787
  <a class="nav-link" href="#/routes" data-nav="routes">
740
788
  <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 +850,7 @@
802
850
  <div class="meta">Native Anthropic Messages · <code>/v1/messages</code></div>
803
851
  </div>
804
852
  <div style="min-width:14rem;">
805
- <select id="select-slot-claude" onchange="switchCliTarget('claude', this.value)">
853
+ <select id="select-slot-claude" aria-label="Claude Code route" onchange="switchCliTarget('claude', this.value)">
806
854
  <option value="">Official endpoint</option>
807
855
  </select>
808
856
  </div>
@@ -817,7 +865,7 @@
817
865
  <div class="meta">Responses WebSocket · <code>/v1/responses</code></div>
818
866
  </div>
819
867
  <div style="min-width:14rem;">
820
- <select id="select-slot-codex" onchange="switchCliTarget('codex', this.value)">
868
+ <select id="select-slot-codex" aria-label="Codex route" onchange="switchCliTarget('codex', this.value)">
821
869
  <option value="">Official endpoint</option>
822
870
  </select>
823
871
  </div>
@@ -936,7 +984,7 @@
936
984
  </div>
937
985
 
938
986
  <!-- Profile Modal Editor -->
939
- <div id="profile-modal-overlay" class="overlay" style="display:none;" onclick="if(event.target===this) closeProfileModal()">
987
+ <div id="profile-modal-overlay" class="overlay" style="display:none;" onmousedown="overlayPressed = event.target === this" onclick="if (overlayPressed && event.target === this) closeProfileModal()">
940
988
  <div class="modal" role="dialog" aria-modal="true" aria-labelledby="form-title">
941
989
  <div class="mh">
942
990
  <div>
@@ -946,7 +994,7 @@
946
994
  <button class="icon" type="button" onclick="closeProfileModal()" aria-label="Close dialog">✕</button>
947
995
  </div>
948
996
 
949
- <form id="profile-form" onsubmit="saveProfile(event)" style="display:flex; flex-direction:column; overflow:hidden;">
997
+ <form id="profile-form" onsubmit="saveProfile(event)" onkeydown="blockImplicitSubmit(event)" style="display:flex; flex-direction:column; overflow:hidden;">
950
998
  <!-- Modal Inset Tabs -->
951
999
  <div style="padding:0.85rem 1.25rem 0.5rem; background:var(--card2);">
952
1000
  <div class="tabs">
@@ -1000,6 +1048,7 @@
1000
1048
  </label>
1001
1049
  <label class="f">Outbound Format
1002
1050
  <select id="p-outformat">
1051
+ <option value="">Auto (detect from the model)</option>
1003
1052
  <option value="openai-chat">OpenAI Chat (/v1/chat/completions)</option>
1004
1053
  <option value="anthropic">Anthropic Messages (/v1/messages)</option>
1005
1054
  <option value="vertex">Google Vertex AI (/models/...)</option>
@@ -1031,20 +1080,12 @@
1031
1080
  <span id="warn-1m-icon">⚠️</span> <span id="warn-1m-text">1M context flag is enabled</span>
1032
1081
  </div>
1033
1082
 
1034
- <div style="display:flex; justify-content:space-between; align-items:center;">
1083
+ <div style="display:flex; justify-content:space-between; align-items:center; gap:0.75rem;">
1035
1084
  <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>
1085
+ <div id="models-status" class="models-status" aria-live="polite"></div>
1037
1086
  </div>
1038
1087
 
1039
1088
  <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
1089
  </div>
1049
1090
 
1050
1091
  <!-- Test Connection Box -->
@@ -1072,7 +1113,6 @@
1072
1113
  </div>
1073
1114
 
1074
1115
  <div id="toast" class="toast"></div>
1075
- <datalist id="model-options"></datalist>
1076
1116
 
1077
1117
  <script>
1078
1118
  // State & Globals
@@ -1163,9 +1203,17 @@
1163
1203
  if (modal) modal.style.display = 'none';
1164
1204
  }
1165
1205
 
1166
- // Close modal on Escape
1206
+ // A text selection that starts in a field and ends on the overlay also clicks the overlay.
1207
+ let overlayPressed = false;
1208
+
1209
+ // Enter in a field would submit the form and save a half-typed value. Only Save Profile saves.
1210
+ function blockImplicitSubmit(e) {
1211
+ if (e.key === 'Enter' && e.target.tagName === 'INPUT' && e.target.type !== 'submit') e.preventDefault();
1212
+ }
1213
+
1214
+ // Close modal on Escape. Inside a field, Escape belongs to the field (it closes the autocomplete list).
1167
1215
  window.addEventListener('keydown', (e) => {
1168
- if (e.key === 'Escape') {
1216
+ if (e.key === 'Escape' && !e.target.closest?.('input, select, textarea')) {
1169
1217
  const modal = document.getElementById('profile-modal-overlay');
1170
1218
  if (modal && modal.style.display !== 'none') {
1171
1219
  closeProfileModal();
@@ -1265,6 +1313,7 @@
1265
1313
  target.classList.add('active');
1266
1314
  target.style.display = 'grid';
1267
1315
  }
1316
+ if (tabId === 'tab-models') ensureModelsLoaded();
1268
1317
  }
1269
1318
 
1270
1319
  // Slot Configs
@@ -1391,9 +1440,16 @@
1391
1440
  1M context
1392
1441
  </label>
1393
1442
  </div>
1394
- <input type="text" id="p-${s.id}" list="model-options" placeholder="${escapeHtml(s.placeholder)}" value="${escapeHtml(val)}">
1443
+ <div class="combo">
1444
+ <input type="text" id="p-${s.id}" 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)}">
1445
+ <button type="button" class="combo-toggle" tabindex="-1" aria-label="Show models for ${escapeHtml(s.name)}">
1446
+ <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>
1447
+ </button>
1448
+ <ul class="combo-list" id="p-${s.id}-list" role="listbox" aria-label="${escapeHtml(s.name)} models" hidden></ul>
1449
+ </div>
1395
1450
  `;
1396
1451
  container.appendChild(slotRow);
1452
+ wireCombo(slotRow.querySelector('.combo'));
1397
1453
  }
1398
1454
  update1mWarnings();
1399
1455
  }
@@ -1591,7 +1647,7 @@
1591
1647
  </div>
1592
1648
  </div>
1593
1649
  <label class="switch" onclick="event.stopPropagation()">
1594
- <input type="checkbox" ${isActiveAny ? 'checked' : ''} onchange="toggleProfileAllCli('${escapeHtml(key)}', this.checked)">
1650
+ <input type="checkbox" aria-label="Use ${escapeHtml(p.name)} (${escapeHtml(key)}) for every compatible CLI" ${isActiveAny ? 'checked' : ''} onchange="toggleProfileAllCli('${escapeHtml(key)}', this.checked)">
1595
1651
  <span></span>
1596
1652
  </label>
1597
1653
  </div>
@@ -1633,7 +1689,7 @@
1633
1689
  body: JSON.stringify({ target, profile: profileKey || null })
1634
1690
  });
1635
1691
  const data = await res.json();
1636
- if (data.ok) {
1692
+ if (data.success) {
1637
1693
  showToast('Updated ' + target + ' routing');
1638
1694
  await loadStatus();
1639
1695
  } else {
@@ -1653,7 +1709,7 @@
1653
1709
  body: JSON.stringify({ profile: key, deactivate: !enable })
1654
1710
  });
1655
1711
  const data = await res.json();
1656
- if (data.ok) {
1712
+ if (data.success) {
1657
1713
  showToast(key + (enable ? ' activated' : ' deactivated'));
1658
1714
  await loadStatus();
1659
1715
  } else {
@@ -1678,7 +1734,7 @@
1678
1734
  document.getElementById('p-key-val').value = p.apiKey ? MASKED_KEY : '';
1679
1735
  const toolVal = p.tool || 'auto';
1680
1736
  document.getElementById('p-tool').value = toolVal;
1681
- document.getElementById('p-outformat').value = p.outFormat || 'openai-chat';
1737
+ document.getElementById('p-outformat').value = p.outFormat || '';
1682
1738
  document.getElementById('p-mode').value = p.mode || 'convert';
1683
1739
  document.getElementById('p-thinking-mode').value = p.thinkingMode || 'auto';
1684
1740
  document.getElementById('btn-delete').style.display = 'inline-flex';
@@ -1801,7 +1857,7 @@
1801
1857
  body: JSON.stringify({ enabled: !isAnyActive })
1802
1858
  });
1803
1859
  const data = await res.json();
1804
- if (data.ok) {
1860
+ if (data.success) {
1805
1861
  showToast(isAnyActive ? 'Using official endpoints' : 'Gateway activated');
1806
1862
  await loadStatus();
1807
1863
  } else {
@@ -1851,7 +1907,7 @@
1851
1907
  body: JSON.stringify({ key, profile: profilePayload })
1852
1908
  });
1853
1909
  const data = await res.json();
1854
- if (data.ok) {
1910
+ if (data.success) {
1855
1911
  showToast('Profile "' + key + '" saved successfully');
1856
1912
  closeProfileModal();
1857
1913
  await loadStatus();
@@ -1875,7 +1931,7 @@
1875
1931
  body: JSON.stringify({ key })
1876
1932
  });
1877
1933
  const data = await res.json();
1878
- if (data.ok) {
1934
+ if (data.success) {
1879
1935
  showToast('Profile "' + key + '" deleted');
1880
1936
  closeProfileModal();
1881
1937
  await loadStatus();
@@ -1949,89 +2005,217 @@
1949
2005
  }
1950
2006
  }
1951
2007
 
2008
+ // Provider models for the slot comboboxes. The Model Slots tab loads them once per source.
2009
+ let modelsLoadedFor = '';
2010
+ let modelsRequest = 0;
2011
+
2012
+ function modelsSourceKey() {
2013
+ return [document.getElementById('p-url').value.trim(), formLoadedKey || '', document.getElementById('p-key-val').value.trim()].join('\n');
2014
+ }
2015
+
2016
+ function setModelsStatus(stateName, text, action) {
2017
+ const el = document.getElementById('models-status');
2018
+ if (!el) return;
2019
+ el.dataset.state = stateName;
2020
+ el.innerHTML = `<span>${escapeHtml(text)}</span>`
2021
+ + (action ? `<button type="button" class="btn btn-outline btn-sm" onclick="ensureModelsLoaded(true)">${action}</button>` : '');
2022
+ }
2023
+
2024
+ // The upstream error body is often JSON; show its message, not the raw text.
2025
+ function modelsError(raw) {
2026
+ let text = String(raw || 'no models returned');
2027
+ try { const j = JSON.parse(text); text = j?.error?.message || j?.error || j?.message || text; } catch {}
2028
+ return String(text).slice(0, 140);
2029
+ }
2030
+
2031
+ async function ensureModelsLoaded(force = false) {
2032
+ if (!force && modelsSourceKey() === modelsLoadedFor) return;
2033
+ await fetchModelList();
2034
+ }
2035
+
1952
2036
  async function fetchModelList() {
1953
- const btn = document.getElementById('btn-fetch-models');
1954
- const browser = document.getElementById('model-browser');
1955
2037
  const baseURL = document.getElementById('p-url').value.trim();
1956
2038
  const apiKeyVal = document.getElementById('p-key-val').value.trim();
1957
2039
  const apiKey = (apiKeyVal === MASKED_KEY) ? (state.config?.profiles?.[formLoadedKey]?.apiKey || '') : apiKeyVal;
2040
+ const source = modelsSourceKey();
2041
+ const request = ++modelsRequest;
1958
2042
 
1959
2043
  if (!baseURL) {
1960
- showToast('Please enter a Base URL first', true);
2044
+ loadedModels = [];
2045
+ modelsLoadedFor = '';
2046
+ setModelsStatus('idle', 'Enter a Base URL on the General tab to list models');
1961
2047
  return;
1962
2048
  }
1963
2049
 
1964
- btn.disabled = true;
1965
- btn.innerText = 'Fetching...';
2050
+ setModelsStatus('loading', 'Loading models...');
1966
2051
  try {
1967
2052
  const res = await api('/api/fetch-models', {
1968
2053
  method: 'POST',
1969
2054
  headers: { 'Content-Type': 'application/json' },
1970
- body: JSON.stringify({ baseURL, apiKey })
2055
+ body: JSON.stringify({ baseURL, apiKey, key: formLoadedKey })
1971
2056
  });
1972
2057
  const data = await res.json();
2058
+ if (request !== modelsRequest) return; // a newer load started while this one waited
1973
2059
  if (data.ok && Array.isArray(data.models)) {
1974
2060
  loadedModels = data.models;
1975
- populateModelOptions(loadedModels);
1976
- renderModelBrowser(loadedModels);
1977
- browser.style.display = 'block';
1978
- showToast('Loaded ' + loadedModels.length + ' models');
2061
+ modelsLoadedFor = source;
2062
+ setModelsStatus('ready', loadedModels.length + (loadedModels.length === 1 ? ' model' : ' models'), 'Reload');
1979
2063
  } else {
1980
- showToast(data.error || 'No models returned from provider', true);
2064
+ loadedModels = [];
2065
+ modelsLoadedFor = '';
2066
+ setModelsStatus('error', 'Models not loaded: ' + modelsError(data.error), 'Retry');
1981
2067
  }
1982
2068
  } catch (err) {
1983
- showToast('Error fetching models: ' + err.message, true);
1984
- } finally {
1985
- btn.disabled = false;
1986
- btn.innerText = 'Browse Models';
2069
+ if (request !== modelsRequest) return;
2070
+ loadedModels = [];
2071
+ modelsLoadedFor = '';
2072
+ setModelsStatus('error', 'Models not loaded: ' + err.message, 'Retry');
1987
2073
  }
2074
+ document.querySelectorAll('.combo[data-open]').forEach(renderComboOptions);
2075
+ }
2076
+
2077
+ // Slot combobox (ARIA combobox with a listbox). Typing filters; the arrow opens the full list.
2078
+ // Free text stays valid: a model the provider does not list is typed and kept.
2079
+ function wireCombo(combo) {
2080
+ const input = combo.querySelector('input');
2081
+ combo.activeIndex = -1;
2082
+ input.addEventListener('input', () => { combo.dataset.filter = '1'; combo.activeIndex = -1; openCombo(combo); });
2083
+ input.addEventListener('keydown', onComboKeydown);
2084
+ input.addEventListener('blur', () => closeCombo(combo));
2085
+ // Keep the focus in the field while the list or the arrow is used.
2086
+ combo.querySelector('.combo-list').addEventListener('mousedown', e => e.preventDefault());
2087
+ const toggle = combo.querySelector('.combo-toggle');
2088
+ toggle.addEventListener('mousedown', e => e.preventDefault());
2089
+ toggle.addEventListener('click', () => {
2090
+ if (combo.hasAttribute('data-open')) return closeCombo(combo);
2091
+ input.focus();
2092
+ input.select(); // typing then replaces the current model instead of joining it
2093
+ delete combo.dataset.filter;
2094
+ openCombo(combo);
2095
+ });
1988
2096
  }
1989
2097
 
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);
2098
+ function openCombo(combo) {
2099
+ combo.setAttribute('data-open', '');
2100
+ combo.querySelector('input').setAttribute('aria-expanded', 'true');
2101
+ combo.querySelector('.combo-list').hidden = false;
2102
+ renderComboOptions(combo);
2103
+ placeCombo(combo);
2104
+ setComboActive(combo, combo.activeIndex); // again, now that the list has its final height
2105
+ }
2106
+
2107
+ function closeCombo(combo) {
2108
+ if (!combo.hasAttribute('data-open')) return;
2109
+ const input = combo.querySelector('input');
2110
+ combo.removeAttribute('data-open');
2111
+ combo.removeAttribute('data-up');
2112
+ delete combo.dataset.filter;
2113
+ combo.activeIndex = -1;
2114
+ input.setAttribute('aria-expanded', 'false');
2115
+ input.removeAttribute('aria-activedescendant');
2116
+ combo.querySelector('.combo-list').hidden = true;
2117
+ }
2118
+
2119
+ // The modal body scrolls and clips the list: open toward the larger room and fit the list into it.
2120
+ function placeCombo(combo) {
2121
+ const list = combo.querySelector('.combo-list');
2122
+ const scroller = combo.closest('.mb');
2123
+ combo.removeAttribute('data-up');
2124
+ list.style.maxHeight = '';
2125
+ if (!scroller) return;
2126
+ const box = scroller.getBoundingClientRect();
2127
+ const field = combo.getBoundingClientRect();
2128
+ const below = box.bottom - field.bottom - 8;
2129
+ const above = field.top - box.top - 8;
2130
+ const up = below < list.offsetHeight && above > below;
2131
+ if (up) combo.setAttribute('data-up', '');
2132
+ list.style.maxHeight = Math.max(120, Math.min(240, up ? above : below)) + 'px';
2133
+ }
2134
+
2135
+ function markMatch(text, q) {
2136
+ const at = q ? text.toLowerCase().indexOf(q) : -1;
2137
+ if (at < 0) return escapeHtml(text);
2138
+ return escapeHtml(text.slice(0, at)) + '<mark>' + escapeHtml(text.slice(at, at + q.length)) + '</mark>' + escapeHtml(text.slice(at + q.length));
2139
+ }
2140
+
2141
+ function renderComboOptions(combo) {
2142
+ const input = combo.querySelector('input');
2143
+ const list = combo.querySelector('.combo-list');
2144
+ const q = combo.dataset.filter ? input.value.trim().toLowerCase() : '';
2145
+ const current = input.value.trim();
2146
+ const matches = loadedModels.filter(m => !q || m.toLowerCase().includes(q)).slice(0, 200);
2147
+ combo.matches = matches;
2148
+ if (!loadedModels.length) {
2149
+ const status = document.getElementById('models-status')?.textContent || '';
2150
+ list.innerHTML = `<li class="combo-empty">${escapeHtml(status || 'No models loaded')}</li>`;
2151
+ return;
1998
2152
  }
1999
- }
2000
-
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);
2153
+ if (!matches.length) {
2154
+ list.innerHTML = '<li class="combo-empty">No listed model matches. The typed name is kept as it is.</li>';
2155
+ return;
2156
+ }
2157
+ if (!(combo.activeIndex >= 0 && combo.activeIndex < matches.length)) combo.activeIndex = matches.indexOf(current);
2158
+ list.innerHTML = matches.map((m, i) => {
2159
+ const cut = m.lastIndexOf('/') + 1;
2160
+ const prefix = cut ? `<span class="pfx">${markMatch(m.slice(0, cut), q)}</span>` : '';
2161
+ return `<li class="combo-opt" role="option" id="${input.id}-opt-${i}" data-i="${i}" aria-selected="${i === combo.activeIndex}"`
2162
+ + ` onmousedown="event.preventDefault(); chooseComboOption(this)">`
2163
+ + `<span class="id">${prefix}${markMatch(m.slice(cut), q)}</span>`
2164
+ + (m === current ? '<span class="cur">current</span>' : '') + '</li>';
2165
+ }).join('');
2166
+ setComboActive(combo, combo.activeIndex);
2167
+ }
2168
+
2169
+ function setComboActive(combo, i) {
2170
+ const input = combo.querySelector('input');
2171
+ combo.activeIndex = i;
2172
+ combo.querySelectorAll('.combo-opt').forEach(li => li.setAttribute('aria-selected', String(Number(li.dataset.i) === i)));
2173
+ const active = i >= 0 ? document.getElementById(`${input.id}-opt-${i}`) : null;
2174
+ if (active) {
2175
+ input.setAttribute('aria-activedescendant', active.id);
2176
+ // Scroll the list only. The browser's scroll-into-view also scrolls the dialog body.
2177
+ const list = combo.querySelector('.combo-list');
2178
+ if (active.offsetTop < list.scrollTop) list.scrollTop = active.offsetTop - 4;
2179
+ else if (active.offsetTop + active.offsetHeight > list.scrollTop + list.clientHeight) list.scrollTop = active.offsetTop + active.offsetHeight - list.clientHeight + 4;
2180
+ } else {
2181
+ input.removeAttribute('aria-activedescendant');
2016
2182
  }
2017
2183
  }
2018
2184
 
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);
2023
- }
2024
-
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;
2185
+ function chooseComboOption(li) {
2186
+ const combo = li.closest('.combo');
2187
+ const model = combo?.matches?.[Number(li.dataset.i)];
2188
+ if (!model) return;
2189
+ combo.querySelector('input').value = model;
2190
+ closeCombo(combo);
2191
+ }
2192
+
2193
+ function onComboKeydown(e) {
2194
+ const combo = e.currentTarget.closest('.combo');
2195
+ const open = combo.hasAttribute('data-open');
2196
+ const count = combo.matches?.length || 0;
2197
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
2198
+ e.preventDefault();
2199
+ if (!open) return openCombo(combo);
2200
+ if (!count) return;
2201
+ const from = combo.activeIndex;
2202
+ const next = e.key === 'ArrowDown' ? (from < 0 ? 0 : (from + 1) % count) : (from <= 0 ? count - 1 : from - 1);
2203
+ setComboActive(combo, next);
2204
+ } else if (e.key === 'Enter') {
2205
+ // Never submit from a slot. Enter picks the highlighted model, or keeps the typed text.
2206
+ e.preventDefault();
2207
+ e.stopPropagation();
2208
+ if (open && combo.activeIndex >= 0) chooseComboOption(document.getElementById(`${e.currentTarget.id}-opt-${combo.activeIndex}`));
2209
+ else closeCombo(combo);
2210
+ } else if (e.key === 'Escape') {
2211
+ if (open) {
2212
+ e.preventDefault();
2213
+ e.stopPropagation();
2214
+ closeCombo(combo);
2215
+ }
2216
+ } else if (e.key === 'Tab') {
2217
+ closeCombo(combo);
2033
2218
  }
2034
- showToast('Selected: ' + modelId);
2035
2219
  }
2036
2220
 
2037
2221
  // Dynamic Model Catalog View
@@ -2104,7 +2288,7 @@
2104
2288
  try {
2105
2289
  const res = await api('/api/catalog/refresh', { method: 'POST' });
2106
2290
  const data = await res.json();
2107
- if (data.ok) {
2291
+ if (data.success) {
2108
2292
  showToast('Model catalog synchronized');
2109
2293
  await renderCatalogView();
2110
2294
  } else {
@@ -2181,7 +2365,7 @@
2181
2365
  try {
2182
2366
  const res = await api('/api/logs/clear', { method: 'POST' });
2183
2367
  const data = await res.json();
2184
- if (data.ok) {
2368
+ if (data.success) {
2185
2369
  showToast('Request inspector logs cleared');
2186
2370
  await loadInspectorLogs();
2187
2371
  }
@@ -2206,6 +2390,23 @@
2206
2390
  }
2207
2391
 
2208
2392
  // Initial Bootstrap
2393
+ // The version check never blocks the dashboard: without an answer there is no notice.
2394
+ async function loadVersion() {
2395
+ try {
2396
+ const v = await (await api('/api/version')).json();
2397
+ document.getElementById('app-version').textContent = 'v' + v.current;
2398
+ if (!v.updateAvailable) return;
2399
+ document.getElementById('update-latest').textContent = v.latest;
2400
+ document.getElementById('update-command').textContent = v.updateCommand;
2401
+ document.getElementById('update-notice').hidden = false;
2402
+ } catch {}
2403
+ }
2404
+
2405
+ function copyUpdateCommand() {
2406
+ const cmd = document.getElementById('update-command').textContent;
2407
+ navigator.clipboard.writeText(cmd).then(() => showToast('Copied: ' + cmd), () => showToast('Copy failed. Select the command and copy it by hand.', true));
2408
+ }
2409
+
2209
2410
  document.addEventListener('DOMContentLoaded', () => {
2210
2411
  // Setup initial theme buttons state
2211
2412
  const currentTheme = document.documentElement.dataset.theme || 'dark';
@@ -2220,6 +2421,7 @@
2220
2421
 
2221
2422
  // Load initial state from edge gateway
2222
2423
  loadStatus();
2424
+ loadVersion();
2223
2425
  });
2224
2426
  </script>
2225
2427
  </body>
package/version.mjs ADDED
@@ -0,0 +1,53 @@
1
+ // The running version, and whether npm has a newer one.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const ROOT = path.dirname(fileURLToPath(import.meta.url));
7
+
8
+ export const CURRENT_VERSION = (() => {
9
+ try { return JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version; } catch { return '0.0.0'; }
10
+ })();
11
+
12
+ const REGISTRY_URL = 'https://registry.npmjs.org/llm-switcher/latest';
13
+ const CACHE_FILE = 'version-check.json';
14
+ const MAX_AGE_MS = 12 * 60 * 60 * 1000;
15
+
16
+ const parse = (v) => {
17
+ const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(String(v ?? ''));
18
+ return m ? m.slice(1).map(Number) : null;
19
+ };
20
+
21
+ /** True only for a release that is newer. A prerelease or an unparseable version is never an update. */
22
+ export function isNewer(latest, current) {
23
+ const a = parse(latest), b = parse(current);
24
+ if (!a || !b) return false;
25
+ for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i];
26
+ return false;
27
+ }
28
+
29
+ // A git checkout updates with git; an npm install with npm.
30
+ const updateCommand = () => (fs.existsSync(path.join(ROOT, '.git')) ? `git -C "${ROOT}" pull` : 'npm install -g llm-switcher@latest');
31
+
32
+ /**
33
+ * Asks the registry at most once per 12 hours; a failed ask also waits, so an offline machine is not
34
+ * slowed on every call. Never throws: without an answer there is simply no notice.
35
+ */
36
+ export async function checkForUpdate({ stateDir, url = process.env.LLM_SWITCHER_REGISTRY_URL || REGISTRY_URL } = {}) {
37
+ const cacheFile = stateDir ? path.join(stateDir, CACHE_FILE) : null;
38
+ let cache = null;
39
+ try { cache = JSON.parse(fs.readFileSync(cacheFile, 'utf8')); } catch {}
40
+
41
+ let latest = cache?.latest ?? null;
42
+ if (!cache || !(Date.now() - (cache.checkedAt || 0) < MAX_AGE_MS)) {
43
+ try {
44
+ const r = await fetch(url, { signal: AbortSignal.timeout(4000), headers: { accept: 'application/json' } });
45
+ if (r.ok) {
46
+ const v = (await r.json())?.version;
47
+ if (parse(v)) latest = v;
48
+ }
49
+ } catch {}
50
+ try { if (cacheFile) fs.writeFileSync(cacheFile, JSON.stringify({ checkedAt: Date.now(), latest })); } catch {}
51
+ }
52
+ return { current: CURRENT_VERSION, latest, updateAvailable: isNewer(latest, CURRENT_VERSION), updateCommand: updateCommand() };
53
+ }