llm-switcher 1.2.0 → 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.
Files changed (48) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +1 -0
  3. package/blindfold/make-certs.sh +3 -1
  4. package/catalog.mjs +132 -42
  5. package/classifier.mjs +238 -0
  6. package/docs/TOKEN-OPTIMIZER-INTEROP.md +110 -110
  7. package/docs/response-matrix.json +1130 -1130
  8. package/formats.mjs +30 -1
  9. package/icons/antigravity.png +0 -0
  10. package/icons/claude.png +0 -0
  11. package/icons/codex.png +0 -0
  12. package/icons/deepseek.png +0 -0
  13. package/icons/gemini.png +0 -0
  14. package/icons/github.png +0 -0
  15. package/icons/groq.png +0 -0
  16. package/icons/intact.svg +1 -0
  17. package/icons/ollama.png +0 -0
  18. package/icons/openai.png +0 -0
  19. package/icons/openrouter.png +0 -0
  20. package/icons/qwen.png +0 -0
  21. package/icons/vertex.png +0 -0
  22. package/mcp.mjs +2 -2
  23. package/package.json +1 -1
  24. package/proxy.mjs +34 -20
  25. package/scripts/run-tests.mjs +3 -1
  26. package/skills/llm-switcher/SKILL.md +93 -93
  27. package/state.mjs +58 -4
  28. package/switch +0 -0
  29. package/switch.mjs +28 -4
  30. package/tests/blindfold-e2e.test.mjs +2 -2
  31. package/tests/blindfold-task5.test.mjs +1 -1
  32. package/tests/blindfold.task3.test.mjs +2 -2
  33. package/tests/blindfold.wire.test.mjs +3 -1
  34. package/tests/catalog.test.mjs +117 -19
  35. package/tests/classifier.test.mjs +210 -0
  36. package/tests/codex-daemon.test.mjs +92 -0
  37. package/tests/formats.test.mjs +30 -0
  38. package/tests/helpers.mjs +24 -24
  39. package/tests/lifecycle.test.mjs +49 -37
  40. package/tests/live-optimizer-interop.mjs +205 -205
  41. package/tests/make-certs.test.mjs +22 -0
  42. package/tests/real-user-sim.test.mjs +3 -0
  43. package/tests/shim.test.mjs +8 -3
  44. package/tests/switch.test.mjs +30 -24
  45. package/tests/ui.test.mjs +90 -0
  46. package/tests/version.test.mjs +78 -0
  47. package/ui.html +1895 -1616
  48. package/version.mjs +53 -0
package/ui.html CHANGED
@@ -1,14 +1,14 @@
1
- <!DOCTYPE html>
1
+ <!doctype html>
2
2
  <html lang="en">
3
3
  <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <meta name="description" content="Local dashboard for the LLM Switcher gateway: route Claude Code, Codex, OpenAI and Vertex clients to a provider of your choice.">
7
- <title>LLM Switcher | Local gateway</title>
8
- <link rel="icon" href="data:,">
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>LLM Switcher · Edge Gateway</title>
7
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0%25' stop-color='%237c6cf6'/%3E%3Cstop offset='100%25' stop-color='%235eead4'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='32' height='32' rx='9' fill='url(%23g)'/%3E%3Cpath d='M8 12h16m-4-4 4 4-4 4M24 20H8m4-4-4 4 4 4' fill='none' stroke='%23fff' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E">
8
+ <meta name="theme-color" content="#7c6cf6">
9
9
  <script>
