llm-switcher 1.2.6 → 1.2.8

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
@@ -811,6 +811,10 @@
811
811
  position: sticky;
812
812
  top: 0;
813
813
  height: auto;
814
+ /* The base rule makes this a scroll container, and a grid item that scrolls has an automatic
815
+ minimum size of zero. The row then collapsed to the padding (16 px measured) and the nav
816
+ was drawn outside it, so no link could be pressed. Reset it, or the height is ignored. */
817
+ overflow: visible;
814
818
  flex-direction: row;
815
819
  flex-wrap: wrap;
816
820
  align-items: center;
@@ -1086,17 +1090,17 @@
1086
1090
  </div>
1087
1091
 
1088
1092
  <!-- Profile Modal Editor -->
1089
- <div id="profile-modal-overlay" class="overlay" style="display:none;" onmousedown="overlayPressed = event.target === this" onclick="if (overlayPressed && event.target === this) closeProfileModal()">
1093
+ <div id="profile-modal-overlay" class="overlay" style="display:none;" onmousedown="overlayPressed = event.target === this" onclick="if (overlayPressed && event.target === this) requestCloseProfileModal()">
1090
1094
  <div class="modal" role="dialog" aria-modal="true" aria-labelledby="form-title">
1091
1095
  <div class="mh">
1092
1096
  <div>
1093
1097
  <strong id="form-title">Edit Provider Profile</strong>
1094
1098
  <span id="active-tag" style="font-size:0.75rem; color:var(--ok); margin-left:0.5rem; font-weight:600;"></span>
1095
1099
  </div>
1096
- <button class="icon" type="button" onclick="closeProfileModal()" aria-label="Close dialog">✕</button>
1100
+ <button class="icon" type="button" onclick="requestCloseProfileModal()" aria-label="Close dialog">✕</button>
1097
1101
  </div>
1098
1102
 
1099
- <form id="profile-form" onsubmit="saveProfile(event)" onkeydown="blockImplicitSubmit(event)" style="display:flex; flex-direction:column; overflow:hidden;">
1103
+ <form id="profile-form" onsubmit="saveProfile(event)" onkeydown="blockImplicitSubmit(event)" oninput="formDirty = true" onchange="formDirty = true" style="display:flex; flex-direction:column; overflow:hidden;">
1100
1104
  <!-- Modal Inset Tabs -->
1101
1105
  <div style="padding:0.85rem 1.25rem 0.5rem; background:var(--card2);">
1102
1106
  <div class="tabs">
@@ -1206,7 +1210,7 @@
1206
1210
  </div>
1207
1211
  <div style="display:flex; gap:0.6rem;">
1208
1212
  <button type="button" id="btn-test-conn" class="btn btn-outline" onclick="testConnection()">Test Connection</button>
1209
- <button type="button" class="btn btn-outline" onclick="closeProfileModal()">Cancel</button>
1213
+ <button type="button" class="btn btn-outline" onclick="requestCloseProfileModal()">Cancel</button>
1210
1214
  <button type="submit" class="btn btn-primary">Save Profile</button>
1211
1215
  </div>
1212
1216
  </div>
@@ -1323,17 +1327,51 @@
1323
1327
  for (const ev of ['focusout', 'click', 'scroll']) addEventListener(ev, hideTip, true);
1324
1328
 
1325
1329
  // Modal Control
1330
+
1331
+ // A result belongs to the profile it was measured on. Cleared on every open, because a dialog
1332
+ // that opens showing "HTTP 200 OK" from the profile before it says the wrong thing about this one.
1333
+ // Empties the three fields, never the box: they are static markup, and innerHTML would delete
1334
+ // them for the rest of the session.
1335
+ function clearTestResult() {
1336
+ const box = document.getElementById('test-result');
1337
+ if (!box) return;
1338
+ for (const id of ['test-status-badge', 'test-latency', 'test-sample']) {
1339
+ const el = document.getElementById(id);
1340
+ if (el) el.textContent = '';
1341
+ }
1342
+ document.getElementById('test-status-badge')?.setAttribute('class', 'badge');
1343
+ box.className = 'test-result-box';
1344
+ }
1345
+
1326
1346
  function openProfileModal(isNew = false) {
1327
1347
  const modal = document.getElementById('profile-modal-overlay');
1328
1348
  if (modal) modal.style.display = 'grid';
1349
+ clearTestResult();
1329
1350
  if (isNew) {
1330
1351
  initNewProfile();
1331
1352
  }
1353
+ // Without this the focus stays on the button behind the overlay, so the first Tab walks into
1354
+ // the page under the dialog instead of through the dialog.
1355
+ const first = modal?.querySelector('input:not([readonly]):not([disabled]), select, textarea, button');
1356
+ first?.focus();
1332
1357
  }
