llm-switcher 1.2.0 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ui.html CHANGED
@@ -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,1078 @@
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
+ aside nav { display: grid; gap: 0.25rem; }
181
+ aside a.nav-link {
182
+ display: flex;
183
+ align-items: center;
184
+ gap: 0.65rem;
185
+ padding: 0.5rem 0.7rem;
186
+ border-radius: 8px;
187
+ color: var(--muted);
188
+ text-decoration: none;
189
+ font-size: 0.88rem;
190
+ font-weight: 500;
191
+ transition: background 0.15s, color 0.15s;
145
192
  }
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);
193
+ aside a.nav-link:hover { color: var(--fg); background: var(--card2); }
194
+ aside a.nav-link[aria-current="page"] {
195
+ background: var(--accent-soft);
196
+ color: var(--accent);
197
+ font-weight: 600;
167
198
  }
168
-
169
- .btn-outline {
170
- background: var(--surface);
171
- color: var(--text);
172
- border-color: var(--border-strong);
199
+ aside a.nav-link svg { width: 18px; height: 18px; flex: none; }
200
+ aside a.nav-link .ct {
201
+ margin-left: auto;
202
+ font-size: 0.72rem;
203
+ color: var(--faint);
204
+ font-variant-numeric: tabular-nums;
205
+ padding: 0.1rem 0.4rem;
206
+ border-radius: 999px;
207
+ background: var(--card2);
173
208
  }
174
- .btn-outline:hover:not(:disabled) {
175
- background: var(--surface-elevated);
176
- border-color: var(--border-strong);
209
+ aside a.nav-link .ct:empty { display: none; }
210
+ aside a.nav-link[aria-current="page"] .ct {
211
+ background: color-mix(in srgb, var(--accent) 20%, transparent);
212
+ color: var(--accent);
177
213
  }
214
+ aside .spacer { flex: 1; }
178
215
 
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);
216
+ .prefs {
217
+ display: flex;
218
+ flex-direction: column;
219
+ gap: 0.65rem;
220
+ padding: 16px 14px;
221
+ border-top: 1px solid var(--line);
222
+ margin-top: 0.5rem;
186
223
  }
187
-
188
- .btn-success {
189
- background: var(--success-muted);
190
- color: var(--success);
191
- border-color: rgba(16, 185, 129, 0.25);
224
+ .seg {
225
+ display: inline-flex;
226
+ background: var(--bg);
227
+ border: 1px solid var(--line);
228
+ border-radius: 8px;
229
+ padding: 4px;
230
+ gap: 3px;
231
+ }
232
+ .seg button {
233
+ font: inherit;
234
+ min-height: 24px;
235
+ padding: 0.15rem 0.6rem;
236
+ font-size: 0.75rem;
237
+ background: transparent;
238
+ color: var(--muted);
239
+ border: 0;
240
+ border-radius: 6px;
241
+ cursor: pointer;
192
242
  }
193
- .btn-success:hover:not(:disabled) {
194
- background: rgba(16, 185, 129, 0.2);
243
+ .seg button:hover { color: var(--fg); }
244
+ .seg button[aria-pressed="true"] {
245
+ background: var(--accent-soft);
246
+ color: var(--accent);
247
+ font-weight: 600;
195
248
  }
196
249
 
197
- /* Telemetry Status Banner */
198
- .status-banner {
250
+ /* Page Area */
251
+ .page {
252
+ min-width: 0;
199
253
  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;
254
+ flex-direction: column;
255
+ min-height: 100vh;
256
+ }
257
+ .ph {
213
258
  display: flex;
214
259
  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;
260
+ justify-content: space-between;
261
+ gap: 0.75rem;
262
+ padding: 1.1rem 2rem;
263
+ border-bottom: 1px solid var(--line);
264
+ min-height: 4.5rem;
265
+ flex-wrap: wrap;
235
266
  }
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;
267
+ .title-box h1 { font-size: 1.25rem; margin: 0; font-weight: 650; }
268
+ .title-box p { margin: 0.15rem 0 0; color: var(--muted); font-size: 0.85rem; }
269
+ .ph-actions { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; }
270
+
271
+ .content {
272
+ padding: 1.5rem 2rem 3rem;
273
+ width: 100%;
274
+ display: grid;
275
+ gap: 1.5rem;
276
+ align-content: start;
242
277
  }
278
+ .content > * { min-width: 0; }
243
279
 
244
- /* Section Headers */
245
- .section-header {
280
+ /* Cards & Grids */
281
+ .sec { display: grid; gap: 0.85rem; }
282
+ .sech { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; flex-wrap: wrap; }
283
+ .card { background: var(--card); border: 1px solid var(--line); border-radius: 12px; }
284
+ .card.pad { padding: 1.1rem 1.25rem; }
285
+ .cardh {
246
286
  display: flex;
247
- justify-content: space-between;
248
287
  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;
288
+ gap: 0.75rem;
289
+ padding: 0.9rem 1.25rem;
290
+ border-bottom: 1px solid var(--line);
291
+ flex-wrap: wrap;
264
292
  }
293
+ .cardh .grow { min-width: min(12rem, 100%); }
294
+ .grow { flex: 1; }
295
+ .muted { color: var(--muted); }
296
+ .faint { color: var(--faint); }
297
+ .small { font-size: 0.8rem; }
298
+ code, .mono { font-family: var(--font-mono); font-size: 0.82rem; }
299
+ code { color: var(--code); }
265
300
 
266
- /* Active Multi-CLI Slots (Rackmount Grid) */
267
- .cli-slots-grid {
301
+ /* Provider Grid (Intact Style) */
302
+ .pgrid {
268
303
  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;
304
+ grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
305
+ gap: 0.85rem;
306
+ }
307
+ .pcard {
278
308
  display: flex;
279
309
  flex-direction: column;
280
- gap: 10px;
310
+ gap: 0.75rem;
311
+ padding: 1rem 1.15rem;
312
+ background: var(--card);
313
+ border: 1px solid var(--line);
314
+ border-radius: 11px;
315
+ color: inherit;
316
+ min-width: 0;
317
+ cursor: pointer;
281
318
  transition: border-color 0.15s, background 0.15s;
282
319
  }
283
- .cli-slot-card.active {
284
- border-color: rgba(56, 189, 248, 0.35);
285
- background: var(--surface);
286
- }
287
- .cli-slot-header {
320
+ .pcard:hover { border-color: var(--accent); }
321
+ .pcard.active { border-color: color-mix(in srgb, var(--ok) 50%, transparent); }
322
+ .pcard .top {
288
323
  display: flex;
289
- justify-content: space-between;
290
- align-items: center;
291
- }
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
324
  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;
325
+ gap: 0.75rem;
326
+ min-width: 0;
311
327
  }
312
- .cli-slot-name {
313
- font-size: 12px;
328
+ .pcard .nm {
314
329
  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;
330
+ font-size: 0.95rem;
343
331
  overflow: hidden;
344
- clip: rect(0, 0, 0, 0);
332
+ text-overflow: ellipsis;
345
333
  white-space: nowrap;
346
- border: 0;
347
334
  }
348
335
 
349
- /* Profile Cards Grid */
350
- .profiles-grid {
336
+ .av {
337
+ width: 36px;
338
+ height: 36px;
339
+ border-radius: 9px;
351
340
  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);
341
+ place-items: center;
342
+ font-weight: 700;
343
+ font-size: 0.85rem;
344
+ color: #ffffff;
345
+ flex: none;
346
+ }
347
+ .av.sm { width: 24px; height: 24px; border-radius: 6px; font-size: 0.65rem; }
348
+ .av.logo {
349
+ background: #ffffff;
350
+ padding: 5px;
351
+ box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
352
+ }
353
+ .av.logo.sm { padding: 3px; }
354
+ .av.logo img {
355
+ width: 100%;
356
+ height: 100%;
357
+ object-fit: contain;
358
+ display: block;
374
359
  }
375
360
 
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;
361
+ .status {
362
+ display: inline-flex;
385
363
  align-items: center;
386
- gap: 6px;
387
- min-width: 0;
388
- flex-wrap: wrap;
364
+ gap: 0.35rem;
365
+ font-size: 0.74rem;
366
+ color: var(--faint);
389
367
  }
390
- .card-name {
391
- font-size: 13px;
392
- font-weight: 600;
393
- color: var(--text);
368
+ .status.on { color: var(--ok); font-weight: 500; }
369
+ .status.on::before {
370
+ content: "";
371
+ width: 6px;
372
+ height: 6px;
373
+ border-radius: 50%;
374
+ background: currentColor;
394
375
  }
395
376
 
396
- /* Badges */
397
377
  .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;
378
+ display: inline-flex;
379
+ align-items: center;
380
+ gap: 0.25rem;
381
+ font-size: 0.72rem;
382
+ font-weight: 500;
383
+ border-radius: 6px;
384
+ padding: 0.1rem 0.45rem;
385
+ background: var(--card2);
386
+ color: var(--muted);
404
387
  white-space: nowrap;
405
388
  border: 1px solid transparent;
406
389
  }
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;
390
+ .badge.ok { background: var(--ok-soft); color: var(--ok); }
391
+ .badge.warn { background: var(--warn-soft); color: var(--warn); }
392
+ .badge.accent { background: var(--accent-soft); color: var(--accent); }
393
+ .badge.err { background: var(--err-soft); color: var(--err); }
394
+ .badge.outline { border-color: var(--line); }
395
+
396
+ .row {
466
397
  display: flex;
467
- gap: 10px;
468
- align-items: flex-start;
398
+ align-items: center;
399
+ gap: 0.85rem;
400
+ padding: 0.85rem 1.25rem;
401
+ border-bottom: 1px solid var(--line);
469
402
  }
403
+ .row:last-child { border-bottom: 0; }
404
+ .row .who { flex: 1; min-width: 0; }
405
+ .row .who .l { font-weight: 600; font-size: 0.92rem; }
406
+ .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
407
 
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 {
408
+ .chips { display: flex; flex-direction: column; gap: 0.35rem; min-width: 0; }
409
+ .chip {
410
+ display: grid;
411
+ grid-template-columns: minmax(0, 1fr) auto;
412
+ align-items: center;
413
+ gap: 0.1rem 0.5rem;
414
+ min-width: 0;
415
+ background: var(--card2);
416
+ border: 1px solid var(--line);
417
+ border-radius: 6px;
418
+ padding: 0.35rem 0.55rem;
419
+ font-size: 0.75rem;
420
+ color: var(--fg);
421
+ }
422
+ .chip .lbl { grid-column: 1 / -1; font-size: 0.7rem; overflow-wrap: anywhere; }
423
+ .chip .val { font-family: var(--font-mono); overflow-wrap: anywhere; }
424
+ .chip .b1m { font-weight: 600; font-size: 0.7rem; }
425
+
426
+ .copyline {
493
427
  display: flex;
494
- justify-content: space-between;
495
428
  align-items: center;
496
- font-size: 11px;
497
- margin-top: 6px;
429
+ gap: 0.5rem;
430
+ background: var(--bg);
431
+ border: 1px solid var(--line);
432
+ border-radius: 8px;
433
+ padding: 0.35rem 0.5rem 0.35rem 0.75rem;
434
+ min-width: 0;
498
435
  }
436
+ .copyline code { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.8rem; }
499
437
 
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;
438
+ .stats {
439
+ display: grid;
440
+ grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
441
+ gap: 0.85rem;
536
442
  }
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;
443
+ .stat {
444
+ background: var(--card);
445
+ border: 1px solid var(--line);
446
+ border-radius: 12px;
447
+ padding: 1rem 1.15rem;
545
448
  }
449
+ .stat .k { color: var(--muted); font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.04em; }
450
+ .stat .v { font-size: 1.5rem; font-weight: 700; margin-top: 0.2rem; }
546
451
 
547
- /* Segmented Tab Bar */
548
- .tab-bar {
452
+ /* Buttons */
453
+ button, .btn {
454
+ font: inherit;
455
+ min-height: 34px;
456
+ padding: 0.35rem 0.85rem;
457
+ border: 0;
458
+ border-radius: 8px;
459
+ cursor: pointer;
549
460
  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;
461
+ align-items: center;
462
+ gap: 0.4rem;
463
+ justify-content: center;
464
+ text-decoration: none;
465
+ font-size: 0.84rem;
564
466
  font-weight: 500;
565
- border-radius: var(--radius-sm);
566
- cursor: pointer;
567
- transition: background 0.15s, border-color 0.15s, color 0.15s;
467
+ transition: filter 0.15s, background 0.15s, color 0.15s, border-color 0.15s;
568
468
  }
569
- .tab-btn:hover { color: var(--text); }
570
- .tab-btn.active {
571
- color: var(--text);
572
- background: var(--surface-elevated);
469
+ button.primary, .btn.btn-primary {
470
+ background: var(--accent-strong);
471
+ color: var(--accent-fg);
573
472
  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
473
  }
585
- .form-group {
586
- display: flex;
587
- flex-direction: column;
588
- gap: 6px;
589
- min-width: 0;
590
- }
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;
474
+ button.primary:hover, .btn.btn-primary:hover { filter: brightness(1.1); }
475
+ button.ghost, .btn.btn-outline {
476
+ background: transparent;
477
+ color: var(--fg);
478
+ border: 1px solid var(--line);
479
+ }
480
+ button.ghost:hover, .btn.btn-outline:hover {
481
+ background: var(--card2);
482
+ border-color: var(--faint);
483
+ }
484
+ button.plain { background: transparent; color: var(--muted); }
485
+ button.plain:hover { color: var(--fg); background: var(--card2); }
486
+ button.danger, .btn.btn-danger {
487
+ background: var(--err-soft);
488
+ color: var(--err);
489
+ border: 1px solid color-mix(in srgb, var(--err) 30%, transparent);
490
+ }
491
+ button.danger:hover, .btn.btn-danger:hover {
492
+ background: var(--err);
493
+ color: #ffffff;
494
+ }
495
+ button.icon { min-height: 28px; min-width: 28px; padding: 0.2rem; background: transparent; color: var(--muted); border-radius: 6px; }
496
+ button.icon:hover { color: var(--fg); background: var(--card2); }
497
+ button:disabled, .btn:disabled { opacity: 0.4; cursor: not-allowed; pointer-events: none; }
498
+ .btn-sm { min-height: 26px; padding: 0.2rem 0.6rem; font-size: 0.78rem; border-radius: 6px; }
499
+
500
+ /* Inputs */
501
+ input, select, textarea {
502
+ font: inherit;
503
+ padding: 0.45rem 0.7rem;
504
+ border: 1px solid var(--line);
505
+ border-radius: 8px;
506
+ background: var(--bg);
507
+ color: var(--fg);
508
+ min-height: 36px;
611
509
  width: 100%;
612
- max-width: 100%;
613
- min-width: 0;
510
+ font-size: 0.85rem;
614
511
  }
615
- input:focus, select:focus {
616
- border-color: var(--border-active);
617
- }
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 {
512
+ input:focus, select:focus, textarea:focus, button:focus-visible, a:focus-visible {
513
+ outline: none;
514
+ border-color: var(--accent);
515
+ box-shadow: 0 0 0 3px var(--accent-soft);
516
+ }
517
+ label.f { display: grid; gap: 0.35rem; font-size: 0.82rem; font-weight: 500; }
518
+ .hint { font-size: 0.75rem; color: var(--faint); font-weight: 400; }
519
+ .search { position: relative; max-width: 18rem; width: 100%; }
520
+ .search svg { position: absolute; left: 0.65rem; top: 50%; transform: translateY(-50%); color: var(--faint); width: 15px; height: 15px; pointer-events: none; }
521
+ .search input { padding-left: 2rem; min-height: 32px; }
522
+
523
+ /* Switch */
524
+ .switch { position: relative; width: 36px; height: 20px; flex: none; display: inline-block; }
525
+ .switch input { position: absolute; opacity: 0; inset: 0; margin: 0; cursor: pointer; min-height: 0; }
526
+ .switch span { position: absolute; inset: 0; border-radius: 999px; background: var(--line); transition: background 0.15s; pointer-events: none; }
527
+ .switch span::after { content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: #ffffff; transition: transform 0.15s; }
528
+ .switch input:checked + span { background: var(--accent-strong); }
529
+ .switch input:checked + span::after { transform: translateX(16px); }
530
+
531
+ /* Modal */
532
+ .overlay {
533
+ position: fixed;
534
+ inset: 0;
535
+ background: rgba(0, 0, 0, 0.65);
536
+ backdrop-filter: blur(3px);
537
+ display: grid;
538
+ place-items: center;
539
+ padding: 1rem;
540
+ z-index: 50;
541
+ }
542
+ .modal {
543
+ background: var(--card);
544
+ border: 1px solid var(--line);
545
+ border-radius: 14px;
546
+ box-shadow: var(--shadow);
547
+ width: min(38rem, 100%);
548
+ max-height: 90vh;
640
549
  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;
550
+ flex-direction: column;
551
+ overflow: hidden;
691
552
  }
692
- .model-browser-header {
553
+ .mh {
693
554
  display: flex;
694
555
  justify-content: space-between;
695
556
  align-items: center;
696
- margin-bottom: 10px;
697
- gap: 10px;
698
- flex-wrap: wrap;
557
+ padding: 1rem 1.25rem;
558
+ border-bottom: 1px solid var(--line);
699
559
  }
700
- .model-list-scroll {
701
- max-height: 220px;
560
+ .mh strong { font-size: 1.05rem; }
561
+ .mb {
562
+ padding: 1.15rem 1.25rem;
702
563
  overflow-y: auto;
703
564
  display: flex;
704
565
  flex-direction: column;
705
- gap: 4px;
706
- padding-right: 4px;
566
+ gap: 1rem;
707
567
  }
708
- .model-row {
568
+ .mf {
709
569
  display: flex;
710
570
  justify-content: space-between;
711
571
  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;
572
+ padding: 0.85rem 1.25rem;
573
+ border-top: 1px solid var(--line);
574
+ background: var(--card2);
575
+ }
576
+
577
+ /* Tabs (Intact style) */
578
+ .tabs {
579
+ display: flex;
580
+ gap: 0.25rem;
581
+ box-shadow: inset 0 -1px 0 var(--line);
582
+ overflow-x: auto;
583
+ overflow-y: hidden;
584
+ scrollbar-width: none;
585
+ }
586
+ .tabs::-webkit-scrollbar { display: none; }
587
+ .tabs button.tab-btn {
588
+ padding: 0.55rem 0.95rem;
589
+ color: var(--muted);
590
+ background: transparent;
591
+ border: 0;
592
+ border-bottom: 2px solid transparent;
593
+ white-space: nowrap;
594
+ font-weight: 500;
595
+ font-size: 0.85rem;
596
+ cursor: pointer;
597
+ border-radius: 0;
598
+ min-height: 0;
718
599
  }
719
- .model-row:hover {
720
- border-color: var(--border-strong);
721
- background: var(--surface-elevated);
600
+ .tabs button.tab-btn:hover { color: var(--fg); }
601
+ .tabs button.tab-btn.active {
602
+ color: var(--fg);
603
+ border-bottom-color: var(--accent);
604
+ font-weight: 600;
722
605
  }
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
606
 
726
- /* Inspector Logs */
607
+ /* Log Items */
727
608
  .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;
609
+ background: var(--card);
610
+ border: 1px solid var(--line);
611
+ border-radius: 9px;
612
+ padding: 0.75rem 1rem;
613
+ margin-bottom: 0.6rem;
733
614
  display: flex;
734
615
  flex-direction: column;
735
- gap: 6px;
736
- }
737
- .log-header {
738
- display: flex;
739
- justify-content: space-between;
740
- align-items: center;
741
- gap: 8px;
742
- flex-wrap: wrap;
616
+ gap: 0.45rem;
743
617
  }
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;
618
+ .log-item details { margin-top: 0.2rem; }
619
+ .log-item details summary { cursor: pointer; color: var(--accent); font-size: 0.78rem; user-select: none; }
620
+ .log-item pre {
621
+ margin-top: 0.4rem;
622
+ background: var(--bg);
623
+ border: 1px solid var(--line);
624
+ border-radius: 6px;
625
+ padding: 0.6rem 0.75rem;
626
+ overflow-x: auto;
627
+ font-size: 0.76rem;
628
+ line-height: 1.4;
757
629
  }
758
630
 
759
- /* Toast Notification */
631
+ /* Toast */
760
632
  .toast {
761
633
  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);
634
+ bottom: 1.25rem;
635
+ left: 50%;
636
+ transform: translateX(-50%);
637
+ background: var(--card2);
638
+ color: var(--fg);
639
+ border: 1px solid var(--line);
640
+ box-shadow: var(--shadow);
641
+ padding: 0.55rem 1.1rem;
642
+ border-radius: 10px;
643
+ font-size: 0.85rem;
644
+ z-index: 60;
772
645
  display: none;
773
- z-index: 1000;
646
+ animation: fadeIn 0.15s ease-out;
774
647
  }
648
+ .toast.err { border-color: var(--err); color: var(--err); }
649
+ @keyframes fadeIn { from { opacity: 0; transform: translate(-50%, 8px); } to { opacity: 1; transform: translate(-50%, 0); } }
775
650
 
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
- }
651
+ /* Test Result Box in Modal */
652
+ .test-result-box {
653
+ border: 1px solid var(--line);
654
+ border-radius: 8px;
655
+ padding: 0.75rem;
656
+ background: var(--bg);
657
+ font-size: 0.8rem;
658
+ display: none;
783
659
  }
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); }
660
+ .test-result-box.success { border-color: color-mix(in srgb, var(--ok) 40%, transparent); }
661
+ .test-result-box.error { border-color: color-mix(in srgb, var(--err) 40%, transparent); }
662
+
663
+ /* Responsive Design (Matching Intact) */
664
+ @media (max-width: 820px) {
665
+ body { grid-template-columns: minmax(0, 1fr); }
666
+ aside, main, .page { min-width: 0; }
667
+ aside {
668
+ position: sticky;
669
+ top: 0;
670
+ height: auto;
671
+ flex-direction: row;
672
+ flex-wrap: wrap;
673
+ align-items: center;
674
+ padding: 0.55rem 0.85rem 0.4rem;
675
+ gap: 0.4rem;
676
+ border-right: 0;
677
+ border-bottom: 1px solid var(--line);
678
+ z-index: 10;
679
+ }
680
+ aside .brand { padding: 0 0.5rem 0 0; margin: 0; border: 0; }
681
+ aside .brand small { display: none; }
682
+ aside .brand b { font-size: 1.15rem; }
683
+ aside .logo { width: 30px; height: 30px; }
684
+ aside nav {
685
+ order: 3;
686
+ flex: 1 0 100%;
687
+ min-width: 0;
688
+ display: flex;
689
+ gap: 0.25rem;
690
+ overflow-x: auto;
691
+ scrollbar-width: none;
692
+ margin: 0 -0.25rem;
693
+ }
694
+ aside nav::-webkit-scrollbar { display: none; }
695
+ aside a.nav-link { padding: 0.45rem 0.65rem; white-space: nowrap; font-size: 0.82rem; }
696
+ aside a.nav-link svg { width: 15px; height: 15px; }
697
+ aside a.nav-link .ct { display: inline-block; font-size: 0.68rem; }
698
+ aside .spacer { flex: 1; }
699
+ aside .prefs {
700
+ width: auto;
701
+ padding: 0;
702
+ border: 0;
703
+ flex-direction: row;
704
+ gap: 0.5rem;
705
+ align-items: center;
706
+ }
707
+ aside .prefs > div { width: auto !important; margin-top: 0 !important; gap: 0.4rem; }
708
+ aside .prefs span { display: none; }
709
+ .ph { padding: 1rem; flex-wrap: wrap; gap: 0.75rem; }
710
+ .content { padding: 1rem 1rem 2.5rem; }
711
+ .row { padding: 0.75rem 1rem; flex-wrap: wrap; }
712
+ .pgrid { grid-template-columns: 1fr; }
794
713
  }
795
714
  </style>
796
715
  </head>
797
716
  <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>
717
+ <!-- Sidebar -->
718
+ <aside>
719
+ <a class="brand" href="#/routes" aria-label="LLM Switcher home">
720
+ <span class="logo">
721
+ <svg viewBox="0 0 32 32" aria-hidden="true">
722
+ <defs>
723
+ <linearGradient id="lsw-grad" x1="0" y1="0" x2="1" y2="1">
724
+ <stop offset="0%" stop-color="#7c6cf6"/>
725
+ <stop offset="100%" stop-color="#5eead4"/>
726
+ </linearGradient>
727
+ </defs>
728
+ <rect width="32" height="32" rx="9" fill="url(#lsw-grad)"/>
729
+ <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"/>
730
+ </svg>
731
+ </span>
732
+ <div>
733
+ <b>LLM Switcher</b>
734
+ <small>Edge Gateway · :<span class="gateway-port">3456</span></small>
811
735
  </div>
812
- </header>
813
-
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>
736
+ </a>
737
+
738
+ <nav id="sideNav">
739
+ <a class="nav-link" href="#/routes" data-nav="routes">
740
+ <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>
741
+ Routes & Profiles<span class="ct" id="ct-routes"></span>
742
+ </a>
743
+ <a class="nav-link" href="#/models" data-nav="models">
744
+ <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>
745
+ Model Catalog<span class="ct" id="ct-models"></span>
746
+ </a>
747
+ <a class="nav-link" href="#/logs" data-nav="logs">
748
+ <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>
749
+ Request Inspector<span class="ct" id="ct-logs"></span>
750
+ </a>
751
+ <a class="nav-link" href="#/doctor" data-nav="doctor">
752
+ <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>
753
+ System Doctor
754
+ </a>
755
+ </nav>
756
+
757
+ <div class="spacer"></div>
758
+
759
+ <div class="prefs" style="padding-top: 18px;">
760
+ <div style="display:flex; justify-content:space-between; align-items:center; width:100%;">
761
+ <span style="font-size:0.75rem; color:var(--muted);">Theme</span>
762
+ <div class="seg sm">
763
+ <button type="button" id="btn-theme-dark" onclick="applyThemeMode('dark')">Dark</button>
764
+ <button type="button" id="btn-theme-light" onclick="applyThemeMode('light')">Light</button>
823
765
  </div>
824
766
  </div>
825
- <div id="status-action">
826
- <button id="btn-toggle" class="btn btn-primary" onclick="toggleProxy()" type="button">Toggle all</button>
767
+ <div style="display:flex; justify-content:space-between; align-items:center; width:100%; margin-top:0.35rem;">
768
+ <span class="status on" id="status-aside-indicator">Gateway Active</span>
769
+ <button type="button" id="btn-toggle" class="btn btn-outline btn-sm" onclick="toggleProxy()">Toggle All</button>
827
770
  </div>
828
771
  </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>
772
+ </aside>
773
+
774
+ <!-- Main Panel -->
775
+ <div class="page">
776
+ <div class="ph">
777
+ <div class="title-box">
778
+ <h1 id="page-title">Routes & Profiles</h1>
779
+ <p id="status-sub">Route Claude Code, Codex, and OpenAI clients independently.</p>
835
780
  </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>
781
+ <div class="ph-actions" id="ph-actions">
782
+ <!-- Rendered dynamically per route -->
854
783
  </div>
784
+ </div>
855
785
 
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>
786
+ <div class="content">
787
+ <!-- VIEW 1: Routes & Profiles -->
788
+ <div id="view-routes" class="view-panel" style="display:none;">
789
+ <div class="sec">
790
+ <!-- Active Coding CLI Targets -->
791
+ <div class="card">
792
+ <div class="cardh">
793
+ <h2 style="font-size:1.02rem; font-weight:600;">Active Coding CLI Targets</h2>
794
+ <span class="badge ok" style="margin-left:auto;">Multi-Active Invariant</span>
864
795
  </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
796
 
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>
797
+ <!-- Claude Code Target Row -->
798
+ <div class="row" id="card-slot-claude">
799
+ <span class="av logo" title="Claude Code CLI"><img src="/icons/claude.png" alt="Claude Code"></span>
800
+ <div class="who">
801
+ <div class="l">Claude Code CLI</div>
802
+ <div class="meta">Native Anthropic Messages · <code>/v1/messages</code></div>
803
+ </div>
804
+ <div style="min-width:14rem;">
805
+ <select id="select-slot-claude" onchange="switchCliTarget('claude', this.value)">
806
+ <option value="">Official endpoint</option>
807
+ </select>
808
+ </div>
809
+ <span id="badge-slot-claude" class="badge outline">OFF</span>
810
+ </div>
885
811
 
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>
812
+ <!-- Codex Target Row -->
813
+ <div class="row" id="card-slot-codex">
814
+ <span class="av logo" title="OpenAI Codex CLI"><img src="/icons/codex.png" alt="OpenAI Codex"></span>
815
+ <div class="who">
816
+ <div class="l">OpenAI Codex CLI</div>
817
+ <div class="meta">Responses WebSocket · <code>/v1/responses</code></div>
818
+ </div>
819
+ <div style="min-width:14rem;">
820
+ <select id="select-slot-codex" onchange="switchCliTarget('codex', this.value)">
821
+ <option value="">Official endpoint</option>
822
+ </select>
823
+ </div>
824
+ <span id="badge-slot-codex" class="badge outline">OFF</span>
825
+ </div>
826
+ </div>
892
827
 
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>
828
+ <!-- Configured Profiles Header -->
829
+ <div class="sech" style="margin-top:0.5rem;">
830
+ <h2 style="font-size:1.02rem; font-weight:600;">Configured Provider Profiles</h2>
831
+ <span class="hint">Click any card to edit configuration</span>
832
+ </div>
902
833
 
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>
834
+ <!-- Profiles Grid -->
835
+ <div id="profiles-grid" class="pgrid"></div>
836
+ </div>
910
837
  </div>
911
838
 
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-...">
839
+ <!-- VIEW 2: Discovered Models Catalog -->
840
+ <div id="view-models" class="view-panel" style="display:none;">
841
+ <div class="sec">
842
+ <div class="card pad">
843
+ <div class="sech">
844
+ <div>
845
+ <h2 style="font-size:1.05rem; font-weight:600;">Dynamic Model Discovery</h2>
846
+ <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>
847
+ </div>
848
+ <div class="search">
849
+ <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>
850
+ <input type="text" id="catalog-search" placeholder="Search models…" oninput="filterCatalogView(this.value)">
851
+ </div>
931
852
  </div>
932
853
  </div>
933
- </div>
934
854
 
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>
855
+ <div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(20rem, 1fr)); gap:1.2rem;">
856
+ <div class="card pad">
857
+ <div class="sech" style="margin-bottom:0.8rem;">
858
+ <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>
859
+ <span class="badge accent" id="badge-claude-count">0 tiers</span>
860
+ </div>
861
+ <div id="catalog-claude-items" style="display:grid; gap:0.6rem;"></div>
971
862
  </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>
863
+ <div class="card pad">
864
+ <div class="sech" style="margin-bottom:0.8rem;">
865
+ <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>
866
+ <span class="badge ok" id="badge-codex-count">0 roles</span>
976
867
  </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>
868
+ <div id="catalog-codex-items" style="display:grid; gap:0.6rem;"></div>
980
869
  </div>
981
870
  </div>
982
871
  </div>
872
+ </div>
983
873
 
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>.
874
+ <!-- VIEW 3: Request Inspector -->
875
+ <div id="view-logs" class="view-panel" style="display:none;">
876
+ <div class="sec">
877
+ <div class="card pad">
878
+ <div class="sech">
879
+ <div>
880
+ <h2 style="font-size:1.05rem; font-weight:600;">Live Request Inspector</h2>
881
+ <p class="hint" style="margin-top:0.2rem;">In-memory ring buffer capturing the most recent traffic through the edge gateway.</p>
882
+ </div>
883
+ <div style="display:flex; align-items:center; gap:0.6rem;">
884
+ <label class="hint" style="display:flex; align-items:center; gap:0.4rem; cursor:pointer;">
885
+ <input type="checkbox" id="chk-auto-refresh-logs" onchange="toggleLogAutoRefresh()" style="width:auto; min-height:0;" checked> Auto-refresh (3s)
886
+ </label>
887
+ <button type="button" class="btn btn-outline btn-sm" onclick="clearInspectorLogs()">Clear Logs</button>
888
+ </div>
990
889
  </div>
991
890
  </div>
992
891
 
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>
892
+ <div id="inspector-logs-container"></div>
893
+ </div>
894
+ </div>
1003
895
 
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.
896
+ <!-- VIEW 4: System Doctor -->
897
+ <div id="view-doctor" class="view-panel" style="display:none;">
898
+ <div class="sec">
899
+ <div class="stats">
900
+ <div class="stat">
901
+ <div class="k">Edge Gateway Port</div>
902
+ <div class="v gateway-port" style="color:var(--accent);">3456</div>
903
+ <div class="status on" style="margin-top:0.3rem;">Bound to 127.0.0.1</div>
1012
904
  </div>
1013
- </div>
1014
905
 
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>
1020
- </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">
906
+ <div class="stat">
907
+ <div class="k">Blindfold Interceptor</div>
908
+ <div class="v" id="blindfold-port-display" style="color:var(--mint); font-size:1.15rem;">Active</div>
909
+ <div class="status on" style="margin-top:0.3rem;">Zero tool mutation</div>
1024
910
  </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">
911
+
912
+ <div class="stat">
913
+ <div class="k">Security Invariant</div>
914
+ <div class="v" style="color:var(--ok);">Clean (100%)</div>
915
+ <div class="hint" style="margin-top:0.3rem;">No settings.json edits</div>
1028
916
  </div>
1029
917
  </div>
1030
- </div>
1031
918
 
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()">
919
+ <div class="card pad">
920
+ <h3 style="font-size:0.95rem; font-weight:600; margin-bottom:0.75rem;">Environment Isolation & Host Routing</h3>
921
+ <div class="sec">
922
+ <div class="copyline">
923
+ <code>NO_PROXY=127.0.0.1,localhost (Safe from RTK/Headroom/VPN conflicts)</code>
924
+ </div>
925
+ <div class="copyline">
926
+ <code>Blindfold Hosts: api.anthropic.com, api.openai.com, chatgpt.com</code>
927
+ </div>
928
+ <div class="copyline">
929
+ <code>Admin Token Protection: mode 0600 (admin.token) required for state mutation</code>
1039
930
  </div>
1040
- <div id="model-items" class="model-list-scroll"></div>
1041
931
  </div>
1042
932
  </div>
1043
933
  </div>
934
+ </div>
935
+ </div>
936
+ </div>
1044
937
 
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>
938
+ <!-- Profile Modal Editor -->
939
+ <div id="profile-modal-overlay" class="overlay" style="display:none;" onclick="if(event.target===this) closeProfileModal()">
940
+ <div class="modal" role="dialog" aria-modal="true" aria-labelledby="form-title">
941
+ <div class="mh">
942
+ <div>
943
+ <strong id="form-title">Edit Provider Profile</strong>
944
+ <span id="active-tag" style="font-size:0.75rem; color:var(--ok); margin-left:0.5rem; font-weight:600;"></span>
945
+ </div>
946
+ <button class="icon" type="button" onclick="closeProfileModal()" aria-label="Close dialog">✕</button>
947
+ </div>
948
+
949
+ <form id="profile-form" onsubmit="saveProfile(event)" style="display:flex; flex-direction:column; overflow:hidden;">
950
+ <!-- Modal Inset Tabs -->
951
+ <div style="padding:0.85rem 1.25rem 0.5rem; background:var(--card2);">
952
+ <div class="tabs">
953
+ <button type="button" id="tab-button-general" class="tab-btn active" onclick="switchTab('tab-general')">General</button>
954
+ <button type="button" id="tab-button-routing" class="tab-btn" onclick="switchTab('tab-routing')">Routing & Protocol</button>
955
+ <button type="button" id="tab-button-models" class="tab-btn" onclick="switchTab('tab-models')">Model Slots</button>
1057
956
  </div>
1058
957
  </div>
1059
958
 
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>
959
+ <div class="mb">
960
+ <!-- TAB 1: General -->
961
+ <div id="tab-general" class="tab-content active" style="display:grid; gap:0.9rem;">
962
+ <div style="background:var(--card2); padding:0.75rem 1rem; border-radius:8px; border:1px solid var(--line);">
963
+ <span class="hint" style="font-weight:600; display:block; margin-bottom:0.4rem;">Template Quick-Fill:</span>
964
+ <div style="display:flex; gap:0.4rem; flex-wrap:wrap;">
965
+ <button type="button" class="btn btn-outline btn-sm" onclick="applyTemplate('intact')">intact</button>
966
+ <button type="button" class="btn btn-outline btn-sm" onclick="applyTemplate('9router')">9Router</button>
967
+ <button type="button" class="btn btn-outline btn-sm" onclick="applyTemplate('openrouter')">OpenRouter</button>
968
+ <button type="button" class="btn btn-outline btn-sm" onclick="applyTemplate('vertex')">Vertex AI</button>
969
+ <button type="button" class="btn btn-outline btn-sm" onclick="applyTemplate('local')">Local Server</button>
970
+ </div>
1065
971
  </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
972
+
973
+ <div style="display:grid; grid-template-columns:1fr 1fr; gap:0.9rem;">
974
+ <label class="f">Profile Key (ID)
975
+ <input type="text" id="p-key" required pattern="[A-Za-z0-9][A-Za-z0-9._\-]{0,63}" placeholder="e.g. intact-claude">
976
+ </label>
977
+ <label class="f">Display Name
978
+ <input type="text" id="p-name" required placeholder="e.g. Intact Claude Gateway">
1069
979
  </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
980
  </div>
981
+
982
+ <label class="f">Base URL
983
+ <input type="url" id="p-url" required placeholder="https://api.example.com/v1">
984
+ </label>
985
+
986
+ <label class="f">API Key
987
+ <input type="password" id="p-key-val" placeholder="sk-...">
988
+ </label>
1073
989
  </div>
1074
990
 
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.
991
+ <!-- TAB 2: Routing -->
992
+ <div id="tab-routing" class="tab-content" style="display:none; gap:0.9rem; flex-direction:column;">
993
+ <div style="display:grid; grid-template-columns:1fr 1fr; gap:0.9rem;">
994
+ <label class="f">Tool Target
995
+ <select id="p-tool" onchange="onToolChange()">
996
+ <option value="auto">All compatible tools (auto)</option>
997
+ <option value="claude">Claude Code only (claude)</option>
998
+ <option value="codex">Codex only (codex)</option>
999
+ </select>
1000
+ </label>
1001
+ <label class="f">Outbound Format
1002
+ <select id="p-outformat">
1003
+ <option value="openai-chat">OpenAI Chat (/v1/chat/completions)</option>
1004
+ <option value="anthropic">Anthropic Messages (/v1/messages)</option>
1005
+ <option value="vertex">Google Vertex AI (/models/...)</option>
1006
+ </select>
1007
+ </label>
1008
+ </div>
1009
+
1010
+ <div style="display:grid; grid-template-columns:1fr 1fr; gap:0.9rem;">
1011
+ <label class="f">Thinking Mode
1012
+ <select id="p-thinking-mode">
1013
+ <option value="auto">AUTO (restores reasoning, strips tags safely)</option>
1014
+ <option value="native">NATIVE (strict reasoning_effort)</option>
1015
+ <option value="off">OFF (never send thinking)</option>
1016
+ </select>
1017
+ </label>
1018
+ <label class="f">Routing Mode
1019
+ <select id="p-mode">
1020
+ <option value="convert">CONVERT (Bi-directional translation)</option>
1021
+ <option value="direct">DIRECT (Passthrough)</option>
1022
+ <option value="hybrid">HYBRID (Automatic)</option>
1023
+ </select>
1024
+ </label>
1078
1025
  </div>
1079
1026
  </div>
1080
- </div>
1081
1027
 
1082
- <datalist id="model-options"></datalist>
1028
+ <!-- TAB 3: Models & 1M -->
1029
+ <div id="tab-models" class="tab-content" style="display:none; gap:0.9rem; flex-direction:column;">
1030
+ <div class="badge warn" id="warn-1m-callout" style="display:none; padding:0.5rem 0.75rem; border-radius:8px;">
1031
+ <span id="warn-1m-icon">⚠️</span> <span id="warn-1m-text">1M context flag is enabled</span>
1032
+ </div>
1033
+
1034
+ <div style="display:flex; justify-content:space-between; align-items:center;">
1035
+ <h3 id="model-slot-heading" style="font-size:0.92rem; font-weight:600;">Model Slots Mapping</h3>
1036
+ <button type="button" id="btn-fetch-models" class="btn btn-outline btn-sm" onclick="fetchModelList()">Browse Models</button>
1037
+ </div>
1038
+
1039
+ <div id="slot-fields" style="display:grid; gap:0.75rem;"></div>
1040
+
1041
+ <div id="model-browser" style="display:none; background:var(--card2); padding:0.85rem; border-radius:8px; border:1px solid var(--line);">
1042
+ <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:0.5rem;">
1043
+ <span id="model-count-label" class="hint">Loaded models</span>
1044
+ <input type="text" id="model-filter" placeholder="Search..." oninput="filterModels()" style="width:12rem; min-height:28px;">
1045
+ </div>
1046
+ <div id="model-items" style="max-height:8rem; overflow-y:auto; display:flex; flex-wrap:wrap; gap:0.35rem;"></div>
1047
+ </div>
1048
+ </div>
1083
1049
 
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>
1050
+ <!-- Test Connection Box -->
1051
+ <div id="test-result" class="test-result-box">
1052
+ <div style="display:flex; justify-content:space-between; align-items:center;">
1053
+ <span id="test-status-badge" class="badge"></span>
1054
+ <span id="test-latency" class="hint mono"></span>
1055
+ </div>
1056
+ <div id="test-sample" class="mono" style="margin-top:0.4rem; font-size:0.75rem; max-height:4rem; overflow:auto;"></div>
1089
1057
  </div>
1090
- <div id="test-sample" class="test-sample"></div>
1091
1058
  </div>
1092
1059
 
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>
1060
+ <div class="mf">
1061
+ <div>
1062
+ <button type="button" id="btn-delete" class="btn btn-danger" onclick="deleteProfile()" style="display:none;">Delete Profile</button>
1063
+ </div>
1064
+ <div style="display:flex; gap:0.6rem;">
1065
+ <button type="button" id="btn-test-conn" class="btn btn-outline" onclick="testConnection()">Test Connection</button>
1066
+ <button type="button" class="btn btn-outline" onclick="closeProfileModal()">Cancel</button>
1067
+ <button type="submit" class="btn btn-primary">Save Profile</button>
1098
1068
  </div>
1099
1069
  </div>
1100
1070
  </form>
1101
1071
  </div>
1102
- </main>
1072
+ </div>
1103
1073
 
1104
- <div id="toast" class="toast" role="status" aria-live="polite" aria-atomic="true"></div>
1074
+ <div id="toast" class="toast"></div>
1075
+ <datalist id="model-options"></datalist>
1105
1076
 
1106
1077
  <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;
1078
+ // State & Globals
1079
+ const ADMIN_TOKEN_KEY = 'llm-switcher-admin-token';
1080
+ const servedToken = document.querySelector('meta[name="llm-switcher-token"]')?.content;
1081
+ const hashTokenMatch = location.hash.match(/(?:^#|&)token=([0-9a-f]+)/);
1082
+ if (servedToken || hashTokenMatch) {
1083
+ sessionStorage.setItem(ADMIN_TOKEN_KEY, servedToken || hashTokenMatch[1]);
1084
+ }
1085
+ if (hashTokenMatch) {
1086
+ history.replaceState(null, '', location.pathname + location.search + '#/routes');
1132
1087
  }
1133
1088
 
1134
1089
  let state = {
1090
+ port: 3456,
1135
1091
  config: null,
1136
1092
  activeProfile: '',
1137
1093
  activeProfiles: {},
@@ -1144,45 +1100,26 @@
1144
1100
  let loadedModels = [];
1145
1101
  let currentProfileBuffer = null;
1146
1102
  let renderedSlotFormat = 'auto';
1103
+ let logRefreshTimer = null;
1104
+ let cachedCatalog = null;
1105
+ let lastLogsCount = 0;
1147
1106
  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
1107
 
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;
1108
+ // API Helper
1109
+ async function api(path, opts = {}) {
1110
+ const token = sessionStorage.getItem(ADMIN_TOKEN_KEY) || document.querySelector('meta[name="llm-switcher-token"]')?.content || '';
1111
+ const headers = {
1112
+ ...opts.headers,
1113
+ 'x-llm-switcher-token': token
1114
+ };
1115
+ const res = await fetch(path, { ...opts, headers });
1116
+ if (res.status === 401) showToast('Unauthorized: check admin.token in server directory', true);
1117
+ if (res.status === 409) setTimeout(loadStatus, 0);
1118
+ return res;
1119
+ }
1182
1120
 
1183
- // Helper: Anti-XSS Sanitizer
1184
1121
  function escapeHtml(str) {
1185
- if (str === null || str === undefined) return '';
1122
+ if (!str) return '';
1186
1123
  return String(str)
1187
1124
  .replace(/&/g, '&amp;')
1188
1125
  .replace(/</g, '&lt;')
@@ -1191,360 +1128,485 @@
1191
1128
  .replace(/'/g, '&#039;');
1192
1129
  }
1193
1130
 
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`);
1131
+ // Theme Mode
1132
+ function applyThemeMode(mode) {
1133
+ document.documentElement.dataset.theme = mode;
1134
+ localStorage.setItem('intact-theme', mode);
1135
+ localStorage.setItem('lsw-theme', mode);
1136
+ document.getElementById('btn-theme-dark')?.setAttribute('aria-pressed', mode === 'dark');
1137
+ document.getElementById('btn-theme-light')?.setAttribute('aria-pressed', mode === 'light');
1138
+ }
1139
+
1140
+ // Toast
1141
+ let toastTimeout = null;
1142
+ function showToast(msg, isErr = false) {
1143
+ const toast = document.getElementById('toast');
1144
+ if (!toast) return;
1145
+ toast.textContent = msg;
1146
+ toast.className = 'toast' + (isErr ? ' err' : '');
1147
+ toast.style.display = 'block';
1148
+ clearTimeout(toastTimeout);
1149
+ toastTimeout = setTimeout(() => { toast.style.display = 'none'; }, 3200);
1150
+ }
1151
+
1152
+ // Modal Control
1153
+ function openProfileModal(isNew = false) {
1154
+ const modal = document.getElementById('profile-modal-overlay');
1155
+ if (modal) modal.style.display = 'grid';
1156
+ if (isNew) {
1157
+ initNewProfile();
1201
1158
  }
1202
- try { localStorage.setItem('lsw-theme', theme); } catch (e) {}
1203
1159
  }
1204
1160
 
1205
- function toggleTheme() {
1206
- const current = document.documentElement.dataset.theme || 'dark';
1207
- applyTheme(current === 'light' ? 'dark' : 'light');
1161
+ function closeProfileModal() {
1162
+ const modal = document.getElementById('profile-modal-overlay');
1163
+ if (modal) modal.style.display = 'none';
1208
1164
  }
1209
1165
 
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);
1166
+ // Close modal on Escape
1167
+ window.addEventListener('keydown', (e) => {
1168
+ if (e.key === 'Escape') {
1169
+ const modal = document.getElementById('profile-modal-overlay');
1170
+ if (modal && modal.style.display !== 'none') {
1171
+ closeProfileModal();
1172
+ }
1173
+ }
1174
+ });
1175
+
1176
+ // Hash Router
1177
+ function handleRoute() {
1178
+ const hash = window.location.hash || '#/routes';
1179
+ const route = hash.replace(/^#\/?/, '').split('/')[0] || 'routes';
1180
+ const validRoutes = ['routes', 'models', 'logs', 'doctor'];
1181
+ const activeRoute = validRoutes.includes(route) ? route : 'routes';
1182
+
1183
+ // Update active nav link
1184
+ document.querySelectorAll('#sideNav .nav-link').forEach(link => {
1185
+ if (link.getAttribute('data-nav') === activeRoute) {
1186
+ link.setAttribute('aria-current', 'page');
1187
+ } else {
1188
+ link.removeAttribute('aria-current');
1189
+ }
1190
+ });
1191
+
1192
+ // Show matching view panel
1193
+ document.querySelectorAll('.view-panel').forEach(panel => {
1194
+ panel.style.display = (panel.id === 'view-' + activeRoute) ? 'block' : 'none';
1195
+ });
1196
+
1197
+ // Update dynamic header
1198
+ updateHeader(activeRoute);
1199
+
1200
+ // Lazy load view content
1201
+ if (activeRoute === 'models') renderCatalogView();
1202
+ if (activeRoute === 'logs') loadInspectorLogs();
1218
1203
  }
1204
+ window.addEventListener('hashchange', handleRoute);
1205
+
1206
+ function updateHeader(route) {
1207
+ const titleEl = document.getElementById('page-title');
1208
+ const subEl = document.getElementById('status-sub');
1209
+ const actionsEl = document.getElementById('ph-actions');
1210
+ if (!titleEl || !subEl || !actionsEl) return;
1219
1211
 
1212
+ const activeMap = state.activeProfiles || (state.config?.activeProfiles || {});
1213
+ const activeTargets = Object.entries(activeMap).filter(([_, v]) => Boolean(v));
1214
+
1215
+ if (route === 'routes') {
1216
+ titleEl.textContent = 'Routes & Profiles';
1217
+ if (activeTargets.length > 0) {
1218
+ const parts = [];
1219
+ if (activeMap.claude) parts.push('Claude: ' + activeMap.claude);
1220
+ if (activeMap.codex) parts.push('Codex: ' + activeMap.codex);
1221
+ subEl.textContent = 'Port ' + state.port + ' · Active for ' + parts.join(' · ');
1222
+ } else {
1223
+ subEl.textContent = 'Gateway is idle. Clients connect directly to official endpoints.';
1224
+ }
1225
+ actionsEl.innerHTML = `
1226
+ <button class="btn btn-primary" onclick="openProfileModal(true)">+ Add Profile</button>
1227
+ <button class="btn btn-outline" id="btn-toggle" onclick="toggleProxy()">${activeTargets.length > 0 ? 'Use official endpoints' : 'Activate compatible routes'}</button>
1228
+ <button class="btn btn-outline" onclick="loadStatus()">Refresh</button>
1229
+ `;
1230
+ } else if (route === 'models') {
1231
+ titleEl.textContent = 'Dynamic Model Catalog';
1232
+ subEl.textContent = 'Auto-polled models from upstream providers to guarantee zero 404/drift errors.';
1233
+ actionsEl.innerHTML = `
1234
+ <button class="btn btn-outline" id="btn-sync-catalog" onclick="refreshCatalogUpstream()">⚡ Sync Models with Upstream</button>
1235
+ <button class="btn btn-outline" onclick="renderCatalogView()">Refresh</button>
1236
+ `;
1237
+ } else if (route === 'logs') {
1238
+ titleEl.textContent = 'Request Inspector';
1239
+ subEl.textContent = 'Real-time HTTP traffic ring buffer passing through the local edge gateway.';
1240
+ actionsEl.innerHTML = `
1241
+ <button class="btn btn-outline btn-sm" onclick="clearInspectorLogs()">Clear</button>
1242
+ <button class="btn btn-outline btn-sm" onclick="loadInspectorLogs()">Refresh</button>
1243
+ `;
1244
+ } else if (route === 'doctor') {
1245
+ titleEl.textContent = 'System Health & Doctor';
1246
+ subEl.textContent = 'Verification of loopback bindings, interceptor certificates, and zero-mutation guarantees.';
1247
+ actionsEl.innerHTML = `
1248
+ <button class="btn btn-outline" onclick="loadStatus()">Re-check Status</button>
1249
+ `;
1250
+ }
1251
+ }
1252
+
1253
+ // Modal Tabs
1220
1254
  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;
1226
- });
1227
- document.querySelectorAll('.tab-pane').forEach(pane => {
1228
- const selected = pane.id === tabId;
1229
- pane.classList.toggle('active', selected);
1230
- pane.hidden = !selected;
1255
+ document.querySelectorAll('#profile-form .tab-btn').forEach(btn => btn.classList.remove('active'));
1256
+ document.querySelectorAll('#profile-form .tab-content').forEach(c => {
1257
+ c.classList.remove('active');
1258
+ c.style.display = 'none';
1231
1259
  });
1260
+
1261
+ const btnId = tabId.replace('tab-', 'tab-button-');
1262
+ document.getElementById(btnId)?.classList.add('active');
1263
+ const target = document.getElementById(tabId);
1264
+ if (target) {
1265
+ target.classList.add('active');
1266
+ target.style.display = 'grid';
1267
+ }
1232
1268
  }
1233
1269
 
1234
- function getActiveSlotConfig(format) {
1235
- return SLOT_CONFIG[format] || SLOT_CONFIG.auto;
1270
+ // Slot Configs
1271
+ function getToolAvatar(profileKey, profile) {
1272
+ const k = (profileKey || '').toLowerCase();
1273
+ const n = (profile?.name || '').toLowerCase();
1274
+ const t = (profile?.tool || '').toLowerCase();
1275
+ const u = (profile?.baseURL || '').toLowerCase();
1276
+
1277
+ let iconFile = 'openai.png';
1278
+ let brandName = 'AI Provider';
1279
+
1280
+ if (k.includes('claude') || n.includes('claude') || t === 'claude' || t === 'anthropic' || u.includes('anthropic')) {
1281
+ iconFile = 'claude.png';
1282
+ brandName = 'Anthropic Claude';
1283
+ } else if (k.includes('codex') || n.includes('codex') || t === 'codex' || t === 'responses') {
1284
+ iconFile = 'codex.png';
1285
+ brandName = 'OpenAI Codex';
1286
+ } else if (k.includes('openai') || n.includes('openai') || u.includes('openai')) {
1287
+ iconFile = 'openai.png';
1288
+ brandName = 'OpenAI';
1289
+ } else if (k.includes('intact') || n.includes('intact')) {
1290
+ iconFile = 'intact.svg';
1291
+ brandName = 'Intact Gateway';
1292
+ } else if (k.includes('gemini') || n.includes('gemini') || k.includes('vertex') || n.includes('vertex') || u.includes('googleapis')) {
1293
+ iconFile = 'gemini.png';
1294
+ brandName = 'Google Gemini';
1295
+ } else if (k.includes('openrouter') || n.includes('openrouter') || u.includes('openrouter')) {
1296
+ iconFile = 'openrouter.png';
1297
+ brandName = 'OpenRouter';
1298
+ } else if (k.includes('local') || k.includes('ollama') || n.includes('ollama') || u.includes('11434')) {
1299
+ iconFile = 'ollama.png';
1300
+ brandName = 'Ollama';
1301
+ } else if (k.includes('antigravity') || n.includes('antigravity')) {
1302
+ iconFile = 'antigravity.png';
1303
+ brandName = 'Antigravity';
1304
+ } else if (k.includes('deepseek') || n.includes('deepseek')) {
1305
+ iconFile = 'deepseek.png';
1306
+ brandName = 'DeepSeek';
1307
+ } else if (k.includes('groq') || n.includes('groq')) {
1308
+ iconFile = 'groq.png';
1309
+ brandName = 'Groq';
1310
+ } else if (k.includes('qwen') || n.includes('qwen')) {
1311
+ iconFile = 'qwen.png';
1312
+ brandName = 'Qwen';
1313
+ } else if (k.includes('github') || n.includes('github')) {
1314
+ iconFile = 'github.png';
1315
+ brandName = 'GitHub Copilot';
1316
+ }
1317
+
1318
+ return '<span class="av logo" title="' + escapeHtml(brandName) + '"><img src="/icons/' + iconFile + '" alt="" loading="lazy"></span>';
1319
+ }
1320
+
1321
+ const SLOT_CONFIGS = {
1322
+ auto: {
1323
+ title: 'Universal Routing Slots',
1324
+ slots: [
1325
+ { id: 'sonnet', name: 'Claude Sonnet', placeholder: 'claude-3-7-sonnet or antigravity/gemini-3.8-flash' },
1326
+ { id: 'opus', name: 'Claude Opus', placeholder: 'claude-3-opus or antigravity/gemini-3.8-flash' },
1327
+ { id: 'haiku', name: 'Claude Haiku', placeholder: 'claude-3-5-haiku or antigravity/gemini-3.8-flash' },
1328
+ { id: 'fable', name: 'Claude Fable', placeholder: 'claude-fable-5-1 or antigravity/gemini-3.8-flash' },
1329
+ { id: 'main', name: 'Codex Main Session', placeholder: 'gpt-5.6-sol or gpt-4o' },
1330
+ { id: 'review', name: 'Codex /review Override', placeholder: 'gpt-5.6-terra or gpt-4o' },
1331
+ { id: 'subagent', name: 'Codex Subagent', placeholder: 'gpt-5.6-luna or o3-mini' },
1332
+ { id: 'default', name: 'Default Fallback Model', placeholder: 'gpt-4o' }
1333
+ ]
1334
+ },
1335
+ claude: {
1336
+ title: 'Claude Code Model Tiers (Anthropic)',
1337
+ slots: [
1338
+ { id: 'sonnet', name: 'Sonnet Slot', placeholder: 'claude-3-7-sonnet' },
1339
+ { id: 'opus', name: 'Opus Slot', placeholder: 'claude-3-opus' },
1340
+ { id: 'haiku', name: 'Haiku Slot', placeholder: 'claude-3-5-haiku' },
1341
+ { id: 'fable', name: 'Fable Slot', placeholder: 'claude-fable-5-1' }
1342
+ ]
1343
+ },
1344
+ codex: {
1345
+ title: 'OpenAI Codex Model Roles (Responses API)',
1346
+ slots: [
1347
+ { id: 'main', name: 'Main Session Model', placeholder: 'gpt-5.6-sol' },
1348
+ { id: 'review', name: 'Review Model', placeholder: 'gpt-5.6-terra' },
1349
+ { id: 'subagent', name: 'Subagent Model', placeholder: 'gpt-5.6-luna' }
1350
+ ]
1351
+ }
1352
+ };
1353
+
1354
+ function getActiveSlotConfig(fmt) {
1355
+ if (fmt === 'claude' || fmt === 'anthropic') return SLOT_CONFIGS.claude;
1356
+ if (fmt === 'codex' || fmt === 'responses') return SLOT_CONFIGS.codex;
1357
+ return SLOT_CONFIGS.auto;
1236
1358
  }
1237
1359
 
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];
1243
- }
1244
- return '';
1360
+ function valueForSlot(defaultModels, slotId) {
1361
+ if (!defaultModels) return '';
1362
+ return defaultModels[slotId] || '';
1245
1363
  }
1246
1364
 
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;
1365
+ function flagForSlot(model1M, slotId) {
1366
+ if (!model1M) return false;
1367
+ return Boolean(model1M[slotId]);
1254
1368
  }
1255
1369
 
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');
1370
+ function renderModelSlots(slotFmt, p) {
1261
1371
  const container = document.getElementById('slot-fields');
1262
-
1263
- if (heading) heading.textContent = cfg.heading;
1264
- if (warnText) warnText.innerHTML = cfg.warning;
1265
- if (warnIcon) warnIcon.textContent = cfg.icon;
1372
+ const heading = document.getElementById('model-slot-heading');
1266
1373
  if (!container) return;
1267
1374
 
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)}">
1375
+ const cfg = getActiveSlotConfig(slotFmt);
1376
+ renderedSlotFormat = slotFmt;
1377
+ if (heading) heading.textContent = cfg.title;
1378
+ container.innerHTML = '';
1379
+
1380
+ for (const s of cfg.slots) {
1381
+ const val = currentProfileBuffer ? valueForSlot(currentProfileBuffer.defaultModels, s.id) : valueForSlot(p?.defaultModels, s.id);
1382
+ const checked = currentProfileBuffer ? flagForSlot(currentProfileBuffer.model1M, s.id) : flagForSlot(p?.model1M, s.id);
1383
+
1384
+ const slotRow = document.createElement('div');
1385
+ slotRow.style.cssText = 'display:flex; flex-direction:column; gap:0.25rem;';
1386
+ slotRow.innerHTML = `
1387
+ <div style="display:flex; justify-content:space-between; align-items:center;">
1388
+ <label for="p-${s.id}" style="font-size:0.82rem; font-weight:500;">${escapeHtml(s.name)}</label>
1389
+ <label style="display:flex; align-items:center; gap:0.35rem; font-size:0.75rem; color:var(--accent); cursor:pointer;">
1390
+ <input type="checkbox" id="p-${s.id}-1m" style="width:13px; height:13px; min-height:0;" onchange="update1mWarnings()" ${checked ? 'checked' : ''}>
1391
+ 1M context
1392
+ </label>
1288
1393
  </div>
1394
+ <input type="text" id="p-${s.id}" list="model-options" placeholder="${escapeHtml(s.placeholder)}" value="${escapeHtml(val)}">
1289
1395
  `;
1290
- }).join('');
1291
-
1292
- renderedSlotFormat = format;
1396
+ container.appendChild(slotRow);
1397
+ }
1293
1398
  update1mWarnings();
1294
1399
  }
1295
1400
 
1296
- function onToolChange(newFmt) {
1297
- collectCurrentSlotValues(renderedSlotFormat);
1298
- renderModelSlots(newFmt);
1299
- renderCodexTab(newFmt);
1300
- if (loadedModels.length > 0) renderModelBrowser(loadedModels);
1301
- formDirty = true;
1401
+ function onToolChange() {
1402
+ const toolVal = document.getElementById('p-tool').value;
1403
+ renderModelSlots(toolVal, currentProfileBuffer);
1302
1404
  }
1303
1405
 
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');
1406
+ function collectCurrentSlotValues() {
1407
+ const cfg = getActiveSlotConfig(renderedSlotFormat);
1408
+ if (!currentProfileBuffer) currentProfileBuffer = {};
1409
+ const defaultModels = { ...(currentProfileBuffer.defaultModels || {}) };
1410
+ const model1M = { ...(currentProfileBuffer.model1M || {}) };
1411
+ for (const s of cfg.slots) {
1412
+ const inp = document.getElementById('p-' + s.id);
1413
+ const chk = document.getElementById('p-' + s.id + '-1m');
1414
+ if (inp) {
1415
+ const val = inp.value.trim();
1416
+ if (val) defaultModels[s.id] = val;
1417
+ else delete defaultModels[s.id];
1418
+ }
1419
+ if (chk) {
1420
+ if (chk.checked) model1M[s.id] = true;
1421
+ else delete model1M[s.id];
1422
+ }
1316
1423
  }
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
-
1424
+ currentProfileBuffer.defaultModels = defaultModels;
1425
+ currentProfileBuffer.model1M = model1M;
1426
+ return { defaultModels, model1M };
1327
1427
  }
1328
1428
 
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
-
1429
+ function update1mWarnings() {
1430
+ const cfg = getActiveSlotConfig(renderedSlotFormat);
1431
+ let any1m = false;
1432
+ for (const s of cfg.slots) {
1433
+ const chk = document.getElementById('p-' + s.id + '-1m');
1434
+ if (chk && chk.checked) any1m = true;
1435
+ }
1436
+ const warnCallout = document.getElementById('warn-1m-callout');
1437
+ if (warnCallout) warnCallout.style.display = any1m ? 'inline-flex' : 'none';
1345
1438
  }
1346
1439
 
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
- }
1440
+ // Prefetch Counts for Nav Badges
1441
+ async function prefetchNavData() {
1442
+ try {
1443
+ const res = await api('/api/catalog');
1444
+ if (res.ok) {
1445
+ const catalog = await res.json();
1446
+ cachedCatalog = catalog;
1447
+ const claudeModels = Array.isArray(catalog.claude?.models) ? catalog.claude.models : [];
1448
+ const codexModels = Array.isArray(catalog.codex?.models) ? catalog.codex.models : [];
1449
+ const count = claudeModels.length + codexModels.length;
1450
+ const ctModels = document.getElementById('ct-models');
1451
+ if (ctModels && count > 0) ctModels.textContent = count;
1452
+ }
1453
+ } catch {}
1360
1454
 
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';
1455
+ try {
1456
+ const res = await api('/api/logs');
1457
+ if (res.ok) {
1458
+ const data = await res.json();
1459
+ const logs = Array.isArray(data.logs) ? data.logs : [];
1460
+ lastLogsCount = logs.length;
1461
+ const ctLogs = document.getElementById('ct-logs');
1462
+ if (ctLogs && lastLogsCount > 0) ctLogs.textContent = lastLogsCount;
1369
1463
  }
1370
- });
1464
+ } catch {}
1371
1465
  }
1372
1466
 
1467
+ // Load Status
1373
1468
  async function loadStatus() {
1374
1469
  try {
1375
1470
  const res = await api('/api/status');
1471
+ if (!res.ok) return;
1376
1472
  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
- }
1473
+ state = { ...state, ...data };
1398
1474
  render();
1475
+ updateHeader(window.location.hash.replace(/^#\/?/, '').split('/')[0] || 'routes');
1476
+ prefetchNavData();
1399
1477
  } catch (err) {
1400
- showToast('Failed to connect to gateway service', true);
1478
+ console.error('Failed to load status', err);
1401
1479
  }
1402
1480
  }
1403
1481
 
1404
- function isAnyTargetActive() {
1405
- return Object.values(state.activeProfiles || {}).some(Boolean);
1406
- }
1407
-
1408
1482
  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');
1483
+ document.querySelectorAll('.gateway-port').forEach(el => {
1484
+ el.textContent = state.port || '3456';
1485
+ });
1413
1486
 
1414
- const activeMap = state.activeProfiles || (state.config.activeProfiles || {});
1487
+ const activeMap = state.activeProfiles || (state.config?.activeProfiles || {});
1415
1488
  const activeTargets = Object.entries(activeMap).filter(([_, v]) => Boolean(v));
1416
1489
  const isAnyActive = activeTargets.length > 0;
1417
1490
 
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';
1491
+ // Update Aside Indicators
1492
+ const asideIndicator = document.getElementById('status-aside-indicator');
1493
+ if (asideIndicator) {
1494
+ asideIndicator.className = isAnyActive ? 'status on' : 'status';
1495
+ asideIndicator.textContent = isAnyActive ? 'Active (' + activeTargets.length + ')' : 'Idle';
1496
+ }
1497
+
1498
+ // Update Nav Counts
1499
+ const profilesList = Object.entries(state.config?.profiles || {});
1500
+ const ctRoutes = document.getElementById('ct-routes');
1501
+ if (ctRoutes) ctRoutes.textContent = isAnyActive ? activeTargets.length + ' on' : profilesList.length;
1502
+
1503
+ const ctModels = document.getElementById('ct-models');
1504
+ if (ctModels && cachedCatalog) {
1505
+ const count = (cachedCatalog.claude?.models?.length || 0) + (cachedCatalog.codex?.models?.length || 0);
1506
+ if (count > 0) ctModels.textContent = count;
1433
1507
  }
1434
1508
 
1509
+ const ctLogs = document.getElementById('ct-logs');
1510
+ if (ctLogs && lastLogsCount > 0) ctLogs.textContent = lastLogsCount;
1511
+
1512
+ // Update Blindfold display
1435
1513
  const bfEl = document.getElementById('blindfold-port-display');
1436
1514
  if (bfEl) {
1437
1515
  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)';
1516
+ bfEl.textContent = (bf && bf.port) ? 'Port ' + bf.port : 'Not active';
1441
1517
  }
1442
1518
 
1443
- // Update the two tool slots
1519
+ // Render CLI Targets Rows
1444
1520
  const slots = [
1445
1521
  { id: 'claude', name: 'Claude Code', key: 'claude' },
1446
1522
  { id: 'codex', name: 'Codex', key: 'codex' }
1447
1523
  ];
1448
1524
 
1449
1525
  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}`);
1526
+ const badge = document.getElementById('badge-slot-' + slot.id);
1527
+ const select = document.getElementById('select-slot-' + slot.id);
1453
1528
  const currentActive = activeMap[slot.key];
1454
1529
 
1455
1530
  if (select) {
1456
1531
  select.innerHTML = '<option value="">Official endpoint</option>';
1457
- for (const [key, p] of Object.entries(state.config.profiles)) {
1532
+ for (const [key, p] of profilesList) {
1458
1533
  const tool = p.tool || 'auto';
1459
1534
  if (tool === 'auto' || tool === slot.key) {
1460
1535
  const opt = document.createElement('option');
1461
1536
  opt.value = key;
1462
- opt.textContent = `${p.name} (${key})`;
1537
+ opt.textContent = p.name + ' (' + key + ')';
1463
1538
  select.appendChild(opt);
1464
1539
  }
1465
1540
  }
1466
1541
  select.value = currentActive || '';
1467
1542
  }
1468
1543
 
1469
- if (currentActive) {
1470
- if (card) card.classList.add('active');
1471
- if (badge) {
1472
- badge.className = 'badge badge-direct';
1544
+ if (badge) {
1545
+ if (currentActive) {
1546
+ badge.className = 'badge ok';
1473
1547
  badge.textContent = currentActive;
1474
- }
1475
- } else {
1476
- if (card) card.classList.remove('active');
1477
- if (badge) {
1478
- badge.className = 'badge badge-outline';
1548
+ } else {
1549
+ badge.className = 'badge outline';
1479
1550
  badge.textContent = 'OFF';
1480
1551
  }
1481
1552
  }
1482
1553
  }
1483
1554
 
1484
- // Render Cards Grid
1555
+ // Render Profiles Grid (Intact Style)
1485
1556
  const grid = document.getElementById('profiles-grid');
1557
+ if (!grid) return;
1486
1558
  grid.innerHTML = '';
1487
1559
 
1488
- for (const [key, p] of Object.entries(state.config.profiles)) {
1560
+ for (const [key, p] of profilesList) {
1489
1561
  const activeCLIs = [];
1490
1562
  if (activeMap.claude === key) activeCLIs.push('Claude');
1491
1563
  if (activeMap.codex === key) activeCLIs.push('Codex');
1492
1564
  const isActiveAny = activeCLIs.length > 0;
1493
1565
  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
1566
 
1497
1567
  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
- });
1568
+ card.className = 'pcard' + (isActiveAny ? ' active' : '');
1569
+ card.setAttribute('aria-label', p.name + ' profile');
1570
+
1571
+ const toolAv = getToolAvatar(key, p);
1572
+ const routeBadge = (p.tool || 'auto') + ' → ' + (p.outFormat || p.mode || 'chat');
1506
1573
 
1507
1574
  const slotFmt = p.tool === 'claude' ? 'anthropic' : p.tool === 'codex' ? 'responses' : 'auto';
1508
1575
  const pCfg = getActiveSlotConfig(slotFmt);
1509
- const modelsSummaryHtml = pCfg.slots.map(s => {
1510
- const val = valueForSlot(p.defaultModels, s.id) || '-';
1576
+ const modelChips = pCfg.slots.map(s => {
1577
+ const val = valueForSlot(p.defaultModels, s.id);
1578
+ if (!val) return '';
1511
1579
  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('');
1580
+ 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>';
1581
+ }).filter(Boolean).join('');
1515
1582
 
1516
1583
  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>
1584
+ <div class="top">
1585
+ ${toolAv}
1586
+ <div style="flex:1; min-width:0;">
1587
+ <div class="nm">${escapeHtml(p.name)}</div>
1588
+ <div style="display:flex; gap:0.4rem; align-items:center; flex-wrap:wrap; margin-top:0.15rem;">
1589
+ <span class="status ${isActiveAny ? 'on' : ''}">${isActiveAny ? 'Active (' + activeCLIs.join(', ') + ')' : 'Idle'}</span>
1590
+ <span class="badge accent">${escapeHtml(routeBadge)}</span>
1591
+ </div>
1529
1592
  </div>
1593
+ <label class="switch" onclick="event.stopPropagation()">
1594
+ <input type="checkbox" ${isActiveAny ? 'checked' : ''} onchange="toggleProfileAllCli('${escapeHtml(key)}', this.checked)">
1595
+ <span></span>
1596
+ </label>
1530
1597
  </div>
1531
- <div class="card-url">${escapeHtml(p.baseURL)}</div>
1532
- <div class="card-models">
1533
- ${modelsSummaryHtml}
1598
+ <div class="copyline" onclick="event.stopPropagation()">
1599
+ <code>${escapeHtml(p.baseURL)}</code>
1534
1600
  </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'}
1601
+ ${modelChips ? '<div class="chips">' + modelChips + '</div>' : ''}
1602
+ <div style="display:flex; justify-content:space-between; align-items:center; margin-top:auto; padding-top:0.5rem; border-top:1px solid var(--line);">
1603
+ <button type="button" class="btn btn-outline btn-sm" onclick="selectProfileForEdit('${escapeHtml(key)}')">
1604
+ ${isEditing ? 'Editing Profile' : 'Edit Profile'}
1538
1605
  </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>
1606
+ <span class="mono faint" style="font-size:0.75rem;">${escapeHtml(key)}</span>
1542
1607
  </div>
1543
1608
  `;
1544
1609
 
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
1610
  grid.appendChild(card);
1549
1611
  }
1550
1612
 
@@ -1554,15 +1616,16 @@
1554
1616
  }
1555
1617
 
1556
1618
  function selectProfileForEdit(key) {
1557
- if (formDirty && key !== formLoadedKey && !confirm('Discard unsaved changes to this provider?')) return;
1619
+ if (formDirty && key !== formLoadedKey && !confirm('Discard unsaved changes?')) return;
1558
1620
  currentlyEditingKey = key;
1559
1621
  creatingNew = false;
1560
1622
  formDirty = false;
1561
1623
  render();
1624
+ openProfileModal(false);
1562
1625
  }
1563
1626
 
1564
1627
  async function switchCliTarget(target, profileKey) {
1565
- showToast(`Setting ${target} active profile...`);
1628
+ showToast('Setting ' + target + ' target...');
1566
1629
  try {
1567
1630
  const res = await api('/api/switch', {
1568
1631
  method: 'POST',
@@ -1570,401 +1633,241 @@
1570
1633
  body: JSON.stringify({ target, profile: profileKey || null })
1571
1634
  });
1572
1635
  const data = await res.json();
1573
- if (data.success) {
1574
- showToast(`Updated ${target} to "${profileKey || 'Official'}"!`);
1575
- loadStatus();
1636
+ if (data.ok) {
1637
+ showToast('Updated ' + target + ' routing');
1638
+ await loadStatus();
1576
1639
  } else {
1577
- showToast(data.error || 'Failed to switch', true);
1640
+ showToast(data.error || 'Failed to switch target', true);
1578
1641
  }
1579
1642
  } catch (err) {
1580
- showToast('Error switching target', true);
1643
+ showToast('Network error switching target', true);
1581
1644
  }
1582
1645
  }
1583
1646
 
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();
1647
+ async function toggleProfileAllCli(key, enable) {
1648
+ showToast((enable ? 'Activating ' : 'Deactivating ') + key + '...');
1649
+ try {
1650
+ const res = await api('/api/switch', {
1651
+ method: 'POST',
1652
+ headers: { 'Content-Type': 'application/json' },
1653
+ body: JSON.stringify({ profile: key, deactivate: !enable })
1654
+ });
1655
+ const data = await res.json();
1656
+ if (data.ok) {
1657
+ showToast(key + (enable ? ' activated' : ' deactivated'));
1658
+ await loadStatus();
1659
+ } else {
1660
+ showToast(data.error || 'Failed to toggle profile', true);
1620
1661
  }
1662
+ } catch (err) {
1663
+ showToast('Network error toggling profile', true);
1621
1664
  }
1622
1665
  }
1623
1666
 
1624
1667
  function loadProfileToForm(key) {
1625
1668
  const p = state.config?.profiles?.[key];
1626
1669
  if (!p) return;
1670
+ formLoadedKey = key;
1627
1671
  currentProfileBuffer = JSON.parse(JSON.stringify(p));
1628
1672
 
1629
- document.getElementById('form-title').innerText = `Edit Provider: ${p.name}`;
1673
+ document.getElementById('form-title').textContent = 'Edit Profile: ' + p.name;
1630
1674
  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;
1675
+ document.getElementById('p-key').readOnly = true;
1676
+ document.getElementById('p-name').value = p.name || '';
1677
+ document.getElementById('p-url').value = p.baseURL || '';
1678
+ document.getElementById('p-key-val').value = p.apiKey ? MASKED_KEY : '';
1679
+ const toolVal = p.tool || 'auto';
1680
+ document.getElementById('p-tool').value = toolVal;
1681
+ document.getElementById('p-outformat').value = p.outFormat || 'openai-chat';
1682
+ document.getElementById('p-mode').value = p.mode || 'convert';
1636
1683
  document.getElementById('p-thinking-mode').value = p.thinkingMode || 'auto';
1637
- 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;
1684
+ document.getElementById('btn-delete').style.display = 'inline-flex';
1685
+
1686
+ const activeMap = state.activeProfiles || (state.config?.activeProfiles || {});
1687
+ const activeTargets = Object.entries(activeMap).filter(([_, v]) => v === key).map(([t]) => t);
1688
+ const activeTag = document.getElementById('active-tag');
1689
+ if (activeTag) {
1690
+ activeTag.textContent = activeTargets.length ? '● Active: ' + activeTargets.join(', ') : '';
1691
+ }
1692
+
1693
+ renderModelSlots(toolVal, p);
1653
1694
  }
1654
1695
 
1655
1696
  function initNewProfile() {
1656
- if (formDirty && !confirm('Discard unsaved changes to this provider?')) return;
1657
- currentlyEditingKey = '';
1658
1697
  creatingNew = true;
1698
+ currentlyEditingKey = '';
1659
1699
  formLoadedKey = null;
1660
- formDirty = false;
1661
1700
  currentProfileBuffer = {
1662
1701
  name: '',
1663
- mode: 'hybrid',
1664
- tool: null,
1665
- outFormat: '',
1702
+ baseURL: '',
1703
+ tool: 'auto',
1704
+ outFormat: 'openai-chat',
1705
+ mode: 'convert',
1666
1706
  thinkingMode: 'auto',
1667
- baseURL: 'https://',
1668
- apiKey: '',
1669
1707
  defaultModels: {},
1670
1708
  model1M: {}
1671
1709
  };
1672
1710
 
1673
- render();
1674
- document.getElementById('form-title').innerText = 'Create New Provider';
1711
+ document.getElementById('form-title').textContent = 'Add New Provider Profile';
1675
1712
  document.getElementById('p-key').value = '';
1676
- document.getElementById('p-key').disabled = false;
1713
+ document.getElementById('p-key').readOnly = false;
1677
1714
  document.getElementById('p-name').value = '';
1678
- document.getElementById('p-mode').value = 'hybrid';
1715
+ document.getElementById('p-url').value = '';
1716
+ document.getElementById('p-key-val').value = '';
1679
1717
  document.getElementById('p-tool').value = 'auto';
1680
- document.getElementById('p-outformat').value = '';
1718
+ document.getElementById('p-outformat').value = 'openai-chat';
1719
+ document.getElementById('p-mode').value = 'convert';
1681
1720
  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
1721
  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
- }
1722
+ document.getElementById('active-tag').textContent = '';
1824
1723
 
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
- }
1724
+ renderModelSlots('auto', currentProfileBuffer);
1725
+ switchTab('tab-general');
1833
1726
  }
1834
1727
 
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);
1728
+ const TEMPLATES = {
1729
+ intact: {
1730
+ name: 'Intact Credential Proxy',
1731
+ baseURL: 'http://127.0.0.1:8080/v1',
1732
+ tool: 'auto',
1733
+ outFormat: 'openai-chat',
1734
+ mode: 'convert',
1735
+ thinkingMode: 'auto',
1736
+ defaultModels: { default: 'gpt-4o', sonnet: 'claude-3-7-sonnet', opus: 'claude-3-opus', haiku: 'claude-3-5-haiku' }
1737
+ },
1738
+ '9router': {
1739
+ name: '9Router Client Proxy',
1740
+ baseURL: 'http://127.0.0.1:20128/v1',
1741
+ tool: 'auto',
1742
+ outFormat: 'openai-chat',
1743
+ mode: 'hybrid',
1744
+ thinkingMode: 'auto',
1745
+ defaultModels: { default: 'gpt-4o', sonnet: 'claude-3-7-sonnet' }
1746
+ },
1747
+ openrouter: {
1748
+ name: 'OpenRouter Gateway',
1749
+ baseURL: 'https://openrouter.ai/api/v1',
1750
+ tool: 'auto',
1751
+ outFormat: 'openai-chat',
1752
+ mode: 'convert',
1753
+ thinkingMode: 'auto',
1754
+ defaultModels: { default: 'anthropic/claude-3.7-sonnet', sonnet: 'anthropic/claude-3.7-sonnet' }
1755
+ },
1756
+ vertex: {
1757
+ name: 'Google Vertex AI',
1758
+ baseURL: 'https://us-central1-aiplatform.googleapis.com/v1',
1759
+ tool: 'anthropic',
1760
+ outFormat: 'vertex',
1761
+ mode: 'convert',
1762
+ thinkingMode: 'auto',
1763
+ defaultModels: { sonnet: 'claude-3-7-sonnet@20250219' }
1764
+ },
1765
+ local: {
1766
+ name: 'Local Ollama / vLLM Server',
1767
+ baseURL: 'http://127.0.0.1:11434/v1',
1768
+ tool: 'auto',
1769
+ outFormat: 'openai-chat',
1770
+ mode: 'direct',
1771
+ thinkingMode: 'off',
1772
+ defaultModels: { default: 'qwen2.5-coder:32b' }
1875
1773
  }
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
- }
1774
+ };
1883
1775
 
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;
1776
+ function applyTemplate(tName) {
1777
+ const t = TEMPLATES[tName];
1778
+ if (!t) return;
1779
+ if (creatingNew) {
1780
+ document.getElementById('p-key').value = tName;
1891
1781
  }
1892
- update1mWarnings();
1893
- formDirty = true;
1894
- showToast(`Assigned "${modelId}" to ${slotId.toUpperCase()}`);
1782
+ document.getElementById('p-name').value = t.name;
1783
+ document.getElementById('p-url').value = t.baseURL;
1784
+ document.getElementById('p-tool').value = t.tool;
1785
+ document.getElementById('p-outformat').value = t.outFormat;
1786
+ document.getElementById('p-mode').value = t.mode;
1787
+ document.getElementById('p-thinking-mode').value = t.thinkingMode;
1788
+ currentProfileBuffer = { ...currentProfileBuffer, ...t };
1789
+ renderModelSlots(t.tool || 'auto', t);
1790
+ showToast('Template "' + t.name + '" applied');
1895
1791
  }
1896
1792
 
1897
1793
  async function toggleProxy() {
1898
- const nextState = !isAnyTargetActive();
1899
- showToast(nextState ? 'Activating compatible routes...' : 'Restoring official endpoints...');
1794
+ const isAnyActive = Object.values(state.activeProfiles || (state.config?.activeProfiles || {})).some(Boolean);
1795
+ const action = isAnyActive ? 'deactivate' : 'activate';
1796
+ showToast((isAnyActive ? 'Switching to official endpoints...' : 'Activating compatible routes...'));
1900
1797
  try {
1901
1798
  const res = await api('/api/toggle', {
1902
1799
  method: 'POST',
1903
1800
  headers: { 'Content-Type': 'application/json' },
1904
- body: JSON.stringify({ enabled: nextState })
1801
+ body: JSON.stringify({ enabled: !isAnyActive })
1905
1802
  });
1906
1803
  const data = await res.json();
1907
- if (data.success) {
1908
- showToast(nextState ? 'Compatible routes are active.' : 'Official endpoints restored.');
1804
+ if (data.ok) {
1805
+ showToast(isAnyActive ? 'Using official endpoints' : 'Gateway activated');
1806
+ await loadStatus();
1909
1807
  } else {
1910
- showToast(data.error || 'Failed to toggle gateway state', true);
1808
+ showToast(data.error || 'Failed to toggle routes', true);
1911
1809
  }
1912
- loadStatus();
1913
1810
  } catch (err) {
1914
- showToast('Failed to toggle gateway state', true);
1811
+ showToast('Network error toggling routes', true);
1915
1812
  }
1916
1813
  }
1917
1814
 
1918
1815
  async function saveProfile(e) {
1919
1816
  e.preventDefault();
1920
1817
  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;
1818
+ if (!key) return;
1923
1819
 
1924
1820
  collectCurrentSlotValues();
1925
1821
  const existing = state.config?.profiles?.[key] || {};
1926
- const mergedModels = { ...(existing.defaultModels || {}), ...(currentProfileBuffer?.defaultModels || {}) };
1927
- const merged1M = { ...(existing.model1M || {}), ...(currentProfileBuffer?.model1M || {}) };
1822
+ const keyVal = document.getElementById('p-key-val').value.trim();
1928
1823
 
1929
- const profile = {
1824
+ const profilePayload = {
1930
1825
  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
1826
  baseURL: document.getElementById('p-url').value.trim(),
1935
- apiKey: document.getElementById('p-key-val').value.trim(),
1936
- defaultModels: mergedModels,
1937
- model1M: merged1M
1827
+ tool: document.getElementById('p-tool').value,
1828
+ outFormat: document.getElementById('p-outformat').value,
1829
+ mode: document.getElementById('p-mode').value,
1830
+ thinkingMode: document.getElementById('p-thinking-mode').value,
1831
+ defaultModels: { ...(existing.defaultModels || {}), ...(currentProfileBuffer?.defaultModels || {}) },
1832
+ model1M: { ...(existing.model1M || {}), ...(currentProfileBuffer?.model1M || {}) }
1938
1833
  };
1939
- profile.outFormat = outFormat;
1940
- collectCodexValues(profile);
1941
1834
 
1835
+ if (keyVal === MASKED_KEY) {
1836
+ profilePayload.apiKey = existing.apiKey || '';
1837
+ } else if (keyVal) {
1838
+ profilePayload.apiKey = keyVal;
1839
+ } else {
1840
+ profilePayload.apiKey = '';
1841
+ }
1842
+
1843
+ if (existing.codexRoles) profilePayload.codexRoles = existing.codexRoles;
1844
+ if (existing.publicModels) profilePayload.publicModels = existing.publicModels;
1845
+
1846
+ showToast('Saving profile "' + key + '"...');
1942
1847
  try {
1943
1848
  const res = await api('/api/save-profile', {
1944
1849
  method: 'POST',
1945
1850
  headers: { 'Content-Type': 'application/json' },
1946
- body: JSON.stringify({ key, profile })
1851
+ body: JSON.stringify({ key, profile: profilePayload })
1947
1852
  });
1948
1853
  const data = await res.json();
1949
- if (data.success) {
1950
- showToast('Provider profile saved.');
1951
- currentlyEditingKey = key;
1952
- creatingNew = false;
1953
- formDirty = false;
1954
- formLoadedKey = null;
1955
- loadStatus();
1854
+ if (data.ok) {
1855
+ showToast('Profile "' + key + '" saved successfully');
1856
+ closeProfileModal();
1857
+ await loadStatus();
1956
1858
  } else {
1957
- showToast(data.error || 'Failed to save', true);
1859
+ showToast(data.error || 'Failed to save profile', true);
1958
1860
  }
1959
1861
  } catch (err) {
1960
- showToast('Error saving provider', true);
1862
+ showToast('Network error saving profile', true);
1961
1863
  }
1962
1864
  }
1963
1865
 
1964
1866
  async function deleteProfile() {
1965
- const key = document.getElementById('p-key').value;
1966
- if (!confirm(`Are you sure you want to delete provider "${key}"?`)) return;
1867
+ const key = document.getElementById('p-key').value.trim();
1868
+ if (!key || !confirm('Delete provider profile "' + key + '" permanently?')) return;
1967
1869
 
1870
+ showToast('Deleting profile "' + key + '"...');
1968
1871
  try {
1969
1872
  const res = await api('/api/delete-profile', {
1970
1873
  method: 'POST',
@@ -1972,21 +1875,18 @@
1972
1875
  body: JSON.stringify({ key })
1973
1876
  });
1974
1877
  const data = await res.json();
1975
- if (data.success) {
1976
- showToast('Provider deleted.');
1977
- currentlyEditingKey = '';
1978
- creatingNew = false;
1979
- formDirty = false;
1980
- loadStatus();
1878
+ if (data.ok) {
1879
+ showToast('Profile "' + key + '" deleted');
1880
+ closeProfileModal();
1881
+ await loadStatus();
1981
1882
  } else {
1982
- showToast(data.error || 'Failed to delete', true);
1883
+ showToast(data.error || 'Failed to delete profile', true);
1983
1884
  }
1984
1885
  } catch (err) {
1985
- showToast('Error deleting provider', true);
1886
+ showToast('Network error deleting profile', true);
1986
1887
  }
1987
1888
  }
1988
1889
 
1989
- // Modern Non-blocking Connection Tester
1990
1890
  async function testConnection() {
1991
1891
  const btn = document.getElementById('btn-test-conn');
1992
1892
  const box = document.getElementById('test-result');
@@ -2002,7 +1902,7 @@
2002
1902
  const currentFmt = document.getElementById('p-tool')?.value || 'auto';
2003
1903
  const cfg = getActiveSlotConfig(currentFmt);
2004
1904
  const primarySlotId = cfg.slots[0]?.id || 'default';
2005
- const modelInput = document.getElementById(`p-${primarySlotId}`) || document.getElementById('p-sonnet') || document.getElementById('p-opus');
1905
+ const modelInput = document.getElementById('p-' + primarySlotId) || document.getElementById('p-sonnet') || document.getElementById('p-opus');
2006
1906
  const testModel = (modelInput?.value || '').trim() || 'default';
2007
1907
 
2008
1908
  const res = await api('/api/test-upstream', {
@@ -2010,7 +1910,7 @@
2010
1910
  headers: { 'Content-Type': 'application/json' },
2011
1911
  body: JSON.stringify({
2012
1912
  baseURL: document.getElementById('p-url').value.trim(),
2013
- apiKey: document.getElementById('p-key-val').value.trim(),
1913
+ apiKey: document.getElementById('p-key-val').value.trim() === MASKED_KEY ? (state.config?.profiles?.[formLoadedKey]?.apiKey || '') : document.getElementById('p-key-val').value.trim(),
2014
1914
  mode: document.getElementById('p-mode').value,
2015
1915
  outFormat: document.getElementById('p-outformat').value,
2016
1916
  key: formLoadedKey,
@@ -2022,47 +1922,202 @@
2022
1922
 
2023
1923
  if (data.ok) {
2024
1924
  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)`);
1925
+ statusBadge.className = 'badge ok';
1926
+ statusBadge.textContent = '✓ HTTP 200 OK (' + (data.outFormat || 'upstream') + ')';
1927
+ latencyBadge.textContent = 'Latency: ' + data.latency + 'ms';
1928
+ sampleBox.textContent = 'Sample Output: ' + data.sample;
1929
+ showToast('Connection test passed (' + data.latency + 'ms)');
2029
1930
  } else {
2030
1931
  box.className = 'test-result-box error';
2031
- statusBadge.textContent = `✗ Failed (${data.status || 'Error'})`;
1932
+ statusBadge.className = 'badge err';
1933
+ statusBadge.textContent = '✗ Failed (' + (data.status || 'Error') + ')';
2032
1934
  latencyBadge.textContent = '';
2033
- sampleBox.textContent = `Error details: ${data.error || 'Connection failed'}`;
2034
- showToast('Upstream test failed', true);
1935
+ sampleBox.textContent = 'Error: ' + (data.error || 'Upstream connection failed');
1936
+ showToast('Upstream connection failed', true);
2035
1937
  }
2036
1938
  } catch (err) {
2037
1939
  box.style.display = 'block';
2038
1940
  box.className = 'test-result-box error';
1941
+ statusBadge.className = 'badge err';
2039
1942
  statusBadge.textContent = '✗ Network Error';
2040
1943
  latencyBadge.textContent = '';
2041
1944
  sampleBox.textContent = err.message;
2042
1945
  showToast('Network error testing upstream', true);
2043
1946
  } finally {
2044
- btn.innerText = 'Test connection';
1947
+ btn.innerText = 'Test Connection';
2045
1948
  btn.disabled = false;
2046
1949
  }
2047
1950
  }
2048
1951
 
2049
- let logRefreshInterval = null;
1952
+ async function fetchModelList() {
1953
+ const btn = document.getElementById('btn-fetch-models');
1954
+ const browser = document.getElementById('model-browser');
1955
+ const baseURL = document.getElementById('p-url').value.trim();
1956
+ const apiKeyVal = document.getElementById('p-key-val').value.trim();
1957
+ const apiKey = (apiKeyVal === MASKED_KEY) ? (state.config?.profiles?.[formLoadedKey]?.apiKey || '') : apiKeyVal;
2050
1958
 
2051
- function toggleLogAutoRefresh(enabled) {
2052
- if (logRefreshInterval) {
2053
- clearInterval(logRefreshInterval);
2054
- logRefreshInterval = null;
1959
+ if (!baseURL) {
1960
+ showToast('Please enter a Base URL first', true);
1961
+ return;
2055
1962
  }
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);
1963
+
1964
+ btn.disabled = true;
1965
+ btn.innerText = 'Fetching...';
1966
+ try {
1967
+ const res = await api('/api/fetch-models', {
1968
+ method: 'POST',
1969
+ headers: { 'Content-Type': 'application/json' },
1970
+ body: JSON.stringify({ baseURL, apiKey })
1971
+ });
1972
+ const data = await res.json();
1973
+ if (data.ok && Array.isArray(data.models)) {
1974
+ loadedModels = data.models;
1975
+ populateModelOptions(loadedModels);
1976
+ renderModelBrowser(loadedModels);
1977
+ browser.style.display = 'block';
1978
+ showToast('Loaded ' + loadedModels.length + ' models');
1979
+ } else {
1980
+ showToast(data.error || 'No models returned from provider', true);
1981
+ }
1982
+ } catch (err) {
1983
+ showToast('Error fetching models: ' + err.message, true);
1984
+ } finally {
1985
+ btn.disabled = false;
1986
+ btn.innerText = 'Browse Models';
1987
+ }
1988
+ }
1989
+
1990
+ function populateModelOptions(models) {
1991
+ const dl = document.getElementById('model-options');
1992
+ if (!dl) return;
1993
+ dl.innerHTML = '';
1994
+ for (const m of models) {
1995
+ const opt = document.createElement('option');
1996
+ opt.value = m;
1997
+ dl.appendChild(opt);
1998
+ }
1999
+ }
2000
+
2001
+ function renderModelBrowser(models) {
2002
+ const container = document.getElementById('model-items');
2003
+ const label = document.getElementById('model-count-label');
2004
+ if (!container) return;
2005
+ container.innerHTML = '';
2006
+ if (label) label.textContent = models.length + ' models found';
2007
+
2008
+ for (const m of models) {
2009
+ const btn = document.createElement('button');
2010
+ btn.type = 'button';
2011
+ btn.className = 'btn btn-outline btn-sm';
2012
+ btn.style.cssText = 'font-size:0.75rem; padding:0.15rem 0.5rem;';
2013
+ btn.textContent = m;
2014
+ btn.onclick = () => selectModel(m);
2015
+ container.appendChild(btn);
2016
+ }
2017
+ }
2018
+
2019
+ function filterModels() {
2020
+ const q = (document.getElementById('model-filter')?.value || '').toLowerCase();
2021
+ const filtered = loadedModels.filter(m => m.toLowerCase().includes(q));
2022
+ renderModelBrowser(filtered);
2023
+ }
2024
+
2025
+ function selectModel(modelId) {
2026
+ const activeTab = document.querySelector('.tab-content.active');
2027
+ const focused = activeTab?.querySelector('input[type="text"]:focus');
2028
+ if (focused && focused.id.startsWith('p-')) {
2029
+ focused.value = modelId;
2030
+ } else {
2031
+ const sonnetInput = document.getElementById('p-sonnet') || document.getElementById('p-default');
2032
+ if (sonnetInput) sonnetInput.value = modelId;
2033
+ }
2034
+ showToast('Selected: ' + modelId);
2035
+ }
2036
+
2037
+ // Dynamic Model Catalog View
2038
+ async function renderCatalogView() {
2039
+ const claudeContainer = document.getElementById('catalog-claude-items');
2040
+ const codexContainer = document.getElementById('catalog-codex-items');
2041
+ if (!claudeContainer || !codexContainer) return;
2042
+
2043
+ try {
2044
+ const res = await api('/api/catalog');
2045
+ if (!res.ok) {
2046
+ showToast('Failed to load model catalog', true);
2047
+ return;
2048
+ }
2049
+ const catalog = await res.json();
2050
+ cachedCatalog = catalog;
2051
+
2052
+ const claudeModels = Array.isArray(catalog.claude?.models) ? catalog.claude.models : [];
2053
+ const codexModels = Array.isArray(catalog.codex?.models) ? catalog.codex.models : [];
2054
+
2055
+ const claudeTiers = new Set(claudeModels.map(m => m.tier));
2056
+ const codexRoles = new Set(codexModels.map(m => m.role));
2057
+
2058
+ const ctModels = document.getElementById('ct-models');
2059
+ if (ctModels) ctModels.textContent = claudeModels.length + codexModels.length;
2060
+
2061
+ document.getElementById('badge-claude-count').textContent = claudeModels.length + ' models · ' + claudeTiers.size + ' tiers';
2062
+ document.getElementById('badge-codex-count').textContent = codexModels.length + ' models · ' + codexRoles.size + ' roles';
2063
+
2064
+ claudeContainer.innerHTML = claudeModels.map(m => `
2065
+ <div class="row" style="padding:0.6rem 0.8rem; background:var(--card2); border-radius:8px;">
2066
+ <div class="who">
2067
+ <div style="display:flex; align-items:center; gap:0.45rem;">
2068
+ <span class="badge accent" style="text-transform:uppercase; font-size:0.7rem;">${escapeHtml(m.tier)}</span>
2069
+ <span class="l mono" style="font-size:0.85rem; font-weight:600;">${escapeHtml(m.id)}</span>
2070
+ </div>
2071
+ </div>
2072
+ <button type="button" class="btn btn-outline btn-sm" onclick="navigator.clipboard.writeText('${escapeHtml(m.id)}'); showToast('Copied ${escapeHtml(m.id)}');">Copy</button>
2073
+ </div>
2074
+ `).join('') || '<div class="hint">No Claude tiers discovered</div>';
2075
+
2076
+ codexContainer.innerHTML = codexModels.map(m => `
2077
+ <div class="row" style="padding:0.6rem 0.8rem; background:var(--card2); border-radius:8px;">
2078
+ <div class="who">
2079
+ <div style="display:flex; align-items:center; gap:0.45rem;">
2080
+ <span class="badge ok" style="text-transform:capitalize; font-size:0.7rem;">${escapeHtml(m.role)}</span>
2081
+ <span class="l mono" style="font-size:0.85rem; font-weight:600;">${escapeHtml(m.id)}</span>
2082
+ </div>
2083
+ </div>
2084
+ <button type="button" class="btn btn-outline btn-sm" onclick="navigator.clipboard.writeText('${escapeHtml(m.id)}'); showToast('Copied ${escapeHtml(m.id)}');">Copy</button>
2085
+ </div>
2086
+ `).join('') || '<div class="hint">No Codex roles discovered</div>';
2087
+ } catch (err) {
2088
+ showToast('Error loading model catalog', true);
2089
+ }
2090
+ }
2091
+
2092
+ function filterCatalogView(q) {
2093
+ const term = (q || '').toLowerCase();
2094
+ document.querySelectorAll('#catalog-claude-items .row, #catalog-codex-items .row').forEach(row => {
2095
+ const text = row.textContent.toLowerCase();
2096
+ row.style.display = text.includes(term) ? 'flex' : 'none';
2097
+ });
2098
+ }
2099
+
2100
+ async function refreshCatalogUpstream() {
2101
+ const btn = document.getElementById('btn-sync-catalog');
2102
+ if (btn) { btn.disabled = true; btn.textContent = 'Syncing...'; }
2103
+ showToast('Refreshing model catalog from upstream...');
2104
+ try {
2105
+ const res = await api('/api/catalog/refresh', { method: 'POST' });
2106
+ const data = await res.json();
2107
+ if (data.ok) {
2108
+ showToast('Model catalog synchronized');
2109
+ await renderCatalogView();
2110
+ } else {
2111
+ showToast(data.error || 'Failed to refresh catalog', true);
2112
+ }
2113
+ } catch (err) {
2114
+ showToast('Network error syncing catalog', true);
2115
+ } finally {
2116
+ if (btn) { btn.disabled = false; btn.textContent = '⚡ Sync Models with Upstream'; }
2063
2117
  }
2064
2118
  }
2065
2119
 
2120
+ // Request Inspector Logs View
2066
2121
  async function loadInspectorLogs() {
2067
2122
  try {
2068
2123
  const res = await api('/api/logs');
@@ -2070,49 +2125,52 @@
2070
2125
  const container = document.getElementById('inspector-logs-container');
2071
2126
  if (!container) return;
2072
2127
 
2128
+ lastLogsCount = (data.logs || []).length;
2129
+ const ctLogs = document.getElementById('ct-logs');
2130
+ if (ctLogs) ctLogs.textContent = lastLogsCount;
2131
+
2073
2132
  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>';
2133
+ 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
2134
  return;
2076
2135
  }
2077
2136
 
2078
2137
  container.innerHTML = '';
2079
2138
  for (const l of data.logs) {
2080
- const row = document.createElement('div');
2081
- row.className = 'log-item';
2139
+ const item = document.createElement('div');
2140
+ item.className = 'log-item';
2082
2141
  const isErr = l.status >= 400;
2083
- const statusClass = isErr ? 'badge-convert' : 'badge-direct';
2084
2142
 
2085
- let details = '';
2143
+ let detailsHtml = '';
2086
2144
  if (l.requestPreview) {
2087
- details += `<div><strong style="color: var(--accent);">Prompt:</strong> ${escapeHtml(l.requestPreview)}</div>`;
2145
+ detailsHtml += '<div><span class="hint" style="font-weight:600; color:var(--accent);">Prompt:</span><pre>' + escapeHtml(l.requestPreview) + '</pre></div>';
2088
2146
  }
2089
2147
  if (l.thinkingChars > 0) {
2090
- details += `<div><strong style="color: #fbbf24;">Thinking:</strong> ${l.thinkingChars} chars extracted</div>`;
2148
+ detailsHtml += '<div><span class="hint" style="font-weight:600; color:var(--warn);">Thinking:</span><pre>' + l.thinkingChars + ' characters extracted</pre></div>';
2091
2149
  }
2092
2150
  if (l.responsePreview) {
2093
- details += `<div><strong style="color: #34d399;">Output:</strong> ${escapeHtml(l.responsePreview)}</div>`;
2151
+ detailsHtml += '<div><span class="hint" style="font-weight:600; color:var(--ok);">Response Output:</span><pre>' + escapeHtml(l.responsePreview) + '</pre></div>';
2094
2152
  }
2095
2153
  if (l.error) {
2096
- details += `<div><strong style="color: #f87171;">Error:</strong> ${escapeHtml(l.error)}</div>`;
2154
+ detailsHtml += '<div><span class="hint" style="font-weight:600; color:var(--err);">Error:</span><pre>' + escapeHtml(l.error) + '</pre></div>';
2097
2155
  }
2098
2156
 
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>
2157
+ item.innerHTML = `
2158
+ <div style="display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:0.5rem;">
2159
+ <div style="display:flex; align-items:center; gap:0.5rem; flex-wrap:wrap;">
2160
+ <span class="mono faint" style="font-size:0.75rem;">${escapeHtml(l.timestamp)}</span>
2161
+ <span class="badge ${isErr ? 'err' : 'ok'}">${escapeHtml(l.status)} ${escapeHtml(l.method || 'POST')}</span>
2162
+ <strong class="mono" style="font-size:0.85rem; color:var(--fg);">${escapeHtml(l.path || '/v1/messages')}</strong>
2163
+ <span class="badge accent">${escapeHtml(l.client || 'Client')}</span>
2106
2164
  </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>
2165
+ <div style="display:flex; align-items:center; gap:0.5rem;">
2166
+ ${l.latency ? '<span class="hint mono">' + escapeHtml(l.latency) + 'ms</span>' : ''}
2167
+ ${l.profile ? '<span class="badge">→ ' + escapeHtml(l.profile) + '</span>' : ''}
2111
2168
  </div>
2112
2169
  </div>
2113
- ${details ? `<div class="log-body">${details}</div>` : ''}
2170
+ ${detailsHtml ? '<details><summary>View Payload Preview</summary>' + detailsHtml + '</details>' : ''}
2114
2171
  `;
2115
- container.appendChild(row);
2172
+
2173
+ container.appendChild(item);
2116
2174
  }
2117
2175
  } catch (err) {
2118
2176
  console.error('Failed to load logs', err);
@@ -2122,28 +2180,47 @@
2122
2180
  async function clearInspectorLogs() {
2123
2181
  try {
2124
2182
  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');
2183
+ const data = await res.json();
2184
+ if (data.ok) {
2185
+ showToast('Request inspector logs cleared');
2186
+ await loadInspectorLogs();
2187
+ }
2128
2188
  } catch (err) {
2129
- showToast('Failed to clear logs', true);
2189
+ showToast('Error clearing logs', true);
2130
2190
  }
2131
2191
  }
2132
2192
 
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);
2139
-
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');
2193
+ function toggleLogAutoRefresh() {
2194
+ const chk = document.getElementById('chk-auto-refresh-logs');
2195
+ if (chk && chk.checked) {
2196
+ clearInterval(logRefreshTimer);
2197
+ logRefreshTimer = setInterval(() => {
2198
+ if (window.location.hash.startsWith('#/logs')) {
2199
+ loadInspectorLogs();
2200
+ }
2201
+ }, 3000);
2202
+ } else {
2203
+ clearInterval(logRefreshTimer);
2204
+ logRefreshTimer = null;
2205
+ }
2145
2206
  }
2146
- loadStatus();
2207
+
2208
+ // Initial Bootstrap
2209
+ document.addEventListener('DOMContentLoaded', () => {
2210
+ // Setup initial theme buttons state
2211
+ const currentTheme = document.documentElement.dataset.theme || 'dark';
2212
+ document.getElementById('btn-theme-dark')?.setAttribute('aria-pressed', currentTheme === 'dark');
2213
+ document.getElementById('btn-theme-light')?.setAttribute('aria-pressed', currentTheme === 'light');
2214
+
2215
+ // Setup log auto-refresh
2216
+ toggleLogAutoRefresh();
2217
+
2218
+ // Trigger initial route
2219
+ handleRoute();
2220
+
2221
+ // Load initial state from edge gateway
2222
+ loadStatus();
2223
+ });
2147
2224
  </script>
2148
2225
  </body>
2149
- </html>
2226
+ </html>