10
10
  try {
11
- const savedTheme = localStorage.getItem('lsw-theme');
11
+ const savedTheme = localStorage.getItem('intact-theme') || localStorage.getItem('lsw-theme');
12
12
  if (savedTheme === 'light' || (!savedTheme && window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches)) {
13
13
  document.documentElement.dataset.theme = 'light';
14
14
  }
@@ -16,1122 +16,1118 @@
16
16
  </script>
17
17
  <style>
18
18
  :root {
19
- /* Dark Theme (Default) - Carbon / Zinc Developer Aesthetic */
20
- --bg: #090a0f;
21
- --surface: #11131a;
22
- --surface-card: #141722;
23
- --surface-elevated: #1a1e2d;
24
- --surface-well: #0a0c12;
25
- --border: rgba(255, 255, 255, 0.08);
26
- --border-strong: rgba(255, 255, 255, 0.16);
27
- --border-active: #38bdf8;
28
- --text: #f8fafc;
29
- --text-muted: #94a3b8;
30
- /* Measured against the darkest and the lightest surface this token sits on
31
- (--bg through --surface-elevated). #64748b read 3.48:1 on the elevated card. */
32
- --text-dim: #7d8ba1;
33
- --primary-bg: #f8fafc;
34
- --primary-fg: #090a0f;
35
- --primary-hover: #e2e8f0;
36
- --accent: #38bdf8;
37
- --accent-muted: rgba(56, 189, 248, 0.1);
38
- --success: #10b981;
39
- --success-muted: rgba(16, 185, 129, 0.12);
40
- --warning: #f59e0b;
41
- --warning-muted: rgba(245, 158, 11, 0.12);
42
- --danger: #f87171;
43
- --danger-muted: rgba(248, 113, 113, 0.12);
19
+ color-scheme: dark;
20
+ --bg: #0c1017;
21
+ --side: #10151e;
22
+ --card: #141a25;
23
+ --card2: #1b2331;
24
+ --line: #232d3d;
25
+ --fg: #e4e9f1;
26
+ --muted: #95a0b3;
27
+ --faint: #8b94a7;
28
+ --accent: #8b80f9;
29
+ --accent-strong: #6c5ce7;
30
+ --accent-soft: rgba(139, 128, 249, 0.15);
31
+ --accent-fg: #ffffff;
32
+ --mint: #5eead4;
33
+ --ok: #4ade80;
34
+ --ok-soft: rgba(74, 222, 128, 0.12);
35
+ --warn: #fbbf24;
36
+ --warn-soft: rgba(251, 191, 36, 0.1);
37
+ --err: #fb7185;
38
+ --err-soft: rgba(251, 113, 133, 0.12);
39
+ --code: #5eead4;
40
+ --shadow: 0 20px 50px rgba(0, 0, 0, 0.55);
41
+
42
+ /* Compatibility aliases */
43
+ --surface: var(--side);
44
+ --surface-card: var(--card);
45
+ --surface-elevated: var(--card2);
46
+ --surface-well: var(--bg);
47
+ --border: var(--line);
48
+ --border-strong: #334155;
49
+ --border-active: var(--accent);
50
+ --text: var(--fg);
51
+ --text-muted: var(--muted);
52
+ --text-dim: var(--faint);
53
+ --primary-bg: var(--accent-strong);
54
+ --primary-fg: var(--accent-fg);
55
+ --primary-hover: var(--accent);
56
+ --accent-muted: var(--accent-soft);
57
+ --success: var(--ok);
58
+ --success-muted: var(--ok-soft);
59
+ --warning: var(--warn);
60
+ --warning-muted: var(--warn-soft);
61
+ --danger: var(--err);
62
+ --danger-muted: var(--err-soft);
44
63
  --radius-sm: 6px;
45
64
  --radius: 8px;
46
- --radius-lg: 10px;
47
- --font-sans: "Segoe UI Variable", "Aptos", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
48
- --font-mono: ui-monospace, "SF Mono", "Cascadia Code", "JetBrains Mono", Menlo, Consolas, monospace;
49
- }
50
-
51
- [data-theme="light"] {
52
- --bg: #f8fafc;
53
- --surface: #ffffff;
54
- --surface-card: #ffffff;
55
- --surface-elevated: #f1f5f9;
56
- --surface-well: #f8fafc;
57
- --border: #e2e8f0;
58
- --border-strong: #cbd5e1;
59
- --border-active: #0284c7;
60
- --text: #0f172a;
61
- --text-muted: #475569;
62
- /* Measured against the two backgrounds these tokens actually sit on (#ffffff and
63
- --bg), and against the 10% tint the badges blend to. The previous values read
64
- 2.45:1 to 4.13:1, below the 4.5:1 floor. */
65
- --text-dim: #64748b;
66
- --primary-bg: #0f172a;
67
- --primary-fg: #ffffff;
68
- --primary-hover: #1e293b;
69
- --accent: #0284c7;
70
- --accent-muted: rgba(2, 132, 199, 0.08);
71
- --success: #047857;
72
- --success-muted: rgba(4, 120, 87, 0.1);
73
- --warning: #92400e;
74
- --warning-muted: rgba(146, 64, 14, 0.1);
75
- --danger: #b91c1c;
76
- --danger-muted: rgba(185, 28, 28, 0.1);
65
+ --radius-lg: 12px;
66
+ --font-sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
67
+ --font-mono: ui-monospace, SFMono-Regular, Consolas, monospace;
68
+ }
69
+
70
+ @media (prefers-color-scheme: light) {
71
+ :root:not([data-theme="dark"]) {
72
+ color-scheme: light;
73
+ --bg: #f3f5f9;
74
+ --side: #ffffff;
75
+ --card: #ffffff;
76
+ --card2: #eef1f6;
77
+ --line: #dfe4ec;
78
+ --fg: #141a25;
79
+ --muted: #526076;
80
+ --faint: #5f6b7d;
81
+ --accent: #5b4bdb;
82
+ --accent-strong: #5b4bdb;
83
+ --accent-soft: rgba(91, 75, 219, 0.1);
84
+ --mint: #0f766e;
85
+ --ok: #166534;
86
+ --ok-soft: rgba(22, 101, 52, 0.1);
87
+ --warn: #92400e;
88
+ --warn-soft: rgba(146, 64, 14, 0.08);
89
+ --err: #be123c;
90
+ --err-soft: rgba(190, 18, 60, 0.08);
91
+ --code: #0f766e;
92
+ --shadow: 0 20px 50px rgba(20, 26, 37, 0.14);
93
+ --border-strong: #cbd5e1;
94
+ }
95
+ }
96
+
97
+ :root[data-theme="light"] {
77
98
  color-scheme: light;
99
+ --bg: #f3f5f9;
100
+ --side: #ffffff;
101
+ --card: #ffffff;
102
+ --card2: #eef1f6;
103
+ --line: #dfe4ec;
104
+ --fg: #141a25;
105
+ --muted: #526076;
106
+ --faint: #5f6b7d;
107
+ --accent: #5b4bdb;
108
+ --accent-strong: #5b4bdb;
109
+ --accent-soft: rgba(91, 75, 219, 0.1);
110
+ --mint: #0f766e;
111
+ --ok: #166534;
112
+ --ok-soft: rgba(22, 101, 52, 0.1);
113
+ --warn: #92400e;
114
+ --warn-soft: rgba(146, 64, 14, 0.08);
115
+ --err: #be123c;
116
+ --err-soft: rgba(190, 18, 60, 0.08);
117
+ --code: #0f766e;
118
+ --shadow: 0 20px 50px rgba(20, 26, 37, 0.14);
119
+ --border-strong: #cbd5e1;
78
120
  }
79
121
 
80
- * { box-sizing: border-box; margin: 0; padding: 0; font-family: var(--font-sans); }
122
+ * { box-sizing: border-box; margin: 0; padding: 0; }
123
+ html, body { height: 100%; }
81
124
  body {
125
+ font: 14px/1.5 var(--font-sans);
82
126
  background: var(--bg);
83
- color: var(--text);
84
- padding: 24px 16px 48px;
85
- min-height: 100dvh;
86
- line-height: 1.5;
127
+ color: var(--fg);
128
+ display: grid;
129
+ grid-template-columns: 15rem 1fr;
87
130
  -webkit-font-smoothing: antialiased;
88
- text-rendering: optimizeLegibility;
89
131
  }
90
- .container { max-width: 1180px; margin: 0 auto; }
91
132
 
92
- /* Header */
93
- header {
133
+ /* Aside Navigation */
134
+ aside {
135
+ background: var(--side);
136
+ border-right: 1px solid var(--line);
137
+ padding: 1.1rem 0.75rem;
94
138
  display: flex;
95
- justify-content: space-between;
96
- align-items: center;
97
- margin-bottom: 20px;
98
- padding-bottom: 16px;
99
- border-bottom: 1px solid var(--border);
100
- }
101
- .title-group { display: flex; align-items: center; gap: 12px; }
102
- .logo-badge {
103
- width: 34px;
104
- height: 32px;
105
- border-radius: var(--radius-sm);
106
- background: var(--surface-elevated);
107
- border: 1px solid var(--border-strong);
108
- display: flex;
109
- align-items: center;
110
- justify-content: center;
111
- color: var(--text);
112
- font-family: var(--font-mono);
113
- font-weight: 700;
114
- font-size: 11px;
115
- letter-spacing: 0.06em;
116
- }
117
- .title-group h1 {
118
- font-size: 17px;
119
- font-weight: 600;
120
- color: var(--text);
121
- letter-spacing: -0.025em;
122
- }
123
- .title-group p {
124
- font-size: 12px;
125
- color: var(--text-dim);
126
- margin-top: 1px;
139
+ flex-direction: column;
140
+ gap: 0.25rem;
141
+ position: sticky;
142
+ top: 0;
143
+ height: 100vh;
144
+ overflow-y: auto;
145
+ z-index: 10;
127
146
  }
128
- .header-actions { display: flex; gap: 8px; align-items: center; }
129
147
 
130
- /* Buttons */
131
- .btn {
132
- display: inline-flex;
148
+ .brand {
149
+ display: flex;
133
150
  align-items: center;
134
- justify-content: center;
135
- gap: 6px;
136
- padding: 7px 14px;
137
- font-size: 12px;
138
- font-weight: 500;
139
- border-radius: var(--radius-sm);
140
- border: 1px solid transparent;
141
- cursor: pointer;
142
- transition: background 0.15s, border-color 0.15s, color 0.15s;
143
- white-space: nowrap;
151
+ gap: 0.7rem;
152
+ padding: 0.35rem 0.5rem 1.25rem;
153
+ margin-bottom: 0.5rem;
154
+ border-bottom: 1px solid var(--line);
144
155
  text-decoration: none;
156
+ color: var(--fg);
157
+ }
158
+ .logo {
159
+ width: 38px;
160
+ height: 38px;
161
+ flex: none;
162
+ filter: drop-shadow(0 4px 14px rgba(124, 108, 246, 0.45));
163
+ }
164
+ .logo svg { width: 100%; height: 100%; display: block; }
165
+ .brand b {
166
+ display: block;
167
+ font-size: 1.25rem;
168
+ font-weight: 800;
169
+ letter-spacing: -0.03em;
170
+ line-height: 1.1;
171
+ color: var(--fg);
172
+ }
173
+ .brand small {
174
+ display: block;
175
+ color: var(--muted);
176
+ font-size: 0.72rem;
177
+ letter-spacing: 0.02em;
178
+ }
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
+
188
+ aside nav { display: grid; gap: 0.25rem; }
189
+ aside a.nav-link {
190
+ display: flex;
191
+ align-items: center;
192
+ gap: 0.65rem;
193
+ padding: 0.5rem 0.7rem;
194
+ border-radius: 8px;
195
+ color: var(--muted);
196
+ text-decoration: none;
197
+ font-size: 0.88rem;
198
+ font-weight: 500;
199
+ transition: background 0.15s, color 0.15s;
145
200
  }
146
- .btn:disabled { opacity: 0.5; cursor: not-allowed; }
147
- .btn:active:not(:disabled) { transform: translateY(1px); }
148
- .btn-sm { padding: 4px 10px; font-size: 11px; border-radius: 5px; }
149
-
150
- .btn:focus-visible,
151
- .tab-btn:focus-visible,
152
- .switch input:focus-visible + .slider,
153
- input:focus-visible,
154
- select:focus-visible {
155
- outline: 2px solid var(--accent);
156
- outline-offset: 2px;
157
- }
158
-
159
- .btn-primary {
160
- background: var(--primary-bg);
161
- color: var(--primary-fg);
162
- border-color: var(--primary-bg);
163
- }
164
- .btn-primary:hover:not(:disabled) {
165
- background: var(--primary-hover);
166
- border-color: var(--primary-hover);
201
+ aside a.nav-link:hover { color: var(--fg); background: var(--card2); }
202
+ aside a.nav-link[aria-current="page"] {
203
+ background: var(--accent-soft);
204
+ color: var(--accent);
205
+ font-weight: 600;
167
206
  }
168
-
169
- .btn-outline {
170
- background: var(--surface);
171
- color: var(--text);
172
- border-color: var(--border-strong);
207
+ aside a.nav-link svg { width: 18px; height: 18px; flex: none; }
208
+ aside a.nav-link .ct {
209
+ margin-left: auto;
210
+ font-size: 0.72rem;
211
+ color: var(--faint);
212
+ font-variant-numeric: tabular-nums;
213
+ padding: 0.1rem 0.4rem;
214
+ border-radius: 999px;
215
+ background: var(--card2);
173
216
  }
174
- .btn-outline:hover:not(:disabled) {
175
- background: var(--surface-elevated);
176
- border-color: var(--border-strong);
217
+ aside a.nav-link .ct:empty { display: none; }
218
+ aside a.nav-link[aria-current="page"] .ct {
219
+ background: color-mix(in srgb, var(--accent) 20%, transparent);
220
+ color: var(--accent);
177
221
  }
222
+ aside .spacer { flex: 1; }
178
223
 
179
- .btn-danger {
180
- background: var(--danger-muted);
181
- color: var(--danger);
182
- border-color: rgba(239, 68, 68, 0.25);
183
- }
184
- .btn-danger:hover:not(:disabled) {
185
- background: rgba(239, 68, 68, 0.2);
224
+ .prefs {
225
+ display: flex;
226
+ flex-direction: column;
227
+ gap: 0.65rem;
228
+ padding: 16px 14px;
229
+ border-top: 1px solid var(--line);
230
+ margin-top: 0.5rem;
186
231
  }
187
-
188
- .btn-success {
189
- background: var(--success-muted);
190
- color: var(--success);
191
- border-color: rgba(16, 185, 129, 0.25);
232
+ .seg {
233
+ display: inline-flex;
234
+ background: var(--bg);
235
+ border: 1px solid var(--line);
236
+ border-radius: 8px;
237
+ padding: 4px;
238
+ gap: 3px;
239
+ }
240
+ .seg button {
241
+ font: inherit;
242
+ min-height: 24px;
243
+ padding: 0.15rem 0.6rem;
244
+ font-size: 0.75rem;
245
+ background: transparent;
246
+ color: var(--muted);
247
+ border: 0;
248
+ border-radius: 6px;
249
+ cursor: pointer;
192
250
  }
193
- .btn-success:hover:not(:disabled) {
194
- background: rgba(16, 185, 129, 0.2);
251
+ .seg button:hover { color: var(--fg); }
252
+ .seg button[aria-pressed="true"] {
253
+ background: var(--accent-soft);
254
+ color: var(--accent);
255
+ font-weight: 600;
195
256
  }
196
257
 
197
- /* Telemetry Status Banner */
198
- .status-banner {
258
+ /* Page Area */
259
+ .page {
260
+ min-width: 0;
199
261
  display: flex;
200
- justify-content: space-between;
201
- align-items: center;
202
- background: var(--surface);
203
- border: 1px solid var(--border);
204
- border-radius: var(--radius);
205
- padding: 14px 18px;
206
- margin-bottom: 24px;
207
- }
208
- .status-left { display: flex; align-items: center; gap: 12px; min-width: 0; }
209
- .status-dot-wrapper {
210
- position: relative;
211
- width: 12px;
212
- height: 12px;
262
+ flex-direction: column;
263
+ min-height: 100vh;
264
+ }
265
+ .ph {
213
266
  display: flex;
214
267
  align-items: center;
215
- justify-content: center;
216
- flex-shrink: 0;
217
- }
218
- .status-dot {
219
- width: 8px;
220
- height: 8px;
221
- border-radius: 50%;
222
- background: var(--text-dim);
223
- box-shadow: 0 0 0 2px rgba(100, 116, 139, 0.2);
224
- transition: background 0.2s, box-shadow 0.2s;
225
- }
226
- .status-dot.active {
227
- background: var(--success);
228
- box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.2);
229
- }
230
- .status-title {
231
- font-size: 13px;
232
- font-weight: 600;
233
- color: var(--text);
234
- letter-spacing: -0.01em;
268
+ justify-content: space-between;
269
+ gap: 0.75rem;
270
+ padding: 1.1rem 2rem;
271
+ border-bottom: 1px solid var(--line);
272
+ min-height: 4.5rem;
273
+ flex-wrap: wrap;
235
274
  }
236
- .status-sub {
237
- font-size: 11px;
238
- color: var(--text-muted);
239
- margin-top: 1px;
240
- font-family: var(--font-mono);
241
- word-break: break-all;
275
+ .title-box h1 { font-size: 1.25rem; margin: 0; font-weight: 650; }
276
+ .title-box p { margin: 0.15rem 0 0; color: var(--muted); font-size: 0.85rem; }
277
+ .ph-actions { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; }
278
+
279
+ .content {
280
+ padding: 1.5rem 2rem 3rem;
281
+ width: 100%;
282
+ display: grid;
283
+ gap: 1.5rem;
284
+ align-content: start;
242
285
  }
286
+ .content > * { min-width: 0; }
243
287
 
244
- /* Section Headers */
245
- .section-header {
288
+ /* Cards & Grids */
289
+ .sec { display: grid; gap: 0.85rem; }
290
+ .sech { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; flex-wrap: wrap; }
291
+ .card { background: var(--card); border: 1px solid var(--line); border-radius: 12px; }
292
+ .card.pad { padding: 1.1rem 1.25rem; }
293
+ .cardh {
246
294
  display: flex;
247
- justify-content: space-between;
248
295
  align-items: center;
249
- margin-bottom: 12px;
250
- }
251
- .section-title {
252
- font-size: 11px;
253
- font-weight: 600;
254
- letter-spacing: 0.05em;
255
- color: var(--text-muted);
256
- text-transform: uppercase;
257
- }
258
- .section-subtitle {
259
- font-size: 11px;
260
- color: var(--text-dim);
261
- font-weight: 400;
262
- text-transform: none;
263
- letter-spacing: 0;
296
+ gap: 0.75rem;
297
+ padding: 0.9rem 1.25rem;
298
+ border-bottom: 1px solid var(--line);
299
+ flex-wrap: wrap;
264
300
  }
301
+ .cardh .grow { min-width: min(12rem, 100%); }
302
+ .grow { flex: 1; }
303
+ .muted { color: var(--muted); }
304
+ .faint { color: var(--faint); }
305
+ .small { font-size: 0.8rem; }
306
+ code, .mono { font-family: var(--font-mono); font-size: 0.82rem; }
307
+ code { color: var(--code); }
265
308
 
266
- /* Active Multi-CLI Slots (Rackmount Grid) */
267
- .cli-slots-grid {
309
+ /* Provider Grid (Intact Style) */
310
+ .pgrid {
268
311
  display: grid;
269
- grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
270
- gap: 12px;
271
- margin-bottom: 24px;
272
- }
273
- .cli-slot-card {
274
- background: var(--surface-card);
275
- border: 1px solid var(--border);
276
- border-radius: var(--radius);
277
- padding: 12px 14px;
312
+ grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
313
+ gap: 0.85rem;
314
+ }
315
+ .pcard {
278
316
  display: flex;
279
317
  flex-direction: column;
280
- gap: 10px;
318
+ gap: 0.75rem;
319
+ padding: 1rem 1.15rem;
320
+ background: var(--card);
321
+ border: 1px solid var(--line);
322
+ border-radius: 11px;
323
+ color: inherit;
324
+ min-width: 0;
325
+ cursor: pointer;
281
326
  transition: border-color 0.15s, background 0.15s;
282
327
  }
283
- .cli-slot-card.active {
284
- border-color: rgba(56, 189, 248, 0.35);
285
- background: var(--surface);
286
- }
287
- .cli-slot-header {
328
+ .pcard:hover { border-color: var(--accent); }
329
+ .pcard.active { border-color: color-mix(in srgb, var(--ok) 50%, transparent); }
330
+ .pcard .top {
288
331
  display: flex;
289
- justify-content: space-between;
290
332
  align-items: center;
333
+ gap: 0.75rem;
334
+ min-width: 0;
291
335
  }
292
- .cli-slot-title-group {
293
- display: flex;
294
- align-items: center;
295
- gap: 8px;
296
- }
297
- .cli-slot-icon {
298
- display: inline-flex;
299
- align-items: center;
300
- justify-content: center;
301
- width: 24px;
302
- height: 24px;
303
- border: 1px solid var(--border-strong);
304
- border-radius: 5px;
305
- color: var(--text-muted);
306
- background: var(--surface-well);
307
- font-family: var(--font-mono);
308
- font-size: 9px;
309
- font-weight: 700;
310
- letter-spacing: 0.04em;
311
- }
312
- .cli-slot-name {
313
- font-size: 12px;
336
+ .pcard .nm {
314
337
  font-weight: 600;
315
- color: var(--text);
316
- }
317
- .cli-slot-route {
318
- font-size: 10px;
319
- color: var(--text-dim);
320
- font-family: var(--font-mono);
321
- }
322
- .cli-slot-select {
323
- background: var(--surface-well);
324
- border: 1px solid var(--border);
325
- border-radius: var(--radius-sm);
326
- padding: 7px 10px;
327
- color: var(--text);
328
- font-size: 12px;
329
- width: 100%;
330
- outline: none;
331
- cursor: pointer;
332
- transition: border-color 0.15s;
333
- }
334
- .cli-slot-select:focus {
335
- border-color: var(--border-active);
336
- }
337
- .sr-only {
338
- position: absolute;
339
- width: 1px;
340
- height: 1px;
341
- padding: 0;
342
- margin: -1px;
338
+ font-size: 0.95rem;
343
339
  overflow: hidden;
344
- clip: rect(0, 0, 0, 0);
340
+ text-overflow: ellipsis;
345
341
  white-space: nowrap;
346
- border: 0;
347
342
  }
348
343
 
349
- /* Profile Cards Grid */
350
- .profiles-grid {
344
+ .av {
345
+ width: 36px;
346
+ height: 36px;
347
+ border-radius: 9px;
351
348
  display: grid;
352
- grid-template-columns: repeat(auto-fill, minmax(310px, 1fr));
353
- gap: 14px;
354
- margin-bottom: 28px;
355
- }
356
- .profile-card {
357
- background: var(--surface-card);
358
- border: 1px solid var(--border);
359
- border-radius: var(--radius);
360
- padding: 14px 16px;
361
- transition: border-color 0.15s, background 0.15s;
362
- position: relative;
363
- }
364
- .profile-card:hover {
365
- border-color: var(--border-strong);
366
- background: var(--surface);
367
- }
368
- .profile-card.editing {
369
- border-color: var(--border-active);
370
- box-shadow: 0 0 0 1px var(--border-active);
371
- }
372
- .profile-card.active {
373
- border-left: 3px solid var(--success);
349
+ place-items: center;
350
+ font-weight: 700;
351
+ font-size: 0.85rem;
352
+ color: #ffffff;
353
+ flex: none;
354
+ }
355
+ .av.sm { width: 24px; height: 24px; border-radius: 6px; font-size: 0.65rem; }
356
+ .av.logo {
357
+ background: #ffffff;
358
+ padding: 5px;
359
+ box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
360
+ }
361
+ .av.logo.sm { padding: 3px; }
362
+ .av.logo img {
363
+ width: 100%;
364
+ height: 100%;
365
+ object-fit: contain;
366
+ display: block;
374
367
  }
375
368
 
376
- .card-top {
377
- display: flex;
378
- justify-content: space-between;
379
- align-items: center;
380
- gap: 8px;
381
- margin-bottom: 6px;
382
- }
383
- .card-name-group {
384
- display: flex;
369
+ .status {
370
+ display: inline-flex;
385
371
  align-items: center;
386
- gap: 6px;
387
- min-width: 0;
388
- flex-wrap: wrap;
372
+ gap: 0.35rem;
373
+ font-size: 0.74rem;
374
+ color: var(--faint);
389
375
  }
390
- .card-name {
391
- font-size: 13px;
392
- font-weight: 600;
393
- color: var(--text);
376
+ .status.on { color: var(--ok); font-weight: 500; }
377
+ .status.on::before {
378
+ content: "";
379
+ width: 6px;
380
+ height: 6px;
381
+ border-radius: 50%;
382
+ background: currentColor;
394
383
  }
395
384
 
396
- /* Badges */
397
385
  .badge {
398
- font-size: 9px;
399
- font-weight: 600;
400
- text-transform: uppercase;
401
- letter-spacing: 0.04em;
402
- padding: 2px 6px;
403
- border-radius: 4px;
386
+ display: inline-flex;
387
+ align-items: center;
388
+ gap: 0.25rem;
389
+ font-size: 0.72rem;
390
+ font-weight: 500;
391
+ border-radius: 6px;
392
+ padding: 0.1rem 0.45rem;
393
+ background: var(--card2);
394
+ color: var(--muted);
404
395
  white-space: nowrap;
405
396
  border: 1px solid transparent;
406
397
  }
407
- .badge-hybrid {
408
- background: rgba(99, 102, 241, 0.12);
409
- color: #818cf8;
410
- border-color: rgba(99, 102, 241, 0.25);
411
- }
412
- .badge-direct {
413
- background: var(--success-muted);
414
- color: var(--success);
415
- border-color: rgba(16, 185, 129, 0.25);
416
- }
417
- .badge-convert {
418
- background: var(--warning-muted);
419
- color: var(--warning);
420
- border-color: rgba(245, 158, 11, 0.25);
421
- }
422
- .badge-outline {
423
- background: transparent;
424
- color: var(--text-dim);
425
- border-color: var(--border);
426
- }
427
- .badge-route {
428
- background: var(--surface-well);
429
- color: var(--text-muted);
430
- border-color: var(--border);
431
- font-family: var(--font-mono);
432
- font-size: 10px;
433
- }
434
- .badge-1m {
435
- background: var(--success-muted);
436
- color: var(--success);
437
- border: 1px solid rgba(16, 185, 129, 0.25);
438
- font-size: 9px;
439
- font-weight: 600;
440
- padding: 1px 4px;
441
- border-radius: 3px;
442
- margin-left: 4px;
443
- }
444
- .badge-1m-warn {
445
- background: var(--warning-muted);
446
- color: var(--warning);
447
- border: 1px solid rgba(245, 158, 11, 0.3);
448
- font-size: 9px;
449
- font-weight: 600;
450
- padding: 1px 5px;
451
- border-radius: 4px;
452
- margin-left: 4px;
453
- white-space: nowrap;
454
- cursor: help;
455
- }
456
-
457
- .warning-callout {
458
- background: var(--warning-muted);
459
- border: 1px solid rgba(245, 158, 11, 0.25);
460
- color: var(--warning);
461
- padding: 10px 12px;
462
- border-radius: var(--radius-sm);
463
- font-size: 12px;
464
- margin-bottom: 14px;
465
- line-height: 1.5;
398
+ .badge.ok { background: var(--ok-soft); color: var(--ok); }
399
+ .badge.warn { background: var(--warn-soft); color: var(--warn); }
400
+ .badge.accent { background: var(--accent-soft); color: var(--accent); }
401
+ .badge.err { background: var(--err-soft); color: var(--err); }
402
+ .badge.outline { border-color: var(--line); }
403
+
404
+ .row {
466
405
  display: flex;
467
- gap: 10px;
468
- align-items: flex-start;
406
+ align-items: center;
407
+ gap: 0.85rem;
408
+ padding: 0.85rem 1.25rem;
409
+ border-bottom: 1px solid var(--line);
469
410
  }
411
+ .row:last-child { border-bottom: 0; }
412
+ .row .who { flex: 1; min-width: 0; }
413
+ .row .who .l { font-weight: 600; font-size: 0.92rem; }
414
+ .row .meta { display: flex; gap: 0.4rem; align-items: center; flex-wrap: wrap; margin-top: 0.2rem; color: var(--muted); font-size: 0.8rem; }
470
415
 
471
- .card-url {
472
- font-size: 11px;
473
- color: var(--text-dim);
474
- word-break: break-all;
475
- margin-bottom: 8px;
476
- font-family: var(--font-mono);
477
- }
478
- .card-models {
479
- font-size: 11px;
480
- color: var(--text-muted);
481
- display: flex;
482
- flex-direction: column;
483
- gap: 4px;
484
- background: var(--surface-well);
485
- border: 1px solid var(--border);
486
- padding: 8px 10px;
487
- border-radius: var(--radius-sm);
488
- margin-bottom: 8px;
489
- font-family: var(--font-mono);
490
- }
491
- .card-models strong { color: var(--text); font-weight: 500; }
492
- .card-footer {
416
+ .chips { display: flex; flex-direction: column; gap: 0.35rem; min-width: 0; }
417
+ .chip {
418
+ display: grid;
419
+ grid-template-columns: minmax(0, 1fr) auto;
420
+ align-items: center;
421
+ gap: 0.1rem 0.5rem;
422
+ min-width: 0;
423
+ background: var(--card2);
424
+ border: 1px solid var(--line);
425
+ border-radius: 6px;
426
+ padding: 0.35rem 0.55rem;
427
+ font-size: 0.75rem;
428
+ color: var(--fg);
429
+ }
430
+ .chip .lbl { grid-column: 1 / -1; font-size: 0.7rem; overflow-wrap: anywhere; }
431
+ .chip .val { font-family: var(--font-mono); overflow-wrap: anywhere; }
432
+ .chip .b1m { font-weight: 600; font-size: 0.7rem; }
433
+
434
+ .copyline {
493
435
  display: flex;
494
- justify-content: space-between;
495
436
  align-items: center;
496
- font-size: 11px;
497
- margin-top: 6px;
437
+ gap: 0.5rem;
438
+ background: var(--bg);
439
+ border: 1px solid var(--line);
440
+ border-radius: 8px;
441
+ padding: 0.35rem 0.5rem 0.35rem 0.75rem;
442
+ min-width: 0;
498
443
  }
444
+ .copyline code { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.8rem; }
499
445
 
500
- /* Minimalist Switch Toggle */
501
- .switch {
502
- position: relative;
503
- display: inline-block;
504
- width: 32px;
505
- height: 18px;
506
- flex-shrink: 0;
507
- }
508
- .switch input { position: absolute; opacity: 0; width: 1px; height: 1px; }
509
- .slider {
510
- position: absolute;
511
- cursor: pointer;
512
- top: 0; left: 0; right: 0; bottom: 0;
513
- background-color: var(--surface-elevated);
514
- border: 1px solid var(--border-strong);
515
- transition: 0.2s;
516
- border-radius: 18px;
517
- }
518
- .slider:before {
519
- position: absolute;
520
- content: "";
521
- height: 12px;
522
- width: 12px;
523
- left: 2px;
524
- bottom: 2px;
525
- background-color: var(--text-dim);
526
- transition: 0.2s;
527
- border-radius: 50%;
528
- }
529
- input:checked + .slider {
530
- background-color: var(--success);
531
- border-color: var(--success);
532
- }
533
- input:checked + .slider:before {
534
- transform: translateX(14px);
535
- background-color: #ffffff;
446
+ .stats {
447
+ display: grid;
448
+ grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
449
+ gap: 0.85rem;
536
450
  }
537
-
538
- /* Editor Form Panel */
539
- .form-panel {
540
- background: var(--surface);
541
- border: 1px solid var(--border);
542
- border-radius: var(--radius-lg);
543
- padding: 20px;
544
- margin-bottom: 30px;
451
+ .stat {
452
+ background: var(--card);
453
+ border: 1px solid var(--line);
454
+ border-radius: 12px;
455
+ padding: 1rem 1.15rem;
545
456
  }
457
+ .stat .k { color: var(--muted); font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.04em; }
458
+ .stat .v { font-size: 1.5rem; font-weight: 700; margin-top: 0.2rem; }
546
459
 
547
- /* Segmented Tab Bar */
548
- .tab-bar {
460
+ /* Buttons */
461
+ button, .btn {
462
+ font: inherit;
463
+ min-height: 34px;
464
+ padding: 0.35rem 0.85rem;
465
+ border: 0;
466
+ border-radius: 8px;
467
+ cursor: pointer;
549
468
  display: inline-flex;
550
- gap: 4px;
551
- margin-bottom: 20px;
552
- background: var(--surface-well);
553
- border: 1px solid var(--border);
554
- border-radius: var(--radius);
555
- padding: 3px;
556
- flex-wrap: wrap;
557
- }
558
- .tab-btn {
559
- background: transparent;
560
- border: none;
561
- color: var(--text-muted);
562
- padding: 6px 12px;
563
- font-size: 12px;
469
+ align-items: center;
470
+ gap: 0.4rem;
471
+ justify-content: center;
472
+ text-decoration: none;
473
+ font-size: 0.84rem;
564
474
  font-weight: 500;
565
- border-radius: var(--radius-sm);
566
- cursor: pointer;
567
- transition: background 0.15s, border-color 0.15s, color 0.15s;
475
+ transition: filter 0.15s, background 0.15s, color 0.15s, border-color 0.15s;
568
476
  }
569
- .tab-btn:hover { color: var(--text); }
570
- .tab-btn.active {
571
- color: var(--text);
572
- background: var(--surface-elevated);
477
+ button.primary, .btn.btn-primary {
478
+ background: var(--accent-strong);
479
+ color: var(--accent-fg);
573
480
  font-weight: 600;
574
- border: 1px solid var(--border-strong);
575
- }
576
-
577
- .tab-pane { display: none; }
578
- .tab-pane.active { display: block; }
579
-
580
- .form-grid {
581
- display: grid;
582
- grid-template-columns: repeat(2, minmax(0, 1fr));
583
- gap: 14px;
584
- }
585
- .form-group {
586
- display: flex;
587
- flex-direction: column;
588
- gap: 6px;
589
- min-width: 0;
590
481
  }
591
- .form-group.full { grid-column: span 2; }
592
- /* .field-heading names a group of settings. A <label> that points at no field
593
- reads to assistive technology as a broken association, so headings use this
594
- instead and keep the same appearance. */
595
- label, .field-heading {
596
- font-size: 11px;
597
- font-weight: 500;
598
- color: var(--text-muted);
599
- text-transform: uppercase;
600
- letter-spacing: 0.04em;
601
- }
602
- input, select {
603
- background: var(--surface-well);
604
- border: 1px solid var(--border);
605
- border-radius: var(--radius-sm);
606
- padding: 8px 11px;
607
- color: var(--text);
608
- font-size: 12px;
609
- outline: none;
610
- transition: border-color 0.15s;
482
+ button.primary:hover, .btn.btn-primary:hover { filter: brightness(1.1); }
483
+ button.ghost, .btn.btn-outline {
484
+ background: transparent;
485
+ color: var(--fg);
486
+ border: 1px solid var(--line);
487
+ }
488
+ button.ghost:hover, .btn.btn-outline:hover {
489
+ background: var(--card2);
490
+ border-color: var(--faint);
491
+ }
492
+ button.plain { background: transparent; color: var(--muted); }
493
+ button.plain:hover { color: var(--fg); background: var(--card2); }
494
+ button.danger, .btn.btn-danger {
495
+ background: var(--err-soft);
496
+ color: var(--err);
497
+ border: 1px solid color-mix(in srgb, var(--err) 30%, transparent);
498
+ }
499
+ button.danger:hover, .btn.btn-danger:hover {
500
+ background: var(--err);
501
+ color: #ffffff;
502
+ }
503
+ button.icon { min-height: 28px; min-width: 28px; padding: 0.2rem; background: transparent; color: var(--muted); border-radius: 6px; }
504
+ button.icon:hover { color: var(--fg); background: var(--card2); }
505
+ button:disabled, .btn:disabled { opacity: 0.4; cursor: not-allowed; pointer-events: none; }
506
+ .btn-sm { min-height: 26px; padding: 0.2rem 0.6rem; font-size: 0.78rem; border-radius: 6px; }
507
+
508
+ /* Inputs */
509
+ input, select, textarea {
510
+ font: inherit;
511
+ padding: 0.45rem 0.7rem;
512
+ border: 1px solid var(--line);
513
+ border-radius: 8px;
514
+ background: var(--bg);
515
+ color: var(--fg);
516
+ min-height: 36px;
611
517
  width: 100%;
612
- max-width: 100%;
613
- min-width: 0;
614
- }
615
- input:focus, select:focus {
616
- border-color: var(--border-active);
518
+ font-size: 0.85rem;
617
519
  }
618
- input:disabled, select:disabled {
619
- color: var(--text-dim);
620
- cursor: not-allowed;
621
- }
622
-
623
- .preset-bar {
624
- display: flex;
625
- gap: 6px;
626
- align-items: center;
627
- margin-bottom: 16px;
628
- flex-wrap: wrap;
629
- }
630
- .preset-label {
631
- font-size: 10px;
632
- color: var(--text-dim);
633
- font-weight: 600;
634
- text-transform: uppercase;
635
- letter-spacing: 0.06em;
636
- margin-right: 4px;
637
- }
638
-
639
- .form-actions {
520
+ input:focus, select:focus, textarea:focus, button:focus-visible, a:focus-visible {
521
+ outline: none;
522
+ border-color: var(--accent);
523
+ box-shadow: 0 0 0 3px var(--accent-soft);
524
+ }
525
+ label.f { display: grid; gap: 0.35rem; font-size: 0.82rem; font-weight: 500; }
526
+ .hint { font-size: 0.75rem; color: var(--faint); font-weight: 400; }
527
+ .search { position: relative; max-width: 18rem; width: 100%; }
528
+ .search svg { position: absolute; left: 0.65rem; top: 50%; transform: translateY(-50%); color: var(--faint); width: 15px; height: 15px; pointer-events: none; }
529
+ .search input { padding-left: 2rem; min-height: 32px; }
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
+
564
+ /* Switch */
565
+ .switch { position: relative; width: 36px; height: 20px; flex: none; display: inline-block; }
566
+ .switch input { position: absolute; opacity: 0; inset: 0; margin: 0; cursor: pointer; min-height: 0; }
567
+ .switch span { position: absolute; inset: 0; border-radius: 999px; background: var(--line); transition: background 0.15s; pointer-events: none; }
568
+ .switch span::after { content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: #ffffff; transition: transform 0.15s; }
569
+ .switch input:checked + span { background: var(--accent-strong); }
570
+ .switch input:checked + span::after { transform: translateX(16px); }
571
+
572
+ /* Modal */
573
+ .overlay {
574
+ position: fixed;
575
+ inset: 0;
576
+ background: rgba(0, 0, 0, 0.65);
577
+ backdrop-filter: blur(3px);
578
+ display: grid;
579
+ place-items: center;
580
+ padding: 1rem;
581
+ z-index: 50;
582
+ }
583
+ .modal {
584
+ background: var(--card);
585
+ border: 1px solid var(--line);
586
+ border-radius: 14px;
587
+ box-shadow: var(--shadow);
588
+ width: min(38rem, 100%);
589
+ max-height: 90vh;
640
590
  display: flex;
641
- justify-content: space-between;
642
- align-items: center;
643
- margin-top: 20px;
644
- padding-top: 16px;
645
- border-top: 1px solid var(--border);
646
- gap: 12px;
647
- flex-wrap: wrap;
648
- }
649
-
650
- /* Test Results Card */
651
- .test-result-box {
652
- margin-top: 14px;
653
- border-radius: var(--radius-sm);
654
- padding: 12px 14px;
655
- font-size: 12px;
656
- display: none;
657
- border: 1px solid transparent;
658
- }
659
- .test-result-box.success {
660
- background: var(--success-muted);
661
- border-color: rgba(16, 185, 129, 0.25);
662
- color: var(--success);
663
- }
664
- .test-result-box.error {
665
- background: var(--danger-muted);
666
- border-color: rgba(239, 68, 68, 0.25);
667
- color: var(--danger);
668
- }
669
- .test-meta { display: flex; gap: 12px; font-weight: 600; margin-bottom: 6px; font-family: var(--font-mono); }
670
- .test-sample {
671
- background: var(--surface-well);
672
- border: 1px solid var(--border);
673
- padding: 8px 10px;
674
- border-radius: 4px;
675
- font-family: var(--font-mono);
676
- font-size: 11px;
677
- white-space: pre-wrap;
678
- word-break: break-all;
679
- max-height: 120px;
680
- overflow-y: auto;
681
- color: var(--text-muted);
682
- }
683
-
684
- /* Model Explorer */
685
- .model-browser {
686
- margin-top: 10px;
687
- background: var(--surface-well);
688
- border: 1px solid var(--border);
689
- border-radius: var(--radius-sm);
690
- padding: 12px;
591
+ flex-direction: column;
592
+ overflow: hidden;
691
593
  }
692
- .model-browser-header {
594
+ .mh {
693
595
  display: flex;
694
596
  justify-content: space-between;
695
597
  align-items: center;
696
- margin-bottom: 10px;
697
- gap: 10px;
698
- flex-wrap: wrap;
598
+ padding: 1rem 1.25rem;
599
+ border-bottom: 1px solid var(--line);
699
600
  }
700
- .model-list-scroll {
701
- max-height: 220px;
601
+ .mh strong { font-size: 1.05rem; }
602
+ .mb {
603
+ padding: 1.15rem 1.25rem;
702
604
  overflow-y: auto;
703
605
  display: flex;
704
606
  flex-direction: column;
705
- gap: 4px;
706
- padding-right: 4px;
607
+ gap: 1rem;
707
608
  }
708
- .model-row {
609
+ .mf {
709
610
  display: flex;
710
611
  justify-content: space-between;
711
612
  align-items: center;
712
- padding: 6px 10px;
713
- background: var(--surface);
714
- border: 1px solid var(--border);
715
- border-radius: 5px;
716
- font-size: 12px;
717
- gap: 10px;
613
+ padding: 0.85rem 1.25rem;
614
+ border-top: 1px solid var(--line);
615
+ background: var(--card2);
616
+ }
617
+
618
+ /* Tabs (Intact style) */
619
+ .tabs {
620
+ display: flex;
621
+ gap: 0.25rem;
622
+ box-shadow: inset 0 -1px 0 var(--line);
623
+ overflow-x: auto;
624
+ overflow-y: hidden;
625
+ scrollbar-width: none;
626
+ }
627
+ .tabs::-webkit-scrollbar { display: none; }
628
+ .tabs button.tab-btn {
629
+ padding: 0.55rem 0.95rem;
630
+ color: var(--muted);
631
+ background: transparent;
632
+ border: 0;
633
+ border-bottom: 2px solid transparent;
634
+ white-space: nowrap;
635
+ font-weight: 500;
636
+ font-size: 0.85rem;
637
+ cursor: pointer;
638
+ border-radius: 0;
639
+ min-height: 0;
718
640
  }
719
- .model-row:hover {
720
- border-color: var(--border-strong);
721
- background: var(--surface-elevated);
641
+ .tabs button.tab-btn:hover { color: var(--fg); }
642
+ .tabs button.tab-btn.active {
643
+ color: var(--fg);
644
+ border-bottom-color: var(--accent);
645
+ font-weight: 600;
722
646
  }
723
- .model-id { font-family: var(--font-mono); color: var(--text); font-size: 11px; word-break: break-all; }
724
- .model-btn-group { display: flex; gap: 4px; flex-shrink: 0; }
725
647
 
726
- /* Inspector Logs */
648
+ /* Log Items */
727
649
  .log-item {
728
- background: var(--surface-well);
729
- border: 1px solid var(--border);
730
- border-radius: var(--radius-sm);
731
- padding: 10px 12px;
732
- font-size: 12px;
650
+ background: var(--card);
651
+ border: 1px solid var(--line);
652
+ border-radius: 9px;
653
+ padding: 0.75rem 1rem;
654
+ margin-bottom: 0.6rem;
733
655
  display: flex;
734
656
  flex-direction: column;
735
- gap: 6px;
657
+ gap: 0.45rem;
736
658
  }
737
- .log-header {
738
- display: flex;
739
- justify-content: space-between;
740
- align-items: center;
741
- gap: 8px;
742
- flex-wrap: wrap;
743
- }
744
- .log-badges { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
745
- .log-body {
746
- background: var(--surface);
747
- border: 1px solid var(--border);
748
- padding: 8px 10px;
749
- border-radius: 4px;
750
- font-family: var(--font-mono);
751
- font-size: 11px;
752
- color: var(--text-muted);
753
- white-space: pre-wrap;
754
- word-break: break-all;
755
- max-height: 120px;
756
- overflow-y: auto;
659
+ .log-item details { margin-top: 0.2rem; }
660
+ .log-item details summary { cursor: pointer; color: var(--accent); font-size: 0.78rem; user-select: none; }
661
+ .log-item pre {
662
+ margin-top: 0.4rem;
663
+ background: var(--bg);
664
+ border: 1px solid var(--line);
665
+ border-radius: 6px;
666
+ padding: 0.6rem 0.75rem;
667
+ overflow-x: auto;
668
+ font-size: 0.76rem;
669
+ line-height: 1.4;
757
670
  }
758
671
 
759
- /* Toast Notification */
672
+ /* Toast */
760
673
  .toast {
761
674
  position: fixed;
762
- bottom: 24px;
763
- right: 24px;
764
- background: var(--surface-elevated);
765
- border: 1px solid var(--border-strong);
766
- color: var(--text);
767
- padding: 9px 16px;
768
- border-radius: var(--radius-sm);
769
- font-size: 12px;
770
- font-weight: 500;
771
- box-shadow: 0 8px 24px rgba(0,0,0,0.4);
675
+ bottom: 1.25rem;
676
+ left: 50%;
677
+ transform: translateX(-50%);
678
+ background: var(--card2);
679
+ color: var(--fg);
680
+ border: 1px solid var(--line);
681
+ box-shadow: var(--shadow);
682
+ padding: 0.55rem 1.1rem;
683
+ border-radius: 10px;
684
+ font-size: 0.85rem;
685
+ z-index: 60;
772
686
  display: none;
773
- z-index: 1000;
687
+ animation: fadeIn 0.15s ease-out;
774
688
  }
689
+ .toast.err { border-color: var(--err); color: var(--err); }
690
+ @keyframes fadeIn { from { opacity: 0; transform: translate(-50%, 8px); } to { opacity: 1; transform: translate(-50%, 0); } }
775
691
 
776
- @media (prefers-reduced-motion: reduce) {
777
- *, *::before, *::after {
778
- scroll-behavior: auto !important;
779
- transition-duration: 0.01ms !important;
780
- animation-duration: 0.01ms !important;
781
- animation-iteration-count: 1 !important;
782
- }
692
+ /* Test Result Box in Modal */
693
+ .test-result-box {
694
+ border: 1px solid var(--line);
695
+ border-radius: 8px;
696
+ padding: 0.75rem;
697
+ background: var(--bg);
698
+ font-size: 0.8rem;
699
+ display: none;
783
700
  }
784
-
785
- @media (max-width: 640px) {
786
- body { padding: 16px 12px; }
787
- .form-grid { grid-template-columns: minmax(0, 1fr); }
788
- .form-group.full { grid-column: span 1; }
789
- .status-banner { flex-direction: column; align-items: stretch; gap: 12px; }
790
- #status-action .btn { width: 100%; }
791
- .form-actions { flex-direction: column; align-items: stretch; }
792
- .form-actions > div { display: flex; flex-direction: column; width: 100%; }
793
- .cli-slots-grid { grid-template-columns: minmax(0, 1fr); }
701
+ .test-result-box.success { border-color: color-mix(in srgb, var(--ok) 40%, transparent); }
702
+ .test-result-box.error { border-color: color-mix(in srgb, var(--err) 40%, transparent); }
703
+
704
+ /* Responsive Design (Matching Intact) */
705
+ @media (max-width: 820px) {
706
+ body { grid-template-columns: minmax(0, 1fr); }
707
+ aside, main, .page { min-width: 0; }
708
+ aside {
709
+ position: sticky;
710
+ top: 0;
711
+ height: auto;
712
+ flex-direction: row;
713
+ flex-wrap: wrap;
714
+ align-items: center;
715
+ padding: 0.55rem 0.85rem 0.4rem;
716
+ gap: 0.4rem;
717
+ border-right: 0;
718
+ border-bottom: 1px solid var(--line);
719
+ z-index: 10;
720
+ }
721
+ aside .brand { padding: 0 0.5rem 0 0; margin: 0; border: 0; }
722
+ aside .brand small { display: none; }
723
+ aside .brand b { font-size: 1.15rem; }
724
+ aside .logo { width: 30px; height: 30px; }
725
+ aside nav {
726
+ order: 3;
727
+ flex: 1 0 100%;
728
+ min-width: 0;
729
+ display: flex;
730
+ gap: 0.25rem;
731
+ overflow-x: auto;
732
+ scrollbar-width: none;
733
+ margin: 0 -0.25rem;
734
+ }
735
+ aside nav::-webkit-scrollbar { display: none; }
736
+ aside a.nav-link { padding: 0.45rem 0.65rem; white-space: nowrap; font-size: 0.82rem; }
737
+ aside a.nav-link svg { width: 15px; height: 15px; }
738
+ aside a.nav-link .ct { display: inline-block; font-size: 0.68rem; }
739
+ aside .spacer { flex: 1; }
740
+ aside .prefs {
741
+ width: auto;
742
+ padding: 0;
743
+ border: 0;
744
+ flex-direction: row;
745
+ gap: 0.5rem;
746
+ align-items: center;
747
+ }
748
+ aside .prefs > div { width: auto !important; margin-top: 0 !important; gap: 0.4rem; }
749
+ aside .prefs span { display: none; }
750
+ .ph { padding: 1rem; flex-wrap: wrap; gap: 0.75rem; }
751
+ .content { padding: 1rem 1rem 2.5rem; }
752
+ .row { padding: 0.75rem 1rem; flex-wrap: wrap; }
753
+ .pgrid { grid-template-columns: 1fr; }
794
754
  }
795
755
  </style>
796
756
  </head>
797
757
  <body>
798
- <main class="container">
799
- <!-- Header -->
800
- <header>
801
- <div class="title-group">
802
- <div class="logo-badge" aria-hidden="true">LS</div>
803
- <div>
804
- <h1>LLM Switcher</h1>
805
- <p>Local gateway on <span class="gateway-port">3456</span>. Route each coding CLI independently.</p>
806
- </div>
807
- </div>
808
- <div class="header-actions">
809
- <button id="btn-theme" class="btn btn-outline btn-sm" onclick="toggleTheme()" type="button">Theme</button>
810
- <button class="btn btn-outline btn-sm" onclick="loadStatus()" type="button">Refresh</button>
758
+ <!-- Sidebar -->
759
+ <aside>
760
+ <a class="brand" href="#/routes">
761
+ <span class="logo">
762
+ <svg viewBox="0 0 32 32" aria-hidden="true">
763
+ <defs>
764
+ <linearGradient id="lsw-grad" x1="0" y1="0" x2="1" y2="1">
765
+ <stop offset="0%" stop-color="#7c6cf6"/>
766
+ <stop offset="100%" stop-color="#5eead4"/>
767
+ </linearGradient>
768
+ </defs>
769
+ <rect width="32" height="32" rx="9" fill="url(#lsw-grad)"/>
770
+ <path d="M8 12h16m-4-4 4 4-4 4M24 20H8m4-4-4 4 4 4" fill="none" stroke="#fff" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
771
+ </svg>
772
+ </span>
773
+ <div>
774
+ <b>LLM Switcher</b>
775
+ <small>Edge Gateway · :<span class="gateway-port">3456</span></small>
776
+ <small id="app-version"></small>
811
777
  </div>
812
- </header>
778
+ </a>
813
779
 
814
- <!-- Status Banner -->
815
- <div class="status-banner" role="status" aria-live="polite">
816
- <div class="status-left">
817
- <div class="status-dot-wrapper">
818
- <div id="status-dot" class="status-dot"></div>
819
- </div>
820
- <div>
821
- <div id="status-title" class="status-title">Checking status...</div>
822
- <div id="status-sub" class="status-sub">Detecting active configuration</div>
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
+
786
+ <nav id="sideNav">
787
+ <a class="nav-link" href="#/routes" data-nav="routes">
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>
789
+ Routes & Profiles<span class="ct" id="ct-routes"></span>
790
+ </a>
791
+ <a class="nav-link" href="#/models" data-nav="models">
792
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="4" width="18" height="6" rx="1.5"/><rect x="3" y="14" width="18" height="6" rx="1.5"/></svg>
793
+ Model Catalog<span class="ct" id="ct-models"></span>
794
+ </a>
795
+ <a class="nav-link" href="#/logs" data-nav="logs">
796
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="m9 15 2 2 4-4"/></svg>
797
+ Request Inspector<span class="ct" id="ct-logs"></span>
798
+ </a>
799
+ <a class="nav-link" href="#/doctor" data-nav="doctor">
800
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="9"/><path d="m9 12 2 2 4-4"/></svg>
801
+ System Doctor
802
+ </a>
803
+ </nav>
804
+
805
+ <div class="spacer"></div>
806
+
807
+ <div class="prefs" style="padding-top: 18px;">
808
+ <div style="display:flex; justify-content:space-between; align-items:center; width:100%;">
809
+ <span style="font-size:0.75rem; color:var(--muted);">Theme</span>
810
+ <div class="seg sm">
811
+ <button type="button" id="btn-theme-dark" onclick="applyThemeMode('dark')">Dark</button>
812
+ <button type="button" id="btn-theme-light" onclick="applyThemeMode('light')">Light</button>
823
813
  </div>
824
814
  </div>
825
- <div id="status-action">
826
- <button id="btn-toggle" class="btn btn-primary" onclick="toggleProxy()" type="button">Toggle all</button>
815
+ <div style="display:flex; justify-content:space-between; align-items:center; width:100%; margin-top:0.35rem;">
816
+ <span class="status on" id="status-aside-indicator">Gateway Active</span>
817
+ <button type="button" id="btn-toggle" class="btn btn-outline btn-sm" onclick="toggleProxy()">Toggle All</button>
827
818
  </div>
828
819
  </div>
829
-
830
- <!-- Active Multi-CLI Slots -->
831
- <div class="section-header" style="margin-bottom: 8px;">
832
- <div>
833
- <span class="section-title">CLI routes</span>
834
- <span class="section-subtitle" style="margin-left: 8px;">Profiles apply independently and at the same time</span>
820
+ </aside>
821
+
822
+ <!-- Main Panel -->
823
+ <div class="page">
824
+ <div class="ph">
825
+ <div class="title-box">
826
+ <h1 id="page-title">Routes & Profiles</h1>
827
+ <p id="status-sub">Route Claude Code, Codex, and OpenAI clients independently.</p>
835
828
  </div>
836
- </div>
837
- <div class="cli-slots-grid">
838
- <!-- Slot: Claude Code -->
839
- <div id="card-slot-claude" class="cli-slot-card">
840
- <div class="cli-slot-header">
841
- <div class="cli-slot-title-group">
842
- <span class="cli-slot-icon" aria-hidden="true">CC</span>
843
- <div>
844
- <div class="cli-slot-name">Claude Code</div>
845
- <div class="cli-slot-route">/v1/messages</div>
846
- </div>
847
- </div>
848
- <span id="badge-slot-claude" class="badge">OFF</span>
849
- </div>
850
- <label class="sr-only" for="select-slot-claude">Profile for Claude Code</label>
851
- <select id="select-slot-claude" class="cli-slot-select" onchange="switchCliTarget('claude', this.value)">
852
- <option value="">Official endpoint</option>
853
- </select>
829
+ <div class="ph-actions" id="ph-actions">
830
+ <!-- Rendered dynamically per route -->
854
831
  </div>
832
+ </div>
855
833
 
856
- <!-- Slot: Codex -->
857
- <div id="card-slot-codex" class="cli-slot-card">
858
- <div class="cli-slot-header">
859
- <div class="cli-slot-title-group">
860
- <span class="cli-slot-icon" aria-hidden="true">CX</span>
861
- <div>
862
- <div class="cli-slot-name">Codex</div>
863
- <div class="cli-slot-route">/v1/responses</div>
834
+ <div class="content">
835
+ <!-- VIEW 1: Routes & Profiles -->
836
+ <div id="view-routes" class="view-panel" style="display:none;">
837
+ <div class="sec">
838
+ <!-- Active Coding CLI Targets -->
839
+ <div class="card">
840
+ <div class="cardh">
841
+ <h2 style="font-size:1.02rem; font-weight:600;">Active Coding CLI Targets</h2>
842
+ <span class="badge ok" style="margin-left:auto;">Multi-Active Invariant</span>
864
843
  </div>
865
- </div>
866
- <span id="badge-slot-codex" class="badge">OFF</span>
867
- </div>
868
- <label class="sr-only" for="select-slot-codex">Profile for Codex</label>
869
- <select id="select-slot-codex" class="cli-slot-select" onchange="switchCliTarget('codex', this.value)">
870
- <option value="">Official endpoint</option>
871
- </select>
872
- </div>
873
844
 
874
- </div>
875
-
876
- <!-- Providers & Profiles List -->
877
- <div class="section-header">
878
- <div>
879
- <span class="section-title">Provider profiles</span>
880
- <span class="section-subtitle" style="margin-left: 8px;">Select a profile to edit it</span>
881
- </div>
882
- <button class="btn btn-outline btn-sm" onclick="initNewProfile()" type="button">Add provider</button>
883
- </div>
884
- <div id="profiles-grid" class="profiles-grid"></div>
845
+ <!-- Claude Code Target Row -->
846
+ <div class="row" id="card-slot-claude">
847
+ <span class="av logo" title="Claude Code CLI"><img src="/icons/claude.png" alt="Claude Code"></span>
848
+ <div class="who">
849
+ <div class="l">Claude Code CLI</div>
850
+ <div class="meta">Native Anthropic Messages · <code>/v1/messages</code></div>
851
+ </div>
852
+ <div style="min-width:14rem;">
853
+ <select id="select-slot-claude" aria-label="Claude Code route" onchange="switchCliTarget('claude', this.value)">
854
+ <option value="">Official endpoint</option>
855
+ </select>
856
+ </div>
857
+ <span id="badge-slot-claude" class="badge outline">OFF</span>
858
+ </div>
885
859
 
886
- <!-- Edit Profile Form Panel -->
887
- <div class="form-panel">
888
- <div class="section-header">
889
- <div id="form-title" class="section-title" style="color: var(--text);">Edit Provider</div>
890
- <span id="active-tag" style="font-size: 11px; color: var(--success); font-weight: 600; font-family: var(--font-mono);"></span>
891
- </div>
860
+ <!-- Codex Target Row -->
861
+ <div class="row" id="card-slot-codex">
862
+ <span class="av logo" title="OpenAI Codex CLI"><img src="/icons/codex.png" alt="OpenAI Codex"></span>
863
+ <div class="who">
864
+ <div class="l">OpenAI Codex CLI</div>
865
+ <div class="meta">Responses WebSocket · <code>/v1/responses</code></div>
866
+ </div>
867
+ <div style="min-width:14rem;">
868
+ <select id="select-slot-codex" aria-label="Codex route" onchange="switchCliTarget('codex', this.value)">
869
+ <option value="">Official endpoint</option>
870
+ </select>
871
+ </div>
872
+ <span id="badge-slot-codex" class="badge outline">OFF</span>
873
+ </div>
874
+ </div>
892
875
 
893
- <!-- Quick Presets -->
894
- <div class="preset-bar">
895
- <span class="preset-label">Presets</span>
896
- <button type="button" class="btn btn-outline btn-sm" onclick="applyTemplate('9router')">9Router</button>
897
- <button type="button" class="btn btn-outline btn-sm" onclick="applyTemplate('openrouter')">OpenRouter</button>
898
- <button type="button" class="btn btn-outline btn-sm" onclick="applyTemplate('codex-vtx')">Codex Vertex</button>
899
- <button type="button" class="btn btn-outline btn-sm" onclick="applyTemplate('momo')">Selfhost Hybrid</button>
900
- <button type="button" class="btn btn-outline btn-sm" onclick="applyTemplate('custom')">Local OpenAI</button>
901
- </div>
876
+ <!-- Configured Profiles Header -->
877
+ <div class="sech" style="margin-top:0.5rem;">
878
+ <h2 style="font-size:1.02rem; font-weight:600;">Configured Provider Profiles</h2>
879
+ <span class="hint">Click any card to edit configuration</span>
880
+ </div>
902
881
 
903
- <!-- Tabs Bar -->
904
- <div class="tab-bar" role="tablist" aria-label="Provider settings">
905
- <button id="tab-button-general" type="button" class="tab-btn active" role="tab" aria-selected="true" aria-controls="tab-general" tabindex="0" onclick="switchTab('tab-general')">General</button>
906
- <button id="tab-button-routing" type="button" class="tab-btn" role="tab" aria-selected="false" aria-controls="tab-routing" tabindex="-1" onclick="switchTab('tab-routing')">Routing</button>
907
- <button id="tab-button-models" type="button" class="tab-btn" role="tab" aria-selected="false" aria-controls="tab-models" tabindex="-1" onclick="switchTab('tab-models')">Models</button>
908
- <button id="tab-button-codex" type="button" class="tab-btn" role="tab" aria-selected="false" aria-controls="tab-codex" tabindex="-1" onclick="switchTab('tab-codex')" hidden>Blindfold</button>
909
- <button id="tab-button-inspector" type="button" class="tab-btn" role="tab" aria-selected="false" aria-controls="tab-inspector" tabindex="-1" onclick="switchTab('tab-inspector'); loadInspectorLogs();">Request log</button>
882
+ <!-- Profiles Grid -->
883
+ <div id="profiles-grid" class="pgrid"></div>
884
+ </div>
910
885
  </div>
911
886
 
912
- <form id="profile-form" onsubmit="saveProfile(event)">
913
- <!-- Tab 1: General & Endpoint -->
914
- <div id="tab-general" class="tab-pane active" role="tabpanel" aria-labelledby="tab-button-general">
915
- <div class="form-grid">
916
- <div class="form-group">
917
- <label for="p-key">Provider key</label>
918
- <input type="text" id="p-key" required pattern="[A-Za-z0-9][A-Za-z0-9._\-]{0,63}" title="Letters, digits, '.', '_' or '-' (max 64 chars)" placeholder="e.g. 9router, openrouter, my-llm">
919
- </div>
920
- <div class="form-group">
921
- <label for="p-name">Display name</label>
922
- <input type="text" id="p-name" required placeholder="e.g. 9Router Cloud, OpenRouter Pro">
923
- </div>
924
- <div class="form-group full">
925
- <label for="p-url">Base URL</label>
926
- <input type="url" id="p-url" required placeholder="https://api.your-provider.com/v1">
927
- </div>
928
- <div class="form-group full">
929
- <label for="p-key-val">API key or bearer token</label>
930
- <input type="password" id="p-key-val" autocomplete="off" placeholder="sk-...">
887
+ <!-- VIEW 2: Discovered Models Catalog -->
888
+ <div id="view-models" class="view-panel" style="display:none;">
889
+ <div class="sec">
890
+ <div class="card pad">
891
+ <div class="sech">
892
+ <div>
893
+ <h2 style="font-size:1.05rem; font-weight:600;">Dynamic Model Discovery</h2>
894
+ <p class="hint" style="margin-top:0.2rem;">Official model names are auto-polled on tool version bumps to prevent drift and 404 errors.</p>
895
+ </div>
896
+ <div class="search">
897
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
898
+ <input type="text" id="catalog-search" placeholder="Search models…" oninput="filterCatalogView(this.value)">
899
+ </div>
931
900
  </div>
932
901
  </div>
933
- </div>
934
902
 
935
- <!-- Tab 2: Protocols & Routing -->
936
- <div id="tab-routing" class="tab-pane" role="tabpanel" aria-labelledby="tab-button-routing" hidden>
937
- <div class="form-grid">
938
- <div class="form-group">
939
- <label for="p-tool">Serves which tool</label>
940
- <select id="p-tool" onchange="onToolChange(this.value)">
941
- <option value="auto">Both — Claude Code and Codex</option>
942
- <option value="anthropic">Claude Code only (/v1/messages)</option>
943
- <option value="responses">Codex only (/v1/responses)</option>
944
- </select>
945
- <span style="font-size: 11px; color: var(--text-dim); margin-top: 2px;">Saved as <code>tool</code> in config.json. OpenAI Chat and Vertex are no longer client inputs: the gateway stands between Claude Code, Codex and the provider.</span>
946
- </div>
947
- <div class="form-group">
948
- <label for="p-outformat">Upstream protocol</label>
949
- <select id="p-outformat">
950
- <option value="">AUTO (Determined by Routing Mode)</option>
951
- <option value="openai-chat">OpenAI Chat Completions</option>
952
- <option value="anthropic">Anthropic Messages</option>
953
- <option value="vertex">Vertex generateContent</option>
954
- </select>
955
- </div>
956
- <div class="form-group full">
957
- <label for="p-thinking-mode">Reasoning mode for Chat upstreams</label>
958
- <select id="p-thinking-mode">
959
- <option value="auto">AUTO — gateway (9Router): restore thinking, inject &lt;think&gt; guide, send thinking + reasoning_effort</option>
960
- <option value="native">NATIVE — strict OpenAI API: only reasoning_effort when client asks, no prompt changes</option>
961
- <option value="off">OFF — never send reasoning parameters</option>
962
- </select>
963
- </div>
964
- <div class="form-group full">
965
- <label for="p-mode">Routing mode</label>
966
- <select id="p-mode">
967
- <option value="hybrid">HYBRID (Claude models -> Direct Native | Other models -> Convert)</option>
968
- <option value="convert">CONVERT (Always convert protocols via gateway)</option>
969
- <option value="direct">DIRECT (Always forward directly via native Anthropic API)</option>
970
- </select>
903
+ <div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(20rem, 1fr)); gap:1.2rem;">
904
+ <div class="card pad">
905
+ <div class="sech" style="margin-bottom:0.8rem;">
906
+ <h3 style="font-size:0.95rem; font-weight:600; color:var(--accent); display:flex; align-items:center; gap:0.5rem;"><span class="av logo sm" style="width:22px; height:22px; padding:2px;"><img src="/icons/claude.png" alt="Claude"></span> Claude Code Tiers</h3>
907
+ <span class="badge accent" id="badge-claude-count">0 tiers</span>
908
+ </div>
909
+ <div id="catalog-claude-items" style="display:grid; gap:0.6rem;"></div>
971
910
  </div>
972
- <div class="form-group full" style="border-top: 1px solid var(--border); padding-top: 12px; margin-top: 4px;">
973
- <div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 4px;">
974
- <span class="field-heading">Token Optimizers</span>
975
- <span style="font-size: 11px; color: var(--accent); font-family: var(--font-mono);">Headroom • Ponytail • RTK</span>
911
+ <div class="card pad">
912
+ <div class="sech" style="margin-bottom:0.8rem;">
913
+ <h3 style="font-size:0.95rem; font-weight:600; color:var(--mint); display:flex; align-items:center; gap:0.5rem;"><span class="av logo sm" style="width:22px; height:22px; padding:2px;"><img src="/icons/codex.png" alt="Codex"></span> Codex Roles</h3>
914
+ <span class="badge ok" id="badge-codex-count">0 roles</span>
976
915
  </div>
977
- <span style="font-size: 11px; color: var(--text-dim); margin-top: 2px;">
978
- <strong>Optimizer chain:</strong> Point the CLI at the optimizer. Point the optimizer upstream to <code>http://127.0.0.1:<span class="gateway-port">3456</span></code>.
979
- </span>
916
+ <div id="catalog-codex-items" style="display:grid; gap:0.6rem;"></div>
980
917
  </div>
981
918
  </div>
982
919
  </div>
920
+ </div>
983
921
 
984
- <!-- Tab 3: Models & 1M Context -->
985
- <div id="tab-models" class="tab-pane" role="tabpanel" aria-labelledby="tab-button-models" hidden>
986
- <div class="warning-callout" id="warn-1m-callout">
987
- <span class="badge badge-convert" id="warn-1m-icon">NOTE</span>
988
- <div id="warn-1m-text">
989
- <strong>1M context notice:</strong> The <code>1M context</code> checkbox only records that a tier may use a 1M window. The gateway unlocks the context on the request itself; it never rewrites <code>settings.json</code> or the launcher environment. A model without native 1M support still answers <code>HTTP 400 (Context Exceeded)</code>.
922
+ <!-- VIEW 3: Request Inspector -->
923
+ <div id="view-logs" class="view-panel" style="display:none;">
924
+ <div class="sec">
925
+ <div class="card pad">
926
+ <div class="sech">
927
+ <div>
928
+ <h2 style="font-size:1.05rem; font-weight:600;">Live Request Inspector</h2>
929
+ <p class="hint" style="margin-top:0.2rem;">In-memory ring buffer capturing the most recent traffic through the edge gateway.</p>
930
+ </div>
931
+ <div style="display:flex; align-items:center; gap:0.6rem;">
932
+ <label class="hint" style="display:flex; align-items:center; gap:0.4rem; cursor:pointer;">
933
+ <input type="checkbox" id="chk-auto-refresh-logs" onchange="toggleLogAutoRefresh()" style="width:auto; min-height:0;" checked> Auto-refresh (3s)
934
+ </label>
935
+ <button type="button" class="btn btn-outline btn-sm" onclick="clearInspectorLogs()">Clear Logs</button>
936
+ </div>
990
937
  </div>
991
938
  </div>
992
939
 
993
- <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; flex-wrap: wrap; gap: 8px;">
994
- <span id="model-slot-heading" style="font-size: 12px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.04em;">Tier Mapping (Claude Code / Switcher)</span>
995
- <button type="button" id="btn-fetch-models" class="btn btn-outline btn-sm" onclick="fetchModelList()">
996
- Fetch models
997
- </button>
998
- </div>
999
-
1000
- <div class="form-grid" id="slot-fields">
1001
- <!-- Dynamically populated via renderModelSlots -->
1002
- </div>
940
+ <div id="inspector-logs-container"></div>
941
+ </div>
942
+ </div>
1003
943
 
1004
- <!-- Codex model config lives with the other model config; only the interceptor stays on its own tab. -->
1005
- <div id="codex-names" style="margin-top: 16px;" hidden>
1006
- <span style="font-size: 12px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.04em;">Names Codex sees</span>
1007
- <div class="warning-callout">
1008
- <span class="badge badge-convert">NOTE</span>
1009
- <div>
1010
- <strong>Codex sees only these names.</strong> The slot aliases <code>main</code>, <code>review</code> and
1011
- <code>subagent</code> stay inside the gateway. Leave a field empty to let Codex keep its own default for that role.
944
+ <!-- VIEW 4: System Doctor -->
945
+ <div id="view-doctor" class="view-panel" style="display:none;">
946
+ <div class="sec">
947
+ <div class="stats">
948
+ <div class="stat">
949
+ <div class="k">Edge Gateway Port</div>
950
+ <div class="v gateway-port" style="color:var(--accent);">3456</div>
951
+ <div class="status on" style="margin-top:0.3rem;">Bound to 127.0.0.1</div>
1012
952
  </div>
1013
- </div>
1014
953
 
1015
- <div class="form-grid" style="margin-top: 12px;">
1016
- <div class="form-group full">
1017
- <label for="p-public-main">Session model name</label>
1018
- <input type="text" id="p-public-main" list="model-options" placeholder="e.g. gpt-5.6-sol">
1019
- <span style="font-size: 11px; color: var(--text-dim); margin-top: 2px;">Shown in the CLI <code>/model</code> picker and sent with every request.</span>
954
+ <div class="stat">
955
+ <div class="k">Blindfold Interceptor</div>
956
+ <div class="v" id="blindfold-port-display" style="color:var(--mint); font-size:1.15rem;">Active</div>
957
+ <div class="status on" style="margin-top:0.3rem;">Zero tool mutation</div>
1020
958
  </div>
1021
- <div class="form-group">
1022
- <label for="p-public-review">Review model name</label>
1023
- <input type="text" id="p-public-review" list="model-options" placeholder="e.g. gpt-5.6-terra">
1024
- </div>
1025
- <div class="form-group">
1026
- <label for="p-public-subagent">Subagent model name</label>
1027
- <input type="text" id="p-public-subagent" list="model-options" placeholder="e.g. gpt-5.6-luna">
959
+
960
+ <div class="stat">
961
+ <div class="k">Security Invariant</div>
962
+ <div class="v" style="color:var(--ok);">Clean (100%)</div>
963
+ <div class="hint" style="margin-top:0.3rem;">No settings.json edits</div>
1028
964
  </div>
1029
965
  </div>
1030
- </div>
1031
966
 
1032
- <!-- Model Explorer Box -->
1033
- <div class="form-group full" id="model-browser" style="display: none; margin-top: 14px;">
1034
- <div class="model-browser">
1035
- <div class="model-browser-header">
1036
- <span id="model-count-label" style="font-size: 11px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.04em;">Available Upstream Models</span>
1037
- <label class="sr-only" for="model-filter">Filter upstream models</label>
1038
- <input type="search" id="model-filter" placeholder="Filter models" style="padding: 5px 9px; font-size: 11px; width: 200px;" oninput="filterModels()">
967
+ <div class="card pad">
968
+ <h3 style="font-size:0.95rem; font-weight:600; margin-bottom:0.75rem;">Environment Isolation & Host Routing</h3>
969
+ <div class="sec">
970
+ <div class="copyline">
971
+ <code>NO_PROXY=127.0.0.1,localhost (Safe from RTK/Headroom/VPN conflicts)</code>
972
+ </div>
973
+ <div class="copyline">
974
+ <code>Blindfold Hosts: api.anthropic.com, api.openai.com, chatgpt.com</code>
975
+ </div>
976
+ <div class="copyline">
977
+ <code>Admin Token Protection: mode 0600 (admin.token) required for state mutation</code>
1039
978
  </div>
1040
- <div id="model-items" class="model-list-scroll"></div>
1041
979
  </div>
1042
980
  </div>
1043
981
  </div>
982
+ </div>
983
+ </div>
984
+ </div>
1044
985
 
1045
- <!-- Tab 4: Live Inspector -->
1046
- <!-- Tab 4: Codex — only the names this CLI is allowed to observe -->
1047
- <div id="tab-codex" class="tab-pane" role="tabpanel" aria-labelledby="tab-button-codex" hidden>
1048
- <div class="form-grid" style="border-top: 1px solid var(--border); padding-top: 12px; margin-top: 12px;">
1049
- <div class="form-group full">
1050
- <span class="field-heading">Interceptor</span>
1051
- <span id="blindfold-port-display" style="font-size: 11px; color: var(--text-dim); margin-top: 2px;">not configured</span>
1052
- <span style="font-size: 11px; color: var(--text-dim); margin-top: 2px;">
1053
- The interceptor is one switcher-wide setting (<code>blindfold</code> in config.json), not a per-profile one.
1054
- Build the certificates with <code>bash blindfold/make-certs.sh</code> — one certificate names all three hosts this switcher routes.
1055
- </span>
1056
- </div>
986
+ <!-- Profile Modal Editor -->
987
+ <div id="profile-modal-overlay" class="overlay" style="display:none;" onmousedown="overlayPressed = event.target === this" onclick="if (overlayPressed && event.target === this) closeProfileModal()">
988
+ <div class="modal" role="dialog" aria-modal="true" aria-labelledby="form-title">
989
+ <div class="mh">
990
+ <div>
991
+ <strong id="form-title">Edit Provider Profile</strong>
992
+ <span id="active-tag" style="font-size:0.75rem; color:var(--ok); margin-left:0.5rem; font-weight:600;"></span>
993
+ </div>
994
+ <button class="icon" type="button" onclick="closeProfileModal()" aria-label="Close dialog">✕</button>
995
+ </div>
996
+
997
+ <form id="profile-form" onsubmit="saveProfile(event)" onkeydown="blockImplicitSubmit(event)" style="display:flex; flex-direction:column; overflow:hidden;">
998
+ <!-- Modal Inset Tabs -->
999
+ <div style="padding:0.85rem 1.25rem 0.5rem; background:var(--card2);">
1000
+ <div class="tabs">
1001
+ <button type="button" id="tab-button-general" class="tab-btn active" onclick="switchTab('tab-general')">General</button>
1002
+ <button type="button" id="tab-button-routing" class="tab-btn" onclick="switchTab('tab-routing')">Routing & Protocol</button>
1003
+ <button type="button" id="tab-button-models" class="tab-btn" onclick="switchTab('tab-models')">Model Slots</button>
1057
1004
  </div>
1058
1005
  </div>
1059
1006
 
1060
- <div id="tab-inspector" class="tab-pane" role="tabpanel" aria-labelledby="tab-button-inspector" hidden>
1061
- <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; flex-wrap: wrap; gap: 8px;">
1062
- <div>
1063
- <span style="font-size: 12px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.04em;">Real-time Request Inspector</span>
1064
- <span style="font-size: 11px; color: var(--text-dim); margin-left: 8px;">Inspect prompt, tokens, latency & reasoning traces</span>
1007
+ <div class="mb">
1008
+ <!-- TAB 1: General -->
1009
+ <div id="tab-general" class="tab-content active" style="display:grid; gap:0.9rem;">
1010
+ <div style="background:var(--card2); padding:0.75rem 1rem; border-radius:8px; border:1px solid var(--line);">
1011
+ <span class="hint" style="font-weight:600; display:block; margin-bottom:0.4rem;">Template Quick-Fill:</span>
1012
+ <div style="display:flex; gap:0.4rem; flex-wrap:wrap;">
1013
+ <button type="button" class="btn btn-outline btn-sm" onclick="applyTemplate('intact')">intact</button>
1014
+ <button type="button" class="btn btn-outline btn-sm" onclick="applyTemplate('9router')">9Router</button>
1015
+ <button type="button" class="btn btn-outline btn-sm" onclick="applyTemplate('openrouter')">OpenRouter</button>
1016
+ <button type="button" class="btn btn-outline btn-sm" onclick="applyTemplate('vertex')">Vertex AI</button>
1017
+ <button type="button" class="btn btn-outline btn-sm" onclick="applyTemplate('local')">Local Server</button>
1018
+ </div>
1065
1019
  </div>
1066
- <div style="display: flex; gap: 8px; align-items: center;">
1067
- <label style="font-size: 11px; color: var(--text-muted); display: flex; align-items: center; gap: 4px; cursor: pointer; text-transform: none;">
1068
- <input type="checkbox" id="chk-auto-refresh-logs" checked onchange="toggleLogAutoRefresh(this.checked)"> Auto-refresh
1020
+
1021
+ <div style="display:grid; grid-template-columns:1fr 1fr; gap:0.9rem;">
1022
+ <label class="f">Profile Key (ID)
1023
+ <input type="text" id="p-key" required pattern="[A-Za-z0-9][A-Za-z0-9._\-]{0,63}" placeholder="e.g. intact-claude">
1024
+ </label>
1025
+ <label class="f">Display Name
1026
+ <input type="text" id="p-name" required placeholder="e.g. Intact Claude Gateway">
1069
1027
  </label>
1070
- <button type="button" class="btn btn-outline btn-sm" onclick="loadInspectorLogs()">Refresh</button>
1071
- <button type="button" class="btn btn-danger btn-sm" onclick="clearInspectorLogs()">Clear</button>
1072
1028
  </div>
1029
+
1030
+ <label class="f">Base URL
1031
+ <input type="url" id="p-url" required placeholder="https://api.example.com/v1">
1032
+ </label>
1033
+
1034
+ <label class="f">API Key
1035
+ <input type="password" id="p-key-val" placeholder="sk-...">
1036
+ </label>
1073
1037
  </div>
1074
1038
 
1075
- <div id="inspector-logs-container" style="display: flex; flex-direction: column; gap: 8px; max-height: 440px; overflow-y: auto;">
1076
- <div style="color: var(--text-dim); font-size: 12px; text-align: center; padding: 24px; background: var(--surface-well); border-radius: var(--radius-sm); border: 1px solid var(--border);">
1077
- No requests recorded yet. Make a request via Claude Code, Codex, or curl.
1039
+ <!-- TAB 2: Routing -->
1040
+ <div id="tab-routing" class="tab-content" style="display:none; gap:0.9rem; flex-direction:column;">
1041
+ <div style="display:grid; grid-template-columns:1fr 1fr; gap:0.9rem;">
1042
+ <label class="f">Tool Target
1043
+ <select id="p-tool" onchange="onToolChange()">
1044
+ <option value="auto">All compatible tools (auto)</option>
1045
+ <option value="claude">Claude Code only (claude)</option>
1046
+ <option value="codex">Codex only (codex)</option>
1047
+ </select>
1048
+ </label>
1049
+ <label class="f">Outbound Format
1050
+ <select id="p-outformat">
1051
+ <option value="">Auto (detect from the model)</option>
1052
+ <option value="openai-chat">OpenAI Chat (/v1/chat/completions)</option>
1053
+ <option value="anthropic">Anthropic Messages (/v1/messages)</option>
1054
+ <option value="vertex">Google Vertex AI (/models/...)</option>
1055
+ </select>
1056
+ </label>
1057
+ </div>
1058
+
1059
+ <div style="display:grid; grid-template-columns:1fr 1fr; gap:0.9rem;">
1060
+ <label class="f">Thinking Mode
1061
+ <select id="p-thinking-mode">
1062
+ <option value="auto">AUTO (restores reasoning, strips tags safely)</option>
1063
+ <option value="native">NATIVE (strict reasoning_effort)</option>
1064
+ <option value="off">OFF (never send thinking)</option>
1065
+ </select>
1066
+ </label>
1067
+ <label class="f">Routing Mode
1068
+ <select id="p-mode">
1069
+ <option value="convert">CONVERT (Bi-directional translation)</option>
1070
+ <option value="direct">DIRECT (Passthrough)</option>
1071
+ <option value="hybrid">HYBRID (Automatic)</option>
1072
+ </select>
1073
+ </label>
1078
1074
  </div>
1079
1075
  </div>
1080
- </div>
1081
1076
 
1082
- <datalist id="model-options"></datalist>
1077
+ <!-- TAB 3: Models & 1M -->
1078
+ <div id="tab-models" class="tab-content" style="display:none; gap:0.9rem; flex-direction:column;">
1079
+ <div class="badge warn" id="warn-1m-callout" style="display:none; padding:0.5rem 0.75rem; border-radius:8px;">
1080
+ <span id="warn-1m-icon">⚠️</span> <span id="warn-1m-text">1M context flag is enabled</span>
1081
+ </div>
1082
+
1083
+ <div style="display:flex; justify-content:space-between; align-items:center; gap:0.75rem;">
1084
+ <h3 id="model-slot-heading" style="font-size:0.92rem; font-weight:600;">Model Slots Mapping</h3>
1085
+ <div id="models-status" class="models-status" aria-live="polite"></div>
1086
+ </div>
1083
1087
 
1084
- <!-- Test Connection Result Box -->
1085
- <div id="test-result" class="test-result-box">
1086
- <div class="test-meta">
1087
- <span id="test-status-badge"></span>
1088
- <span id="test-latency"></span>
1088
+ <div id="slot-fields" style="display:grid; gap:0.75rem;"></div>
1089
+ </div>
1090
+
1091
+ <!-- Test Connection Box -->
1092
+ <div id="test-result" class="test-result-box">
1093
+ <div style="display:flex; justify-content:space-between; align-items:center;">
1094
+ <span id="test-status-badge" class="badge"></span>
1095
+ <span id="test-latency" class="hint mono"></span>
1096
+ </div>
1097
+ <div id="test-sample" class="mono" style="margin-top:0.4rem; font-size:0.75rem; max-height:4rem; overflow:auto;"></div>
1089
1098
  </div>
1090
- <div id="test-sample" class="test-sample"></div>
1091
1099
  </div>
1092
1100
 
1093
- <div class="form-actions">
1094
- <button type="button" id="btn-test-conn" class="btn btn-outline" onclick="testConnection()">Test connection</button>
1095
- <div style="display: flex; gap: 8px;">
1096
- <button type="button" id="btn-delete" class="btn btn-danger" onclick="deleteProfile()" style="display: none;">Delete</button>
1097
- <button type="submit" class="btn btn-primary">Save Provider</button>
1101
+ <div class="mf">
1102
+ <div>
1103
+ <button type="button" id="btn-delete" class="btn btn-danger" onclick="deleteProfile()" style="display:none;">Delete Profile</button>
1104
+ </div>
1105
+ <div style="display:flex; gap:0.6rem;">
1106
+ <button type="button" id="btn-test-conn" class="btn btn-outline" onclick="testConnection()">Test Connection</button>
1107
+ <button type="button" class="btn btn-outline" onclick="closeProfileModal()">Cancel</button>
1108
+ <button type="submit" class="btn btn-primary">Save Profile</button>
1098
1109
  </div>
1099
1110
  </div>
1100
1111
  </form>
1101
1112
  </div>
1102
- </main>
1113
+ </div>
1103
1114
 
1104
- <div id="toast" class="toast" role="status" aria-live="polite" aria-atomic="true"></div>
1115
+ <div id="toast" class="toast"></div>
1105
1116
 
1106
1117
  <script>
1107
- // /api/* needs the admin token. The gateway puts it in this page; an older `switch ui` link still
1108
- // brings it in the URL fragment, which is cleared from the address bar.
1109
- const ADMIN_TOKEN_KEY = 'llmSwitcherAdminToken';
1110
- (() => {
1111
- localStorage.removeItem(ADMIN_TOKEN_KEY);
1112
- const served = document.querySelector('meta[name="llm-switcher-token"]')?.content;
1113
- const m = location.hash.match(/(?:^#|&)token=([0-9a-f]+)/);
1114
- if (served || m) sessionStorage.setItem(ADMIN_TOKEN_KEY, served || m[1]);
1115
- if (m) history.replaceState(null, '', location.pathname + location.search);
1116
- })();
1117
-
1118
- // A config change carries the revision this page loaded. The gateway answers 409 when another tab
1119
- // or the CLI saved in the meantime; the page then reloads instead of overwriting that change.
1120
- const CHANGE_PATHS = new Set(['/api/switch', '/api/toggle', '/api/save-profile', '/api/delete-profile']);
1121
-
1122
- async function api(path, opts = {}) {
1123
- const headers = { ...(opts.headers || {}), 'x-llm-switcher-token': sessionStorage.getItem(ADMIN_TOKEN_KEY) || '' };
1124
- let body = opts.body;
1125
- if (CHANGE_PATHS.has(path) && typeof body === 'string' && state.revision) {
1126
- body = JSON.stringify({ ...JSON.parse(body), revision: state.revision });
1127
- }
1128
- const res = await fetch(path, { ...opts, headers, body });
1129
- if (res.status === 401) showToast('Not authorized. Open the dashboard with `switch ui`.', true);
1130
- if (res.status === 409) setTimeout(loadStatus, 0);
1131
- return res;
1118
+ // State & Globals
1119
+ const ADMIN_TOKEN_KEY = 'llm-switcher-admin-token';
1120
+ const servedToken = document.querySelector('meta[name="llm-switcher-token"]')?.content;
1121
+ const hashTokenMatch = location.hash.match(/(?:^#|&)token=([0-9a-f]+)/);
1122
+ if (servedToken || hashTokenMatch) {
1123
+ sessionStorage.setItem(ADMIN_TOKEN_KEY, servedToken || hashTokenMatch[1]);
1124
+ }
1125
+ if (hashTokenMatch) {
1126
+ history.replaceState(null, '', location.pathname + location.search + '#/routes');
1132
1127
  }
1133
1128
 
1134
1129
  let state = {
1130
+ port: 3456,
1135
1131
  config: null,
1136
1132
  activeProfile: '',
1137
1133
  activeProfiles: {},
@@ -1144,45 +1140,26 @@
1144
1140
  let loadedModels = [];
1145
1141
  let currentProfileBuffer = null;
1146
1142
  let renderedSlotFormat = 'auto';
1143
+ let logRefreshTimer = null;
1144
+ let cachedCatalog = null;
1145
+ let lastLogsCount = 0;
1147
1146
  const MASKED_KEY = '__LLM_SWITCHER_KEEP_KEY__';
1148
- // Order matters: publicModels is read by position when codexRoles has no entry.
1149
- const CODEX_ROLE_SLOTS = ['main', 'review', 'subagent'];
1150
-
1151
- const LEGACY_SLOT_KEYS = {
1152
- main: ['opus'],
1153
- review: ['sonnet'],
1154
- subagent: ['fast', 'fallback', 'haiku', 'fable'],
1155
- default: ['sonnet', 'opus', 'haiku', 'fable']
1156
- };
1157
1147
 
1158
- const SLOT_CONFIG = {
1159
- anthropic: {
1160
- heading: 'Claude Code tier mapping',
1161
- slots: [
1162
- { id: 'opus', label: 'Opus Model', placeholder: 'e.g. ag/claude-opus-4-6-thinking', allow1M: true },
1163
- { id: 'sonnet', label: 'Sonnet Model', placeholder: 'e.g. ag/claude-sonnet-4-6', allow1M: true },
1164
- { id: 'haiku', label: 'Haiku Model', placeholder: 'e.g. ag/gemini-3.7-flash-high', allow1M: true },
1165
- { id: 'fable', label: 'Fable Model', placeholder: 'e.g. ag/gemini-3.8-flash-high', allow1M: true }
1166
- ],
1167
- warning: '<strong>1M context notice:</strong> The <code>1M context</code> checkbox only records that a tier may use a 1M window. The gateway unlocks the context on the request itself; it never rewrites <code>settings.json</code> or the launcher environment. A model without native 1M support still answers <code>HTTP 400 (Context Exceeded)</code>.',
1168
- icon: 'NOTE'
1169
- },
1170
- responses: {
1171
- heading: 'Codex model roles',
1172
- slots: [
1173
- { id: 'main', label: 'Main model', placeholder: 'e.g. ag/gemini-3.8-flash or gpt-4o', allow1M: true },
1174
- { id: 'review', label: 'Review model', placeholder: 'e.g. ag/gemini-3.7-flash-medium', allow1M: false },
1175
- { id: 'subagent', label: 'Subagent model', placeholder: 'e.g. ag/gemini-3.6-flash-low', allow1M: false }
1176
- ],
1177
- warning: '<strong>Context notice:</strong> The launcher applies <code>model</code>, <code>review_model</code>, and <code>agents.default_subagent_model</code> through Codex config overrides. The 1M option also sets <code>model_context_window</code> and the auto-compact limit.',
1178
- icon: 'NOTE'
1179
- },
1180
- };
1181
- SLOT_CONFIG.auto = SLOT_CONFIG.anthropic;
1148
+ // API Helper
1149
+ async function api(path, opts = {}) {
1150
+ const token = sessionStorage.getItem(ADMIN_TOKEN_KEY) || document.querySelector('meta[name="llm-switcher-token"]')?.content || '';
1151
+ const headers = {
1152
+ ...opts.headers,
1153
+ 'x-llm-switcher-token': token
1154
+ };
1155
+ const res = await fetch(path, { ...opts, headers });
1156
+ if (res.status === 401) showToast('Unauthorized: check admin.token in server directory', true);
1157
+ if (res.status === 409) setTimeout(loadStatus, 0);
1158
+ return res;
1159
+ }
1182
1160
 
1183
- // Helper: Anti-XSS Sanitizer
1184
1161
  function escapeHtml(str) {
1185
- if (str === null || str === undefined) return '';
1162
+ if (!str) return '';
1186
1163
  return String(str)
1187
1164
  .replace(/&/g, '&amp;')
1188
1165
  .replace(/</g, '&lt;')
@@ -1191,360 +1168,501 @@
1191
1168
  .replace(/'/g, '&#039;');
1192
1169
  }
1193
1170
 
1194
- function applyTheme(theme) {
1195
- document.documentElement.dataset.theme = theme;
1196
- const btn = document.getElementById('btn-theme');
1197
- if (btn) {
1198
- const nextTheme = theme === 'light' ? 'dark' : 'light';
1199
- btn.textContent = `${nextTheme[0].toUpperCase()}${nextTheme.slice(1)} theme`;
1200
- btn.setAttribute('aria-label', `Use ${nextTheme} theme`);
1171
+ // Theme Mode
1172
+ function applyThemeMode(mode) {
1173
+ document.documentElement.dataset.theme = mode;
1174
+ localStorage.setItem('intact-theme', mode);
1175
+ localStorage.setItem('lsw-theme', mode);
1176
+ document.getElementById('btn-theme-dark')?.setAttribute('aria-pressed', mode === 'dark');
1177
+ document.getElementById('btn-theme-light')?.setAttribute('aria-pressed', mode === 'light');
1178
+ }
1179
+
1180
+ // Toast
1181
+ let toastTimeout = null;
1182
+ function showToast(msg, isErr = false) {
1183
+ const toast = document.getElementById('toast');
1184
+ if (!toast) return;
1185
+ toast.textContent = msg;
1186
+ toast.className = 'toast' + (isErr ? ' err' : '');
1187
+ toast.style.display = 'block';
1188
+ clearTimeout(toastTimeout);
1189
+ toastTimeout = setTimeout(() => { toast.style.display = 'none'; }, 3200);
1190
+ }
1191
+
1192
+ // Modal Control
1193
+ function openProfileModal(isNew = false) {
1194
+ const modal = document.getElementById('profile-modal-overlay');
1195
+ if (modal) modal.style.display = 'grid';
1196
+ if (isNew) {
1197
+ initNewProfile();
1201
1198
  }
1202
- try { localStorage.setItem('lsw-theme', theme); } catch (e) {}
1203
1199
  }
1204
1200
 
1205
- function toggleTheme() {
1206
- const current = document.documentElement.dataset.theme || 'dark';
1207
- applyTheme(current === 'light' ? 'dark' : 'light');
1201
+ function closeProfileModal() {
1202
+ const modal = document.getElementById('profile-modal-overlay');
1203
+ if (modal) modal.style.display = 'none';
1208
1204
  }
1209
1205
 
1210
- function showToast(msg, isError = false) {
1211
- const t = document.getElementById('toast');
1212
- t.innerText = msg;
1213
- t.style.borderColor = isError ? 'rgba(239, 68, 68, 0.4)' : 'var(--border-strong)';
1214
- t.style.background = isError ? 'var(--danger-muted)' : 'var(--surface-elevated)';
1215
- t.style.color = isError ? 'var(--danger)' : 'var(--text)';
1216
- t.style.display = 'block';
1217
- setTimeout(() => t.style.display = 'none', 3500);
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();
1218
1212
  }
1219
1213
 
1220
- function switchTab(tabId) {
1221
- document.querySelectorAll('.tab-btn').forEach(btn => {
1222
- const selected = btn.getAttribute('aria-controls') === tabId;
1223
- btn.classList.toggle('active', selected);
1224
- btn.setAttribute('aria-selected', String(selected));
1225
- btn.tabIndex = selected ? 0 : -1;
1214
+ // Close modal on Escape. Inside a field, Escape belongs to the field (it closes the autocomplete list).
1215
+ window.addEventListener('keydown', (e) => {
1216
+ if (e.key === 'Escape' && !e.target.closest?.('input, select, textarea')) {
1217
+ const modal = document.getElementById('profile-modal-overlay');
1218
+ if (modal && modal.style.display !== 'none') {
1219
+ closeProfileModal();
1220
+ }
1221
+ }
1222
+ });
1223
+
1224
+ // Hash Router
1225
+ function handleRoute() {
1226
+ const hash = window.location.hash || '#/routes';
1227
+ const route = hash.replace(/^#\/?/, '').split('/')[0] || 'routes';
1228
+ const validRoutes = ['routes', 'models', 'logs', 'doctor'];
1229
+ const activeRoute = validRoutes.includes(route) ? route : 'routes';
1230
+
1231
+ // Update active nav link
1232
+ document.querySelectorAll('#sideNav .nav-link').forEach(link => {
1233
+ if (link.getAttribute('data-nav') === activeRoute) {
1234
+ link.setAttribute('aria-current', 'page');
1235
+ } else {
1236
+ link.removeAttribute('aria-current');
1237
+ }
1226
1238
  });
1227
- document.querySelectorAll('.tab-pane').forEach(pane => {
1228
- const selected = pane.id === tabId;
1229
- pane.classList.toggle('active', selected);
1230
- pane.hidden = !selected;
1239
+
1240
+ // Show matching view panel
1241
+ document.querySelectorAll('.view-panel').forEach(panel => {
1242
+ panel.style.display = (panel.id === 'view-' + activeRoute) ? 'block' : 'none';
1231
1243
  });
1244
+
1245
+ // Update dynamic header
1246
+ updateHeader(activeRoute);
1247
+
1248
+ // Lazy load view content
1249
+ if (activeRoute === 'models') renderCatalogView();
1250
+ if (activeRoute === 'logs') loadInspectorLogs();
1232
1251
  }
1252
+ window.addEventListener('hashchange', handleRoute);
1253
+
1254
+ function updateHeader(route) {
1255
+ const titleEl = document.getElementById('page-title');
1256
+ const subEl = document.getElementById('status-sub');
1257
+ const actionsEl = document.getElementById('ph-actions');
1258
+ if (!titleEl || !subEl || !actionsEl) return;
1259
+
1260
+ const activeMap = state.activeProfiles || (state.config?.activeProfiles || {});
1261
+ const activeTargets = Object.entries(activeMap).filter(([_, v]) => Boolean(v));
1262
+
1263
+ if (route === 'routes') {
1264
+ titleEl.textContent = 'Routes & Profiles';
1265
+ if (activeTargets.length > 0) {
1266
+ const parts = [];
1267
+ if (activeMap.claude) parts.push('Claude: ' + activeMap.claude);
1268
+ if (activeMap.codex) parts.push('Codex: ' + activeMap.codex);
1269
+ subEl.textContent = 'Port ' + state.port + ' · Active for ' + parts.join(' · ');
1270
+ } else {
1271
+ subEl.textContent = 'Gateway is idle. Clients connect directly to official endpoints.';
1272
+ }
1273
+ actionsEl.innerHTML = `
1274
+ <button class="btn btn-primary" onclick="openProfileModal(true)">+ Add Profile</button>
1275
+ <button class="btn btn-outline" id="btn-toggle" onclick="toggleProxy()">${activeTargets.length > 0 ? 'Use official endpoints' : 'Activate compatible routes'}</button>
1276
+ <button class="btn btn-outline" onclick="loadStatus()">Refresh</button>
1277
+ `;
1278
+ } else if (route === 'models') {
1279
+ titleEl.textContent = 'Dynamic Model Catalog';
1280
+ subEl.textContent = 'Auto-polled models from upstream providers to guarantee zero 404/drift errors.';
1281
+ actionsEl.innerHTML = `
1282
+ <button class="btn btn-outline" id="btn-sync-catalog" onclick="refreshCatalogUpstream()">⚡ Sync Models with Upstream</button>
1283
+ <button class="btn btn-outline" onclick="renderCatalogView()">Refresh</button>
1284
+ `;
1285
+ } else if (route === 'logs') {
1286
+ titleEl.textContent = 'Request Inspector';
1287
+ subEl.textContent = 'Real-time HTTP traffic ring buffer passing through the local edge gateway.';
1288
+ actionsEl.innerHTML = `
1289
+ <button class="btn btn-outline btn-sm" onclick="clearInspectorLogs()">Clear</button>
1290
+ <button class="btn btn-outline btn-sm" onclick="loadInspectorLogs()">Refresh</button>
1291
+ `;
1292
+ } else if (route === 'doctor') {
1293
+ titleEl.textContent = 'System Health & Doctor';
1294
+ subEl.textContent = 'Verification of loopback bindings, interceptor certificates, and zero-mutation guarantees.';
1295
+ actionsEl.innerHTML = `
1296
+ <button class="btn btn-outline" onclick="loadStatus()">Re-check Status</button>
1297
+ `;
1298
+ }
1299
+ }
1300
+
1301
+ // Modal Tabs
1302
+ function switchTab(tabId) {
1303
+ document.querySelectorAll('#profile-form .tab-btn').forEach(btn => btn.classList.remove('active'));
1304
+ document.querySelectorAll('#profile-form .tab-content').forEach(c => {
1305
+ c.classList.remove('active');
1306
+ c.style.display = 'none';
1307
+ });
1308
+
1309
+ const btnId = tabId.replace('tab-', 'tab-button-');
1310
+ document.getElementById(btnId)?.classList.add('active');
1311
+ const target = document.getElementById(tabId);
1312
+ if (target) {
1313
+ target.classList.add('active');
1314
+ target.style.display = 'grid';
1315
+ }
1316
+ if (tabId === 'tab-models') ensureModelsLoaded();
1317
+ }
1318
+
1319
+ // Slot Configs
1320
+ function getToolAvatar(profileKey, profile) {
1321
+ const k = (profileKey || '').toLowerCase();
1322
+ const n = (profile?.name || '').toLowerCase();
1323
+ const t = (profile?.tool || '').toLowerCase();
1324
+ const u = (profile?.baseURL || '').toLowerCase();
1325
+
1326
+ let iconFile = 'openai.png';
1327
+ let brandName = 'AI Provider';
1328
+
1329
+ if (k.includes('claude') || n.includes('claude') || t === 'claude' || t === 'anthropic' || u.includes('anthropic')) {
1330
+ iconFile = 'claude.png';
1331
+ brandName = 'Anthropic Claude';
1332
+ } else if (k.includes('codex') || n.includes('codex') || t === 'codex' || t === 'responses') {
1333
+ iconFile = 'codex.png';
1334
+ brandName = 'OpenAI Codex';
1335
+ } else if (k.includes('openai') || n.includes('openai') || u.includes('openai')) {
1336
+ iconFile = 'openai.png';
1337
+ brandName = 'OpenAI';
1338
+ } else if (k.includes('intact') || n.includes('intact')) {
1339
+ iconFile = 'intact.svg';
1340
+ brandName = 'Intact Gateway';
1341
+ } else if (k.includes('gemini') || n.includes('gemini') || k.includes('vertex') || n.includes('vertex') || u.includes('googleapis')) {
1342
+ iconFile = 'gemini.png';
1343
+ brandName = 'Google Gemini';
1344
+ } else if (k.includes('openrouter') || n.includes('openrouter') || u.includes('openrouter')) {
1345
+ iconFile = 'openrouter.png';
1346
+ brandName = 'OpenRouter';
1347
+ } else if (k.includes('local') || k.includes('ollama') || n.includes('ollama') || u.includes('11434')) {
1348
+ iconFile = 'ollama.png';
1349
+ brandName = 'Ollama';
1350
+ } else if (k.includes('antigravity') || n.includes('antigravity')) {
1351
+ iconFile = 'antigravity.png';
1352
+ brandName = 'Antigravity';
1353
+ } else if (k.includes('deepseek') || n.includes('deepseek')) {
1354
+ iconFile = 'deepseek.png';
1355
+ brandName = 'DeepSeek';
1356
+ } else if (k.includes('groq') || n.includes('groq')) {
1357
+ iconFile = 'groq.png';
1358
+ brandName = 'Groq';
1359
+ } else if (k.includes('qwen') || n.includes('qwen')) {
1360
+ iconFile = 'qwen.png';
1361
+ brandName = 'Qwen';
1362
+ } else if (k.includes('github') || n.includes('github')) {
1363
+ iconFile = 'github.png';
1364
+ brandName = 'GitHub Copilot';
1365
+ }
1233
1366
 
1234
- function getActiveSlotConfig(format) {
1235
- return SLOT_CONFIG[format] || SLOT_CONFIG.auto;
1367
+ return '<span class="av logo" title="' + escapeHtml(brandName) + '"><img src="/icons/' + iconFile + '" alt="" loading="lazy"></span>';
1236
1368
  }
1237
1369
 
1238
- function valueForSlot(values, slot) {
1239
- const source = values || {};
1240
- if (Object.hasOwn(source, slot)) return source[slot] || '';
1241
- for (const legacyKey of LEGACY_SLOT_KEYS[slot] || []) {
1242
- if (source[legacyKey]) return source[legacyKey];
1370
+ const SLOT_CONFIGS = {
1371
+ auto: {
1372
+ title: 'Universal Routing Slots',
1373
+ slots: [
1374
+ { id: 'sonnet', name: 'Claude Sonnet', placeholder: 'claude-3-7-sonnet or antigravity/gemini-3.8-flash' },
1375
+ { id: 'opus', name: 'Claude Opus', placeholder: 'claude-3-opus or antigravity/gemini-3.8-flash' },
1376
+ { id: 'haiku', name: 'Claude Haiku', placeholder: 'claude-3-5-haiku or antigravity/gemini-3.8-flash' },
1377
+ { id: 'fable', name: 'Claude Fable', placeholder: 'claude-fable-5-1 or antigravity/gemini-3.8-flash' },
1378
+ { id: 'main', name: 'Codex Main Session', placeholder: 'gpt-5.6-sol or gpt-4o' },
1379
+ { id: 'review', name: 'Codex /review Override', placeholder: 'gpt-5.6-terra or gpt-4o' },
1380
+ { id: 'subagent', name: 'Codex Subagent', placeholder: 'gpt-5.6-luna or o3-mini' },
1381
+ { id: 'default', name: 'Default Fallback Model', placeholder: 'gpt-4o' }
1382
+ ]
1383
+ },
1384
+ claude: {
1385
+ title: 'Claude Code Model Tiers (Anthropic)',
1386
+ slots: [
1387
+ { id: 'sonnet', name: 'Sonnet Slot', placeholder: 'claude-3-7-sonnet' },
1388
+ { id: 'opus', name: 'Opus Slot', placeholder: 'claude-3-opus' },
1389
+ { id: 'haiku', name: 'Haiku Slot', placeholder: 'claude-3-5-haiku' },
1390
+ { id: 'fable', name: 'Fable Slot', placeholder: 'claude-fable-5-1' }
1391
+ ]
1392
+ },
1393
+ codex: {
1394
+ title: 'OpenAI Codex Model Roles (Responses API)',
1395
+ slots: [
1396
+ { id: 'main', name: 'Main Session Model', placeholder: 'gpt-5.6-sol' },
1397
+ { id: 'review', name: 'Review Model', placeholder: 'gpt-5.6-terra' },
1398
+ { id: 'subagent', name: 'Subagent Model', placeholder: 'gpt-5.6-luna' }
1399
+ ]
1243
1400
  }
1244
- return '';
1401
+ };
1402
+
1403
+ function getActiveSlotConfig(fmt) {
1404
+ if (fmt === 'claude' || fmt === 'anthropic') return SLOT_CONFIGS.claude;
1405
+ if (fmt === 'codex' || fmt === 'responses') return SLOT_CONFIGS.codex;
1406
+ return SLOT_CONFIGS.auto;
1245
1407
  }
1246
1408
 
1247
- function flagForSlot(values, slot) {
1248
- const source = values || {};
1249
- if (Object.hasOwn(source, slot)) return Boolean(source[slot]);
1250
- for (const legacyKey of LEGACY_SLOT_KEYS[slot] || []) {
1251
- if (Object.hasOwn(source, legacyKey)) return Boolean(source[legacyKey]);
1252
- }
1253
- return false;
1409
+ function valueForSlot(defaultModels, slotId) {
1410
+ if (!defaultModels) return '';
1411
+ return defaultModels[slotId] || '';
1254
1412
  }
1255
1413
 
1256
- function renderModelSlots(format) {
1257
- const cfg = getActiveSlotConfig(format);
1258
- const heading = document.getElementById('model-slot-heading');
1259
- const warnText = document.getElementById('warn-1m-text');
1260
- const warnIcon = document.getElementById('warn-1m-icon');
1261
- const container = document.getElementById('slot-fields');
1414
+ function flagForSlot(model1M, slotId) {
1415
+ if (!model1M) return false;
1416
+ return Boolean(model1M[slotId]);
1417
+ }
1262
1418
 
1263
- if (heading) heading.textContent = cfg.heading;
1264
- if (warnText) warnText.innerHTML = cfg.warning;
1265
- if (warnIcon) warnIcon.textContent = cfg.icon;
1419
+ function renderModelSlots(slotFmt, p) {
1420
+ const container = document.getElementById('slot-fields');
1421
+ const heading = document.getElementById('model-slot-heading');
1266
1422
  if (!container) return;
1267
1423
 
1268
- const p = currentProfileBuffer || {};
1269
- const defs = p.defaultModels || {};
1270
- const m1m = p.model1M || {};
1271
-
1272
- container.innerHTML = cfg.slots.map(s => {
1273
- const val = valueForSlot(defs, s.id);
1274
- const checked = flagForSlot(m1m, s.id);
1275
-
1276
- return `
1277
- <div class="form-group ${cfg.slots.length === 1 ? 'full' : ''}">
1278
- <div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 4px;">
1279
- <label for="p-${s.id}">${escapeHtml(s.label)}</label>
1280
- ${s.allow1M ? `
1281
- <label style="display: flex; align-items: center; gap: 4px; font-size: 11px; cursor: pointer; color: var(--accent); text-transform: none;">
1282
- <input type="checkbox" id="p-${s.id}-1m" style="width: 13px; height: 13px;" onchange="update1mWarnings()" ${checked ? 'checked' : ''}> 1M context
1283
- <span id="warn-${s.id}-1m" class="badge-1m-warn" style="display: ${checked ? 'inline-block' : 'none'};" title="Risk of HTTP 400 if model does not support 1M context">Check support</span>
1284
- </label>
1285
- ` : ''}
1286
- </div>
1287
- <input type="text" id="p-${s.id}" list="model-options" placeholder="${escapeHtml(s.placeholder)}" value="${escapeHtml(val)}">
1424
+ const cfg = getActiveSlotConfig(slotFmt);
1425
+ renderedSlotFormat = slotFmt;
1426
+ if (heading) heading.textContent = cfg.title;
1427
+ container.innerHTML = '';
1428
+
1429
+ for (const s of cfg.slots) {
1430
+ const val = currentProfileBuffer ? valueForSlot(currentProfileBuffer.defaultModels, s.id) : valueForSlot(p?.defaultModels, s.id);
1431
+ const checked = currentProfileBuffer ? flagForSlot(currentProfileBuffer.model1M, s.id) : flagForSlot(p?.model1M, s.id);
1432
+
1433
+ const slotRow = document.createElement('div');
1434
+ slotRow.style.cssText = 'display:flex; flex-direction:column; gap:0.25rem;';
1435
+ slotRow.innerHTML = `
1436
+ <div style="display:flex; justify-content:space-between; align-items:center;">
1437
+ <label for="p-${s.id}" style="font-size:0.82rem; font-weight:500;">${escapeHtml(s.name)}</label>
1438
+ <label style="display:flex; align-items:center; gap:0.35rem; font-size:0.75rem; color:var(--accent); cursor:pointer;">
1439
+ <input type="checkbox" id="p-${s.id}-1m" style="width:13px; height:13px; min-height:0;" onchange="update1mWarnings()" ${checked ? 'checked' : ''}>
1440
+ 1M context
1441
+ </label>
1442
+ </div>
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>
1288
1449
  </div>
1289
1450
  `;
1290
- }).join('');
1291
-
1292
- renderedSlotFormat = format;
1451
+ container.appendChild(slotRow);
1452
+ wireCombo(slotRow.querySelector('.combo'));
1453
+ }
1293
1454
  update1mWarnings();
1294
1455
  }
1295
1456
 
1296
- function onToolChange(newFmt) {
1297
- collectCurrentSlotValues(renderedSlotFormat);
1298
- renderModelSlots(newFmt);
1299
- renderCodexTab(newFmt);
1300
- if (loadedModels.length > 0) renderModelBrowser(loadedModels);
1301
- formDirty = true;
1457
+ function onToolChange() {
1458
+ const toolVal = document.getElementById('p-tool').value;
1459
+ renderModelSlots(toolVal, currentProfileBuffer);
1302
1460
  }
1303
1461
 
1304
- // The Codex tab applies to every profile that can serve the Responses protocol: `responses` and
1305
- // `auto`. The gateway applies these settings to both, so hiding them would hide live settings.
1306
- const servesCodex = (format) => format === 'responses' || !format || format === 'auto';
1307
-
1308
- function renderCodexTab(format) {
1309
- const applies = servesCodex(format);
1310
- const btn = document.getElementById('tab-button-codex');
1311
- if (btn) btn.hidden = !applies;
1312
- const names = document.getElementById('codex-names');
1313
- if (names) names.hidden = !applies;
1314
- if (!applies && document.getElementById('tab-codex')?.classList.contains('active')) {
1315
- switchTab('tab-general');
1462
+ function collectCurrentSlotValues() {
1463
+ const cfg = getActiveSlotConfig(renderedSlotFormat);
1464
+ if (!currentProfileBuffer) currentProfileBuffer = {};
1465
+ const defaultModels = { ...(currentProfileBuffer.defaultModels || {}) };
1466
+ const model1M = { ...(currentProfileBuffer.model1M || {}) };
1467
+ for (const s of cfg.slots) {
1468
+ const inp = document.getElementById('p-' + s.id);
1469
+ const chk = document.getElementById('p-' + s.id + '-1m');
1470
+ if (inp) {
1471
+ const val = inp.value.trim();
1472
+ if (val) defaultModels[s.id] = val;
1473
+ else delete defaultModels[s.id];
1474
+ }
1475
+ if (chk) {
1476
+ if (chk.checked) model1M[s.id] = true;
1477
+ else delete model1M[s.id];
1478
+ }
1316
1479
  }
1317
-
1318
- const p = currentProfileBuffer || {};
1319
- const roles = p.codexRoles || {};
1320
- const published = Array.isArray(p.publicModels) ? p.publicModels : [];
1321
- CODEX_ROLE_SLOTS.forEach((slot, i) => {
1322
- const input = document.getElementById(`p-public-${slot}`);
1323
- // A saved role key wins even when blank; the position fallback is for profiles without codexRoles.
1324
- if (input) input.value = Object.hasOwn(roles, slot) ? (roles[slot] || '') : (published[i] || '');
1325
- });
1326
-
1480
+ currentProfileBuffer.defaultModels = defaultModels;
1481
+ currentProfileBuffer.model1M = model1M;
1482
+ return { defaultModels, model1M };
1327
1483
  }
1328
1484
 
1329
- // One set of inputs feeds both keys: codexRoles is the role mapping the gateway
1330
- // resolves, publicModels is the flat list /v1/models serves. Writing only one of
1331
- // them lets the two drift apart.
1332
- function collectCodexValues(profile) {
1333
- if (!servesCodex(document.getElementById('p-tool')?.value || 'auto')) return;
1334
-
1335
- const roles = {};
1336
- const published = [];
1337
- CODEX_ROLE_SLOTS.forEach(slot => {
1338
- const value = document.getElementById(`p-public-${slot}`)?.value.trim() || '';
1339
- roles[slot] = value;
1340
- if (value && !published.includes(value)) published.push(value);
1341
- });
1342
- profile.codexRoles = roles;
1343
- profile.publicModels = published;
1344
-
1485
+ function update1mWarnings() {
1486
+ const cfg = getActiveSlotConfig(renderedSlotFormat);
1487
+ let any1m = false;
1488
+ for (const s of cfg.slots) {
1489
+ const chk = document.getElementById('p-' + s.id + '-1m');
1490
+ if (chk && chk.checked) any1m = true;
1491
+ }
1492
+ const warnCallout = document.getElementById('warn-1m-callout');
1493
+ if (warnCallout) warnCallout.style.display = any1m ? 'inline-flex' : 'none';
1345
1494
  }
1346
1495
 
1347
- function collectCurrentSlotValues(format = renderedSlotFormat) {
1348
- if (!currentProfileBuffer) currentProfileBuffer = {};
1349
- if (!currentProfileBuffer.defaultModels) currentProfileBuffer.defaultModels = {};
1350
- if (!currentProfileBuffer.model1M) currentProfileBuffer.model1M = {};
1351
-
1352
- const cfg = getActiveSlotConfig(format);
1353
- cfg.slots.forEach(s => {
1354
- const inp = document.getElementById(`p-${s.id}`);
1355
- if (inp) currentProfileBuffer.defaultModels[s.id] = inp.value.trim();
1356
- const chk = document.getElementById(`p-${s.id}-1m`);
1357
- if (chk) currentProfileBuffer.model1M[s.id] = chk.checked;
1358
- });
1359
- }
1496
+ // Prefetch Counts for Nav Badges
1497
+ async function prefetchNavData() {
1498
+ try {
1499
+ const res = await api('/api/catalog');
1500
+ if (res.ok) {
1501
+ const catalog = await res.json();
1502
+ cachedCatalog = catalog;
1503
+ const claudeModels = Array.isArray(catalog.claude?.models) ? catalog.claude.models : [];
1504
+ const codexModels = Array.isArray(catalog.codex?.models) ? catalog.codex.models : [];
1505
+ const count = claudeModels.length + codexModels.length;
1506
+ const ctModels = document.getElementById('ct-models');
1507
+ if (ctModels && count > 0) ctModels.textContent = count;
1508
+ }
1509
+ } catch {}
1360
1510
 
1361
- function update1mWarnings() {
1362
- const currentFmt = document.getElementById('p-tool')?.value || 'auto';
1363
- const cfg = getActiveSlotConfig(currentFmt);
1364
- cfg.slots.forEach(s => {
1365
- const chk = document.getElementById(`p-${s.id}-1m`);
1366
- const badge = document.getElementById(`warn-${s.id}-1m`);
1367
- if (chk && badge) {
1368
- badge.style.display = chk.checked ? 'inline-block' : 'none';
1511
+ try {
1512
+ const res = await api('/api/logs');
1513
+ if (res.ok) {
1514
+ const data = await res.json();
1515
+ const logs = Array.isArray(data.logs) ? data.logs : [];
1516
+ lastLogsCount = logs.length;
1517
+ const ctLogs = document.getElementById('ct-logs');
1518
+ if (ctLogs && lastLogsCount > 0) ctLogs.textContent = lastLogsCount;
1369
1519
  }
1370
- });
1520
+ } catch {}
1371
1521
  }
1372
1522
 
1523
+ // Load Status
1373
1524
  async function loadStatus() {
1374
1525
  try {
1375
1526
  const res = await api('/api/status');
1527
+ if (!res.ok) return;
1376
1528
  const data = await res.json();
1377
- if (res.status === 401) {
1378
- document.getElementById('status-title').textContent = 'Dashboard locked';
1379
- document.getElementById('status-sub').textContent = 'Run `switch ui` in a terminal. It opens this page with its access token.';
1380
- return;
1381
- }
1382
- if (!res.ok) {
1383
- showToast(data.error || `Gateway error HTTP ${res.status}`, true);
1384
- return;
1385
- }
1386
- state = data;
1387
- document.querySelectorAll('.gateway-port').forEach(el => { el.textContent = state.port; });
1388
- if (!creatingNew && (!currentlyEditingKey || !state.config.profiles[currentlyEditingKey])) {
1389
- // Pick the first profile that is actually active for any CLI target,
1390
- // falling back to the legacy activeProfile or the first profile key.
1391
- const activeMap = state.activeProfiles || {};
1392
- const firstActiveKey = Object.values(activeMap).find(k => k && state.config.profiles[k]);
1393
- currentlyEditingKey = firstActiveKey
1394
- || (state.config.profiles[state.activeProfile] ? state.activeProfile : null)
1395
- || (Object.keys(state.config.profiles)[0] || '');
1396
- formDirty = false;
1397
- }
1529
+ state = { ...state, ...data };
1398
1530
  render();
1531
+ updateHeader(window.location.hash.replace(/^#\/?/, '').split('/')[0] || 'routes');
1532
+ prefetchNavData();
1399
1533
  } catch (err) {
1400
- showToast('Failed to connect to gateway service', true);
1534
+ console.error('Failed to load status', err);
1401
1535
  }
1402
1536
  }
1403
1537
 
1404
- function isAnyTargetActive() {
1405
- return Object.values(state.activeProfiles || {}).some(Boolean);
1406
- }
1407
-
1408
1538
  function render() {
1409
- const dot = document.getElementById('status-dot');
1410
- const title = document.getElementById('status-title');
1411
- const sub = document.getElementById('status-sub');
1412
- const toggleBtn = document.getElementById('btn-toggle');
1539
+ document.querySelectorAll('.gateway-port').forEach(el => {
1540
+ el.textContent = state.port || '3456';
1541
+ });
1413
1542
 
1414
- const activeMap = state.activeProfiles || (state.config.activeProfiles || {});
1543
+ const activeMap = state.activeProfiles || (state.config?.activeProfiles || {});
1415
1544
  const activeTargets = Object.entries(activeMap).filter(([_, v]) => Boolean(v));
1416
1545
  const isAnyActive = activeTargets.length > 0;
1417
1546
 
1418
- if (isAnyActive) {
1419
- dot.className = 'status-dot active';
1420
- title.innerText = `Gateway active for ${activeTargets.length} client${activeTargets.length > 1 ? 's' : ''}`;
1421
- const parts = [];
1422
- if (activeMap.claude) parts.push(`Claude: ${activeMap.claude}`);
1423
- if (activeMap.codex) parts.push(`Codex: ${activeMap.codex}`);
1424
- sub.innerText = `Port ${state.port} | ${parts.join(' | ')}`;
1425
- toggleBtn.className = 'btn btn-danger';
1426
- toggleBtn.innerText = 'Use official endpoints';
1427
- } else {
1428
- dot.className = 'status-dot';
1429
- title.innerText = 'Official endpoints in use';
1430
- sub.innerText = 'Gateway is idle. Clients connect directly to official endpoints.';
1431
- toggleBtn.className = 'btn btn-success';
1432
- toggleBtn.innerText = 'Activate compatible routes';
1547
+ // Update Aside Indicators
1548
+ const asideIndicator = document.getElementById('status-aside-indicator');
1549
+ if (asideIndicator) {
1550
+ asideIndicator.className = isAnyActive ? 'status on' : 'status';
1551
+ asideIndicator.textContent = isAnyActive ? 'Active (' + activeTargets.length + ')' : 'Idle';
1552
+ }
1553
+
1554
+ // Update Nav Counts
1555
+ const profilesList = Object.entries(state.config?.profiles || {});
1556
+ const ctRoutes = document.getElementById('ct-routes');
1557
+ if (ctRoutes) ctRoutes.textContent = isAnyActive ? activeTargets.length + ' on' : profilesList.length;
1558
+
1559
+ const ctModels = document.getElementById('ct-models');
1560
+ if (ctModels && cachedCatalog) {
1561
+ const count = (cachedCatalog.claude?.models?.length || 0) + (cachedCatalog.codex?.models?.length || 0);
1562
+ if (count > 0) ctModels.textContent = count;
1433
1563
  }
1434
1564
 
1565
+ const ctLogs = document.getElementById('ct-logs');
1566
+ if (ctLogs && lastLogsCount > 0) ctLogs.textContent = lastLogsCount;
1567
+
1568
+ // Update Blindfold display
1435
1569
  const bfEl = document.getElementById('blindfold-port-display');
1436
1570
  if (bfEl) {
1437
1571
  const bf = state.config?.blindfold;
1438
- bfEl.textContent = (bf && bf.port)
1439
- ? `port ${bf.port} — host ${bf.host || 'api.anthropic.com, api.openai.com, chatgpt.com'}, prefix ${bf.prefix || 'default'}`
1440
- : 'not configured (the tools reach the provider directly)';
1572
+ bfEl.textContent = (bf && bf.port) ? 'Port ' + bf.port : 'Not active';
1441
1573
  }
1442
1574
 
1443
- // Update the two tool slots
1575
+ // Render CLI Targets Rows
1444
1576
  const slots = [
1445
1577
  { id: 'claude', name: 'Claude Code', key: 'claude' },
1446
1578
  { id: 'codex', name: 'Codex', key: 'codex' }
1447
1579
  ];
1448
1580
 
1449
1581
  for (const slot of slots) {
1450
- const card = document.getElementById(`card-slot-${slot.id}`);
1451
- const badge = document.getElementById(`badge-slot-${slot.id}`);
1452
- const select = document.getElementById(`select-slot-${slot.id}`);
1582
+ const badge = document.getElementById('badge-slot-' + slot.id);
1583
+ const select = document.getElementById('select-slot-' + slot.id);
1453
1584
  const currentActive = activeMap[slot.key];
1454
1585
 
1455
1586
  if (select) {
1456
1587
  select.innerHTML = '<option value="">Official endpoint</option>';
1457
- for (const [key, p] of Object.entries(state.config.profiles)) {
1588
+ for (const [key, p] of profilesList) {
1458
1589
  const tool = p.tool || 'auto';
1459
1590
  if (tool === 'auto' || tool === slot.key) {
1460
1591
  const opt = document.createElement('option');
1461
1592
  opt.value = key;
1462
- opt.textContent = `${p.name} (${key})`;
1593
+ opt.textContent = p.name + ' (' + key + ')';
1463
1594
  select.appendChild(opt);
1464
1595
  }
1465
1596
  }
1466
1597
  select.value = currentActive || '';
1467
1598
  }
1468
1599
 
1469
- if (currentActive) {
1470
- if (card) card.classList.add('active');
1471
- if (badge) {
1472
- badge.className = 'badge badge-direct';
1600
+ if (badge) {
1601
+ if (currentActive) {
1602
+ badge.className = 'badge ok';
1473
1603
  badge.textContent = currentActive;
1474
- }
1475
- } else {
1476
- if (card) card.classList.remove('active');
1477
- if (badge) {
1478
- badge.className = 'badge badge-outline';
1604
+ } else {
1605
+ badge.className = 'badge outline';
1479
1606
  badge.textContent = 'OFF';
1480
1607
  }
1481
1608
  }
1482
1609
  }
1483
1610
 
1484
- // Render Cards Grid
1611
+ // Render Profiles Grid (Intact Style)
1485
1612
  const grid = document.getElementById('profiles-grid');
1613
+ if (!grid) return;
1486
1614
  grid.innerHTML = '';
1487
1615
 
1488
- for (const [key, p] of Object.entries(state.config.profiles)) {
1616
+ for (const [key, p] of profilesList) {
1489
1617
  const activeCLIs = [];
1490
1618
  if (activeMap.claude === key) activeCLIs.push('Claude');
1491
1619
  if (activeMap.codex === key) activeCLIs.push('Codex');
1492
1620
  const isActiveAny = activeCLIs.length > 0;
1493
1621
  const isEditing = (key === currentlyEditingKey);
1494
- const shortFmt = (f) => ({ auto: 'auto', claude: 'claude', codex: 'codex' }[f] || f || 'auto');
1495
- const routeBadge = `${shortFmt(p.tool || 'auto')}→${shortFmt(p.outFormat || p.mode)}`;
1496
1622
 
1497
1623
  const card = document.createElement('div');
1498
- card.className = `profile-card ${isEditing ? 'editing' : ''} ${isActiveAny ? 'active' : ''}`;
1499
- card.setAttribute('aria-label', `${p.name} provider profile`);
1500
- card.style.cursor = 'pointer';
1501
- card.addEventListener('click', (ev) => {
1502
- // Don't trigger if clicked on toggle switch or interactive buttons
1503
- if (ev.target.closest('.card-toggle') || ev.target.closest('input') || ev.target.closest('button')) return;
1504
- selectProfileForEdit(key);
1505
- });
1624
+ card.className = 'pcard' + (isActiveAny ? ' active' : '');
1625
+ card.setAttribute('aria-label', p.name + ' profile');
1626
+
1627
+ const toolAv = getToolAvatar(key, p);
1628
+ const routeBadge = (p.tool || 'auto') + ' → ' + (p.outFormat || p.mode || 'chat');
1506
1629
 
1507
1630
  const slotFmt = p.tool === 'claude' ? 'anthropic' : p.tool === 'codex' ? 'responses' : 'auto';
1508
1631
  const pCfg = getActiveSlotConfig(slotFmt);
1509
- const modelsSummaryHtml = pCfg.slots.map(s => {
1510
- const val = valueForSlot(p.defaultModels, s.id) || '-';
1632
+ const modelChips = pCfg.slots.map(s => {
1633
+ const val = valueForSlot(p.defaultModels, s.id);
1634
+ if (!val) return '';
1511
1635
  const has1m = flagForSlot(p.model1M, s.id);
1512
- const label = s.id.charAt(0).toUpperCase() + s.id.slice(1);
1513
- return `<div><strong>${label}:</strong> ${escapeHtml(val)} ${has1m ? '<span class="badge-1m">1M</span>' : ''}</div>`;
1514
- }).join('');
1636
+ return '<div class="chip"><span class="faint lbl">' + escapeHtml(s.name) + '</span><span class="val">' + escapeHtml(val) + '</span>' + (has1m ? '<b class="b1m">1M</b>' : '') + '</div>';
1637
+ }).filter(Boolean).join('');
1515
1638
 
1516
1639
  card.innerHTML = `
1517
- <div class="card-top">
1518
- <div class="card-name-group">
1519
- <div class="card-name">${escapeHtml(p.name)}</div>
1520
- <span class="badge badge-${escapeHtml(p.mode || 'hybrid')}">${escapeHtml(p.mode || 'hybrid')}</span>
1521
- <span class="badge badge-route" title="Client input -> Upstream output">${escapeHtml(routeBadge)}</span>
1522
- </div>
1523
- <div class="card-toggle" style="display: flex; align-items: center; gap: 6px; flex-shrink: 0;">
1524
- <span style="font-size: 11px; font-weight: 600; color: ${isActiveAny ? 'var(--success)' : 'var(--text-dim)'};">${isActiveAny ? 'ACTIVE' : 'OFF'}</span>
1525
- <label class="switch">
1526
- <input type="checkbox" aria-label="Activate ${escapeHtml(p.name)} for compatible clients" ${isActiveAny ? 'checked' : ''}>
1527
- <span class="slider"></span>
1528
- </label>
1640
+ <div class="top">
1641
+ ${toolAv}
1642
+ <div style="flex:1; min-width:0;">
1643
+ <div class="nm">${escapeHtml(p.name)}</div>
1644
+ <div style="display:flex; gap:0.4rem; align-items:center; flex-wrap:wrap; margin-top:0.15rem;">
1645
+ <span class="status ${isActiveAny ? 'on' : ''}">${isActiveAny ? 'Active (' + activeCLIs.join(', ') + ')' : 'Idle'}</span>
1646
+ <span class="badge accent">${escapeHtml(routeBadge)}</span>
1647
+ </div>
1529
1648
  </div>
1649
+ <label class="switch" onclick="event.stopPropagation()">
1650
+ <input type="checkbox" aria-label="Use ${escapeHtml(p.name)} (${escapeHtml(key)}) for every compatible CLI" ${isActiveAny ? 'checked' : ''} onchange="toggleProfileAllCli('${escapeHtml(key)}', this.checked)">
1651
+ <span></span>
1652
+ </label>
1530
1653
  </div>
1531
- <div class="card-url">${escapeHtml(p.baseURL)}</div>
1532
- <div class="card-models">
1533
- ${modelsSummaryHtml}
1654
+ <div class="copyline" onclick="event.stopPropagation()">
1655
+ <code>${escapeHtml(p.baseURL)}</code>
1534
1656
  </div>
1535
- <div class="card-footer">
1536
- <button type="button" class="btn btn-outline btn-sm profile-edit" aria-pressed="${isEditing}" onclick="selectProfileForEdit('${escapeHtml(key)}')">
1537
- ${isEditing ? 'Editing' : 'Edit profile'}
1657
+ ${modelChips ? '<div class="chips">' + modelChips + '</div>' : ''}
1658
+ <div style="display:flex; justify-content:space-between; align-items:center; margin-top:auto; padding-top:0.5rem; border-top:1px solid var(--line);">
1659
+ <button type="button" class="btn btn-outline btn-sm" onclick="selectProfileForEdit('${escapeHtml(key)}')">
1660
+ ${isEditing ? 'Editing Profile' : 'Edit Profile'}
1538
1661
  </button>
1539
- <div>
1540
- ${activeCLIs.map(c => `<span class="badge badge-direct" style="font-size: 9px; margin-left: 3px;">${escapeHtml(c)}</span>`).join('')}
1541
- </div>
1662
+ <span class="mono faint" style="font-size:0.75rem;">${escapeHtml(key)}</span>
1542
1663
  </div>
1543
1664
  `;
1544
1665
 
1545
- const toggleWrap = card.querySelector('.card-toggle');
1546
- toggleWrap.addEventListener('click', ev => ev.stopPropagation());
1547
- toggleWrap.querySelector('input').addEventListener('change', ev => toggleProfileAllCli(key, ev.target.checked));
1548
1666
  grid.appendChild(card);
1549
1667
  }
1550
1668
 
@@ -1554,15 +1672,16 @@
1554
1672
  }
1555
1673
 
1556
1674
  function selectProfileForEdit(key) {
1557
- if (formDirty && key !== formLoadedKey && !confirm('Discard unsaved changes to this provider?')) return;
1675
+ if (formDirty && key !== formLoadedKey && !confirm('Discard unsaved changes?')) return;
1558
1676
  currentlyEditingKey = key;
1559
1677
  creatingNew = false;
1560
1678
  formDirty = false;
1561
1679
  render();
1680
+ openProfileModal(false);
1562
1681
  }
1563
1682
 
1564
1683
  async function switchCliTarget(target, profileKey) {
1565
- showToast(`Setting ${target} active profile...`);
1684
+ showToast('Setting ' + target + ' target...');
1566
1685
  try {
1567
1686
  const res = await api('/api/switch', {
1568
1687
  method: 'POST',
@@ -1571,400 +1690,240 @@
1571
1690
  });
1572
1691
  const data = await res.json();
1573
1692
  if (data.success) {
1574
- showToast(`Updated ${target} to "${profileKey || 'Official'}"!`);
1575
- loadStatus();
1693
+ showToast('Updated ' + target + ' routing');
1694
+ await loadStatus();
1576
1695
  } else {
1577
- showToast(data.error || 'Failed to switch', true);
1696
+ showToast(data.error || 'Failed to switch target', true);
1578
1697
  }
1579
1698
  } catch (err) {
1580
- showToast('Error switching target', true);
1699
+ showToast('Network error switching target', true);
1581
1700
  }
1582
1701
  }
1583
1702
 
1584
- async function toggleProfileAllCli(key, isChecked) {
1585
- if (isChecked) {
1586
- showToast(`Activating provider "${key}"...`);
1587
- try {
1588
- const res = await api('/api/switch', {
1589
- method: 'POST',
1590
- headers: { 'Content-Type': 'application/json' },
1591
- body: JSON.stringify({ profile: key })
1592
- });
1593
- const data = await res.json();
1594
- if (data.success) {
1595
- showToast(`Provider "${key}" activated!`);
1596
- loadStatus();
1597
- } else {
1598
- showToast(data.error || 'Failed to switch', true);
1599
- loadStatus();
1600
- }
1601
- } catch (err) {
1602
- showToast('Error activating provider', true);
1603
- loadStatus();
1604
- }
1605
- } else {
1606
- showToast(`Deactivating provider "${key}"...`);
1607
- try {
1608
- const res = await api('/api/switch', {
1609
- method: 'POST',
1610
- headers: { 'Content-Type': 'application/json' },
1611
- body: JSON.stringify({ deactivate: key })
1612
- });
1613
- const data = await res.json();
1614
- if (!data.success) throw new Error(data.error || 'Failed');
1615
- showToast(`Deactivated "${key}"!`);
1616
- loadStatus();
1617
- } catch (err) {
1618
- showToast('Error deactivating provider', true);
1619
- loadStatus();
1703
+ async function toggleProfileAllCli(key, enable) {
1704
+ showToast((enable ? 'Activating ' : 'Deactivating ') + key + '...');
1705
+ try {
1706
+ const res = await api('/api/switch', {
1707
+ method: 'POST',
1708
+ headers: { 'Content-Type': 'application/json' },
1709
+ body: JSON.stringify({ profile: key, deactivate: !enable })
1710
+ });
1711
+ const data = await res.json();
1712
+ if (data.success) {
1713
+ showToast(key + (enable ? ' activated' : ' deactivated'));
1714
+ await loadStatus();
1715
+ } else {
1716
+ showToast(data.error || 'Failed to toggle profile', true);
1620
1717
  }
1718
+ } catch (err) {
1719
+ showToast('Network error toggling profile', true);
1621
1720
  }
1622
1721
  }
1623
1722
 
1624
1723
  function loadProfileToForm(key) {
1625
1724
  const p = state.config?.profiles?.[key];
1626
1725
  if (!p) return;
1726
+ formLoadedKey = key;
1627
1727
  currentProfileBuffer = JSON.parse(JSON.stringify(p));
1628
1728
 
1629
- document.getElementById('form-title').innerText = `Edit Provider: ${p.name}`;
1729
+ document.getElementById('form-title').textContent = 'Edit Profile: ' + p.name;
1630
1730
  document.getElementById('p-key').value = key;
1631
- document.getElementById('p-key').disabled = true;
1632
- document.getElementById('p-name').value = p.name;
1633
- document.getElementById('p-mode').value = p.mode || 'hybrid';
1634
- const slotFmt = p.tool === 'claude' ? 'anthropic' : p.tool === 'codex' ? 'responses' : 'auto';
1635
- document.getElementById('p-tool').value = slotFmt;
1636
- document.getElementById('p-thinking-mode').value = p.thinkingMode || 'auto';
1731
+ document.getElementById('p-key').readOnly = true;
1732
+ document.getElementById('p-name').value = p.name || '';
1733
+ document.getElementById('p-url').value = p.baseURL || '';
1734
+ document.getElementById('p-key-val').value = p.apiKey ? MASKED_KEY : '';
1735
+ const toolVal = p.tool || 'auto';
1736
+ document.getElementById('p-tool').value = toolVal;
1637
1737
  document.getElementById('p-outformat').value = p.outFormat || '';
1638
- document.getElementById('p-url').value = p.baseURL;
1639
- document.getElementById('p-key-val').value = p.apiKey || '';
1640
-
1641
- renderModelSlots(slotFmt);
1642
- renderCodexTab(slotFmt);
1643
-
1644
- document.getElementById('btn-delete').style.display = Object.keys(state.config.profiles).length > 1 ? 'block' : 'none';
1645
- const usedBy = Object.entries(state.activeProfiles || {}).filter(([, v]) => v === key).map(([t]) => t);
1646
- document.getElementById('active-tag').innerText = usedBy.length ? `(Active: ${usedBy.join(', ')})` : '';
1647
-
1648
- document.getElementById('model-browser').style.display = 'none';
1649
- document.getElementById('test-result').style.display = 'none';
1650
- loadedModels = [];
1651
- formLoadedKey = key;
1652
- formDirty = false;
1738
+ document.getElementById('p-mode').value = p.mode || 'convert';
1739
+ document.getElementById('p-thinking-mode').value = p.thinkingMode || 'auto';
1740
+ document.getElementById('btn-delete').style.display = 'inline-flex';
1741
+
1742
+ const activeMap = state.activeProfiles || (state.config?.activeProfiles || {});
1743
+ const activeTargets = Object.entries(activeMap).filter(([_, v]) => v === key).map(([t]) => t);
1744
+ const activeTag = document.getElementById('active-tag');
1745
+ if (activeTag) {
1746
+ activeTag.textContent = activeTargets.length ? '● Active: ' + activeTargets.join(', ') : '';
1747
+ }
1748
+
1749
+ renderModelSlots(toolVal, p);
1653
1750
  }
1654
1751
 
1655
1752
  function initNewProfile() {
1656
- if (formDirty && !confirm('Discard unsaved changes to this provider?')) return;
1657
- currentlyEditingKey = '';
1658
1753
  creatingNew = true;
1754
+ currentlyEditingKey = '';
1659
1755
  formLoadedKey = null;
1660
- formDirty = false;
1661
1756
  currentProfileBuffer = {
1662
1757
  name: '',
1663
- mode: 'hybrid',
1664
- tool: null,
1665
- outFormat: '',
1758
+ baseURL: '',
1759
+ tool: 'auto',
1760
+ outFormat: 'openai-chat',
1761
+ mode: 'convert',
1666
1762
  thinkingMode: 'auto',
1667
- baseURL: 'https://',
1668
- apiKey: '',
1669
1763
  defaultModels: {},
1670
1764
  model1M: {}
1671
1765
  };
1672
1766
 
1673
- render();
1674
- document.getElementById('form-title').innerText = 'Create New Provider';
1767
+ document.getElementById('form-title').textContent = 'Add New Provider Profile';
1675
1768
  document.getElementById('p-key').value = '';
1676
- document.getElementById('p-key').disabled = false;
1769
+ document.getElementById('p-key').readOnly = false;
1677
1770
  document.getElementById('p-name').value = '';
1678
- document.getElementById('p-mode').value = 'hybrid';
1771
+ document.getElementById('p-url').value = '';
1772
+ document.getElementById('p-key-val').value = '';
1679
1773
  document.getElementById('p-tool').value = 'auto';
1680
- document.getElementById('p-outformat').value = '';
1774
+ document.getElementById('p-outformat').value = 'openai-chat';
1775
+ document.getElementById('p-mode').value = 'convert';
1681
1776
  document.getElementById('p-thinking-mode').value = 'auto';
1682
- document.getElementById('p-url').value = 'https://';
1683
- document.getElementById('p-key-val').value = '';
1684
-
1685
- renderModelSlots('auto');
1686
- renderCodexTab('auto');
1687
-
1688
1777
  document.getElementById('btn-delete').style.display = 'none';
1689
- document.getElementById('active-tag').innerText = '';
1690
- document.getElementById('model-browser').style.display = 'none';
1691
- document.getElementById('test-result').style.display = 'none';
1692
- loadedModels = [];
1693
- switchTab('tab-general');
1694
- }
1695
-
1696
- function setKeyIfNew(k) {
1697
- const input = document.getElementById('p-key');
1698
- if (!input.disabled) input.value = k;
1699
- }
1700
-
1701
- function applyTemplate(type) {
1702
- if (type === '9router') {
1703
- setKeyIfNew('9router');
1704
- document.getElementById('p-name').value = '9Router Cloud';
1705
- document.getElementById('p-mode').value = 'convert';
1706
- document.getElementById('p-tool').value = 'auto';
1707
- document.getElementById('p-outformat').value = 'openai-chat';
1708
- document.getElementById('p-url').value = 'https://api.9router.com/v1';
1709
- document.getElementById('p-thinking-mode').value = 'auto';
1710
- currentProfileBuffer = {
1711
- defaultModels: {
1712
- opus: 'ag/claude-opus-4-6-thinking',
1713
- sonnet: 'ag/claude-sonnet-4-6',
1714
- haiku: 'ag/gemini-3.7-flash-high',
1715
- fable: 'ag/gemini-3.8-flash-high'
1716
- },
1717
- model1M: { opus: true, sonnet: true, haiku: false, fable: true }
1718
- };
1719
- renderModelSlots('auto');
1720
- } else if (type === 'openrouter') {
1721
- setKeyIfNew('openrouter');
1722
- document.getElementById('p-name').value = 'OpenRouter';
1723
- document.getElementById('p-mode').value = 'convert';
1724
- document.getElementById('p-tool').value = 'auto';
1725
- document.getElementById('p-outformat').value = 'openai-chat';
1726
- document.getElementById('p-url').value = 'https://openrouter.ai/api/v1';
1727
- currentProfileBuffer = {
1728
- defaultModels: {
1729
- opus: 'anthropic/claude-3.7-sonnet:thinking',
1730
- sonnet: 'anthropic/claude-3.7-sonnet',
1731
- haiku: 'google/gemini-2.0-flash-001',
1732
- fable: 'qwen/qwq-32b'
1733
- },
1734
- model1M: { opus: true, sonnet: true, haiku: false, fable: false }
1735
- };
1736
- renderModelSlots('auto');
1737
- } else if (type === 'codex-vtx') {
1738
- setKeyIfNew('codex-vertex');
1739
- document.getElementById('p-name').value = 'Codex to Vertex';
1740
- document.getElementById('p-mode').value = 'convert';
1741
- document.getElementById('p-tool').value = 'responses';
1742
- document.getElementById('p-outformat').value = 'vertex';
1743
- document.getElementById('p-url').value = 'https://api.9router.com/v1';
1744
- document.getElementById('p-thinking-mode').value = 'auto';
1745
- currentProfileBuffer = {
1746
- defaultModels: {
1747
- main: 'gemini-3.8-flash',
1748
- review: 'gemini-3.7-flash-medium',
1749
- subagent: 'gemini-3.6-flash-low'
1750
- },
1751
- model1M: { main: true, review: false, subagent: false }
1752
- };
1753
- renderModelSlots('responses');
1754
- } else if (type === 'momo') {
1755
- setKeyIfNew('selfhost');
1756
- document.getElementById('p-name').value = 'Selfhost Hybrid';
1757
- document.getElementById('p-mode').value = 'hybrid';
1758
- document.getElementById('p-tool').value = 'auto';
1759
- document.getElementById('p-outformat').value = '';
1760
- document.getElementById('p-url').value = 'https://llm.example.com/v1';
1761
- currentProfileBuffer = {
1762
- defaultModels: {
1763
- opus: 'claude-opus-4-7',
1764
- sonnet: 'claude-sonnet-4-6',
1765
- haiku: 'claude-haiku-4-5-20251001',
1766
- fable: 'claude-haiku-4-5-20251001'
1767
- },
1768
- model1M: { opus: true, sonnet: true, haiku: false, fable: false }
1769
- };
1770
- renderModelSlots('auto');
1771
- } else if (type === 'custom') {
1772
- setKeyIfNew('local-openai');
1773
- document.getElementById('p-name').value = 'Local OpenAI Server';
1774
- document.getElementById('p-mode').value = 'convert';
1775
- document.getElementById('p-tool').value = 'auto';
1776
- document.getElementById('p-outformat').value = 'openai-chat';
1777
- document.getElementById('p-url').value = 'http://127.0.0.1:8000/v1';
1778
- document.getElementById('p-thinking-mode').value = 'native';
1779
- currentProfileBuffer = {
1780
- defaultModels: { opus: 'gpt-4o' },
1781
- model1M: { opus: false }
1782
- };
1783
- renderModelSlots('auto');
1784
- }
1785
- formDirty = true;
1786
- showToast(`Applied ${type} template!`);
1787
- }
1788
-
1789
- async function fetchModelList() {
1790
- const baseURL = document.getElementById('p-url').value.trim();
1791
- const apiKey = document.getElementById('p-key-val').value.trim();
1792
- if (!baseURL) {
1793
- showToast('Please enter Base URL first', true);
1794
- switchTab('tab-general');
1795
- return;
1796
- }
1797
-
1798
- const btn = document.getElementById('btn-fetch-models');
1799
- btn.innerText = 'Loading...';
1800
- btn.disabled = true;
1801
-
1802
- try {
1803
- const res = await api('/api/fetch-models', {
1804
- method: 'POST',
1805
- headers: { 'Content-Type': 'application/json' },
1806
- body: JSON.stringify({ baseURL, apiKey, key: formLoadedKey })
1807
- });
1808
- const data = await res.json();
1809
- if (data.ok && Array.isArray(data.models)) {
1810
- loadedModels = data.models;
1811
- populateModelOptions(loadedModels);
1812
- renderModelBrowser(loadedModels);
1813
- showToast(`Loaded ${loadedModels.length} models from upstream!`);
1814
- } else {
1815
- showToast(`Failed to load models: ${data.error || 'Unknown error'}`, true);
1816
- }
1817
- } catch (err) {
1818
- showToast(`Fetch error: ${err.message}`, true);
1819
- } finally {
1820
- btn.innerText = 'Fetch models';
1821
- btn.disabled = false;
1822
- }
1823
- }
1778
+ document.getElementById('active-tag').textContent = '';
1824
1779
 
1825
- function populateModelOptions(models) {
1826
- const datalist = document.getElementById('model-options');
1827
- datalist.innerHTML = '';
1828
- for (const m of models) {
1829
- const opt = document.createElement('option');
1830
- opt.value = m;
1831
- datalist.appendChild(opt);
1832
- }
1780
+ renderModelSlots('auto', currentProfileBuffer);
1781
+ switchTab('tab-general');
1833
1782
  }
1834
1783
 
1835
- function renderModelBrowser(models) {
1836
- const container = document.getElementById('model-browser');
1837
- const items = document.getElementById('model-items');
1838
- const countLabel = document.getElementById('model-count-label');
1839
-
1840
- container.style.display = 'block';
1841
- countLabel.innerText = `Available Models (${models.length})`;
1842
- items.innerHTML = '';
1843
-
1844
- if (models.length === 0) {
1845
- items.innerHTML = '<div style="color: var(--text-dim); font-size: 12px; padding: 6px;">No matching models found.</div>';
1846
- return;
1847
- }
1848
-
1849
- const currentFmt = document.getElementById('p-tool')?.value || 'auto';
1850
- const cfg = getActiveSlotConfig(currentFmt);
1851
-
1852
- for (const m of models) {
1853
- const row = document.createElement('div');
1854
- row.className = 'model-row';
1855
-
1856
- const idSpan = document.createElement('span');
1857
- idSpan.className = 'model-id';
1858
- idSpan.textContent = m;
1859
-
1860
- const btnGroup = document.createElement('div');
1861
- btnGroup.className = 'model-btn-group';
1862
-
1863
- cfg.slots.forEach(s => {
1864
- const btn = document.createElement('button');
1865
- btn.type = 'button';
1866
- btn.className = 'btn btn-outline btn-sm';
1867
- btn.textContent = s.id.charAt(0).toUpperCase() + s.id.slice(1);
1868
- btn.onclick = () => selectModel(s.id, m);
1869
- btnGroup.appendChild(btn);
1870
- });
1871
-
1872
- row.appendChild(idSpan);
1873
- row.appendChild(btnGroup);
1874
- items.appendChild(row);
1784
+ const TEMPLATES = {
1785
+ intact: {
1786
+ name: 'Intact Credential Proxy',
1787
+ baseURL: 'http://127.0.0.1:8080/v1',
1788
+ tool: 'auto',
1789
+ outFormat: 'openai-chat',
1790
+ mode: 'convert',
1791
+ thinkingMode: 'auto',
1792
+ defaultModels: { default: 'gpt-4o', sonnet: 'claude-3-7-sonnet', opus: 'claude-3-opus', haiku: 'claude-3-5-haiku' }
1793
+ },
1794
+ '9router': {
1795
+ name: '9Router Client Proxy',
1796
+ baseURL: 'http://127.0.0.1:20128/v1',
1797
+ tool: 'auto',
1798
+ outFormat: 'openai-chat',
1799
+ mode: 'hybrid',
1800
+ thinkingMode: 'auto',
1801
+ defaultModels: { default: 'gpt-4o', sonnet: 'claude-3-7-sonnet' }
1802
+ },
1803
+ openrouter: {
1804
+ name: 'OpenRouter Gateway',
1805
+ baseURL: 'https://openrouter.ai/api/v1',
1806
+ tool: 'auto',
1807
+ outFormat: 'openai-chat',
1808
+ mode: 'convert',
1809
+ thinkingMode: 'auto',
1810
+ defaultModels: { default: 'anthropic/claude-3.7-sonnet', sonnet: 'anthropic/claude-3.7-sonnet' }
1811
+ },
1812
+ vertex: {
1813
+ name: 'Google Vertex AI',
1814
+ baseURL: 'https://us-central1-aiplatform.googleapis.com/v1',
1815
+ tool: 'anthropic',
1816
+ outFormat: 'vertex',
1817
+ mode: 'convert',
1818
+ thinkingMode: 'auto',
1819
+ defaultModels: { sonnet: 'claude-3-7-sonnet@20250219' }
1820
+ },
1821
+ local: {
1822
+ name: 'Local Ollama / vLLM Server',
1823
+ baseURL: 'http://127.0.0.1:11434/v1',
1824
+ tool: 'auto',
1825
+ outFormat: 'openai-chat',
1826
+ mode: 'direct',
1827
+ thinkingMode: 'off',
1828
+ defaultModels: { default: 'qwen2.5-coder:32b' }
1875
1829
  }
1876
- }
1877
-
1878
- function filterModels() {
1879
- const q = (document.getElementById('model-filter').value || '').toLowerCase().trim();
1880
- const filtered = loadedModels.filter(m => m.toLowerCase().includes(q));
1881
- renderModelBrowser(filtered);
1882
- }
1830
+ };
1883
1831
 
1884
- function selectModel(slotId, modelId) {
1885
- const targetInput = document.getElementById('p-' + slotId);
1886
- if (targetInput) targetInput.value = modelId;
1887
- const is1M = /1m|gemini-3|glm-5|kimi|nemotron-3-ultra/i.test(modelId);
1888
- const chk1M = document.getElementById(`p-${slotId}-1m`);
1889
- if (chk1M && is1M) {
1890
- chk1M.checked = true;
1832
+ function applyTemplate(tName) {
1833
+ const t = TEMPLATES[tName];
1834
+ if (!t) return;
1835
+ if (creatingNew) {
1836
+ document.getElementById('p-key').value = tName;
1891
1837
  }
1892
- update1mWarnings();
1893
- formDirty = true;
1894
- showToast(`Assigned "${modelId}" to ${slotId.toUpperCase()}`);
1838
+ document.getElementById('p-name').value = t.name;
1839
+ document.getElementById('p-url').value = t.baseURL;
1840
+ document.getElementById('p-tool').value = t.tool;
1841
+ document.getElementById('p-outformat').value = t.outFormat;
1842
+ document.getElementById('p-mode').value = t.mode;
1843
+ document.getElementById('p-thinking-mode').value = t.thinkingMode;
1844
+ currentProfileBuffer = { ...currentProfileBuffer, ...t };
1845
+ renderModelSlots(t.tool || 'auto', t);
1846
+ showToast('Template "' + t.name + '" applied');
1895
1847
  }
1896
1848
 
1897
1849
  async function toggleProxy() {
1898
- const nextState = !isAnyTargetActive();
1899
- showToast(nextState ? 'Activating compatible routes...' : 'Restoring official endpoints...');
1850
+ const isAnyActive = Object.values(state.activeProfiles || (state.config?.activeProfiles || {})).some(Boolean);
1851
+ const action = isAnyActive ? 'deactivate' : 'activate';
1852
+ showToast((isAnyActive ? 'Switching to official endpoints...' : 'Activating compatible routes...'));
1900
1853
  try {
1901
1854
  const res = await api('/api/toggle', {
1902
1855
  method: 'POST',
1903
1856
  headers: { 'Content-Type': 'application/json' },
1904
- body: JSON.stringify({ enabled: nextState })
1857
+ body: JSON.stringify({ enabled: !isAnyActive })
1905
1858
  });
1906
1859
  const data = await res.json();
1907
1860
  if (data.success) {
1908
- showToast(nextState ? 'Compatible routes are active.' : 'Official endpoints restored.');
1861
+ showToast(isAnyActive ? 'Using official endpoints' : 'Gateway activated');
1862
+ await loadStatus();
1909
1863
  } else {
1910
- showToast(data.error || 'Failed to toggle gateway state', true);
1864
+ showToast(data.error || 'Failed to toggle routes', true);
1911
1865
  }
1912
- loadStatus();
1913
1866
  } catch (err) {
1914
- showToast('Failed to toggle gateway state', true);
1867
+ showToast('Network error toggling routes', true);
1915
1868
  }
1916
1869
  }
1917
1870
 
1918
1871
  async function saveProfile(e) {
1919
1872
  e.preventDefault();
1920
1873
  const key = document.getElementById('p-key').value.trim();
1921
- const outFormat = document.getElementById('p-outformat').value;
1922
- if (creatingNew && state.config?.profiles?.[key] && !confirm(`Provider "${key}" already exists. Overwrite it?`)) return;
1874
+ if (!key) return;
1923
1875
 
1924
1876
  collectCurrentSlotValues();
1925
1877
  const existing = state.config?.profiles?.[key] || {};
1926
- const mergedModels = { ...(existing.defaultModels || {}), ...(currentProfileBuffer?.defaultModels || {}) };
1927
- const merged1M = { ...(existing.model1M || {}), ...(currentProfileBuffer?.model1M || {}) };
1878
+ const keyVal = document.getElementById('p-key-val').value.trim();
1928
1879
 
1929
- const profile = {
1880
+ const profilePayload = {
1930
1881
  name: document.getElementById('p-name').value.trim(),
1931
- mode: document.getElementById('p-mode').value,
1932
- tool: ({ anthropic: 'claude', responses: 'codex' }[document.getElementById('p-tool').value]) || null,
1933
- thinkingMode: document.getElementById('p-thinking-mode').value || 'auto',
1934
1882
  baseURL: document.getElementById('p-url').value.trim(),
1935
- apiKey: document.getElementById('p-key-val').value.trim(),
1936
- defaultModels: mergedModels,
1937
- model1M: merged1M
1883
+ tool: document.getElementById('p-tool').value,
1884
+ outFormat: document.getElementById('p-outformat').value,
1885
+ mode: document.getElementById('p-mode').value,
1886
+ thinkingMode: document.getElementById('p-thinking-mode').value,
1887
+ defaultModels: { ...(existing.defaultModels || {}), ...(currentProfileBuffer?.defaultModels || {}) },
1888
+ model1M: { ...(existing.model1M || {}), ...(currentProfileBuffer?.model1M || {}) }
1938
1889
  };
1939
- profile.outFormat = outFormat;
1940
- collectCodexValues(profile);
1941
1890
 
1891
+ if (keyVal === MASKED_KEY) {
1892
+ profilePayload.apiKey = existing.apiKey || '';
1893
+ } else if (keyVal) {
1894
+ profilePayload.apiKey = keyVal;
1895
+ } else {
1896
+ profilePayload.apiKey = '';
1897
+ }
1898
+
1899
+ if (existing.codexRoles) profilePayload.codexRoles = existing.codexRoles;
1900
+ if (existing.publicModels) profilePayload.publicModels = existing.publicModels;
1901
+
1902
+ showToast('Saving profile "' + key + '"...');
1942
1903
  try {
1943
1904
  const res = await api('/api/save-profile', {
1944
1905
  method: 'POST',
1945
1906
  headers: { 'Content-Type': 'application/json' },
1946
- body: JSON.stringify({ key, profile })
1907
+ body: JSON.stringify({ key, profile: profilePayload })
1947
1908
  });
1948
1909
  const data = await res.json();
1949
1910
  if (data.success) {
1950
- showToast('Provider profile saved.');
1951
- currentlyEditingKey = key;
1952
- creatingNew = false;
1953
- formDirty = false;
1954
- formLoadedKey = null;
1955
- loadStatus();
1911
+ showToast('Profile "' + key + '" saved successfully');
1912
+ closeProfileModal();
1913
+ await loadStatus();
1956
1914
  } else {
1957
- showToast(data.error || 'Failed to save', true);
1915
+ showToast(data.error || 'Failed to save profile', true);
1958
1916
  }
1959
1917
  } catch (err) {
1960
- showToast('Error saving provider', true);
1918
+ showToast('Network error saving profile', true);
1961
1919
  }
1962
1920
  }
1963
1921
 
1964
1922
  async function deleteProfile() {
1965
- const key = document.getElementById('p-key').value;
1966
- if (!confirm(`Are you sure you want to delete provider "${key}"?`)) return;
1923
+ const key = document.getElementById('p-key').value.trim();
1924
+ if (!key || !confirm('Delete provider profile "' + key + '" permanently?')) return;
1967
1925
 
1926
+ showToast('Deleting profile "' + key + '"...');
1968
1927
  try {
1969
1928
  const res = await api('/api/delete-profile', {
1970
1929
  method: 'POST',
@@ -1973,20 +1932,17 @@
1973
1932
  });
1974
1933
  const data = await res.json();
1975
1934
  if (data.success) {
1976
- showToast('Provider deleted.');
1977
- currentlyEditingKey = '';
1978
- creatingNew = false;
1979
- formDirty = false;
1980
- loadStatus();
1935
+ showToast('Profile "' + key + '" deleted');
1936
+ closeProfileModal();
1937
+ await loadStatus();
1981
1938
  } else {
1982
- showToast(data.error || 'Failed to delete', true);
1939
+ showToast(data.error || 'Failed to delete profile', true);
1983
1940
  }
1984
1941
  } catch (err) {
1985
- showToast('Error deleting provider', true);
1942
+ showToast('Network error deleting profile', true);
1986
1943
  }
1987
1944
  }
1988
1945
 
1989
- // Modern Non-blocking Connection Tester
1990
1946
  async function testConnection() {
1991
1947
  const btn = document.getElementById('btn-test-conn');
1992
1948
  const box = document.getElementById('test-result');
@@ -2002,7 +1958,7 @@
2002
1958
  const currentFmt = document.getElementById('p-tool')?.value || 'auto';
2003
1959
  const cfg = getActiveSlotConfig(currentFmt);
2004
1960
  const primarySlotId = cfg.slots[0]?.id || 'default';
2005
- const modelInput = document.getElementById(`p-${primarySlotId}`) || document.getElementById('p-sonnet') || document.getElementById('p-opus');
1961
+ const modelInput = document.getElementById('p-' + primarySlotId) || document.getElementById('p-sonnet') || document.getElementById('p-opus');
2006
1962
  const testModel = (modelInput?.value || '').trim() || 'default';
2007
1963
 
2008
1964
  const res = await api('/api/test-upstream', {
@@ -2010,7 +1966,7 @@
2010
1966
  headers: { 'Content-Type': 'application/json' },
2011
1967
  body: JSON.stringify({
2012
1968
  baseURL: document.getElementById('p-url').value.trim(),
2013
- apiKey: document.getElementById('p-key-val').value.trim(),
1969
+ apiKey: document.getElementById('p-key-val').value.trim() === MASKED_KEY ? (state.config?.profiles?.[formLoadedKey]?.apiKey || '') : document.getElementById('p-key-val').value.trim(),
2014
1970
  mode: document.getElementById('p-mode').value,
2015
1971
  outFormat: document.getElementById('p-outformat').value,
2016
1972
  key: formLoadedKey,
@@ -2022,47 +1978,330 @@
2022
1978
 
2023
1979
  if (data.ok) {
2024
1980
  box.className = 'test-result-box success';
2025
- statusBadge.textContent = `✓ HTTP 200 OK (${data.outFormat || 'upstream'})`;
2026
- latencyBadge.textContent = `Latency: ${data.latency}ms`;
2027
- sampleBox.textContent = `Sample Output: ${data.sample}`;
2028
- showToast(`Connection successful (${data.latency}ms)`);
1981
+ statusBadge.className = 'badge ok';
1982
+ statusBadge.textContent = '✓ HTTP 200 OK (' + (data.outFormat || 'upstream') + ')';
1983
+ latencyBadge.textContent = 'Latency: ' + data.latency + 'ms';
1984
+ sampleBox.textContent = 'Sample Output: ' + data.sample;
1985
+ showToast('Connection test passed (' + data.latency + 'ms)');
2029
1986
  } else {
2030
1987
  box.className = 'test-result-box error';
2031
- statusBadge.textContent = `✗ Failed (${data.status || 'Error'})`;
1988
+ statusBadge.className = 'badge err';
1989
+ statusBadge.textContent = '✗ Failed (' + (data.status || 'Error') + ')';
2032
1990
  latencyBadge.textContent = '';
2033
- sampleBox.textContent = `Error details: ${data.error || 'Connection failed'}`;
2034
- showToast('Upstream test failed', true);
1991
+ sampleBox.textContent = 'Error: ' + (data.error || 'Upstream connection failed');
1992
+ showToast('Upstream connection failed', true);
2035
1993
  }
2036
1994
  } catch (err) {
2037
1995
  box.style.display = 'block';
2038
1996
  box.className = 'test-result-box error';
1997
+ statusBadge.className = 'badge err';
2039
1998
  statusBadge.textContent = '✗ Network Error';
2040
1999
  latencyBadge.textContent = '';
2041
2000
  sampleBox.textContent = err.message;
2042
2001
  showToast('Network error testing upstream', true);
2043
2002
  } finally {
2044
- btn.innerText = 'Test connection';
2003
+ btn.innerText = 'Test Connection';
2045
2004
  btn.disabled = false;
2046
2005
  }
2047
2006
  }
2048
2007
 
2049
- let logRefreshInterval = null;
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
+
2036
+ async function fetchModelList() {
2037
+ const baseURL = document.getElementById('p-url').value.trim();
2038
+ const apiKeyVal = document.getElementById('p-key-val').value.trim();
2039
+ const apiKey = (apiKeyVal === MASKED_KEY) ? (state.config?.profiles?.[formLoadedKey]?.apiKey || '') : apiKeyVal;
2040
+ const source = modelsSourceKey();
2041
+ const request = ++modelsRequest;
2050
2042
 
2051
- function toggleLogAutoRefresh(enabled) {
2052
- if (logRefreshInterval) {
2053
- clearInterval(logRefreshInterval);
2054
- logRefreshInterval = null;
2043
+ if (!baseURL) {
2044
+ loadedModels = [];
2045
+ modelsLoadedFor = '';
2046
+ setModelsStatus('idle', 'Enter a Base URL on the General tab to list models');
2047
+ return;
2055
2048
  }
2056
- if (enabled) {
2057
- logRefreshInterval = setInterval(() => {
2058
- const tab = document.getElementById('tab-inspector');
2059
- if (tab && tab.classList.contains('active')) {
2060
- loadInspectorLogs();
2061
- }
2062
- }, 2500);
2049
+
2050
+ setModelsStatus('loading', 'Loading models...');
2051
+ try {
2052
+ const res = await api('/api/fetch-models', {
2053
+ method: 'POST',
2054
+ headers: { 'Content-Type': 'application/json' },
2055
+ body: JSON.stringify({ baseURL, apiKey, key: formLoadedKey })
2056
+ });
2057
+ const data = await res.json();
2058
+ if (request !== modelsRequest) return; // a newer load started while this one waited
2059
+ if (data.ok && Array.isArray(data.models)) {
2060
+ loadedModels = data.models;
2061
+ modelsLoadedFor = source;
2062
+ setModelsStatus('ready', loadedModels.length + (loadedModels.length === 1 ? ' model' : ' models'), 'Reload');
2063
+ } else {
2064
+ loadedModels = [];
2065
+ modelsLoadedFor = '';
2066
+ setModelsStatus('error', 'Models not loaded: ' + modelsError(data.error), 'Retry');
2067
+ }
2068
+ } catch (err) {
2069
+ if (request !== modelsRequest) return;
2070
+ loadedModels = [];
2071
+ modelsLoadedFor = '';
2072
+ setModelsStatus('error', 'Models not loaded: ' + err.message, 'Retry');
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
+ });
2096
+ }
2097
+
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;
2152
+ }
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');
2182
+ }
2183
+ }
2184
+
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);
2218
+ }
2219
+ }
2220
+
2221
+ // Dynamic Model Catalog View
2222
+ async function renderCatalogView() {
2223
+ const claudeContainer = document.getElementById('catalog-claude-items');
2224
+ const codexContainer = document.getElementById('catalog-codex-items');
2225
+ if (!claudeContainer || !codexContainer) return;
2226
+
2227
+ try {
2228
+ const res = await api('/api/catalog');
2229
+ if (!res.ok) {
2230
+ showToast('Failed to load model catalog', true);
2231
+ return;
2232
+ }
2233
+ const catalog = await res.json();
2234
+ cachedCatalog = catalog;
2235
+
2236
+ const claudeModels = Array.isArray(catalog.claude?.models) ? catalog.claude.models : [];
2237
+ const codexModels = Array.isArray(catalog.codex?.models) ? catalog.codex.models : [];
2238
+
2239
+ const claudeTiers = new Set(claudeModels.map(m => m.tier));
2240
+ const codexRoles = new Set(codexModels.map(m => m.role));
2241
+
2242
+ const ctModels = document.getElementById('ct-models');
2243
+ if (ctModels) ctModels.textContent = claudeModels.length + codexModels.length;
2244
+
2245
+ document.getElementById('badge-claude-count').textContent = claudeModels.length + ' models · ' + claudeTiers.size + ' tiers';
2246
+ document.getElementById('badge-codex-count').textContent = codexModels.length + ' models · ' + codexRoles.size + ' roles';
2247
+
2248
+ claudeContainer.innerHTML = claudeModels.map(m => `
2249
+ <div class="row" style="padding:0.6rem 0.8rem; background:var(--card2); border-radius:8px;">
2250
+ <div class="who">
2251
+ <div style="display:flex; align-items:center; gap:0.45rem;">
2252
+ <span class="badge accent" style="text-transform:uppercase; font-size:0.7rem;">${escapeHtml(m.tier)}</span>
2253
+ <span class="l mono" style="font-size:0.85rem; font-weight:600;">${escapeHtml(m.id)}</span>
2254
+ </div>
2255
+ </div>
2256
+ <button type="button" class="btn btn-outline btn-sm" onclick="navigator.clipboard.writeText('${escapeHtml(m.id)}'); showToast('Copied ${escapeHtml(m.id)}');">Copy</button>
2257
+ </div>
2258
+ `).join('') || '<div class="hint">No Claude tiers discovered</div>';
2259
+
2260
+ codexContainer.innerHTML = codexModels.map(m => `
2261
+ <div class="row" style="padding:0.6rem 0.8rem; background:var(--card2); border-radius:8px;">
2262
+ <div class="who">
2263
+ <div style="display:flex; align-items:center; gap:0.45rem;">
2264
+ <span class="badge ok" style="text-transform:capitalize; font-size:0.7rem;">${escapeHtml(m.role)}</span>
2265
+ <span class="l mono" style="font-size:0.85rem; font-weight:600;">${escapeHtml(m.id)}</span>
2266
+ </div>
2267
+ </div>
2268
+ <button type="button" class="btn btn-outline btn-sm" onclick="navigator.clipboard.writeText('${escapeHtml(m.id)}'); showToast('Copied ${escapeHtml(m.id)}');">Copy</button>
2269
+ </div>
2270
+ `).join('') || '<div class="hint">No Codex roles discovered</div>';
2271
+ } catch (err) {
2272
+ showToast('Error loading model catalog', true);
2063
2273
  }
2064
2274
  }
2065
2275
 
2276
+ function filterCatalogView(q) {
2277
+ const term = (q || '').toLowerCase();
2278
+ document.querySelectorAll('#catalog-claude-items .row, #catalog-codex-items .row').forEach(row => {
2279
+ const text = row.textContent.toLowerCase();
2280
+ row.style.display = text.includes(term) ? 'flex' : 'none';
2281
+ });
2282
+ }
2283
+
2284
+ async function refreshCatalogUpstream() {
2285
+ const btn = document.getElementById('btn-sync-catalog');
2286
+ if (btn) { btn.disabled = true; btn.textContent = 'Syncing...'; }
2287
+ showToast('Refreshing model catalog from upstream...');
2288
+ try {
2289
+ const res = await api('/api/catalog/refresh', { method: 'POST' });
2290
+ const data = await res.json();
2291
+ if (data.success) {
2292
+ showToast('Model catalog synchronized');
2293
+ await renderCatalogView();
2294
+ } else {
2295
+ showToast(data.error || 'Failed to refresh catalog', true);
2296
+ }
2297
+ } catch (err) {
2298
+ showToast('Network error syncing catalog', true);
2299
+ } finally {
2300
+ if (btn) { btn.disabled = false; btn.textContent = '⚡ Sync Models with Upstream'; }
2301
+ }
2302
+ }
2303
+
2304
+ // Request Inspector Logs View
2066
2305
  async function loadInspectorLogs() {
2067
2306
  try {
2068
2307
  const res = await api('/api/logs');
@@ -2070,49 +2309,52 @@
2070
2309
  const container = document.getElementById('inspector-logs-container');
2071
2310
  if (!container) return;
2072
2311
 
2312
+ lastLogsCount = (data.logs || []).length;
2313
+ const ctLogs = document.getElementById('ct-logs');
2314
+ if (ctLogs) ctLogs.textContent = lastLogsCount;
2315
+
2073
2316
  if (!data.logs || data.logs.length === 0) {
2074
- container.innerHTML = '<div style="color: var(--text-dim); font-size: 12px; text-align: center; padding: 24px; background: var(--surface-well); border-radius: var(--radius-sm); border: 1px solid var(--border);">No requests recorded yet. Make a request via Claude Code, Codex, or curl.</div>';
2317
+ container.innerHTML = '<div class="card pad hint" style="text-align:center; padding:2rem 1rem;">No requests recorded yet in the ring buffer. Make a request via Claude Code or Codex.</div>';
2075
2318
  return;
2076
2319
  }
2077
2320
 
2078
2321
  container.innerHTML = '';
2079
2322
  for (const l of data.logs) {
2080
- const row = document.createElement('div');
2081
- row.className = 'log-item';
2323
+ const item = document.createElement('div');
2324
+ item.className = 'log-item';
2082
2325
  const isErr = l.status >= 400;
2083
- const statusClass = isErr ? 'badge-convert' : 'badge-direct';
2084
2326
 
2085
- let details = '';
2327
+ let detailsHtml = '';
2086
2328
  if (l.requestPreview) {
2087
- details += `<div><strong style="color: var(--accent);">Prompt:</strong> ${escapeHtml(l.requestPreview)}</div>`;
2329
+ detailsHtml += '<div><span class="hint" style="font-weight:600; color:var(--accent);">Prompt:</span><pre>' + escapeHtml(l.requestPreview) + '</pre></div>';
2088
2330
  }
2089
2331
  if (l.thinkingChars > 0) {
2090
- details += `<div><strong style="color: #fbbf24;">Thinking:</strong> ${l.thinkingChars} chars extracted</div>`;
2332
+ detailsHtml += '<div><span class="hint" style="font-weight:600; color:var(--warn);">Thinking:</span><pre>' + l.thinkingChars + ' characters extracted</pre></div>';
2091
2333
  }
2092
2334
  if (l.responsePreview) {
2093
- details += `<div><strong style="color: #34d399;">Output:</strong> ${escapeHtml(l.responsePreview)}</div>`;
2335
+ detailsHtml += '<div><span class="hint" style="font-weight:600; color:var(--ok);">Response Output:</span><pre>' + escapeHtml(l.responsePreview) + '</pre></div>';
2094
2336
  }
2095
2337
  if (l.error) {
2096
- details += `<div><strong style="color: #f87171;">Error:</strong> ${escapeHtml(l.error)}</div>`;
2338
+ detailsHtml += '<div><span class="hint" style="font-weight:600; color:var(--err);">Error:</span><pre>' + escapeHtml(l.error) + '</pre></div>';
2097
2339
  }
2098
2340
 
2099
- row.innerHTML = `
2100
- <div class="log-header">
2101
- <div class="log-badges">
2102
- <span class="badge ${statusClass}">${l.status}</span>
2103
- <span style="font-family: var(--font-mono); font-size: 11px; color: var(--text);">${escapeHtml(l.clientFormat)} ➔ ${escapeHtml(l.outFormat)}</span>
2104
- <span style="font-size: 11px; color: var(--text-muted); font-family: var(--font-mono);">[${escapeHtml(l.model)}]</span>
2105
- <span style="font-size: 10px; color: var(--text-dim);">${escapeHtml(l.timestamp)}</span>
2341
+ item.innerHTML = `
2342
+ <div style="display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:0.5rem;">
2343
+ <div style="display:flex; align-items:center; gap:0.5rem; flex-wrap:wrap;">
2344
+ <span class="mono faint" style="font-size:0.75rem;">${escapeHtml(l.timestamp)}</span>
2345
+ <span class="badge ${isErr ? 'err' : 'ok'}">${escapeHtml(l.status)} ${escapeHtml(l.method || 'POST')}</span>
2346
+ <strong class="mono" style="font-size:0.85rem; color:var(--fg);">${escapeHtml(l.path || '/v1/messages')}</strong>
2347
+ <span class="badge accent">${escapeHtml(l.client || 'Client')}</span>
2106
2348
  </div>
2107
- <div style="display: flex; gap: 8px; font-size: 11px; color: var(--text-dim); font-family: var(--font-mono);">
2108
- <span>${l.duration} ms</span>
2109
- <span>in ${l.tokens?.prompt || 0} tokens</span>
2110
- <span>out ${l.tokens?.completion || 0} tokens</span>
2349
+ <div style="display:flex; align-items:center; gap:0.5rem;">
2350
+ ${l.latency ? '<span class="hint mono">' + escapeHtml(l.latency) + 'ms</span>' : ''}
2351
+ ${l.profile ? '<span class="badge">→ ' + escapeHtml(l.profile) + '</span>' : ''}
2111
2352
  </div>
2112
2353
  </div>
2113
- ${details ? `<div class="log-body">${details}</div>` : ''}
2354
+ ${detailsHtml ? '<details><summary>View Payload Preview</summary>' + detailsHtml + '</details>' : ''}
2114
2355
  `;
2115
- container.appendChild(row);
2356
+
2357
+ container.appendChild(item);
2116
2358
  }
2117
2359
  } catch (err) {
2118
2360
  console.error('Failed to load logs', err);
@@ -2122,28 +2364,65 @@
2122
2364
  async function clearInspectorLogs() {
2123
2365
  try {
2124
2366
  const res = await api('/api/logs/clear', { method: 'POST' });
2125
- loadInspectorLogs();
2126
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
2127
- showToast('Logs cleared');
2367
+ const data = await res.json();
2368
+ if (data.success) {
2369
+ showToast('Request inspector logs cleared');
2370
+ await loadInspectorLogs();
2371
+ }
2128
2372
  } catch (err) {
2129
- showToast('Failed to clear logs', true);
2373
+ showToast('Error clearing logs', true);
2130
2374
  }
2131
2375
  }
2132
2376
 
2133
- // Track form edits so status refreshes don't overwrite them.
2134
- const markDirty = (ev) => {
2135
- if (ev.target.id !== 'chk-auto-refresh-logs' && ev.target.id !== 'model-filter') formDirty = true;
2136
- };
2137
- document.getElementById('profile-form').addEventListener('input', markDirty);
2138
- document.getElementById('profile-form').addEventListener('change', markDirty);
2377
+ function toggleLogAutoRefresh() {
2378
+ const chk = document.getElementById('chk-auto-refresh-logs');
2379
+ if (chk && chk.checked) {
2380
+ clearInterval(logRefreshTimer);
2381
+ logRefreshTimer = setInterval(() => {
2382
+ if (window.location.hash.startsWith('#/logs')) {
2383
+ loadInspectorLogs();
2384
+ }
2385
+ }, 3000);
2386
+ } else {
2387
+ clearInterval(logRefreshTimer);
2388
+ logRefreshTimer = null;
2389
+ }
2390
+ }
2139
2391
 
2140
- toggleLogAutoRefresh(true);
2141
- try {
2142
- applyTheme(localStorage.getItem('lsw-theme') || document.documentElement.dataset.theme || 'dark');
2143
- } catch (e) {
2144
- applyTheme(document.documentElement.dataset.theme || 'dark');
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));
2145
2408
  }
2146
- loadStatus();
2409
+
2410
+ document.addEventListener('DOMContentLoaded', () => {
2411
+ // Setup initial theme buttons state
2412
+ const currentTheme = document.documentElement.dataset.theme || 'dark';
2413
+ document.getElementById('btn-theme-dark')?.setAttribute('aria-pressed', currentTheme === 'dark');
2414
+ document.getElementById('btn-theme-light')?.setAttribute('aria-pressed', currentTheme === 'light');
2415
+
2416
+ // Setup log auto-refresh
2417
+ toggleLogAutoRefresh();
2418
+
2419
+ // Trigger initial route
2420
+ handleRoute();
2421
+
2422
+ // Load initial state from edge gateway
2423
+ loadStatus();
2424
+ loadVersion();
2425
+ });
2147
2426
  </script>
2148
2427
  </body>
2149
- </html>
2428
+ </html>