@flame0510/project-aether 1.2.0 → 1.4.0
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/README.md +3 -1
- package/agent-templates/README.md +42 -22
- package/agent-templates/base-image/Dockerfile +42 -33
- package/agent-templates/base-image/entrypoint.sh +67 -12
- package/app/agents/BrowserAccessSection.tsx +510 -0
- package/app/agents/ChannelManager.tsx +19 -11
- package/app/agents/ImageDownloadBanner.tsx +53 -19
- package/app/agents/ModelSection.tsx +316 -0
- package/app/agents/PageClient.tsx +708 -167
- package/app/agents/UpdateSection.tsx +300 -0
- package/app/agents/create/PageClient.tsx +11 -49
- package/app/agents/create/page.tsx +8 -21
- package/app/api/agents/[id]/backup/route.ts +26 -69
- package/app/api/agents/[id]/channels/pairing/route.ts +3 -3
- package/app/api/agents/[id]/channels/telegram/route.ts +2 -2
- package/app/api/agents/[id]/cold-backup/route.ts +56 -0
- package/app/api/agents/[id]/devices/route.ts +126 -0
- package/app/api/agents/[id]/invite-link/route.ts +53 -0
- package/app/api/agents/[id]/lifecycle/route.ts +3 -0
- package/app/api/agents/[id]/model/route.ts +113 -0
- package/app/api/agents/[id]/open-control-ui/route.ts +58 -0
- package/app/api/agents/[id]/recreate/route.ts +33 -187
- package/app/api/agents/[id]/restart/route.ts +5 -0
- package/app/api/agents/[id]/restore/route.ts +40 -70
- package/app/api/agents/[id]/route.ts +38 -169
- package/app/api/agents/[id]/update/rollback/route.ts +30 -0
- package/app/api/agents/[id]/update/route.ts +50 -0
- package/app/api/agents/activity-summary/route.ts +67 -0
- package/app/api/agents/create/route.ts +91 -145
- package/app/api/agents/devices-summary/route.ts +37 -0
- package/app/api/agents/download-image/route.ts +16 -9
- package/app/api/agents/image-status/route.ts +31 -111
- package/app/api/agents/models-summary/route.ts +163 -0
- package/app/api/agents/route.ts +25 -49
- package/app/api/agents/token/route.ts +33 -10
- package/app/api/assistant/route.ts +37 -16
- package/app/api/gateway/agent/route.ts +37 -6
- package/app/api/gateway/provider/balance/route.ts +5 -2
- package/app/api/gateway/provider/keys.ts +13 -1
- package/app/api/gateway/provider/route.ts +43 -12
- package/app/api/gateway/sync.ts +335 -76
- package/app/api/models/route.ts +28 -34
- package/app/api/provider/auth.ts +65 -0
- package/app/api/provider/upstream.ts +9 -2
- package/app/api/provider/v1/chat/completions/route.ts +22 -16
- package/app/api/provider/v1/models/route.ts +26 -133
- package/app/api/setup/agent-image/route.ts +14 -42
- package/app/components/DashboardToolbar.tsx +1 -1
- package/app/components/PulseChat.tsx +25 -39
- package/app/components/ui/RemoveButton.tsx +46 -0
- package/app/components/ui/Select.tsx +3 -2
- package/app/components/ui/index.ts +1 -0
- package/app/credentials/PageClient.tsx +2 -2
- package/app/gateway/PageClient.tsx +253 -674
- package/app/globals.css +8 -0
- package/app/lib/models-context.tsx +43 -7
- package/app/wizard/useWizard.ts +6 -1
- package/bin/rev4a.js +116 -50
- package/daemon.js +6 -6
- package/docs/ARCHITECTURE.md +110 -12
- package/docs/FRONTEND-ARCHITECTURE.md +31 -2
- package/docs/REV4A.md +93 -33
- package/docs/dev/API-REFERENCE.md +723 -178
- package/docs/dev/DATABASE.md +96 -0
- package/docs/dev/GATEWAY.md +250 -93
- package/docs/dev/PROVIDERS.md +26 -13
- package/docs/rag/DATA-FRESHNESS.md +59 -28
- package/docs/rag/GLOSSARY.md +27 -16
- package/docs/rag/REV4A-OVERVIEW.md +37 -25
- package/docs/rag/WHAT-I-CAN-ANSWER.md +10 -8
- package/instrumentation.ts +52 -1
- package/lib/agent-busy.ts +21 -0
- package/lib/agent-devices.ts +361 -0
- package/lib/agent-edit-state.ts +108 -0
- package/lib/agent-edit.ts +157 -0
- package/lib/agent-images.ts +375 -0
- package/lib/agent-ports-server.ts +27 -0
- package/lib/agent-ports.ts +68 -0
- package/lib/agent-readiness.ts +110 -0
- package/lib/agent-recreate-state.ts +108 -0
- package/lib/agent-recreate.ts +305 -0
- package/lib/agent-restore-state.ts +107 -0
- package/lib/agent-restore.ts +135 -0
- package/lib/agent-setup.ts +66 -17
- package/lib/agent-update-state.ts +122 -0
- package/lib/agent-update.ts +448 -0
- package/lib/agent-versions.json +14 -0
- package/lib/agent-versions.ts +80 -0
- package/lib/buildAgentImage.ts +88 -290
- package/lib/channelManager.ts +153 -64
- package/lib/cold-backup.ts +354 -0
- package/lib/container-file.ts +27 -0
- package/lib/credentials/delivery.ts +3 -3
- package/lib/db-bootstrap.mjs +76 -0
- package/lib/docker-utils.ts +3 -3
- package/lib/model-catalogue.ts +140 -27
- package/lib/provider-balance.ts +33 -12
- package/lib/rev4a-paths.ts +0 -21
- package/model-pricing.json +118 -110
- package/models.config.json +27 -12
- package/package.json +1 -1
- package/app/api/gateway/route.ts +0 -191
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
4
|
-
import { Button, ConfirmModal, Input, LoadingSpinner, Metric, ModalityIcons, Page, PageHeader, Pill, Surface
|
|
4
|
+
import { Button, ConfirmModal, Input, LoadingSpinner, Metric, ModalityIcons, Page, PageHeader, Pill, Surface } from '../components/ui';
|
|
5
5
|
import PasswordInput from '../components/PasswordInput';
|
|
6
6
|
import { Skeleton } from '../components/Skeleton';
|
|
7
|
+
import { useModels } from '../lib/models-context';
|
|
7
8
|
|
|
8
9
|
/* ------------------------------------------------------------------ */
|
|
9
10
|
/* Types */
|
|
10
11
|
/* ------------------------------------------------------------------ */
|
|
11
|
-
type Tab = 'providers' | 'agents';
|
|
12
|
-
|
|
13
12
|
interface ModelInfo {
|
|
14
13
|
id: string;
|
|
15
14
|
name: string;
|
|
@@ -32,22 +31,6 @@ interface GatewayProvider {
|
|
|
32
31
|
apiKey?: string;
|
|
33
32
|
}
|
|
34
33
|
|
|
35
|
-
interface GatewayAgent {
|
|
36
|
-
agentId: string;
|
|
37
|
-
containerName: string;
|
|
38
|
-
state: string;
|
|
39
|
-
defaultModel: string;
|
|
40
|
-
fallbacks: string[];
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
interface GatewayData {
|
|
44
|
-
timestamp: number;
|
|
45
|
-
gateway: string;
|
|
46
|
-
status: string;
|
|
47
|
-
agents: { total: number; list: GatewayAgent[] };
|
|
48
|
-
apiKeys: { configured: string[]; all: { provider: string; configured: boolean }[] };
|
|
49
|
-
}
|
|
50
|
-
|
|
51
34
|
interface ProviderApiResponse {
|
|
52
35
|
providers: GatewayProvider[];
|
|
53
36
|
}
|
|
@@ -60,280 +43,30 @@ function LoadingOverlay({ message }: { message: string }) {
|
|
|
60
43
|
}
|
|
61
44
|
|
|
62
45
|
/* ------------------------------------------------------------------ */
|
|
63
|
-
/*
|
|
64
|
-
/* ------------------------------------------------------------------ */
|
|
65
|
-
function FallbackSelector({
|
|
66
|
-
availableModels,
|
|
67
|
-
selected,
|
|
68
|
-
onChange,
|
|
69
|
-
}: {
|
|
70
|
-
availableModels: { id: string; name: string; providerLabel: string }[];
|
|
71
|
-
selected: string[];
|
|
72
|
-
onChange: (models: string[]) => void;
|
|
73
|
-
}) {
|
|
74
|
-
const [isOpen, setIsOpen] = useState(false);
|
|
75
|
-
|
|
76
|
-
const toggleModel = (id: string) => {
|
|
77
|
-
if (selected.includes(id)) {
|
|
78
|
-
onChange(selected.filter((s) => s !== id));
|
|
79
|
-
} else {
|
|
80
|
-
onChange([...selected, id]);
|
|
81
|
-
}
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
return (
|
|
85
|
-
<div style={{ position: 'relative' }}>
|
|
86
|
-
<div
|
|
87
|
-
onClick={() => setIsOpen(!isOpen)}
|
|
88
|
-
style={{
|
|
89
|
-
cursor: 'pointer',
|
|
90
|
-
padding: '8px 10px',
|
|
91
|
-
borderRadius: 0,
|
|
92
|
-
border: '1px solid var(--border)',
|
|
93
|
-
background: 'var(--surface)',
|
|
94
|
-
fontSize: 12,
|
|
95
|
-
minHeight: 36,
|
|
96
|
-
display: 'flex',
|
|
97
|
-
flexWrap: 'wrap',
|
|
98
|
-
gap: 4,
|
|
99
|
-
alignItems: 'center',
|
|
100
|
-
}}
|
|
101
|
-
>
|
|
102
|
-
{selected.length === 0 && (
|
|
103
|
-
<span style={{ color: 'var(--text-dim)' }}>No fallbacks</span>
|
|
104
|
-
)}
|
|
105
|
-
{selected.map((id) => {
|
|
106
|
-
const m = availableModels.find((a) => a.id === id);
|
|
107
|
-
return (
|
|
108
|
-
<span
|
|
109
|
-
key={id}
|
|
110
|
-
onClick={(e) => {
|
|
111
|
-
e.stopPropagation();
|
|
112
|
-
toggleModel(id);
|
|
113
|
-
}}
|
|
114
|
-
style={{
|
|
115
|
-
display: 'inline-flex',
|
|
116
|
-
alignItems: 'center',
|
|
117
|
-
gap: 4,
|
|
118
|
-
padding: '3px 8px',
|
|
119
|
-
borderRadius: 0,
|
|
120
|
-
background: 'rgba(167,139,250,0.14)',
|
|
121
|
-
border: '1px solid rgba(167,139,250,0.24)',
|
|
122
|
-
fontSize: 11,
|
|
123
|
-
cursor: 'pointer',
|
|
124
|
-
}}
|
|
125
|
-
>
|
|
126
|
-
{m?.name || id}
|
|
127
|
-
<span style={{ marginLeft: 2, opacity: 0.6 }}>✕</span>
|
|
128
|
-
</span>
|
|
129
|
-
);
|
|
130
|
-
})}
|
|
131
|
-
<span style={{ color: 'var(--text-dim)', fontSize: 11, marginLeft: 'auto' }}>▼</span>
|
|
132
|
-
</div>
|
|
133
|
-
|
|
134
|
-
{isOpen && (
|
|
135
|
-
<div
|
|
136
|
-
style={{
|
|
137
|
-
position: 'absolute',
|
|
138
|
-
top: '100%',
|
|
139
|
-
left: 0,
|
|
140
|
-
right: 0,
|
|
141
|
-
zIndex: 100,
|
|
142
|
-
marginTop: 4,
|
|
143
|
-
background: 'var(--surface)',
|
|
144
|
-
border: '1px solid var(--border)',
|
|
145
|
-
borderRadius: 0,
|
|
146
|
-
maxHeight: 220,
|
|
147
|
-
overflowY: 'auto',
|
|
148
|
-
boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
|
|
149
|
-
}}
|
|
150
|
-
>
|
|
151
|
-
{availableModels.map((m) => {
|
|
152
|
-
const isSel = selected.includes(m.id);
|
|
153
|
-
return (
|
|
154
|
-
<div
|
|
155
|
-
key={m.id}
|
|
156
|
-
onClick={() => toggleModel(m.id)}
|
|
157
|
-
style={{
|
|
158
|
-
padding: '8px 12px',
|
|
159
|
-
cursor: 'pointer',
|
|
160
|
-
fontSize: 12,
|
|
161
|
-
display: 'flex',
|
|
162
|
-
alignItems: 'center',
|
|
163
|
-
gap: 8,
|
|
164
|
-
background: isSel ? 'rgba(var(--green-rgb,34,197,94),0.06)' : 'transparent',
|
|
165
|
-
}}
|
|
166
|
-
>
|
|
167
|
-
<input
|
|
168
|
-
type="checkbox"
|
|
169
|
-
checked={isSel}
|
|
170
|
-
readOnly
|
|
171
|
-
style={{ accentColor: 'var(--green)', pointerEvents: 'none' }}
|
|
172
|
-
/>
|
|
173
|
-
<div>
|
|
174
|
-
<div style={{ fontWeight: isSel ? 500 : 400 }}>{m.name}</div>
|
|
175
|
-
<div style={{ fontSize: 10, color: 'var(--text-dim)', fontFamily: 'monospace' }}>{m.id}</div>
|
|
176
|
-
</div>
|
|
177
|
-
</div>
|
|
178
|
-
);
|
|
179
|
-
})}
|
|
180
|
-
{availableModels.length === 0 && (
|
|
181
|
-
<div style={{ padding: '12px', color: 'var(--text-dim)', fontSize: 12, textAlign: 'center' }}>
|
|
182
|
-
No models available
|
|
183
|
-
</div>
|
|
184
|
-
)}
|
|
185
|
-
</div>
|
|
186
|
-
)}
|
|
187
|
-
|
|
188
|
-
{isOpen && (
|
|
189
|
-
<div
|
|
190
|
-
onClick={() => setIsOpen(false)}
|
|
191
|
-
style={{ position: 'fixed', inset: 0, zIndex: 99 }}
|
|
192
|
-
/>
|
|
193
|
-
)}
|
|
194
|
-
</div>
|
|
195
|
-
);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/* ------------------------------------------------------------------ */
|
|
199
|
-
/* AgentEditForm — select + fallback pills (inline) */
|
|
46
|
+
/* Component (main) */
|
|
200
47
|
/* ------------------------------------------------------------------ */
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
onCancel: () => void;
|
|
217
|
-
}) {
|
|
218
|
-
const [modelInput, setModelInput] = useState(initialModel || '');
|
|
219
|
-
const [fallbacks, setFallbacks] = useState<string[]>(
|
|
220
|
-
[...new Set(initialFallbacks ? initialFallbacks.split(',').map((s) => s.trim()).filter(Boolean) : [])],
|
|
221
|
-
);
|
|
222
|
-
const fallbackCandidates = allModels.filter((m) => m.id !== modelInput);
|
|
223
|
-
|
|
224
|
-
const toggleFallback = (id: string) => {
|
|
225
|
-
setFallbacks((prev) => prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id]);
|
|
226
|
-
};
|
|
227
|
-
|
|
228
|
-
return (
|
|
229
|
-
<div style={{ display: 'grid', gap: 10, borderTop: '1px solid var(--border)', paddingTop: 12 }}>
|
|
230
|
-
<div style={{ fontSize: 12, color: 'var(--text-dim)' }}>
|
|
231
|
-
Update model for <strong>{agentId}</strong>.
|
|
232
|
-
</div>
|
|
233
|
-
|
|
234
|
-
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
|
235
|
-
{/* Primary model — styled native select */}
|
|
236
|
-
<div style={{ flex: 1, minWidth: 220 }}>
|
|
237
|
-
<div style={{ fontSize: 11, color: 'var(--text-dim)', marginBottom: 4 }}>Primary model</div>
|
|
238
|
-
<select
|
|
239
|
-
value={modelInput}
|
|
240
|
-
onChange={(e) => setModelInput(e.target.value)}
|
|
241
|
-
style={{
|
|
242
|
-
width: '100%',
|
|
243
|
-
background: 'var(--bg3)',
|
|
244
|
-
color: 'var(--text)',
|
|
245
|
-
border: '1px solid var(--border)',
|
|
246
|
-
borderRadius: 0,
|
|
247
|
-
fontSize: 11,
|
|
248
|
-
padding: '6px 28px 6px 8px',
|
|
249
|
-
fontFamily: 'var(--font-mono-stack)',
|
|
250
|
-
cursor: 'pointer',
|
|
251
|
-
outline: 'none',
|
|
252
|
-
WebkitAppearance: 'none',
|
|
253
|
-
MozAppearance: 'none',
|
|
254
|
-
appearance: 'none',
|
|
255
|
-
backgroundImage: "url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12'><path fill='%238a8a92' d='M6 8L1 3h10z'/></svg>\")",
|
|
256
|
-
backgroundRepeat: 'no-repeat',
|
|
257
|
-
backgroundPosition: 'right 8px center',
|
|
258
|
-
}}
|
|
259
|
-
>
|
|
260
|
-
<option value="" disabled>Select model…</option>
|
|
261
|
-
{allModels.map((m) => (
|
|
262
|
-
<option key={m.id} value={m.id}>
|
|
263
|
-
{m.name} — {m.providerLabel}
|
|
264
|
-
</option>
|
|
265
|
-
))}
|
|
266
|
-
</select>
|
|
267
|
-
</div>
|
|
268
|
-
|
|
269
|
-
{/* Fallback pills selector */}
|
|
270
|
-
<div style={{ flex: 1, minWidth: 220 }}>
|
|
271
|
-
<div style={{ fontSize: 11, color: 'var(--text-dim)', marginBottom: 4 }}>Fallback</div>
|
|
272
|
-
<div style={{
|
|
273
|
-
display: 'flex',
|
|
274
|
-
flexWrap: 'wrap',
|
|
275
|
-
gap: 4,
|
|
276
|
-
padding: 4,
|
|
277
|
-
minHeight: 36,
|
|
278
|
-
borderRadius: 0,
|
|
279
|
-
border: '1px solid var(--border)',
|
|
280
|
-
background: 'var(--surface)',
|
|
281
|
-
}}>
|
|
282
|
-
{fallbackCandidates.length === 0 ? (
|
|
283
|
-
<span style={{ color: 'var(--text-dim)', fontSize: 11, padding: '6px 4px' }}>No models available</span>
|
|
284
|
-
) : fallbacks.length === 0 ? (
|
|
285
|
-
<span style={{ color: 'var(--text-dim)', fontSize: 11, padding: '6px 4px' }}>Click a model to add as fallback</span>
|
|
286
|
-
) : null}
|
|
287
|
-
{fallbackCandidates.map((m) => {
|
|
288
|
-
const isSelected = fallbacks.includes(m.id);
|
|
289
|
-
return (
|
|
290
|
-
<span
|
|
291
|
-
key={m.id}
|
|
292
|
-
onClick={() => toggleFallback(m.id)}
|
|
293
|
-
style={{
|
|
294
|
-
display: 'inline-flex',
|
|
295
|
-
alignItems: 'center',
|
|
296
|
-
gap: 3,
|
|
297
|
-
padding: '3px 8px',
|
|
298
|
-
borderRadius: 0,
|
|
299
|
-
fontSize: 11,
|
|
300
|
-
cursor: 'pointer',
|
|
301
|
-
background: isSelected
|
|
302
|
-
? 'rgba(167,139,250,0.18)'
|
|
303
|
-
: 'rgba(255,255,255,0.05)',
|
|
304
|
-
border: isSelected
|
|
305
|
-
? '1px solid rgba(167,139,250,0.35)'
|
|
306
|
-
: '1px solid rgba(255,255,255,0.1)',
|
|
307
|
-
transition: 'all 0.12s',
|
|
308
|
-
}}
|
|
309
|
-
>
|
|
310
|
-
{m.name}
|
|
311
|
-
{isSelected && <span style={{ marginLeft: 2, opacity: 0.6 }}>✕</span>}
|
|
312
|
-
</span>
|
|
313
|
-
);
|
|
314
|
-
})}
|
|
315
|
-
</div>
|
|
316
|
-
</div>
|
|
317
|
-
</div>
|
|
318
|
-
|
|
319
|
-
<div style={{ display: 'flex', gap: 8 }}>
|
|
320
|
-
<Button variant="primary" size="sm" loading={saving} disabled={!modelInput.trim()} onClick={() => onSave(modelInput, fallbacks)}>
|
|
321
|
-
{saving ? 'Saving…' : 'Save'}
|
|
322
|
-
</Button>
|
|
323
|
-
<Button variant="secondary" size="sm" onClick={onCancel}>
|
|
324
|
-
Cancel
|
|
325
|
-
</Button>
|
|
326
|
-
</div>
|
|
327
|
-
</div>
|
|
328
|
-
);
|
|
48
|
+
/** The sync part of a provider-route response. */
|
|
49
|
+
interface SyncOutcomeView { ok: boolean; summary: string }
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Message for a change that was saved, given how far the sync got.
|
|
53
|
+
*
|
|
54
|
+
* The save and its propagation are separate outcomes. Every handler here used to
|
|
55
|
+
* check only `status === 'ok'` and ignore `sync`, so a key or toggle saved while
|
|
56
|
+
* Docker was stopped read as a complete success — one said "Agent sync complete"
|
|
57
|
+
* unconditionally.
|
|
58
|
+
*/
|
|
59
|
+
function savedMessage(saved: string, sync: SyncOutcomeView | undefined): { type: 'success' | 'warning'; text: string } {
|
|
60
|
+
if (!sync) return { type: 'success', text: saved };
|
|
61
|
+
if (sync.ok) return { type: 'success', text: `${saved} ${sync.summary}` };
|
|
62
|
+
return { type: 'warning', text: `${saved} ${sync.summary} Run Sync All Agents once the cause is fixed.` };
|
|
329
63
|
}
|
|
330
64
|
|
|
331
|
-
/* ------------------------------------------------------------------ */
|
|
332
|
-
/* Component (main) */
|
|
333
|
-
/* ------------------------------------------------------------------ */
|
|
334
65
|
export default function GatewayPageClient() {
|
|
335
|
-
|
|
336
|
-
|
|
66
|
+
// This page is the only thing that changes which models are on offer, so it is
|
|
67
|
+
// the only thing that has to tell the rest of the app to re-read them. Without
|
|
68
|
+
// this the chat panel's select kept the old list for the whole session.
|
|
69
|
+
const { refresh: refreshOfferedModels } = useModels();
|
|
337
70
|
const [providers, setProviders] = useState<GatewayProvider[]>([]);
|
|
338
71
|
const [loading, setLoading] = useState(true);
|
|
339
72
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -347,20 +80,15 @@ export default function GatewayPageClient() {
|
|
|
347
80
|
// API key form state
|
|
348
81
|
const [apiKeyInputs, setApiKeyInputs] = useState<Record<string, string>>({});
|
|
349
82
|
const [originalKeys, setOriginalKeys] = useState<Record<string, string>>({});
|
|
350
|
-
const [messages, setMessages] = useState<Record<string, { type: 'success' | 'error'; text: string } | null>>({});
|
|
83
|
+
const [messages, setMessages] = useState<Record<string, { type: 'success' | 'error' | 'warning'; text: string } | null>>({});
|
|
351
84
|
const [revealedKeys, setRevealedKeys] = useState<Record<string, string | null>>({});
|
|
352
85
|
const [revealing, setRevealing] = useState<Record<string, boolean>>({});
|
|
353
86
|
|
|
354
87
|
// Per-provider model filter (purely local, no requests)
|
|
355
88
|
const [modelSearch, setModelSearch] = useState<Record<string, string>>({});
|
|
356
89
|
|
|
357
|
-
// Agent edit state
|
|
358
|
-
const [editingAgent, setEditingAgent] = useState<string | null>(null);
|
|
359
|
-
const [agentModelInput, setAgentModelInput] = useState('');
|
|
360
|
-
const [agentFallbackInput, setAgentFallbackInput] = useState('');
|
|
361
|
-
|
|
362
90
|
// Balance fetch state (per provider)
|
|
363
|
-
const [balances, setBalances] = useState<Record<string, { balance: string; label?: string; loading?: boolean; error?: string } | null>>({});
|
|
91
|
+
const [balances, setBalances] = useState<Record<string, { balance: string; label?: string; loading?: boolean; error?: string; unsupported?: boolean } | null>>({});
|
|
364
92
|
const [fetchingBalance, setFetchingBalance] = useState<Record<string, boolean>>({});
|
|
365
93
|
|
|
366
94
|
// Accordion state — which provider cards are expanded
|
|
@@ -371,10 +99,7 @@ export default function GatewayPageClient() {
|
|
|
371
99
|
const loadAll = useCallback(async () => {
|
|
372
100
|
setLoading(true);
|
|
373
101
|
try {
|
|
374
|
-
const
|
|
375
|
-
fetch('/api/gateway/provider'),
|
|
376
|
-
fetch('/api/gateway'),
|
|
377
|
-
]);
|
|
102
|
+
const provRes = await fetch('/api/gateway/provider');
|
|
378
103
|
if (provRes.ok) {
|
|
379
104
|
const provJson: ProviderApiResponse = await provRes.json();
|
|
380
105
|
setProviders(provJson.providers);
|
|
@@ -410,6 +135,9 @@ export default function GatewayPageClient() {
|
|
|
410
135
|
const bJson = await bRes.json();
|
|
411
136
|
if (bRes.ok && bJson.balance) {
|
|
412
137
|
setBalances((prev) => ({ ...prev, [bp]: { balance: bJson.balance, label: bJson.label } }));
|
|
138
|
+
} else if (bJson.reason === 'no-balance-api') {
|
|
139
|
+
// Permanent: this provider has no balance API for the account — hide the badge.
|
|
140
|
+
setBalances((prev) => ({ ...prev, [bp]: { balance: '', unsupported: true } }));
|
|
413
141
|
} else {
|
|
414
142
|
setBalances((prev) => ({ ...prev, [bp]: { balance: '—', error: bJson.error || 'Failed' } }));
|
|
415
143
|
}
|
|
@@ -418,10 +146,6 @@ export default function GatewayPageClient() {
|
|
|
418
146
|
}
|
|
419
147
|
}
|
|
420
148
|
}
|
|
421
|
-
if (gwRes.ok) {
|
|
422
|
-
const gwJson: GatewayData = await gwRes.json();
|
|
423
|
-
setGatewayData(gwJson);
|
|
424
|
-
}
|
|
425
149
|
} catch (e: unknown) {
|
|
426
150
|
setError(e instanceof Error ? e.message : String(e));
|
|
427
151
|
} finally {
|
|
@@ -477,6 +201,8 @@ export default function GatewayPageClient() {
|
|
|
477
201
|
const json = await res.json();
|
|
478
202
|
if (res.ok && json.balance) {
|
|
479
203
|
setBalances((prev) => ({ ...prev, [provider]: { balance: json.balance, label: json.label } }));
|
|
204
|
+
} else if (json.reason === 'no-balance-api') {
|
|
205
|
+
setBalances((prev) => ({ ...prev, [provider]: { balance: '', unsupported: true } }));
|
|
480
206
|
} else {
|
|
481
207
|
setBalances((prev) => ({ ...prev, [provider]: { balance: '—', error: json.error || 'Failed' } }));
|
|
482
208
|
}
|
|
@@ -501,9 +227,10 @@ export default function GatewayPageClient() {
|
|
|
501
227
|
});
|
|
502
228
|
const json = await res.json();
|
|
503
229
|
if (json.status === 'ok') {
|
|
504
|
-
setMessages((prev) => ({ ...prev, [provider]:
|
|
230
|
+
setMessages((prev) => ({ ...prev, [provider]: savedMessage('API key saved.', json.sync) }));
|
|
505
231
|
setOriginalKeys((prev) => ({ ...prev, [provider]: key }));
|
|
506
232
|
setProviders((prev) => prev.map((p) => p.provider === provider ? { ...p, configured: true } : p));
|
|
233
|
+
refreshOfferedModels(); // a new key can make models available
|
|
507
234
|
await loadAll();
|
|
508
235
|
} else {
|
|
509
236
|
setMessages((prev) => ({ ...prev, [provider]: { type: 'error', text: json.error || 'Error' } }));
|
|
@@ -534,8 +261,9 @@ export default function GatewayPageClient() {
|
|
|
534
261
|
});
|
|
535
262
|
const json = await res.json();
|
|
536
263
|
if (json.status === 'ok') {
|
|
537
|
-
setMessages((prev) => ({ ...prev, [provider]:
|
|
264
|
+
setMessages((prev) => ({ ...prev, [provider]: savedMessage('API key removed.', json.sync) }));
|
|
538
265
|
setProviders((prev) => prev.map((p) => p.provider === provider ? { ...p, configured: false } : p));
|
|
266
|
+
refreshOfferedModels(); // its models are no longer offered
|
|
539
267
|
await loadAll();
|
|
540
268
|
} else {
|
|
541
269
|
setMessages((prev) => ({ ...prev, [provider]: { type: 'error', text: json.error || 'Error' } }));
|
|
@@ -560,7 +288,7 @@ export default function GatewayPageClient() {
|
|
|
560
288
|
});
|
|
561
289
|
const json = await res.json();
|
|
562
290
|
if (json.status === 'ok') {
|
|
563
|
-
setMessages((prev) => ({ ...prev, rev4a:
|
|
291
|
+
setMessages((prev) => ({ ...prev, rev4a: savedMessage('REV4A API KEY saved.', json.sync) }));
|
|
564
292
|
setOriginalKeys((prev) => ({ ...prev, rev4a: key }));
|
|
565
293
|
await loadAll();
|
|
566
294
|
} else {
|
|
@@ -593,6 +321,10 @@ export default function GatewayPageClient() {
|
|
|
593
321
|
),
|
|
594
322
|
})),
|
|
595
323
|
);
|
|
324
|
+
refreshOfferedModels();
|
|
325
|
+
if (json.sync && !json.sync.ok) {
|
|
326
|
+
setMessages((prev) => ({ ...prev, [`model-${modelId}`]: savedMessage(`${enabled ? 'Enabled' : 'Disabled'}.`, json.sync) }));
|
|
327
|
+
}
|
|
596
328
|
} else {
|
|
597
329
|
setMessages((prev) => ({ ...prev, [`model-${modelId}`]: { type: 'error', text: json.error || 'Error' } }));
|
|
598
330
|
}
|
|
@@ -602,63 +334,9 @@ export default function GatewayPageClient() {
|
|
|
602
334
|
});
|
|
603
335
|
}
|
|
604
336
|
|
|
605
|
-
/* ---- Agent model update ---- */
|
|
606
|
-
async function handleUpdateAgent(key: string, model?: string, fallbacksFromForm?: string[]) {
|
|
607
|
-
if (!model && !agentModelInput.trim()) return;
|
|
608
|
-
const modelValue = model || agentModelInput.trim();
|
|
609
|
-
const fallbacksValue = fallbacksFromForm || (agentFallbackInput.trim()
|
|
610
|
-
? agentFallbackInput.split(',').map((s) => s.trim()).filter(Boolean)
|
|
611
|
-
: []);
|
|
612
|
-
|
|
613
|
-
// Resolve container name from agentId or containerName
|
|
614
|
-
const agent = gatewayData?.agents?.list?.find(
|
|
615
|
-
(a) => a.containerName === key || a.agentId === key,
|
|
616
|
-
);
|
|
617
|
-
const containerName = agent?.containerName || key;
|
|
618
|
-
|
|
619
|
-
// Overlay: only during the PUT call
|
|
620
|
-
setOverlay({ active: true, message: 'Updating model…' });
|
|
621
|
-
try {
|
|
622
|
-
const res = await fetch('/api/gateway/agent', {
|
|
623
|
-
method: 'PUT',
|
|
624
|
-
headers: { 'Content-Type': 'application/json' },
|
|
625
|
-
body: JSON.stringify({ containerName, model: modelValue, fallbacks: fallbacksValue }),
|
|
626
|
-
});
|
|
627
|
-
const json = await res.json();
|
|
628
|
-
setOverlay({ active: false, message: '' });
|
|
629
|
-
|
|
630
|
-
if (json.status === 'ok') {
|
|
631
|
-
setMessages((prev) => ({ ...prev, [`agent-${key}`]: { type: 'success', text: 'Restarting gateway…' } }));
|
|
632
|
-
setEditingAgent(null);
|
|
633
|
-
setAgentModelInput('');
|
|
634
|
-
setAgentFallbackInput('');
|
|
635
|
-
// Poll in background: GET /api/gateway every second until it responds
|
|
636
|
-
for (let attempt = 0; attempt < 8; attempt++) {
|
|
637
|
-
await new Promise((r) => setTimeout(r, 1000));
|
|
638
|
-
try {
|
|
639
|
-
const gwRes = await fetch('/api/gateway');
|
|
640
|
-
if (gwRes.ok) {
|
|
641
|
-
const gwJson: GatewayData = await gwRes.json();
|
|
642
|
-
setGatewayData(gwJson);
|
|
643
|
-
break;
|
|
644
|
-
}
|
|
645
|
-
} catch { /* retry */ }
|
|
646
|
-
}
|
|
647
|
-
// Clear the message
|
|
648
|
-
setMessages((prev) => ({ ...prev, [`agent-${key}`]: null }));
|
|
649
|
-
} else {
|
|
650
|
-
setMessages((prev) => ({ ...prev, [`agent-${key}`]: { type: 'error', text: json.error || 'Error' } }));
|
|
651
|
-
}
|
|
652
|
-
} catch (e: unknown) {
|
|
653
|
-
setOverlay({ active: false, message: '' });
|
|
654
|
-
setMessages((prev) => ({ ...prev, [`agent-${key}`]: { type: 'error', text: e instanceof Error ? e.message : 'Network error' } }));
|
|
655
|
-
}
|
|
656
|
-
}
|
|
657
|
-
|
|
658
337
|
/* ---- Helpers ---- */
|
|
659
338
|
const configuredCount = providers.filter((p) => p.configured && p.provider !== 'rev4a').length;
|
|
660
339
|
const rev4aKeyConfigured = providers.find((p) => p.provider === 'rev4a')?.configured ?? false;
|
|
661
|
-
const agentCount = gatewayData?.agents?.total ?? 0;
|
|
662
340
|
const allEnabledModels = providers
|
|
663
341
|
.filter((p) => p.configured)
|
|
664
342
|
.flatMap((p) =>
|
|
@@ -701,7 +379,7 @@ export default function GatewayPageClient() {
|
|
|
701
379
|
<PageHeader
|
|
702
380
|
eyebrow=""
|
|
703
381
|
title="Gateway"
|
|
704
|
-
description="
|
|
382
|
+
description="Providers and the model catalogue. API keys are stored in .env."
|
|
705
383
|
/>
|
|
706
384
|
|
|
707
385
|
{/* Sync all agents — propagate provider baseUrl + models to every container */}
|
|
@@ -716,7 +394,10 @@ export default function GatewayPageClient() {
|
|
|
716
394
|
const data = await res.json();
|
|
717
395
|
setOverlay({ active: false, message: '' });
|
|
718
396
|
if (data.status === 'ok') {
|
|
719
|
-
setMessages((prev) => ({ ...prev, refresh: { type: 'success', text:
|
|
397
|
+
setMessages((prev) => ({ ...prev, refresh: { type: 'success', text: data.sync?.summary ?? 'Synced.' } }));
|
|
398
|
+
refreshOfferedModels();
|
|
399
|
+
} else {
|
|
400
|
+
setMessages((prev) => ({ ...prev, refresh: { type: 'error', text: data.error || data.sync?.summary || 'Sync failed' } }));
|
|
720
401
|
}
|
|
721
402
|
} catch (e: unknown) {
|
|
722
403
|
setOverlay({ active: false, message: '' });
|
|
@@ -727,7 +408,7 @@ export default function GatewayPageClient() {
|
|
|
727
408
|
↻ Sync All Agents
|
|
728
409
|
</Button>
|
|
729
410
|
{messages.refresh && (
|
|
730
|
-
<span style={{ fontSize: 11, color: messages.refresh.type === 'success' ? '#34d399' : '#ef4444' }}>
|
|
411
|
+
<span style={{ fontSize: 11, color: messages.refresh.type === 'success' ? '#34d399' : messages.refresh.type === 'warning' ? 'var(--yellow)' : '#ef4444' }}>
|
|
731
412
|
{messages.refresh.text}
|
|
732
413
|
</span>
|
|
733
414
|
)}
|
|
@@ -739,13 +420,11 @@ export default function GatewayPageClient() {
|
|
|
739
420
|
<>
|
|
740
421
|
<Skeleton height={72} style={{ border: '1px solid rgba(255,255,255,0.06)' }} />
|
|
741
422
|
<Skeleton height={72} style={{ border: '1px solid rgba(255,255,255,0.06)' }} />
|
|
742
|
-
<Skeleton height={72} style={{ border: '1px solid rgba(255,255,255,0.06)' }} />
|
|
743
423
|
</>
|
|
744
424
|
) : (
|
|
745
425
|
<>
|
|
746
426
|
<Metric title="Configured providers" value={`${configuredCount} / ${providers.filter((p) => p.provider !== 'rev4a').length}`} subtitle="DeepSeek, OpenRouter, Codex, …" tone={configuredCount > 0 ? 'success' : 'warning'} />
|
|
747
427
|
<Metric title="Active models" value={allEnabledModels.length} subtitle="for all configured providers" tone={allEnabledModels.length > 0 ? 'success' : 'warning'} />
|
|
748
|
-
<Metric title="Agents" value={agentCount} subtitle="with AGENT_ID label" tone={agentCount > 0 ? 'success' : 'warning'} />
|
|
749
428
|
</>
|
|
750
429
|
)}
|
|
751
430
|
</section>
|
|
@@ -785,8 +464,8 @@ export default function GatewayPageClient() {
|
|
|
785
464
|
<div style={{
|
|
786
465
|
flex: '1 1 100%', padding: '6px 10px', borderRadius: 0,
|
|
787
466
|
fontSize: 12, fontWeight: 500,
|
|
788
|
-
background: messages['rev4a']!.type === 'success' ? 'rgba(var(--green-rgb,34,197,94),0.1)' : 'rgba(var(--red-rgb,239,68,68),0.1)',
|
|
789
|
-
color: messages['rev4a']!.type === 'success' ? 'var(--green)' : 'var(--red)',
|
|
467
|
+
background: messages['rev4a']!.type === 'success' ? 'rgba(var(--green-rgb,34,197,94),0.1)' : messages['rev4a']!.type === 'warning' ? 'rgba(245,158,11,0.1)' : 'rgba(var(--red-rgb,239,68,68),0.1)',
|
|
468
|
+
color: messages['rev4a']!.type === 'success' ? 'var(--green)' : messages['rev4a']!.type === 'warning' ? 'var(--yellow)' : 'var(--red)',
|
|
790
469
|
}}>
|
|
791
470
|
{messages['rev4a']!.text}
|
|
792
471
|
</div>
|
|
@@ -796,331 +475,231 @@ export default function GatewayPageClient() {
|
|
|
796
475
|
</Surface>
|
|
797
476
|
</div>
|
|
798
477
|
|
|
799
|
-
{/*
|
|
800
|
-
<
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
}
|
|
814
|
-
]}
|
|
815
|
-
/>
|
|
478
|
+
{/* Providers */}
|
|
479
|
+
<section style={{ display: 'grid', gap: 16 }}>
|
|
480
|
+
{providers.filter((p) => p.provider !== 'rev4a').map((p) => {
|
|
481
|
+
const enabledCount = p.models.filter((m) => m.enabled).length;
|
|
482
|
+
const msg = messages[p.provider];
|
|
483
|
+
const isExpanded = expandedProviders.has(p.provider);
|
|
484
|
+
|
|
485
|
+
const toggleExpanded = () => {
|
|
486
|
+
setExpandedProviders((prev) => {
|
|
487
|
+
const next = new Set(prev);
|
|
488
|
+
if (next.has(p.provider)) next.delete(p.provider);
|
|
489
|
+
else next.add(p.provider);
|
|
490
|
+
return next;
|
|
491
|
+
});
|
|
492
|
+
};
|
|
816
493
|
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
<span
|
|
873
|
-
title={balances[p.provider]?.label || ''}
|
|
874
|
-
onClick={(e) => { e.stopPropagation(); fetchBalance(p.provider); }}
|
|
875
|
-
style={{
|
|
876
|
-
fontSize: 11, fontWeight: 600,
|
|
877
|
-
color: 'var(--green)',
|
|
878
|
-
background: 'rgba(var(--green-rgb,34,197,94),0.1)',
|
|
879
|
-
padding: '2px 8px', borderRadius: 0,
|
|
880
|
-
border: '1px solid rgba(var(--green-rgb,34,197,94),0.2)',
|
|
881
|
-
cursor: 'pointer',
|
|
882
|
-
}}
|
|
883
|
-
>
|
|
884
|
-
{balances[p.provider]!.balance}
|
|
885
|
-
</span>
|
|
886
|
-
) : (
|
|
887
|
-
<span
|
|
888
|
-
onClick={(e) => { e.stopPropagation(); fetchBalance(p.provider); }}
|
|
889
|
-
style={{
|
|
890
|
-
fontSize: 11,
|
|
891
|
-
color: 'var(--text-dim)',
|
|
892
|
-
opacity: 0.5,
|
|
893
|
-
cursor: 'pointer',
|
|
894
|
-
}}
|
|
895
|
-
>
|
|
896
|
-
{fetchingBalance[p.provider] ? '…' : '…'}
|
|
897
|
-
</span>
|
|
898
|
-
)
|
|
899
|
-
)}
|
|
900
|
-
</div>
|
|
494
|
+
return (
|
|
495
|
+
<Surface key={p.provider} variant="panel">
|
|
496
|
+
{/* Accordion header — always visible, clickable */}
|
|
497
|
+
<div
|
|
498
|
+
onClick={toggleExpanded}
|
|
499
|
+
style={{
|
|
500
|
+
padding: '12px 18px',
|
|
501
|
+
display: 'flex',
|
|
502
|
+
alignItems: 'center',
|
|
503
|
+
gap: 12,
|
|
504
|
+
flexWrap: 'wrap',
|
|
505
|
+
cursor: 'pointer',
|
|
506
|
+
userSelect: 'none',
|
|
507
|
+
}}
|
|
508
|
+
>
|
|
509
|
+
{/* Expand/collapse arrow */}
|
|
510
|
+
<span style={{
|
|
511
|
+
fontSize: 10,
|
|
512
|
+
color: 'var(--text-dim)',
|
|
513
|
+
transition: 'transform 0.15s',
|
|
514
|
+
transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)',
|
|
515
|
+
minWidth: 12,
|
|
516
|
+
textAlign: 'center',
|
|
517
|
+
}}>
|
|
518
|
+
▶
|
|
519
|
+
</span>
|
|
520
|
+
<div style={{ width: 8, height: 8, borderRadius: '50%', background: p.configured ? 'var(--green)' : 'var(--gray)', flexShrink: 0 }} />
|
|
521
|
+
<div style={{ fontWeight: 600, fontSize: 14 }}>{p.label}</div>
|
|
522
|
+
{p.configured ? <Pill tone="success">Connected</Pill> : <Pill tone="warning">Not configured</Pill>}
|
|
523
|
+
{enabledCount > 0 ? <Pill tone="accent">{enabledCount} active models</Pill> : <Pill>{enabledCount} active models</Pill>}
|
|
524
|
+
<span style={{ marginLeft: 'auto', fontSize: 11, color: 'var(--text-dim)' }}>
|
|
525
|
+
{p.models.length} models
|
|
526
|
+
</span>
|
|
527
|
+
{/* Balance badge — auto-loaded for supported providers with configured key.
|
|
528
|
+
Same squared chip in every state: "..." while loading, "—" (dimmed, tooltip) on error.
|
|
529
|
+
Hidden entirely when the provider has no balance API for the account (permanent). */}
|
|
530
|
+
{p.configured && ['deepseek','openrouter','glm'].includes(p.provider) && !balances[p.provider]?.unsupported && (
|
|
531
|
+
<button
|
|
532
|
+
type="button"
|
|
533
|
+
title={balances[p.provider]?.error || balances[p.provider]?.label || ''}
|
|
534
|
+
onClick={(e) => { e.stopPropagation(); fetchBalance(p.provider); }}
|
|
535
|
+
style={{
|
|
536
|
+
fontSize: 11, fontWeight: 600,
|
|
537
|
+
color: 'var(--green)',
|
|
538
|
+
background: 'rgba(var(--green-rgb,34,197,94),0.1)',
|
|
539
|
+
padding: '2px 8px', borderRadius: 0,
|
|
540
|
+
border: '1px solid rgba(var(--green-rgb,34,197,94),0.2)',
|
|
541
|
+
cursor: 'pointer',
|
|
542
|
+
opacity: balances[p.provider]?.error ? 0.5 : 1,
|
|
543
|
+
}}
|
|
544
|
+
>
|
|
545
|
+
{balances[p.provider] && !balances[p.provider]?.error ? balances[p.provider]!.balance : balances[p.provider]?.error ? '—' : '...'}
|
|
546
|
+
</button>
|
|
547
|
+
)}
|
|
548
|
+
</div>
|
|
901
549
|
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
550
|
+
{/* Accordion body — expandable */}
|
|
551
|
+
{isExpanded && (
|
|
552
|
+
<div style={{ padding: '0 18px 16px 18px', display: 'grid', gap: 14, borderTop: '1px solid var(--border)', paddingTop: 14 }}>
|
|
553
|
+
{/* API Key input with an inline show/hide toggle */}
|
|
554
|
+
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
|
555
|
+
<PasswordInput
|
|
556
|
+
value={apiKeyInputs[p.provider] ?? ''}
|
|
557
|
+
onChange={(v) => setApiKeyInputs((prev) => ({ ...prev, [p.provider]: v }))}
|
|
558
|
+
placeholder={`API Key ${p.label}`}
|
|
559
|
+
style={{ flex: 1, minWidth: 200 }}
|
|
560
|
+
/>
|
|
561
|
+
<Button variant="primary" size="sm"
|
|
562
|
+
loading={overlay.active}
|
|
563
|
+
disabled={apiKeyInputs[p.provider]?.trim() === originalKeys[p.provider]?.trim()}
|
|
564
|
+
onClick={() => handleSave(p.provider)}>
|
|
565
|
+
Save
|
|
566
|
+
</Button>
|
|
567
|
+
{p.configured && (
|
|
568
|
+
<Button variant="danger" size="sm" disabled={overlay.active} onClick={() => handleRemove(p.provider)}>
|
|
569
|
+
Remove
|
|
918
570
|
</Button>
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
571
|
+
)}
|
|
572
|
+
</div>
|
|
573
|
+
|
|
574
|
+
{p.baseUrl && (
|
|
575
|
+
<div style={{ fontSize: 11, color: 'var(--text-dim)' }}>
|
|
576
|
+
Endpoint: <code style={{ background: 'rgba(255,255,255,0.06)', padding: '2px 6px', borderRadius: 0 }}>{p.baseUrl}</code>
|
|
577
|
+
{' • '}<a href={p.docsUrl} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--violet)' }}>Get key ↗</a>
|
|
924
578
|
</div>
|
|
579
|
+
)}
|
|
925
580
|
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
581
|
+
{/* Models with checkboxes */}
|
|
582
|
+
{p.models.length > 0 && (() => {
|
|
583
|
+
const query = modelSearch[p.provider] || '';
|
|
584
|
+
const visibleModels = filterModels(p.models, query);
|
|
585
|
+
// Below this many, everything fits on screen and a
|
|
586
|
+
// search box is just noise.
|
|
587
|
+
const showSearch = p.models.length > 6;
|
|
588
|
+
return (
|
|
589
|
+
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 12 }}>
|
|
590
|
+
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 8, marginBottom: 8, flexWrap: 'wrap' }}>
|
|
591
|
+
<div style={{ fontSize: 11, color: 'var(--text-dim)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
|
592
|
+
Models
|
|
593
|
+
<span style={{ marginLeft: 8, textTransform: 'none', letterSpacing: 'normal', color: 'var(--violet)', opacity: 0.7, fontSize: 10 }}>
|
|
594
|
+
in / out per 1M tokens
|
|
595
|
+
</span>
|
|
596
|
+
</div>
|
|
597
|
+
{query && (
|
|
598
|
+
<span style={{ fontSize: 10, color: 'var(--text-dim)' }}>
|
|
599
|
+
{visibleModels.length} of {p.models.length}
|
|
600
|
+
</span>
|
|
601
|
+
)}
|
|
930
602
|
</div>
|
|
931
|
-
)}
|
|
932
603
|
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
<span style={{ marginLeft: 8, textTransform: 'none', letterSpacing: 'normal', color: 'var(--violet)', opacity: 0.7, fontSize: 10 }}>
|
|
946
|
-
in / out per 1M tokens
|
|
947
|
-
</span>
|
|
948
|
-
</div>
|
|
949
|
-
{query && (
|
|
950
|
-
<span style={{ fontSize: 10, color: 'var(--text-dim)' }}>
|
|
951
|
-
{visibleModels.length} of {p.models.length}
|
|
952
|
-
</span>
|
|
953
|
-
)}
|
|
604
|
+
{showSearch && (
|
|
605
|
+
<div style={{ marginBottom: 8 }}>
|
|
606
|
+
{/* Deliberately type="text": Safari clears type="search"
|
|
607
|
+
on Escape via a `search` event React never sees,
|
|
608
|
+
leaving an empty box over a still-filtered list. */}
|
|
609
|
+
<Input
|
|
610
|
+
type="text"
|
|
611
|
+
value={query}
|
|
612
|
+
placeholder="Filter by name or provider — e.g. kimi, moonshot, flash"
|
|
613
|
+
aria-label={`Filter ${p.label} models`}
|
|
614
|
+
onChange={(e) => setModelSearch((prev) => ({ ...prev, [p.provider]: e.target.value }))}
|
|
615
|
+
/>
|
|
954
616
|
</div>
|
|
617
|
+
)}
|
|
955
618
|
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
leaving an empty box over a still-filtered list. */}
|
|
961
|
-
<Input
|
|
962
|
-
type="text"
|
|
963
|
-
value={query}
|
|
964
|
-
placeholder="Filter by name or provider — e.g. kimi, moonshot, flash"
|
|
965
|
-
aria-label={`Filter ${p.label} models`}
|
|
966
|
-
onChange={(e) => setModelSearch((prev) => ({ ...prev, [p.provider]: e.target.value }))}
|
|
967
|
-
/>
|
|
619
|
+
<div style={{ display: 'grid', gap: 4, maxHeight: 360, overflowY: 'auto' }}>
|
|
620
|
+
{visibleModels.length === 0 && (
|
|
621
|
+
<div style={{ fontSize: 12, color: 'var(--text-dim)', padding: '8px 4px' }}>
|
|
622
|
+
No model matches “{query}”.
|
|
968
623
|
</div>
|
|
969
624
|
)}
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
//
|
|
981
|
-
//
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
<
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
625
|
+
{visibleModels.map((m) => {
|
|
626
|
+
const modelMsg = messages[`model-${m.id}`];
|
|
627
|
+
const pricing = m.pricing;
|
|
628
|
+
// -1/-1 is OpenRouter's marker for router models,
|
|
629
|
+
// whose price and capabilities both vary per call.
|
|
630
|
+
const isDynamic = pricing?.input === -1 && pricing?.output === -1;
|
|
631
|
+
return (
|
|
632
|
+
// Only the name/id are inside the <label>: clicking
|
|
633
|
+
// anywhere in a label toggles its checkbox, and the
|
|
634
|
+
// badges below carry tooltips people will click to
|
|
635
|
+
// read — which would silently flip the model and
|
|
636
|
+
// restart every agent container.
|
|
637
|
+
<div key={m.id} style={{
|
|
638
|
+
display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 10, padding: '5px 8px',
|
|
639
|
+
background: m.enabled ? 'rgba(var(--green-rgb,34,197,94),0.06)' : 'transparent',
|
|
640
|
+
fontSize: 12,
|
|
641
|
+
}}>
|
|
642
|
+
<input type="checkbox" checked={m.enabled} disabled={overlay.active}
|
|
643
|
+
id={`model-${m.id}`}
|
|
644
|
+
onChange={(e) => handleToggleModel(m.id, e.target.checked)}
|
|
645
|
+
style={{ accentColor: 'var(--green)', cursor: 'pointer' }} />
|
|
646
|
+
<div style={{ flex: 1, minWidth: 0 }}>
|
|
647
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
|
648
|
+
<label
|
|
649
|
+
htmlFor={`model-${m.id}`}
|
|
650
|
+
style={{ fontWeight: m.enabled ? 500 : 400, cursor: overlay.active ? 'wait' : 'pointer' }}
|
|
651
|
+
>
|
|
652
|
+
{m.name}
|
|
653
|
+
</label>
|
|
654
|
+
<ModalityIcons modality={m.modality} dynamic={isDynamic} />
|
|
655
|
+
</div>
|
|
656
|
+
<div style={{ fontSize: 11, color: 'var(--text-dim)', fontFamily: 'monospace' }}>
|
|
657
|
+
{m.id}
|
|
658
|
+
<span style={{ marginLeft: 8, color: 'var(--violet)', fontWeight: 500 }}>
|
|
659
|
+
{fmtPrice(pricing)}
|
|
660
|
+
</span>
|
|
661
|
+
{m.pricingLive && (
|
|
662
|
+
<span
|
|
663
|
+
title="Live price from the OpenRouter API"
|
|
664
|
+
style={{ marginLeft: 6, display: 'inline-block', verticalAlign: 'middle' }}
|
|
1003
665
|
>
|
|
1004
|
-
|
|
1005
|
-
</label>
|
|
1006
|
-
<ModalityIcons modality={m.modality} dynamic={isDynamic} />
|
|
1007
|
-
</div>
|
|
1008
|
-
<div style={{ fontSize: 11, color: 'var(--text-dim)', fontFamily: 'monospace' }}>
|
|
1009
|
-
{m.id}
|
|
1010
|
-
<span style={{ marginLeft: 8, color: 'var(--violet)', fontWeight: 500 }}>
|
|
1011
|
-
{fmtPrice(pricing)}
|
|
666
|
+
<Pill tone="success">LIVE</Pill>
|
|
1012
667
|
</span>
|
|
1013
|
-
|
|
1014
|
-
<span
|
|
1015
|
-
title="Live price from the OpenRouter API"
|
|
1016
|
-
style={{ marginLeft: 6, display: 'inline-block', verticalAlign: 'middle' }}
|
|
1017
|
-
>
|
|
1018
|
-
<Pill tone="success">LIVE</Pill>
|
|
1019
|
-
</span>
|
|
1020
|
-
)}
|
|
1021
|
-
</div>
|
|
668
|
+
)}
|
|
1022
669
|
</div>
|
|
1023
|
-
{modelMsg && (
|
|
1024
|
-
<span style={{ fontSize: 11, color: modelMsg.type === 'success' ? 'var(--green)' : 'var(--red)' }}>
|
|
1025
|
-
{modelMsg.text}
|
|
1026
|
-
</span>
|
|
1027
|
-
)}
|
|
1028
670
|
</div>
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
671
|
+
{modelMsg && (
|
|
672
|
+
// Its own full-width line under the name, not a third column. As an
|
|
673
|
+
// unconstrained flex item a long sync summary took its natural width
|
|
674
|
+
// and crushed the name/id column to a few pixels. Indented past the
|
|
675
|
+
// checkbox so it reads as belonging to this model.
|
|
676
|
+
<div style={{
|
|
677
|
+
flexBasis: '100%', paddingLeft: 23, fontSize: 11, lineHeight: 1.45,
|
|
678
|
+
overflowWrap: 'anywhere',
|
|
679
|
+
color: modelMsg.type === 'success' ? 'var(--green)' : modelMsg.type === 'warning' ? 'var(--yellow)' : 'var(--red)',
|
|
680
|
+
}}>
|
|
681
|
+
{modelMsg.text}
|
|
682
|
+
</div>
|
|
683
|
+
)}
|
|
684
|
+
</div>
|
|
685
|
+
);
|
|
686
|
+
})}
|
|
1039
687
|
</div>
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
</Surface>
|
|
1044
|
-
);
|
|
1045
|
-
})}
|
|
1046
|
-
</section>
|
|
1047
|
-
)}
|
|
688
|
+
</div>
|
|
689
|
+
);
|
|
690
|
+
})()}
|
|
1048
691
|
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
{allEnabledModels.length > 0 && (
|
|
1055
|
-
<div style={{ marginBottom: 16 }}><Surface variant="panel">
|
|
1056
|
-
<div style={{ padding: '12px 16px' }}>
|
|
1057
|
-
<div style={{ fontSize: 11, color: 'var(--text-dim)', marginBottom: 8, textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
|
1058
|
-
Models available for agents ({allEnabledModels.length})
|
|
1059
|
-
</div>
|
|
1060
|
-
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
|
1061
|
-
{allEnabledModels.map((m) => {
|
|
1062
|
-
const tone: Tone | undefined = m.providerLabel === 'Rev4a Aliases' ? 'accent' : undefined;
|
|
1063
|
-
return <Pill key={m.id} tone={tone}>{m.id} <span style={{ opacity: 0.5 }}>({m.providerLabel})</span></Pill>;
|
|
1064
|
-
})}
|
|
692
|
+
{msg && (
|
|
693
|
+
<div className={`msg-${msg.type}`}>
|
|
694
|
+
{msg.text}
|
|
695
|
+
</div>
|
|
696
|
+
)}
|
|
1065
697
|
</div>
|
|
1066
|
-
|
|
1067
|
-
</Surface></div>
|
|
1068
|
-
)}
|
|
1069
|
-
|
|
1070
|
-
{(!gatewayData?.agents?.list || gatewayData.agents.list.length === 0) ? (
|
|
1071
|
-
<Surface variant="panel">
|
|
1072
|
-
<div style={{ padding: '24px 16px', textAlign: 'center', fontSize: 13, color: 'var(--text-dim)' }}>
|
|
1073
|
-
No agents found. Start a container with the <code style={{ background: 'rgba(255,255,255,0.06)', padding: '2px 6px', borderRadius: 0 }}>AGENT_ID</code> label.
|
|
1074
|
-
</div>
|
|
698
|
+
)}
|
|
1075
699
|
</Surface>
|
|
1076
|
-
)
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
const key = agent.agentId || agent.containerName;
|
|
1080
|
-
const msg = messages[`agent-${key}`];
|
|
1081
|
-
const isEditing = editingAgent === key;
|
|
1082
|
-
return (
|
|
1083
|
-
<Surface key={key} variant="panel">
|
|
1084
|
-
<div style={{ padding: '16px 18px', display: 'grid', gap: 12 }}>
|
|
1085
|
-
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
|
1086
|
-
<div className={'gateway-agent-dot' + (agent.state === 'running' ? ' gateway-agent-dot--ok' : ' gateway-agent-dot--err')} />
|
|
1087
|
-
<div style={{ fontWeight: 600 }}>{agent.agentId}</div>
|
|
1088
|
-
<Pill tone={agent.state === 'running' ? 'success' : 'danger'}>{agent.state}</Pill>
|
|
1089
|
-
</div>
|
|
1090
|
-
<div style={{ display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '4px 16px', fontSize: 12 }}>
|
|
1091
|
-
<span style={{ color: 'var(--text-dim)' }}>Primary model:</span>
|
|
1092
|
-
<span style={{ fontWeight: 500 }}>{agent.defaultModel || '—'}</span>
|
|
1093
|
-
<span style={{ color: 'var(--text-dim)' }}>Fallback:</span>
|
|
1094
|
-
<span style={{ fontWeight: 500 }}>{agent.fallbacks.length > 0 ? agent.fallbacks.join(', ') : '—'}</span>
|
|
1095
|
-
</div>
|
|
1096
|
-
{isEditing ? (
|
|
1097
|
-
<AgentEditForm
|
|
1098
|
-
agentId={agent.agentId}
|
|
1099
|
-
allModels={allEnabledModels}
|
|
1100
|
-
initialModel={agent.defaultModel}
|
|
1101
|
-
initialFallbacks={agent.fallbacks.join(', ')}
|
|
1102
|
-
saving={overlay.active}
|
|
1103
|
-
onSave={(model, fallbacks) => handleUpdateAgent(key, model, fallbacks)}
|
|
1104
|
-
onCancel={() => setEditingAgent(null)}
|
|
1105
|
-
/>
|
|
1106
|
-
) : (
|
|
1107
|
-
<Button variant="secondary" size="sm" onClick={() => { setEditingAgent(key); setAgentModelInput(agent.defaultModel || ''); setAgentFallbackInput(agent.fallbacks.join(', ')); }}>
|
|
1108
|
-
✏️ Change model
|
|
1109
|
-
</Button>
|
|
1110
|
-
)}
|
|
1111
|
-
{msg && (
|
|
1112
|
-
<div className={msg.type === 'success' ? 'msg-success' : 'msg-error'}>
|
|
1113
|
-
{msg.text}
|
|
1114
|
-
</div>
|
|
1115
|
-
)}
|
|
1116
|
-
</div>
|
|
1117
|
-
</Surface>
|
|
1118
|
-
);
|
|
1119
|
-
})}
|
|
1120
|
-
</div>
|
|
1121
|
-
)}
|
|
1122
|
-
</section>
|
|
1123
|
-
)}
|
|
700
|
+
);
|
|
701
|
+
})}
|
|
702
|
+
</section>
|
|
1124
703
|
<ConfirmModal
|
|
1125
704
|
open={removeConfirm !== null}
|
|
1126
705
|
onClose={() => setRemoveConfirm(null)}
|