llm-switcher 1.3.4 → 1.3.6
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 +13 -0
- package/package.json +1 -1
- package/tests/dashboard.e2e.test.mjs +45 -8
- package/tests/update.e2e.test.mjs +3 -2
- package/ui.html +40 -21
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog — LLM Switcher
|
|
2
2
|
|
|
3
|
+
## Release 1.3.6
|
|
4
|
+
|
|
5
|
+
- **Dashboard from a bookmark:** In 1.3.4 the tab kept the token in `sessionStorage`. A bookmark, a typed URL or a new tab then had no token: every call got HTTP 401, and the dashboard showed no profiles and every route as OFF. The token is now in `localStorage`, so after one `switch ui` every tab of that browser works. The page still does not carry the token.
|
|
6
|
+
- **Dashboard without a token:** The header now says to open the dashboard with `switch ui`. Before, it said "Gateway is idle", which looked like a config with no routes.
|
|
7
|
+
|
|
8
|
+
## Release 1.3.5
|
|
9
|
+
|
|
10
|
+
- **Dashboard motion:** The switch, the checkbox, the dialog, the toast and the tooltip used easing curves that overshoot and bounce back. They now use one smooth ease-out curve (`--ease-out`). The durations did not change.
|
|
11
|
+
- **Dashboard elevation:** The dialog, the toast and the model list used a wide 50 px shadow on top of their border. The shadow is now short and tight, so the border defines the edge, in both themes.
|
|
12
|
+
- **Dialog tabs on a phone:** At 390 px wide, the profile dialog cut off the **Model Slots** tab, and the tab bar hides its scrollbar. On a narrow screen the tabs now have less padding, and they wrap if they still do not fit.
|
|
13
|
+
- **API key field:** The field has `autocomplete="off"`, so a password manager does not offer to save the key.
|
|
14
|
+
- **Update now that times out:** The message "The new gateway did not answer" now also says what the last check saw: the gateway from before the update, an HTTP status, or a network error.
|
|
15
|
+
|
|
3
16
|
## Release 1.3.4
|
|
4
17
|
|
|
5
18
|
- **The dashboard page carries no token:** `/ui` sent `admin.token` in the page, and any program on the machine can load `/ui`. The page now gets the token only from the `#token=` of the private launcher that `switch ui` opens (a file with mode 0600). The tab keeps it in `sessionStorage`, so it does not stay in the browser after the tab closes. A tab opened at `/ui` directly says to use `switch ui`. `switch status` and the gateway start line say the same.
|
package/package.json
CHANGED
|
@@ -24,17 +24,37 @@ describe('dashboard, driven in a real browser', { skip: skipReason() }, () => {
|
|
|
24
24
|
await page.goto(fx.tokenUrl());
|
|
25
25
|
});
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
// Close the tab itself, then raise the main one: a tab left in the background gets throttled
|
|
28
|
+
// timers and no animation frames, which stalls every later test.
|
|
29
|
+
async function closeTab(tab) {
|
|
30
|
+
await tab.send('Page.close').catch(() => {});
|
|
31
|
+
tab.close();
|
|
32
|
+
await page.send('Page.bringToFront');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
it('a new tab, opened from a bookmark after switch ui ran once, shows the profiles', async () => {
|
|
36
|
+
const fresh = await browser.newPage();
|
|
37
|
+
try {
|
|
38
|
+
await fresh.goto(fx.dashboardUrl + '#/routes');
|
|
39
|
+
await fresh.waitFor(`document.querySelectorAll('#profiles-grid .pcard').length === ${Object.keys(fx.config().profiles).length}`, 'the profile cards');
|
|
40
|
+
} finally {
|
|
41
|
+
await closeTab(fresh);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('a browser that never got the launcher token says so on the page, not only in a toast', async () => {
|
|
28
46
|
const fresh = await browser.newPage();
|
|
29
47
|
try {
|
|
30
|
-
await fresh.goto(fx.dashboardUrl);
|
|
31
|
-
await fresh.
|
|
48
|
+
await fresh.goto(fx.dashboardUrl + '#/routes');
|
|
49
|
+
await fresh.evaluate(`localStorage.clear(), sessionStorage.clear()`);
|
|
50
|
+
await fresh.reload();
|
|
51
|
+
await fresh.waitFor(`/switch ui/.test(document.getElementById('status-sub')?.textContent || '')`, 'the switch ui hint in the header');
|
|
32
52
|
} finally {
|
|
33
|
-
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
await page.
|
|
53
|
+
await closeTab(fresh);
|
|
54
|
+
// The storage is shared by the origin, so the main tab needs the token again. A #hash change
|
|
55
|
+
// alone fires no load event, so leave the page first.
|
|
56
|
+
await page.goto('about:blank');
|
|
57
|
+
await page.goto(fx.tokenUrl());
|
|
38
58
|
}
|
|
39
59
|
});
|
|
40
60
|
|
|
@@ -776,6 +796,23 @@ describe('dashboard, driven in a real browser', { skip: skipReason() }, () => {
|
|
|
776
796
|
}
|
|
777
797
|
});
|
|
778
798
|
|
|
799
|
+
it('shows every dialog tab whole on a phone', async () => {
|
|
800
|
+
await page.resize(390, 844);
|
|
801
|
+
try {
|
|
802
|
+
await editProfile('intact-claude');
|
|
803
|
+
await page.waitFor('document.getElementById("profile-modal-overlay").style.display === "grid"', 'the dialog');
|
|
804
|
+
const cut = await page.evaluate(`(() => {
|
|
805
|
+
const bar = document.querySelector('#profile-form .tabs').getBoundingClientRect();
|
|
806
|
+
return [...document.querySelectorAll('#profile-form .tabs .tab-btn')]
|
|
807
|
+
.filter(b => { const r = b.getBoundingClientRect(); return r.left < bar.left - 0.5 || r.right > bar.right + 0.5; })
|
|
808
|
+
.map(b => b.textContent.trim());
|
|
809
|
+
})()`);
|
|
810
|
+
assert.deepEqual(cut, [], 'these tabs are cut off by the tab bar at 390 px');
|
|
811
|
+
} finally {
|
|
812
|
+
await page.resize(1280, 900);
|
|
813
|
+
}
|
|
814
|
+
});
|
|
815
|
+
|
|
779
816
|
it('does not let a long profile name break the dialog header', async () => {
|
|
780
817
|
const cfg = fx.config();
|
|
781
818
|
cfg.profiles['intact-claude'].name = 'N'.repeat(300);
|
|
@@ -186,7 +186,7 @@ test('switch update asks the running gateway and waits for the new one', { timeo
|
|
|
186
186
|
test('the dashboard Update now button installs the release and reloads on the new gateway', { timeout: 90000, skip: skipReason() }, async (t) => {
|
|
187
187
|
const fx = await fixture(t, { registryUrl: await registry(t, '9.9.12') });
|
|
188
188
|
await fx.start();
|
|
189
|
-
await fx.waitFor(fx.pid, 'the gateway');
|
|
189
|
+
const firstPid = await fx.waitFor(fx.pid, 'the gateway');
|
|
190
190
|
fx.release('9.9.12');
|
|
191
191
|
const browser = await launchBrowser();
|
|
192
192
|
t.after(() => browser.close());
|
|
@@ -200,7 +200,8 @@ test('the dashboard Update now button installs the release and reloads on the ne
|
|
|
200
200
|
} catch (err) {
|
|
201
201
|
// A CI runner cannot be watched: put what the gateway and the page saw into the failure.
|
|
202
202
|
const stage = await page.evaluate(`document.getElementById('update-status')?.textContent || ''`).catch(() => '(page gone)');
|
|
203
|
-
|
|
203
|
+
// The pid seen from this process tells a gateway that does not answer from a page that cannot reach it.
|
|
204
|
+
err.message += `\nupdate status on the page: ${stage}\npid on /health from the test: ${await fx.pid()} (first gateway ${firstPid})\ngateway log:\n${fx.log()}\npage problems: ${JSON.stringify(page.problems)}`;
|
|
204
205
|
throw err;
|
|
205
206
|
}
|
|
206
207
|
assert.equal(await page.evaluate(`document.getElementById('update-notice').hidden`), true);
|
package/ui.html
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
<style>
|
|
18
18
|
:root {
|
|
19
19
|
color-scheme: dark;
|
|
20
|
+
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
|
20
21
|
--bg: #0c1017;
|
|
21
22
|
--side: #10151e;
|
|
22
23
|
--card: #141a25;
|
|
@@ -37,7 +38,7 @@
|
|
|
37
38
|
--err: #fb7185;
|
|
38
39
|
--err-soft: rgba(251, 113, 133, 0.12);
|
|
39
40
|
--code: #5eead4;
|
|
40
|
-
--shadow: 0
|
|
41
|
+
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 12px -6px rgba(0, 0, 0, 0.55);
|
|
41
42
|
|
|
42
43
|
/* Compatibility aliases */
|
|
43
44
|
--surface: var(--side);
|
|
@@ -89,7 +90,7 @@
|
|
|
89
90
|
--err: #be123c;
|
|
90
91
|
--err-soft: rgba(190, 18, 60, 0.08);
|
|
91
92
|
--code: #0f766e;
|
|
92
|
-
--shadow: 0
|
|
93
|
+
--shadow: 0 1px 2px rgba(20, 26, 37, 0.08), 0 8px 12px -6px rgba(20, 26, 37, 0.16);
|
|
93
94
|
--border-strong: #cbd5e1;
|
|
94
95
|
}
|
|
95
96
|
}
|
|
@@ -115,7 +116,7 @@
|
|
|
115
116
|
--err: #be123c;
|
|
116
117
|
--err-soft: rgba(190, 18, 60, 0.08);
|
|
117
118
|
--code: #0f766e;
|
|
118
|
-
--shadow: 0
|
|
119
|
+
--shadow: 0 1px 2px rgba(20, 26, 37, 0.08), 0 8px 12px -6px rgba(20, 26, 37, 0.16);
|
|
119
120
|
--border-strong: #cbd5e1;
|
|
120
121
|
}
|
|
121
122
|
|
|
@@ -593,7 +594,7 @@
|
|
|
593
594
|
.switch { position: relative; width: 36px; height: 20px; flex: none; display: inline-block; vertical-align: middle; }
|
|
594
595
|
.switch input { position: absolute; opacity: 0; inset: 0; margin: 0; cursor: pointer; min-height: 0; }
|
|
595
596
|
.switch span { position: absolute; inset: 0; border-radius: 999px; background: var(--line); box-shadow: inset 0 0 0 1.5px var(--faint); transition: background-color 0.25s, box-shadow 0.25s; pointer-events: none; }
|
|
596
|
-
.switch span::after { content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 999px; background: #ffffff; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); transition: transform 0.25s
|
|
597
|
+
.switch span::after { content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 999px; background: #ffffff; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); transition: transform 0.25s var(--ease-out), width 0.15s; }
|
|
597
598
|
.switch input:active + span::after { width: 20px; }
|
|
598
599
|
.switch input:checked + span { background: var(--accent-strong); box-shadow: none; }
|
|
599
600
|
.switch input:checked + span::after { transform: translateX(16px); }
|
|
@@ -627,7 +628,7 @@
|
|
|
627
628
|
border: solid var(--accent-fg);
|
|
628
629
|
border-width: 0 2px 2px 0;
|
|
629
630
|
transform: rotate(45deg) scale(0);
|
|
630
|
-
transition: transform 0.15s
|
|
631
|
+
transition: transform 0.15s var(--ease-out);
|
|
631
632
|
}
|
|
632
633
|
input[type="checkbox"]:not(.switch input):checked { background: var(--accent-strong); border-color: var(--accent-strong); }
|
|
633
634
|
input[type="checkbox"]:not(.switch input):checked::before { transform: rotate(45deg) scale(1); }
|
|
@@ -646,7 +647,7 @@
|
|
|
646
647
|
opacity: 1;
|
|
647
648
|
transition: opacity 0.18s;
|
|
648
649
|
}
|
|
649
|
-
.overlay .modal { transition: transform 0.18s
|
|
650
|
+
.overlay .modal { transition: transform 0.18s var(--ease-out); }
|
|
650
651
|
@starting-style { .overlay { opacity: 0; } .overlay .modal { transform: scale(0.96) translateY(8px); } }
|
|
651
652
|
.modal {
|
|
652
653
|
background: var(--card);
|
|
@@ -693,6 +694,7 @@
|
|
|
693
694
|
scrollbar-width: none;
|
|
694
695
|
}
|
|
695
696
|
.tabs::-webkit-scrollbar { display: none; }
|
|
697
|
+
.tabs-wrap { padding: 0.85rem 1.25rem 0.5rem; background: var(--card2); }
|
|
696
698
|
.tabs button.tab-btn {
|
|
697
699
|
padding: 0.55rem 0.95rem;
|
|
698
700
|
color: var(--muted);
|
|
@@ -712,6 +714,13 @@
|
|
|
712
714
|
border-bottom-color: var(--accent);
|
|
713
715
|
font-weight: 600;
|
|
714
716
|
}
|
|
717
|
+
/* A phone has no room for all three tabs at full padding, and the bar hides its scrollbar:
|
|
718
|
+
tighten them, and wrap rather than hide a tab when even that is not enough. */
|
|
719
|
+
@media (max-width: 480px) {
|
|
720
|
+
.tabs-wrap { padding-inline: 1rem; }
|
|
721
|
+
.tabs { flex-wrap: wrap; }
|
|
722
|
+
.tabs button.tab-btn { padding-inline: 0.6rem; }
|
|
723
|
+
}
|
|
715
724
|
|
|
716
725
|
/* Log Items */
|
|
717
726
|
.log-item {
|
|
@@ -757,7 +766,7 @@
|
|
|
757
766
|
gap: 0.6rem;
|
|
758
767
|
overflow: hidden;
|
|
759
768
|
max-width: 90vw;
|
|
760
|
-
animation: fadeIn 0.25s
|
|
769
|
+
animation: fadeIn 0.25s var(--ease-out);
|
|
761
770
|
}
|
|
762
771
|
.toast::before { content: ""; width: 8px; height: 8px; flex: none; border-radius: 50%; background: var(--ok); box-shadow: 0 0 0 3px var(--ok-soft); }
|
|
763
772
|
.toast::after { content: ""; position: absolute; left: 0; bottom: 0; width: 100%; height: 2px; background: var(--accent); transform-origin: left; animation: toastbar 3.2s linear forwards; }
|
|
@@ -782,7 +791,7 @@
|
|
|
782
791
|
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.25);
|
|
783
792
|
opacity: 1;
|
|
784
793
|
translate: 0 0;
|
|
785
|
-
transition: opacity 0.15s, translate 0.2s
|
|
794
|
+
transition: opacity 0.15s, translate 0.2s var(--ease-out), display 0.15s;
|
|
786
795
|
transition-behavior: allow-discrete;
|
|
787
796
|
}
|
|
788
797
|
.tip[hidden] { display: none; opacity: 0; translate: 0 4px; }
|
|
@@ -1123,7 +1132,7 @@
|
|
|
1123
1132
|
|
|
1124
1133
|
<form id="profile-form" onsubmit="saveProfile(event)" onkeydown="blockImplicitSubmit(event)" oninput="formDirty = true" onchange="formDirty = true" style="display:flex; flex-direction:column; overflow:hidden;">
|
|
1125
1134
|
<!-- Modal Inset Tabs -->
|
|
1126
|
-
<div
|
|
1135
|
+
<div class="tabs-wrap">
|
|
1127
1136
|
<div class="tabs">
|
|
1128
1137
|
<button type="button" id="tab-button-general" class="tab-btn active" onclick="switchTab('tab-general')">General</button>
|
|
1129
1138
|
<button type="button" id="tab-button-routing" class="tab-btn" onclick="switchTab('tab-routing')">Routing & Protocol</button>
|
|
@@ -1160,7 +1169,7 @@
|
|
|
1160
1169
|
</label>
|
|
1161
1170
|
|
|
1162
1171
|
<label class="f">API Key
|
|
1163
|
-
<input type="password" id="p-key-val" placeholder="sk-...">
|
|
1172
|
+
<input type="password" id="p-key-val" placeholder="sk-..." autocomplete="off">
|
|
1164
1173
|
</label>
|
|
1165
1174
|
</div>
|
|
1166
1175
|
|
|
@@ -1247,12 +1256,13 @@
|
|
|
1247
1256
|
<script>
|
|
1248
1257
|
// State & Globals
|
|
1249
1258
|
const ADMIN_TOKEN_KEY = 'llm-switcher-admin-token';
|
|
1250
|
-
// The token comes only in the #token of the private launcher that `switch ui` opens
|
|
1251
|
-
//
|
|
1259
|
+
// The token comes only in the #token of the private launcher that `switch ui` opens. The page itself
|
|
1260
|
+
// never carries it: any process on the machine can load /ui. localStorage, not sessionStorage, so a
|
|
1261
|
+
// bookmark or a new tab in the same browser keeps working after one `switch ui`.
|
|
1252
1262
|
const NO_TOKEN_HINT = 'This tab has no access token. Open the dashboard with `switch ui`.';
|
|
1253
1263
|
const hashTokenMatch = location.hash.match(/(?:^#|&)token=([0-9a-f]+)/);
|
|
1254
1264
|
if (hashTokenMatch) {
|
|
1255
|
-
|
|
1265
|
+
localStorage.setItem(ADMIN_TOKEN_KEY, hashTokenMatch[1]);
|
|
1256
1266
|
}
|
|
1257
1267
|
if (hashTokenMatch) {
|
|
1258
1268
|
history.replaceState(null, '', location.pathname + location.search + '#/routes');
|
|
@@ -1280,7 +1290,7 @@
|
|
|
1280
1290
|
|
|
1281
1291
|
// API Helper
|
|
1282
1292
|
async function api(path, opts = {}) {
|
|
1283
|
-
const token =
|
|
1293
|
+
const token = localStorage.getItem(ADMIN_TOKEN_KEY) || '';
|
|
1284
1294
|
const headers = {
|
|
1285
1295
|
...opts.headers,
|
|
1286
1296
|
'x-llm-switcher-token': token
|
|
@@ -1452,6 +1462,8 @@
|
|
|
1452
1462
|
const subEl = document.getElementById('status-sub');
|
|
1453
1463
|
const actionsEl = document.getElementById('ph-actions');
|
|
1454
1464
|
if (!titleEl || !subEl || !actionsEl) return;
|
|
1465
|
+
// Without the token every call is a 401, and the empty state would read as "nothing routed".
|
|
1466
|
+
const noToken = !localStorage.getItem(ADMIN_TOKEN_KEY);
|
|
1455
1467
|
|
|
1456
1468
|
const activeMap = state.activeProfiles || (state.config?.activeProfiles || {});
|
|
1457
1469
|
const activeTargets = Object.entries(activeMap).filter(([_, v]) => Boolean(v));
|
|
@@ -1465,7 +1477,7 @@
|
|
|
1465
1477
|
if (activeMap.agy) parts.push('agy: ' + activeMap.agy);
|
|
1466
1478
|
subEl.textContent = 'Port ' + state.port + ' · Active for ' + parts.join(' · ');
|
|
1467
1479
|
} else {
|
|
1468
|
-
subEl.textContent = 'Gateway is idle. Clients connect directly to official endpoints.';
|
|
1480
|
+
subEl.textContent = noToken ? NO_TOKEN_HINT : 'Gateway is idle. Clients connect directly to official endpoints.';
|
|
1469
1481
|
}
|
|
1470
1482
|
actionsEl.innerHTML = `
|
|
1471
1483
|
<button class="btn btn-primary" onclick="openProfileModal(true)">+ Add Profile</button>
|
|
@@ -2777,15 +2789,22 @@
|
|
|
2777
2789
|
return;
|
|
2778
2790
|
}
|
|
2779
2791
|
say('Installed v' + result.to + '. Restarting the gateway...');
|
|
2792
|
+
let last = 'no check ran';
|
|
2780
2793
|
for (let n = 0; n < 60; n++) {
|
|
2781
2794
|
await new Promise(r => setTimeout(r, 500));
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2795
|
+
try {
|
|
2796
|
+
const r = await fetch('/health?challenge=ui', { cache: 'no-store' });
|
|
2797
|
+
const pid = (await r.json()).pid ?? null;
|
|
2798
|
+
if (pid && pid !== before) {
|
|
2799
|
+
location.reload();
|
|
2800
|
+
return;
|
|
2801
|
+
}
|
|
2802
|
+
last = pid ? 'the gateway from before the update (pid ' + pid + ')' : 'HTTP ' + r.status + ' with no pid';
|
|
2803
|
+
} catch (err) {
|
|
2804
|
+
last = err.message;
|
|
2786
2805
|
}
|
|
2787
2806
|
}
|
|
2788
|
-
throw new Error('The new gateway did not answer. Read proxy.log.');
|
|
2807
|
+
throw new Error('The new gateway did not answer (last check: ' + last + '). Read proxy.log.');
|
|
2789
2808
|
} catch (err) {
|
|
2790
2809
|
say(err.message);
|
|
2791
2810
|
showToast('Update failed: ' + err.message, true);
|
|
@@ -2794,7 +2813,7 @@
|
|
|
2794
2813
|
}
|
|
2795
2814
|
|
|
2796
2815
|
document.addEventListener('DOMContentLoaded', () => {
|
|
2797
|
-
if (!
|
|
2816
|
+
if (!localStorage.getItem(ADMIN_TOKEN_KEY)) showToast(NO_TOKEN_HINT, true);
|
|
2798
2817
|
// Setup initial theme buttons state
|
|
2799
2818
|
const currentTheme = document.documentElement.dataset.theme || 'dark';
|
|
2800
2819
|
document.getElementById('btn-theme-dark')?.setAttribute('aria-pressed', currentTheme === 'dark');
|