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