1333
1358
 
1334
1359
  function closeProfileModal() {
1335
1360
  const modal = document.getElementById('profile-modal-overlay');
1336
1361
  if (modal) modal.style.display = 'none';
1362
+ formDirty = false;
1363
+ // render() reads this to draw the ring and the "Editing Profile" label. Left set, a card stayed
1364
+ // marked as being edited after Escape, Cancel or a press on the dark area.
1365
+ currentlyEditingKey = '';
1366
+ formLoadedKey = null;
1367
+ render();
1368
+ }
1369
+
1370
+ // The close button, Cancel, the dark area and Escape. A save or a delete calls closeProfileModal
1371
+ // directly, because by then nothing is left to lose.
1372
+ function requestCloseProfileModal() {
1373
+ if (formDirty && !confirm('Discard unsaved changes?')) return;
1374
+ closeProfileModal();
1337
1375
  }
1338
1376
 
1339
1377
  // A text selection that starts in a field and ends on the overlay also clicks the overlay.
@@ -1349,7 +1387,7 @@
1349
1387
  if (e.key === 'Escape' && !e.target.closest?.('input, select, textarea')) {
1350
1388
  const modal = document.getElementById('profile-modal-overlay');
1351
1389
  if (modal && modal.style.display !== 'none') {
1352
- closeProfileModal();
1390
+ requestCloseProfileModal();
1353
1391
  }
1354
1392
  }
1355
1393
  });
@@ -1405,7 +1443,7 @@
1405
1443
  }
1406
1444
  actionsEl.innerHTML = `
1407
1445
  <button class="btn btn-primary" onclick="openProfileModal(true)">+ Add Profile</button>
1408
- <button class="btn btn-outline" id="btn-toggle" onclick="toggleProxy()">${activeTargets.length > 0 ? 'Use official endpoints' : 'Activate compatible routes'}</button>
1446
+ <button class="btn btn-outline" id="btn-toggle-routes" onclick="toggleProxy()">${activeTargets.length > 0 ? 'Use official endpoints' : 'Activate compatible routes'}</button>
1409
1447
  <button class="btn btn-outline" onclick="loadStatus()">Refresh</button>
1410
1448
  `;
1411
1449
  } else if (route === 'models') {
@@ -1856,13 +1894,13 @@
1856
1894
  const data = await res.json();
1857
1895
  if (data.success) {
1858
1896
  showToast('Updated ' + target + ' routing');
1859
- await loadStatus();
1860
1897
  } else {
1861
1898
  showToast(data.error || 'Failed to switch target', true);
1862
1899
  }
1863
1900
  } catch (err) {
1864
1901
  showToast('Network error switching target', true);
1865
1902
  }
1903
+ await loadStatus();
1866
1904
  }
1867
1905
 
1868
1906
  async function toggleProfileAllCli(key, enable) {
@@ -1876,19 +1914,20 @@
1876
1914
  const data = await res.json();
1877
1915
  if (data.success) {
1878
1916
  showToast(key + (enable ? ' activated' : ' deactivated'));
1879
- await loadStatus();
1880
1917
  } else {
1881
1918
  showToast(data.error || 'Failed to toggle profile', true);
1882
1919
  }
1883
1920
  } catch (err) {
1884
1921
  showToast('Network error toggling profile', true);
1885
1922
  }
1923
+ await loadStatus();
1886
1924
  }
1887
1925
 
