@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.
Files changed (60) hide show
  1. package/README.md +2 -1
  2. package/app/agents/ModelSection.tsx +313 -0
  3. package/app/agents/PageClient.tsx +83 -4
  4. package/app/agents/create/page.tsx +8 -21
  5. package/app/api/agents/[id]/model/route.ts +113 -0
  6. package/app/api/agents/[id]/recreate/route.ts +10 -34
  7. package/app/api/agents/[id]/route.ts +10 -29
  8. package/app/api/agents/create/route.ts +59 -57
  9. package/app/api/agents/models-summary/route.ts +163 -0
  10. package/app/api/assistant/route.ts +36 -15
  11. package/app/api/credentials/[id]/sync/route.ts +3 -3
  12. package/app/api/credentials/detect/route.ts +126 -176
  13. package/app/api/credentials/route.ts +3 -0
  14. package/app/api/gateway/agent/route.ts +23 -6
  15. package/app/api/gateway/provider/keys.ts +13 -1
  16. package/app/api/gateway/provider/route.ts +43 -12
  17. package/app/api/gateway/sync.ts +248 -72
  18. package/app/api/models/route.ts +28 -34
  19. package/app/api/provider/auth.ts +65 -0
  20. package/app/api/provider/upstream.ts +9 -2
  21. package/app/api/provider/v1/chat/completions/route.ts +22 -16
  22. package/app/api/provider/v1/models/route.ts +26 -133
  23. package/app/components/PulseChat.tsx +25 -39
  24. package/app/components/Skeleton.tsx +132 -0
  25. package/app/components/ui/RemoveButton.tsx +46 -0
  26. package/app/components/ui/Select.tsx +3 -2
  27. package/app/components/ui/index.ts +1 -0
  28. package/app/credentials/PageClient.tsx +461 -140
  29. package/app/credentials/loading.tsx +19 -5
  30. package/app/gateway/PageClient.tsx +257 -673
  31. package/app/globals.css +8 -0
  32. package/app/lib/models-context.tsx +43 -7
  33. package/app/wizard/useWizard.ts +6 -1
  34. package/bin/rev4a.js +73 -9
  35. package/docs/ARCHITECTURE.md +92 -33
  36. package/docs/FRONTEND-ARCHITECTURE.md +36 -6
  37. package/docs/REV4A.md +62 -30
  38. package/docs/dev/API-REFERENCE.md +490 -227
  39. package/docs/dev/DATABASE.md +8 -3
  40. package/docs/dev/GATEWAY.md +236 -92
  41. package/docs/dev/PROVIDERS.md +44 -44
  42. package/docs/rag/DATA-FRESHNESS.md +57 -28
  43. package/docs/rag/GLOSSARY.md +20 -18
  44. package/docs/rag/REV4A-OVERVIEW.md +28 -32
  45. package/docs/rag/WHAT-I-CAN-ANSWER.md +10 -12
  46. package/instrumentation.ts +9 -1
  47. package/lib/agent-readiness.ts +110 -0
  48. package/lib/channelManager.ts +64 -22
  49. package/lib/container-file.ts +27 -0
  50. package/lib/credentials/delivery.ts +212 -119
  51. package/lib/credentials/detect.ts +229 -97
  52. package/lib/credentials/providers.ts +38 -7
  53. package/lib/credentials/vault.ts +78 -13
  54. package/lib/docker-exec.ts +50 -14
  55. package/lib/model-catalogue.ts +140 -27
  56. package/lib/rev4a-paths.ts +0 -21
  57. package/model-pricing.json +118 -110
  58. package/models.config.json +27 -12
  59. package/package.json +1 -1
  60. 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, Tabs, type Tone } from '../components/ui';
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
- /* FallbackSelector — pick one or more fallback models from list */
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
- function AgentEditForm({
202
- agentId,
203
- allModels,
204
- initialModel,
205
- initialFallbacks,
206
- saving,
207
- onSave,
208
- onCancel,
209
- }: {
210
- agentId: string;
211
- allModels: { id: string; name: string; providerLabel: string }[];
212
- initialModel: string;
213
- initialFallbacks: string;
214
- saving: boolean;
215
- onSave: (model: string, fallbacks: string[]) => void;
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
- const [tab, setTab] = useState<Tab>('providers');
336
- const [gatewayData, setGatewayData] = useState<GatewayData | null>(null);
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 [provRes, gwRes] = await Promise.all([
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]: { type: 'success', text: 'API key saved.' } }));
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]: { type: 'success', text: 'API key removed.' } }));
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: { type: 'success', text: 'REV4A API KEY saved. Agent sync complete.' } }));
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="Provider, models and agents. API keys are stored in .env."
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: `Synced ${data.sync?.length ?? 0} agent(s)` } }));
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
- {/* Tabs */}
800
- <Tabs
801
- items={[
802
- {
803
- label: 'Provider',
804
- active: tab === 'providers',
805
- onClick: () => setTab('providers'),
806
- count: `${configuredCount}/${providers.filter(p => p.provider !== 'rev4a').length}`,
807
- },
808
- {
809
- label: 'Agents',
810
- active: tab === 'agents',
811
- onClick: () => setTab('agents'),
812
- count: agentCount,
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
- {/* TAB: PROVIDERS */}
819
- {/* =========================================================== */}
820
- {tab === 'providers' && (
821
- <section style={{ display: 'grid', gap: 16 }}>
822
- {providers.filter((p) => p.provider !== 'rev4a').map((p) => {
823
- const enabledCount = p.models.filter((m) => m.enabled).length;
824
- const msg = messages[p.provider];
825
- const isExpanded = expandedProviders.has(p.provider);
826
-
827
- const toggleExpanded = () => {
828
- setExpandedProviders((prev) => {
829
- const next = new Set(prev);
830
- if (next.has(p.provider)) next.delete(p.provider);
831
- else next.add(p.provider);
832
- return next;
833
- });
834
- };
835
-
836
- return (
837
- <Surface key={p.provider} variant="panel">
838
- {/* Accordion header — always visible, clickable */}
839
- <div
840
- onClick={toggleExpanded}
841
- style={{
842
- padding: '12px 18px',
843
- display: 'flex',
844
- alignItems: 'center',
845
- gap: 12,
846
- flexWrap: 'wrap',
847
- cursor: 'pointer',
848
- userSelect: 'none',
849
- }}
850
- >
851
- {/* Expand/collapse arrow */}
852
- <span style={{
853
- fontSize: 10,
854
- color: 'var(--text-dim)',
855
- transition: 'transform 0.15s',
856
- transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)',
857
- minWidth: 12,
858
- textAlign: 'center',
859
- }}>
860
- ▶
861
- </span>
862
- <div style={{ width: 8, height: 8, borderRadius: '50%', background: p.configured ? 'var(--green)' : 'var(--gray)', flexShrink: 0 }} />
863
- <div style={{ fontWeight: 600, fontSize: 14 }}>{p.label}</div>
864
- {p.configured ? <Pill tone="success">Connected</Pill> : <Pill tone="warning">Not configured</Pill>}
865
- {enabledCount > 0 ? <Pill tone="accent">{enabledCount} active models</Pill> : <Pill>{enabledCount} active models</Pill>}
866
- <span style={{ marginLeft: 'auto', fontSize: 11, color: 'var(--text-dim)' }}>
867
- {p.models.length} models
868
- </span>
869
- {/* Balance badge — auto-loaded for supported providers with configured key */}
870
- {p.configured && ['deepseek','openrouter','glm'].includes(p.provider) && (
871
- balances[p.provider] && !balances[p.provider]?.error ? (
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>
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
- {/* Accordion body — expandable */}
903
- {isExpanded && (
904
- <div style={{ padding: '0 18px 16px 18px', display: 'grid', gap: 14, borderTop: '1px solid var(--border)', paddingTop: 14 }}>
905
- {/* API Key input con show/hide integrato */}
906
- <div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
907
- <PasswordInput
908
- value={apiKeyInputs[p.provider] ?? ''}
909
- onChange={(v) => setApiKeyInputs((prev) => ({ ...prev, [p.provider]: v }))}
910
- placeholder={`API Key ${p.label}`}
911
- style={{ flex: 1, minWidth: 200 }}
912
- />
913
- <Button variant="primary" size="sm"
914
- loading={overlay.active}
915
- disabled={apiKeyInputs[p.provider]?.trim() === originalKeys[p.provider]?.trim()}
916
- onClick={() => handleSave(p.provider)}>
917
- Save
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
- {p.configured && (
920
- <Button variant="danger" size="sm" disabled={overlay.active} onClick={() => handleRemove(p.provider)}>
921
- Remove
922
- </Button>
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
- {p.baseUrl && (
927
- <div style={{ fontSize: 11, color: 'var(--text-dim)' }}>
928
- Endpoint: <code style={{ background: 'rgba(255,255,255,0.06)', padding: '2px 6px', borderRadius: 0 }}>{p.baseUrl}</code>
929
- {' • '}<a href={p.docsUrl} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--violet)' }}>Get key ↗</a>
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
- {/* Models with checkboxes */}
934
- {p.models.length > 0 && (() => {
935
- const query = modelSearch[p.provider] || '';
936
- const visibleModels = filterModels(p.models, query);
937
- // Below this many, everything fits on screen and a
938
- // search box is just noise.
939
- const showSearch = p.models.length > 6;
940
- return (
941
- <div style={{ borderTop: '1px solid var(--border)', paddingTop: 12 }}>
942
- <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 8, marginBottom: 8, flexWrap: 'wrap' }}>
943
- <div style={{ fontSize: 11, color: 'var(--text-dim)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
944
- Models
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
- {showSearch && (
957
- <div style={{ marginBottom: 8 }}>
958
- {/* Deliberately type="text": Safari clears type="search"
959
- on Escape via a `search` event React never sees,
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
- <div style={{ display: 'grid', gap: 4, maxHeight: 360, overflowY: 'auto' }}>
972
- {visibleModels.length === 0 && (
973
- <div style={{ fontSize: 12, color: 'var(--text-dim)', padding: '8px 4px' }}>
974
- No model matches “{query}”.
975
- </div>
976
- )}
977
- {visibleModels.map((m) => {
978
- const modelMsg = messages[`model-${m.id}`];
979
- const pricing = m.pricing;
980
- // -1/-1 is OpenRouter's marker for router models,
981
- // whose price and capabilities both vary per call.
982
- const isDynamic = pricing?.input === -1 && pricing?.output === -1;
983
- return (
984
- // Only the name/id are inside the <label>: clicking
985
- // anywhere in a label toggles its checkbox, and the
986
- // badges below carry tooltips people will click to
987
- // read — which would silently flip the model and
988
- // restart every agent container.
989
- <div key={m.id} style={{
990
- display: 'flex', alignItems: 'center', gap: 10, padding: '5px 8px',
991
- background: m.enabled ? 'rgba(var(--green-rgb,34,197,94),0.06)' : 'transparent',
992
- fontSize: 12,
993
- }}>
994
- <input type="checkbox" checked={m.enabled} disabled={overlay.active}
995
- id={`model-${m.id}`}
996
- onChange={(e) => handleToggleModel(m.id, e.target.checked)}
997
- style={{ accentColor: 'var(--green)', cursor: 'pointer' }} />
998
- <div style={{ flex: 1, minWidth: 0 }}>
999
- <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
1000
- <label
1001
- htmlFor={`model-${m.id}`}
1002
- style={{ fontWeight: m.enabled ? 500 : 400, cursor: overlay.active ? 'wait' : 'pointer' }}
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
- {m.name}
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
- {m.pricingLive && (
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
- </div>
1032
- </div>
1033
- );
1034
- })()}
1035
-
1036
- {msg && (
1037
- <div className={msg.type === 'success' ? 'msg-success' : 'msg-error'}>
1038
- {msg.text}
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
- </div>
1042
- )}
1043
- </Surface>
1044
- );
1045
- })}
1046
- </section>
1047
- )}
693
+ </div>
694
+ );
695
+ })()}
1048
696
 
1049
- {/* =========================================================== */}
1050
- {/* TAB: AGENTS */}
1051
- {/* =========================================================== */}
1052
- {tab === 'agents' && (
1053
- <section>
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
- </div>
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
- <div style={{ display: 'grid', gap: 14 }}>
1078
- {gatewayData.agents.list.map((agent) => {
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)}