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/CHANGELOG.md +11 -0
- package/README.md +1 -0
- package/blindfold/make-certs.sh +3 -1
- package/catalog.mjs +132 -42
- package/mcp.mjs +2 -2
- package/package.json +1 -1
- package/proxy.mjs +8 -6
- package/scripts/run-tests.mjs +3 -1
- package/state.mjs +58 -4
- package/switch.mjs +28 -4
- package/tests/blindfold-e2e.test.mjs +2 -2
- package/tests/blindfold-task5.test.mjs +1 -1
- package/tests/blindfold.task3.test.mjs +2 -2
- package/tests/blindfold.wire.test.mjs +3 -1
- package/tests/catalog.test.mjs +117 -19
- package/tests/codex-daemon.test.mjs +92 -0
- package/tests/lifecycle.test.mjs +49 -37
- package/tests/make-certs.test.mjs +22 -0
- package/tests/real-user-sim.test.mjs +3 -0
- package/tests/shim.test.mjs +8 -3
- package/tests/switch.test.mjs +30 -24
- package/tests/ui.test.mjs +90 -0
- package/tests/version.test.mjs +78 -0
- package/ui.html +285 -83
- package/version.mjs +53 -0
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"
|
|
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
|
-
<
|
|
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
|
-
//
|
|
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
|
-
<
|
|
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.
|
|
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.
|
|
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 || '
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1976
|
-
|
|
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
|
-
|
|
2064
|
+
loadedModels = [];
|
|
2065
|
+
modelsLoadedFor = '';
|
|
2066
|
+
setModelsStatus('error', 'Models not loaded: ' + modelsError(data.error), 'Retry');
|
|
1981
2067
|
}
|
|
1982
2068
|
} catch (err) {
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
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
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
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
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
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
|
|
2020
|
-
const
|
|
2021
|
-
const
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
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.
|
|
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.
|
|
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
|
+
}
|