1888
1926
  function loadProfileToForm(key) {
1889
1927
  const p = state.config?.profiles?.[key];
1890
1928
  if (!p) return;
1891
1929
  formLoadedKey = key;
1930
+ formDirty = false;
1892
1931
  currentProfileBuffer = JSON.parse(JSON.stringify(p));
1893
1932
 
1894
1933
  document.getElementById('form-title').textContent = 'Edit Profile: ' + p.name;
@@ -1916,6 +1955,7 @@
1916
1955
 
1917
1956
  function initNewProfile() {
1918
1957
  creatingNew = true;
1958
+ formDirty = false;
1919
1959
  currentlyEditingKey = '';
1920
1960
  formLoadedKey = null;
1921
1961
  currentProfileBuffer = {
@@ -1977,7 +2017,7 @@
1977
2017
  vertex: {
1978
2018
  name: 'Google Vertex AI',
1979
2019
  baseURL: 'https://us-central1-aiplatform.googleapis.com/v1',
1980
- tool: 'anthropic',
2020
+ tool: 'claude',
1981
2021
  outFormat: 'vertex',
1982
2022
  mode: 'convert',
1983
2023
  thinkingMode: 'auto',
@@ -2007,6 +2047,7 @@
2007
2047
  document.getElementById('p-mode').value = t.mode;
2008
2048
  document.getElementById('p-thinking-mode').value = t.thinkingMode;
2009
2049
  currentProfileBuffer = { ...currentProfileBuffer, ...t };
2050
+ formDirty = true;
2010
2051
  renderModelSlots(t.tool || 'auto', t);
2011
2052
  showToast('Template "' + t.name + '" applied');
2012
2053
  }
@@ -2024,13 +2065,13 @@
2024
2065
  const data = await res.json();
2025
2066
  if (data.success) {
2026
2067
  showToast(isAnyActive ? 'Using official endpoints' : 'Gateway activated');
2027
- await loadStatus();
2028
2068
  } else {
2029
2069
  showToast(data.error || 'Failed to toggle routes', true);
2030
2070
  }
2031
2071
  } catch (err) {
2032
2072
  showToast('Network error toggling routes', true);
2033
2073
  }
2074
+ await loadStatus();
2034
2075
  }
2035
2076
 
2036
2077
  async function saveProfile(e) {
@@ -2039,19 +2080,29 @@
2039
2080
  if (!key) return;
2040
2081
 
2041
2082
  collectCurrentSlotValues();
2083
+ if (creatingNew && state.config?.profiles?.[key]) {
2084
+ showToast('Profile "' + key + '" already exists. Use another key, or edit that profile.', true);
2085
+ return;
2086
+ }
2042
2087
  const existing = state.config?.profiles?.[key] || {};
2043
2088
  const keyVal = document.getElementById('p-key-val').value.trim();
2044
2089
 
2045
2090
  const profilePayload = {
2046
2091
  name: document.getElementById('p-name').value.trim(),
2047
2092
  baseURL: document.getElementById('p-url').value.trim(),
2048
- tool: document.getElementById('p-tool').value,
2093
+ // "auto" in the list means a profile for both tools, which config.json stores as null.
2094
+ tool: document.getElementById('p-tool').value === 'auto' ? null : document.getElementById('p-tool').value,
2049
2095
  outFormat: document.getElementById('p-outformat').value,
2050
2096
  mode: document.getElementById('p-mode').value,
2051
- thinkingMode: document.getElementById('p-thinking-mode').value,
2052
- defaultModels: { ...(existing.defaultModels || {}), ...(currentProfileBuffer?.defaultModels || {}) },
2053
- model1M: { ...(existing.model1M || {}), ...(currentProfileBuffer?.model1M || {}) }
2097
+ // The buffer already holds what the profile had, minus what the person emptied.
2098
+ defaultModels: { ...(currentProfileBuffer?.defaultModels || {}) }
2054
2099
  };
2100
+ // A save with no edit must leave the profile as it was: write a default only where the profile
2101
+ // already had the key.
2102
+ const model1M = { ...(currentProfileBuffer?.model1M || {}) };
2103
+ if (Object.keys(model1M).length || existing.model1M) profilePayload.model1M = model1M;
2104
+ const thinkingMode = document.getElementById('p-thinking-mode').value;
2105
+ if (thinkingMode !== 'auto' || existing.thinkingMode) profilePayload.thinkingMode = thinkingMode;
2055
2106
 
2056
2107
  if (keyVal === MASKED_KEY) {
2057
2108
  profilePayload.apiKey = existing.apiKey || '';
@@ -2065,6 +2116,15 @@
2065
2116
  if (existing.publicModels) profilePayload.publicModels = existing.publicModels;
2066
2117
 
2067
2118
  showToast('Saving profile "' + key + '"...');
2119
+ // A double-click sent the save twice. The same pattern as the catalog sync button: disabled
2120
+ // while the request runs, re-enabled in `finally` so a failed save can be tried again.
2121
+ const submit = e.target?.querySelector?.('button[type="submit"]')
2122
+ || document.querySelector('#profile-modal-overlay button[type="submit"]');
2123
+ if (submit) {
2124
+ if (submit.disabled) return;
2125
+ submit.disabled = true;
2126
+ submit.setAttribute('aria-busy', 'true');
2127
+ }
2068
2128
  try {
2069
2129
  const res = await api('/api/save-profile', {
2070
2130
  method: 'POST',
@@ -2081,6 +2141,11 @@
2081
2141
  }
2082
2142
  } catch (err) {
2083
2143
  showToast('Network error saving profile', true);
2144
+ } finally {
2145
+ if (submit) {
2146
+ submit.disabled = false;
2147
+ submit.removeAttribute('aria-busy');
2148
+ }
2084
2149
  }
2085
2150
  }
2086
2151
 
@@ -2356,6 +2421,7 @@
2356
2421
  const model = combo?.matches?.[Number(li.dataset.i)];
2357
2422
  if (!model) return;
2358
2423
  combo.querySelector('input').value = model;
2424
+ formDirty = true;
2359
2425
  closeCombo(combo);
2360
2426
  update1mWarnings();
2361
2427
  }
@@ -2412,8 +2478,15 @@
2412
2478
  const ctModels = document.getElementById('ct-models');
2413
2479
  if (ctModels) ctModels.textContent = claudeModels.length + codexModels.length;
2414
2480
 
2415
- document.getElementById('badge-claude-count').textContent = claudeModels.length + ' models · ' + claudeTiers.size + ' tiers';
2416
- document.getElementById('badge-codex-count').textContent = codexModels.length + ' models · ' + codexRoles.size + ' roles';
2481
+ // `data-full` is what the search box restores when it is emptied again.
2482
+ for (const [id, text] of [
2483
+ ['badge-claude-count', claudeModels.length + ' models · ' + claudeTiers.size + ' tiers'],
2484
+ ['badge-codex-count', codexModels.length + ' models · ' + codexRoles.size + ' roles']
2485
+ ]) {
2486
+ const el = document.getElementById(id);
2487
+ el.textContent = text;
2488
+ el.dataset.full = text;
2489
+ }
2417
2490
 
2418
2491
  claudeContainer.innerHTML = claudeModels.map(m => `
2419
2492
  <div class="row" style="padding:0.6rem 0.8rem; background:var(--card2); border-radius:8px;">
@@ -2443,12 +2516,40 @@
2443
2516
  }
2444
2517
  }
2445
2518
 
2519
+ // A filter that hides every row but keeps the header count reads as a broken page: the header
2520
+ // still promised 11 models over an empty list. So the count follows the filter, and an empty
2521
+ // result says so in words.
2446
2522
  function filterCatalogView(q) {
2447
2523
  const term = (q || '').toLowerCase();
2448
- document.querySelectorAll('#catalog-claude-items .row, #catalog-codex-items .row').forEach(row => {
2449
- const text = row.textContent.toLowerCase();
2450
- row.style.display = text.includes(term) ? 'flex' : 'none';
2451
- });
2524
+ for (const [list, badge, unit] of [
2525
+ ['#catalog-claude-items', 'badge-claude-count', 'models'],
2526
+ ['#catalog-codex-items', 'badge-codex-count', 'models']
2527
+ ]) {
2528
+ const container = document.querySelector(list);
2529
+ if (!container) continue;
2530
+ let shown = 0;
2531
+ container.querySelectorAll('.row').forEach(row => {
2532
+ const hit = row.textContent.toLowerCase().includes(term);
2533
+ row.style.display = hit ? 'flex' : 'none';
2534
+ if (hit) shown++;
2535
+ });
2536
+ let empty = container.querySelector('.catalog-empty');
2537
+ if (!shown) {
2538
+ if (!empty) {
2539
+ empty = document.createElement('div');
2540
+ empty.className = 'catalog-empty hint';
2541
+ empty.style.padding = '0.8rem';
2542
+ container.appendChild(empty);
2543
+ }
2544
+ empty.textContent = term ? 'No model matches "' + term + '".' : 'No model in this list.';
2545
+ empty.style.display = 'block';
2546
+ } else if (empty) {
2547
+ empty.style.display = 'none';
2548
+ }
2549
+ const el = document.getElementById(badge);
2550
+ // `data-full` is the unfiltered text that the render wrote. An empty box restores it.
2551
+ if (el) el.textContent = term ? shown + ' ' + unit + ' shown' : (el.dataset.full || el.textContent);
2552
+ }
2452
2553
  }
2453
2554
 
2454
2555
  async function refreshCatalogUpstream() {