@flame0510/project-aether 1.1.15 → 1.3.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 +2 -1
- package/app/agents/ModelSection.tsx +313 -0
- package/app/agents/PageClient.tsx +83 -4
- package/app/agents/create/page.tsx +8 -21
- package/app/api/agents/[id]/model/route.ts +113 -0
- package/app/api/agents/[id]/recreate/route.ts +10 -34
- package/app/api/agents/[id]/route.ts +10 -29
- package/app/api/agents/create/route.ts +59 -57
- package/app/api/agents/models-summary/route.ts +163 -0
- package/app/api/assistant/route.ts +36 -15
- package/app/api/credentials/[id]/sync/route.ts +3 -3
- package/app/api/credentials/detect/route.ts +126 -176
- package/app/api/credentials/route.ts +3 -0
- package/app/api/gateway/agent/route.ts +23 -6
- package/app/api/gateway/provider/keys.ts +13 -1
- package/app/api/gateway/provider/route.ts +43 -12
- package/app/api/gateway/sync.ts +248 -72
- 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/components/PulseChat.tsx +25 -39
- package/app/components/Skeleton.tsx +132 -0
- 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 +461 -140
- package/app/credentials/loading.tsx +19 -5
- package/app/gateway/PageClient.tsx +257 -673
- 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 +73 -9
- package/docs/ARCHITECTURE.md +92 -33
- package/docs/FRONTEND-ARCHITECTURE.md +36 -6
- package/docs/REV4A.md +62 -30
- package/docs/dev/API-REFERENCE.md +490 -227
- package/docs/dev/DATABASE.md +8 -3
- package/docs/dev/GATEWAY.md +236 -92
- package/docs/dev/PROVIDERS.md +44 -44
- package/docs/rag/DATA-FRESHNESS.md +57 -28
- package/docs/rag/GLOSSARY.md +20 -18
- package/docs/rag/REV4A-OVERVIEW.md +28 -32
- package/docs/rag/WHAT-I-CAN-ANSWER.md +10 -12
- package/instrumentation.ts +9 -1
- package/lib/agent-readiness.ts +110 -0
- package/lib/channelManager.ts +64 -22
- package/lib/container-file.ts +27 -0
- package/lib/credentials/delivery.ts +212 -119
- package/lib/credentials/detect.ts +229 -97
- package/lib/credentials/providers.ts +38 -7
- package/lib/credentials/vault.ts +78 -13
- package/lib/docker-exec.ts +50 -14
- package/lib/model-catalogue.ts +140 -27
- 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,18 +80,13 @@ 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
91
|
const [balances, setBalances] = useState<Record<string, { balance: string; label?: string; loading?: boolean; error?: string } | null>>({});
|
|
364
92
|
const [fetchingBalance, setFetchingBalance] = useState<Record<string, boolean>>({});
|
|
@@ -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);
|
|
@@ -418,10 +143,6 @@ export default function GatewayPageClient() {
|
|
|
418
143
|
}
|
|
419
144
|
}
|
|
420
145
|
}
|
|
421
|
-
if (gwRes.ok) {
|
|
422
|
-
const gwJson: GatewayData = await gwRes.json();
|
|
423
|
-
setGatewayData(gwJson);
|
|
424
|
-
}
|
|
425
146
|
} catch (e: unknown) {
|
|
426
147
|
setError(e instanceof Error ? e.message : String(e));
|
|
427
148
|
} finally {
|
|
@@ -501,9 +222,10 @@ export default function GatewayPageClient() {
|
|
|
501
222
|
});
|
|
502
223
|
const json = await res.json();
|
|
503
224
|
if (json.status === 'ok') {
|
|
504
|
-
setMessages((prev) => ({ ...prev, [provider]:
|
|
225
|
+
setMessages((prev) => ({ ...prev, [provider]: savedMessage('API key saved.', json.sync) }));
|
|
505
226
|
setOriginalKeys((prev) => ({ ...prev, [provider]: key }));
|
|
506
227
|
setProviders((prev) => prev.map((p) => p.provider === provider ? { ...p, configured: true } : p));
|
|
228
|
+
refreshOfferedModels(); // a new key can make models available
|
|
507
229
|
await loadAll();
|
|
508
230
|
} else {
|
|
509
231
|
setMessages((prev) => ({ ...prev, [provider]: { type: 'error', text: json.error || 'Error' } }));
|
|
@@ -534,8 +256,9 @@ export default function GatewayPageClient() {
|
|
|
534
256
|
});
|
|
535
257
|
const json = await res.json();
|
|
536
258
|
if (json.status === 'ok') {
|
|
537
|
-
setMessages((prev) => ({ ...prev, [provider]:
|
|
259
|
+
setMessages((prev) => ({ ...prev, [provider]: savedMessage('API key removed.', json.sync) }));
|
|
538
260
|
setProviders((prev) => prev.map((p) => p.provider === provider ? { ...p, configured: false } : p));
|
|
261
|
+
refreshOfferedModels(); // its models are no longer offered
|
|
539
262
|
await loadAll();
|
|
540
263
|
} else {
|
|
541
264
|
setMessages((prev) => ({ ...prev, [provider]: { type: 'error', text: json.error || 'Error' } }));
|
|
@@ -560,7 +283,7 @@ export default function GatewayPageClient() {
|
|
|
560
283
|
});
|
|
561
284
|
const json = await res.json();
|
|
562
285
|
if (json.status === 'ok') {
|
|
563
|
-
setMessages((prev) => ({ ...prev, rev4a:
|
|
286
|
+
setMessages((prev) => ({ ...prev, rev4a: savedMessage('REV4A API KEY saved.', json.sync) }));
|
|
564
287
|
setOriginalKeys((prev) => ({ ...prev, rev4a: key }));
|
|
565
288
|
await loadAll();
|
|
566
289
|
} else {
|
|
@@ -593,6 +316,10 @@ export default function GatewayPageClient() {
|
|
|
593
316
|
),
|
|
594
317
|
})),
|
|
595
318
|
);
|
|
319
|
+
refreshOfferedModels();
|
|
320
|
+
if (json.sync && !json.sync.ok) {
|
|
321
|
+
setMessages((prev) => ({ ...prev, [`model-${modelId}`]: savedMessage(`${enabled ? 'Enabled' : 'Disabled'}.`, json.sync) }));
|
|
322
|
+
}
|
|
596
323
|
} else {
|
|
597
324
|
setMessages((prev) => ({ ...prev, [`model-${modelId}`]: { type: 'error', text: json.error || 'Error' } }));
|
|
598
325
|
}
|
|
@@ -602,63 +329,9 @@ export default function GatewayPageClient() {
|
|
|
602
329
|
});
|
|
603
330
|
}
|
|
604
331
|
|
|
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
332
|
/* ---- Helpers ---- */
|
|
659
333
|
const configuredCount = providers.filter((p) => p.configured && p.provider !== 'rev4a').length;
|
|
660
334
|
const rev4aKeyConfigured = providers.find((p) => p.provider === 'rev4a')?.configured ?? false;
|
|
661
|
-
const agentCount = gatewayData?.agents?.total ?? 0;
|
|
662
335
|
const allEnabledModels = providers
|
|
663
336
|
.filter((p) => p.configured)
|
|
664
337
|
.flatMap((p) =>
|
|
@@ -701,7 +374,7 @@ export default function GatewayPageClient() {
|
|
|
701
374
|
<PageHeader
|
|
702
375
|
eyebrow=""
|
|
703
376
|
title="Gateway"
|
|
704
|
-
description="
|
|
377
|
+
description="Providers and the model catalogue. API keys are stored in .env."
|
|
705
378
|
/>
|
|
706
379
|
|
|
707
380
|
{/* Sync all agents — propagate provider baseUrl + models to every container */}
|
|
@@ -716,7 +389,10 @@ export default function GatewayPageClient() {
|
|
|
716
389
|
const data = await res.json();
|
|
717
390
|
setOverlay({ active: false, message: '' });
|
|
718
391
|
if (data.status === 'ok') {
|
|
719
|
-
setMessages((prev) => ({ ...prev, refresh: { type: 'success', text:
|
|
392
|
+
setMessages((prev) => ({ ...prev, refresh: { type: 'success', text: data.sync?.summary ?? 'Synced.' } }));
|
|
393
|
+
refreshOfferedModels();
|
|
394
|
+
} else {
|
|
395
|
+
setMessages((prev) => ({ ...prev, refresh: { type: 'error', text: data.error || data.sync?.summary || 'Sync failed' } }));
|
|
720
396
|
}
|
|
721
397
|
} catch (e: unknown) {
|
|
722
398
|
setOverlay({ active: false, message: '' });
|
|
@@ -727,7 +403,7 @@ export default function GatewayPageClient() {
|
|
|
727
403
|
↻ Sync All Agents
|
|
728
404
|
</Button>
|
|
729
405
|
{messages.refresh && (
|
|
730
|
-
<span style={{ fontSize: 11, color: messages.refresh.type === 'success' ? '#34d399' : '#ef4444' }}>
|
|
406
|
+
<span style={{ fontSize: 11, color: messages.refresh.type === 'success' ? '#34d399' : messages.refresh.type === 'warning' ? 'var(--yellow)' : '#ef4444' }}>
|
|
731
407
|
{messages.refresh.text}
|
|
732
408
|
</span>
|
|
733
409
|
)}
|
|
@@ -739,13 +415,11 @@ export default function GatewayPageClient() {
|
|
|
739
415
|
<>
|
|
740
416
|
<Skeleton height={72} style={{ border: '1px solid rgba(255,255,255,0.06)' }} />
|
|
741
417
|
<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
418
|
</>
|
|
744
419
|
) : (
|
|
745
420
|
<>
|
|
746
421
|
<Metric title="Configured providers" value={`${configuredCount} / ${providers.filter((p) => p.provider !== 'rev4a').length}`} subtitle="DeepSeek, OpenRouter, Codex, …" tone={configuredCount > 0 ? 'success' : 'warning'} />
|
|
747
422
|
<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
423
|
</>
|
|
750
424
|
)}
|
|
751
425
|
</section>
|
|
@@ -785,8 +459,8 @@ export default function GatewayPageClient() {
|
|
|
785
459
|
<div style={{
|
|
786
460
|
flex: '1 1 100%', padding: '6px 10px', borderRadius: 0,
|
|
787
461
|
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)',
|
|
462
|
+
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)',
|
|
463
|
+
color: messages['rev4a']!.type === 'success' ? 'var(--green)' : messages['rev4a']!.type === 'warning' ? 'var(--yellow)' : 'var(--red)',
|
|
790
464
|
}}>
|
|
791
465
|
{messages['rev4a']!.text}
|
|
792
466
|
</div>
|
|
@@ -796,331 +470,241 @@ export default function GatewayPageClient() {
|
|
|
796
470
|
</Surface>
|
|
797
471
|
</div>
|
|
798
472
|
|
|
799
|
-
{/*
|
|
800
|
-
<
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
}
|
|
814
|
-
]}
|
|
815
|
-
/>
|
|
473
|
+
{/* Providers */}
|
|
474
|
+
<section style={{ display: 'grid', gap: 16 }}>
|
|
475
|
+
{providers.filter((p) => p.provider !== 'rev4a').map((p) => {
|
|
476
|
+
const enabledCount = p.models.filter((m) => m.enabled).length;
|
|
477
|
+
const msg = messages[p.provider];
|
|
478
|
+
const isExpanded = expandedProviders.has(p.provider);
|
|
479
|
+
|
|
480
|
+
const toggleExpanded = () => {
|
|
481
|
+
setExpandedProviders((prev) => {
|
|
482
|
+
const next = new Set(prev);
|
|
483
|
+
if (next.has(p.provider)) next.delete(p.provider);
|
|
484
|
+
else next.add(p.provider);
|
|
485
|
+
return next;
|
|
486
|
+
});
|
|
487
|
+
};
|
|
816
488
|
|
|
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
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
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>
|
|
489
|
+
return (
|
|
490
|
+
<Surface key={p.provider} variant="panel">
|
|
491
|
+
{/* Accordion header — always visible, clickable */}
|
|
492
|
+
<div
|
|
493
|
+
onClick={toggleExpanded}
|
|
494
|
+
style={{
|
|
495
|
+
padding: '12px 18px',
|
|
496
|
+
display: 'flex',
|
|
497
|
+
alignItems: 'center',
|
|
498
|
+
gap: 12,
|
|
499
|
+
flexWrap: 'wrap',
|
|
500
|
+
cursor: 'pointer',
|
|
501
|
+
userSelect: 'none',
|
|
502
|
+
}}
|
|
503
|
+
>
|
|
504
|
+
{/* Expand/collapse arrow */}
|
|
505
|
+
<span style={{
|
|
506
|
+
fontSize: 10,
|
|
507
|
+
color: 'var(--text-dim)',
|
|
508
|
+
transition: 'transform 0.15s',
|
|
509
|
+
transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)',
|
|
510
|
+
minWidth: 12,
|
|
511
|
+
textAlign: 'center',
|
|
512
|
+
}}>
|
|
513
|
+
▶
|
|
514
|
+
</span>
|
|
515
|
+
<div style={{ width: 8, height: 8, borderRadius: '50%', background: p.configured ? 'var(--green)' : 'var(--gray)', flexShrink: 0 }} />
|
|
516
|
+
<div style={{ fontWeight: 600, fontSize: 14 }}>{p.label}</div>
|
|
517
|
+
{p.configured ? <Pill tone="success">Connected</Pill> : <Pill tone="warning">Not configured</Pill>}
|
|
518
|
+
{enabledCount > 0 ? <Pill tone="accent">{enabledCount} active models</Pill> : <Pill>{enabledCount} active models</Pill>}
|
|
519
|
+
<span style={{ marginLeft: 'auto', fontSize: 11, color: 'var(--text-dim)' }}>
|
|
520
|
+
{p.models.length} models
|
|
521
|
+
</span>
|
|
522
|
+
{/* Balance badge — auto-loaded for supported providers with configured key */}
|
|
523
|
+
{p.configured && ['deepseek','openrouter','glm'].includes(p.provider) && (
|
|
524
|
+
balances[p.provider] && !balances[p.provider]?.error ? (
|
|
525
|
+
<span
|
|
526
|
+
title={balances[p.provider]?.label || ''}
|
|
527
|
+
onClick={(e) => { e.stopPropagation(); fetchBalance(p.provider); }}
|
|
528
|
+
style={{
|
|
529
|
+
fontSize: 11, fontWeight: 600,
|
|
530
|
+
color: 'var(--green)',
|
|
531
|
+
background: 'rgba(var(--green-rgb,34,197,94),0.1)',
|
|
532
|
+
padding: '2px 8px', borderRadius: 0,
|
|
533
|
+
border: '1px solid rgba(var(--green-rgb,34,197,94),0.2)',
|
|
534
|
+
cursor: 'pointer',
|
|
535
|
+
}}
|
|
536
|
+
>
|
|
537
|
+
{balances[p.provider]!.balance}
|
|
538
|
+
</span>
|
|
539
|
+
) : (
|
|
540
|
+
<span
|
|
541
|
+
onClick={(e) => { e.stopPropagation(); fetchBalance(p.provider); }}
|
|
542
|
+
style={{
|
|
543
|
+
fontSize: 11,
|
|
544
|
+
color: 'var(--text-dim)',
|
|
545
|
+
opacity: 0.5,
|
|
546
|
+
cursor: 'pointer',
|
|
547
|
+
}}
|
|
548
|
+
>
|
|
549
|
+
{fetchingBalance[p.provider] ? '…' : '…'}
|
|
550
|
+
</span>
|
|
551
|
+
)
|
|
552
|
+
)}
|
|
553
|
+
</div>
|
|
901
554
|
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
555
|
+
{/* Accordion body — expandable */}
|
|
556
|
+
{isExpanded && (
|
|
557
|
+
<div style={{ padding: '0 18px 16px 18px', display: 'grid', gap: 14, borderTop: '1px solid var(--border)', paddingTop: 14 }}>
|
|
558
|
+
{/* API Key input con show/hide integrato */}
|
|
559
|
+
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
|
560
|
+
<PasswordInput
|
|
561
|
+
value={apiKeyInputs[p.provider] ?? ''}
|
|
562
|
+
onChange={(v) => setApiKeyInputs((prev) => ({ ...prev, [p.provider]: v }))}
|
|
563
|
+
placeholder={`API Key ${p.label}`}
|
|
564
|
+
style={{ flex: 1, minWidth: 200 }}
|
|
565
|
+
/>
|
|
566
|
+
<Button variant="primary" size="sm"
|
|
567
|
+
loading={overlay.active}
|
|
568
|
+
disabled={apiKeyInputs[p.provider]?.trim() === originalKeys[p.provider]?.trim()}
|
|
569
|
+
onClick={() => handleSave(p.provider)}>
|
|
570
|
+
Save
|
|
571
|
+
</Button>
|
|
572
|
+
{p.configured && (
|
|
573
|
+
<Button variant="danger" size="sm" disabled={overlay.active} onClick={() => handleRemove(p.provider)}>
|
|
574
|
+
Remove
|
|
918
575
|
</Button>
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
576
|
+
)}
|
|
577
|
+
</div>
|
|
578
|
+
|
|
579
|
+
{p.baseUrl && (
|
|
580
|
+
<div style={{ fontSize: 11, color: 'var(--text-dim)' }}>
|
|
581
|
+
Endpoint: <code style={{ background: 'rgba(255,255,255,0.06)', padding: '2px 6px', borderRadius: 0 }}>{p.baseUrl}</code>
|
|
582
|
+
{' • '}<a href={p.docsUrl} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--violet)' }}>Get key ↗</a>
|
|
924
583
|
</div>
|
|
584
|
+
)}
|
|
925
585
|
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
586
|
+
{/* Models with checkboxes */}
|
|
587
|
+
{p.models.length > 0 && (() => {
|
|
588
|
+
const query = modelSearch[p.provider] || '';
|
|
589
|
+
const visibleModels = filterModels(p.models, query);
|
|
590
|
+
// Below this many, everything fits on screen and a
|
|
591
|
+
// search box is just noise.
|
|
592
|
+
const showSearch = p.models.length > 6;
|
|
593
|
+
return (
|
|
594
|
+
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 12 }}>
|
|
595
|
+
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 8, marginBottom: 8, flexWrap: 'wrap' }}>
|
|
596
|
+
<div style={{ fontSize: 11, color: 'var(--text-dim)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
|
597
|
+
Models
|
|
598
|
+
<span style={{ marginLeft: 8, textTransform: 'none', letterSpacing: 'normal', color: 'var(--violet)', opacity: 0.7, fontSize: 10 }}>
|
|
599
|
+
in / out per 1M tokens
|
|
600
|
+
</span>
|
|
601
|
+
</div>
|
|
602
|
+
{query && (
|
|
603
|
+
<span style={{ fontSize: 10, color: 'var(--text-dim)' }}>
|
|
604
|
+
{visibleModels.length} of {p.models.length}
|
|
605
|
+
</span>
|
|
606
|
+
)}
|
|
930
607
|
</div>
|
|
931
|
-
)}
|
|
932
608
|
|
|
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
|
-
)}
|
|
609
|
+
{showSearch && (
|
|
610
|
+
<div style={{ marginBottom: 8 }}>
|
|
611
|
+
{/* Deliberately type="text": Safari clears type="search"
|
|
612
|
+
on Escape via a `search` event React never sees,
|
|
613
|
+
leaving an empty box over a still-filtered list. */}
|
|
614
|
+
<Input
|
|
615
|
+
type="text"
|
|
616
|
+
value={query}
|
|
617
|
+
placeholder="Filter by name or provider — e.g. kimi, moonshot, flash"
|
|
618
|
+
aria-label={`Filter ${p.label} models`}
|
|
619
|
+
onChange={(e) => setModelSearch((prev) => ({ ...prev, [p.provider]: e.target.value }))}
|
|
620
|
+
/>
|
|
954
621
|
</div>
|
|
622
|
+
)}
|
|
955
623
|
|
|
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
|
-
/>
|
|
624
|
+
<div style={{ display: 'grid', gap: 4, maxHeight: 360, overflowY: 'auto' }}>
|
|
625
|
+
{visibleModels.length === 0 && (
|
|
626
|
+
<div style={{ fontSize: 12, color: 'var(--text-dim)', padding: '8px 4px' }}>
|
|
627
|
+
No model matches “{query}”.
|
|
968
628
|
</div>
|
|
969
629
|
)}
|
|
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
|
-
|
|
630
|
+
{visibleModels.map((m) => {
|
|
631
|
+
const modelMsg = messages[`model-${m.id}`];
|
|
632
|
+
const pricing = m.pricing;
|
|
633
|
+
// -1/-1 is OpenRouter's marker for router models,
|
|
634
|
+
// whose price and capabilities both vary per call.
|
|
635
|
+
const isDynamic = pricing?.input === -1 && pricing?.output === -1;
|
|
636
|
+
return (
|
|
637
|
+
// Only the name/id are inside the <label>: clicking
|
|
638
|
+
// anywhere in a label toggles its checkbox, and the
|
|
639
|
+
// badges below carry tooltips people will click to
|
|
640
|
+
// read — which would silently flip the model and
|
|
641
|
+
// restart every agent container.
|
|
642
|
+
<div key={m.id} style={{
|
|
643
|
+
display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 10, padding: '5px 8px',
|
|
644
|
+
background: m.enabled ? 'rgba(var(--green-rgb,34,197,94),0.06)' : 'transparent',
|
|
645
|
+
fontSize: 12,
|
|
646
|
+
}}>
|
|
647
|
+
<input type="checkbox" checked={m.enabled} disabled={overlay.active}
|
|
648
|
+
id={`model-${m.id}`}
|
|
649
|
+
onChange={(e) => handleToggleModel(m.id, e.target.checked)}
|
|
650
|
+
style={{ accentColor: 'var(--green)', cursor: 'pointer' }} />
|
|
651
|
+
<div style={{ flex: 1, minWidth: 0 }}>
|
|
652
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
|
653
|
+
<label
|
|
654
|
+
htmlFor={`model-${m.id}`}
|
|
655
|
+
style={{ fontWeight: m.enabled ? 500 : 400, cursor: overlay.active ? 'wait' : 'pointer' }}
|
|
656
|
+
>
|
|
657
|
+
{m.name}
|
|
658
|
+
</label>
|
|
659
|
+
<ModalityIcons modality={m.modality} dynamic={isDynamic} />
|
|
660
|
+
</div>
|
|
661
|
+
<div style={{ fontSize: 11, color: 'var(--text-dim)', fontFamily: 'monospace' }}>
|
|
662
|
+
{m.id}
|
|
663
|
+
<span style={{ marginLeft: 8, color: 'var(--violet)', fontWeight: 500 }}>
|
|
664
|
+
{fmtPrice(pricing)}
|
|
665
|
+
</span>
|
|
666
|
+
{m.pricingLive && (
|
|
667
|
+
<span
|
|
668
|
+
title="Live price from the OpenRouter API"
|
|
669
|
+
style={{ marginLeft: 6, display: 'inline-block', verticalAlign: 'middle' }}
|
|
1003
670
|
>
|
|
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)}
|
|
671
|
+
<Pill tone="success">LIVE</Pill>
|
|
1012
672
|
</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>
|
|
673
|
+
)}
|
|
1022
674
|
</div>
|
|
1023
|
-
{modelMsg && (
|
|
1024
|
-
<span style={{ fontSize: 11, color: modelMsg.type === 'success' ? 'var(--green)' : 'var(--red)' }}>
|
|
1025
|
-
{modelMsg.text}
|
|
1026
|
-
</span>
|
|
1027
|
-
)}
|
|
1028
675
|
</div>
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
676
|
+
{modelMsg && (
|
|
677
|
+
// Its own full-width line under the name, not a third column. As an
|
|
678
|
+
// unconstrained flex item a long sync summary took its natural width
|
|
679
|
+
// and crushed the name/id column to a few pixels. Indented past the
|
|
680
|
+
// checkbox so it reads as belonging to this model.
|
|
681
|
+
<div style={{
|
|
682
|
+
flexBasis: '100%', paddingLeft: 23, fontSize: 11, lineHeight: 1.45,
|
|
683
|
+
overflowWrap: 'anywhere',
|
|
684
|
+
color: modelMsg.type === 'success' ? 'var(--green)' : modelMsg.type === 'warning' ? 'var(--yellow)' : 'var(--red)',
|
|
685
|
+
}}>
|
|
686
|
+
{modelMsg.text}
|
|
687
|
+
</div>
|
|
688
|
+
)}
|
|
689
|
+
</div>
|
|
690
|
+
);
|
|
691
|
+
})}
|
|
1039
692
|
</div>
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
</Surface>
|
|
1044
|
-
);
|
|
1045
|
-
})}
|
|
1046
|
-
</section>
|
|
1047
|
-
)}
|
|
693
|
+
</div>
|
|
694
|
+
);
|
|
695
|
+
})()}
|
|
1048
696
|
|
|
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
|
-
})}
|
|
697
|
+
{msg && (
|
|
698
|
+
<div className={`msg-${msg.type}`}>
|
|
699
|
+
{msg.text}
|
|
700
|
+
</div>
|
|
701
|
+
)}
|
|
1065
702
|
</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>
|
|
703
|
+
)}
|
|
1075
704
|
</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
|
-
)}
|
|
705
|
+
);
|
|
706
|
+
})}
|
|
707
|
+
</section>
|
|
1124
708
|
<ConfirmModal
|
|
1125
709
|
open={removeConfirm !== null}
|
|
1126
710
|
onClose={() => setRemoveConfirm(null)}
|