@flame0510/project-aether 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.md +3 -1
  2. package/agent-templates/README.md +42 -22
  3. package/agent-templates/base-image/Dockerfile +42 -33
  4. package/agent-templates/base-image/entrypoint.sh +67 -12
  5. package/app/agents/BrowserAccessSection.tsx +510 -0
  6. package/app/agents/ChannelManager.tsx +19 -11
  7. package/app/agents/ImageDownloadBanner.tsx +53 -19
  8. package/app/agents/ModelSection.tsx +316 -0
  9. package/app/agents/PageClient.tsx +708 -167
  10. package/app/agents/UpdateSection.tsx +300 -0
  11. package/app/agents/create/PageClient.tsx +11 -49
  12. package/app/agents/create/page.tsx +8 -21
  13. package/app/api/agents/[id]/backup/route.ts +26 -69
  14. package/app/api/agents/[id]/channels/pairing/route.ts +3 -3
  15. package/app/api/agents/[id]/channels/telegram/route.ts +2 -2
  16. package/app/api/agents/[id]/cold-backup/route.ts +56 -0
  17. package/app/api/agents/[id]/devices/route.ts +126 -0
  18. package/app/api/agents/[id]/invite-link/route.ts +53 -0
  19. package/app/api/agents/[id]/lifecycle/route.ts +3 -0
  20. package/app/api/agents/[id]/model/route.ts +113 -0
  21. package/app/api/agents/[id]/open-control-ui/route.ts +58 -0
  22. package/app/api/agents/[id]/recreate/route.ts +33 -187
  23. package/app/api/agents/[id]/restart/route.ts +5 -0
  24. package/app/api/agents/[id]/restore/route.ts +40 -70
  25. package/app/api/agents/[id]/route.ts +38 -169
  26. package/app/api/agents/[id]/update/rollback/route.ts +30 -0
  27. package/app/api/agents/[id]/update/route.ts +50 -0
  28. package/app/api/agents/activity-summary/route.ts +67 -0
  29. package/app/api/agents/create/route.ts +91 -145
  30. package/app/api/agents/devices-summary/route.ts +37 -0
  31. package/app/api/agents/download-image/route.ts +16 -9
  32. package/app/api/agents/image-status/route.ts +31 -111
  33. package/app/api/agents/models-summary/route.ts +163 -0
  34. package/app/api/agents/route.ts +25 -49
  35. package/app/api/agents/token/route.ts +33 -10
  36. package/app/api/assistant/route.ts +37 -16
  37. package/app/api/gateway/agent/route.ts +37 -6
  38. package/app/api/gateway/provider/balance/route.ts +5 -2
  39. package/app/api/gateway/provider/keys.ts +13 -1
  40. package/app/api/gateway/provider/route.ts +43 -12
  41. package/app/api/gateway/sync.ts +335 -76
  42. package/app/api/models/route.ts +28 -34
  43. package/app/api/provider/auth.ts +65 -0
  44. package/app/api/provider/upstream.ts +9 -2
  45. package/app/api/provider/v1/chat/completions/route.ts +22 -16
  46. package/app/api/provider/v1/models/route.ts +26 -133
  47. package/app/api/setup/agent-image/route.ts +14 -42
  48. package/app/components/DashboardToolbar.tsx +1 -1
  49. package/app/components/PulseChat.tsx +25 -39
  50. package/app/components/ui/RemoveButton.tsx +46 -0
  51. package/app/components/ui/Select.tsx +3 -2
  52. package/app/components/ui/index.ts +1 -0
  53. package/app/credentials/PageClient.tsx +2 -2
  54. package/app/gateway/PageClient.tsx +253 -674
  55. package/app/globals.css +8 -0
  56. package/app/lib/models-context.tsx +43 -7
  57. package/app/wizard/useWizard.ts +6 -1
  58. package/bin/rev4a.js +116 -50
  59. package/daemon.js +6 -6
  60. package/docs/ARCHITECTURE.md +110 -12
  61. package/docs/FRONTEND-ARCHITECTURE.md +31 -2
  62. package/docs/REV4A.md +93 -33
  63. package/docs/dev/API-REFERENCE.md +723 -178
  64. package/docs/dev/DATABASE.md +96 -0
  65. package/docs/dev/GATEWAY.md +250 -93
  66. package/docs/dev/PROVIDERS.md +26 -13
  67. package/docs/rag/DATA-FRESHNESS.md +59 -28
  68. package/docs/rag/GLOSSARY.md +27 -16
  69. package/docs/rag/REV4A-OVERVIEW.md +37 -25
  70. package/docs/rag/WHAT-I-CAN-ANSWER.md +10 -8
  71. package/instrumentation.ts +52 -1
  72. package/lib/agent-busy.ts +21 -0
  73. package/lib/agent-devices.ts +361 -0
  74. package/lib/agent-edit-state.ts +108 -0
  75. package/lib/agent-edit.ts +157 -0
  76. package/lib/agent-images.ts +375 -0
  77. package/lib/agent-ports-server.ts +27 -0
  78. package/lib/agent-ports.ts +68 -0
  79. package/lib/agent-readiness.ts +110 -0
  80. package/lib/agent-recreate-state.ts +108 -0
  81. package/lib/agent-recreate.ts +305 -0
  82. package/lib/agent-restore-state.ts +107 -0
  83. package/lib/agent-restore.ts +135 -0
  84. package/lib/agent-setup.ts +66 -17
  85. package/lib/agent-update-state.ts +122 -0
  86. package/lib/agent-update.ts +448 -0
  87. package/lib/agent-versions.json +14 -0
  88. package/lib/agent-versions.ts +80 -0
  89. package/lib/buildAgentImage.ts +88 -290
  90. package/lib/channelManager.ts +153 -64
  91. package/lib/cold-backup.ts +354 -0
  92. package/lib/container-file.ts +27 -0
  93. package/lib/credentials/delivery.ts +3 -3
  94. package/lib/db-bootstrap.mjs +76 -0
  95. package/lib/docker-utils.ts +3 -3
  96. package/lib/model-catalogue.ts +140 -27
  97. package/lib/provider-balance.ts +33 -12
  98. package/lib/rev4a-paths.ts +0 -21
  99. package/model-pricing.json +118 -110
  100. package/models.config.json +27 -12
  101. package/package.json +1 -1
  102. 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,20 +80,15 @@ export default function GatewayPageClient() {
347
80
  // API key form state
348
81
  const [apiKeyInputs, setApiKeyInputs] = useState<Record<string, string>>({});
349
82
  const [originalKeys, setOriginalKeys] = useState<Record<string, string>>({});
350
- const [messages, setMessages] = useState<Record<string, { type: 'success' | 'error'; text: string } | null>>({});
83
+ const [messages, setMessages] = useState<Record<string, { type: 'success' | 'error' | 'warning'; text: string } | null>>({});
351
84
  const [revealedKeys, setRevealedKeys] = useState<Record<string, string | null>>({});
352
85
  const [revealing, setRevealing] = useState<Record<string, boolean>>({});
353
86
 
354
87
  // Per-provider model filter (purely local, no requests)
355
88
  const [modelSearch, setModelSearch] = useState<Record<string, string>>({});
356
89
 
357
- // Agent edit state
358
- const [editingAgent, setEditingAgent] = useState<string | null>(null);
359
- const [agentModelInput, setAgentModelInput] = useState('');
360
- const [agentFallbackInput, setAgentFallbackInput] = useState('');
361
-
362
90
  // Balance fetch state (per provider)
363
- const [balances, setBalances] = useState<Record<string, { balance: string; label?: string; loading?: boolean; error?: string } | null>>({});
91
+ const [balances, setBalances] = useState<Record<string, { balance: string; label?: string; loading?: boolean; error?: string; unsupported?: boolean } | null>>({});
364
92
  const [fetchingBalance, setFetchingBalance] = useState<Record<string, boolean>>({});
365
93
 
366
94
  // Accordion state — which provider cards are expanded
@@ -371,10 +99,7 @@ export default function GatewayPageClient() {
371
99
  const loadAll = useCallback(async () => {
372
100
  setLoading(true);
373
101
  try {
374
- const [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);
@@ -410,6 +135,9 @@ export default function GatewayPageClient() {
410
135
  const bJson = await bRes.json();
411
136
  if (bRes.ok && bJson.balance) {
412
137
  setBalances((prev) => ({ ...prev, [bp]: { balance: bJson.balance, label: bJson.label } }));
138
+ } else if (bJson.reason === 'no-balance-api') {
139
+ // Permanent: this provider has no balance API for the account — hide the badge.
140
+ setBalances((prev) => ({ ...prev, [bp]: { balance: '', unsupported: true } }));
413
141
  } else {
414
142
  setBalances((prev) => ({ ...prev, [bp]: { balance: '—', error: bJson.error || 'Failed' } }));
415
143
  }
@@ -418,10 +146,6 @@ export default function GatewayPageClient() {
418
146
  }
419
147
  }
420
148
  }
421
- if (gwRes.ok) {
422
- const gwJson: GatewayData = await gwRes.json();
423
- setGatewayData(gwJson);
424
- }
425
149
  } catch (e: unknown) {
426
150
  setError(e instanceof Error ? e.message : String(e));
427
151
  } finally {
@@ -477,6 +201,8 @@ export default function GatewayPageClient() {
477
201
  const json = await res.json();
478
202
  if (res.ok && json.balance) {
479
203
  setBalances((prev) => ({ ...prev, [provider]: { balance: json.balance, label: json.label } }));
204
+ } else if (json.reason === 'no-balance-api') {
205
+ setBalances((prev) => ({ ...prev, [provider]: { balance: '', unsupported: true } }));
480
206
  } else {
481
207
  setBalances((prev) => ({ ...prev, [provider]: { balance: '—', error: json.error || 'Failed' } }));
482
208
  }
@@ -501,9 +227,10 @@ export default function GatewayPageClient() {
501
227
  });
502
228
  const json = await res.json();
503
229
  if (json.status === 'ok') {
504
- setMessages((prev) => ({ ...prev, [provider]: { type: 'success', text: 'API key saved.' } }));
230
+ setMessages((prev) => ({ ...prev, [provider]: savedMessage('API key saved.', json.sync) }));
505
231
  setOriginalKeys((prev) => ({ ...prev, [provider]: key }));
506
232
  setProviders((prev) => prev.map((p) => p.provider === provider ? { ...p, configured: true } : p));
233
+ refreshOfferedModels(); // a new key can make models available
507
234
  await loadAll();
508
235
  } else {
509
236
  setMessages((prev) => ({ ...prev, [provider]: { type: 'error', text: json.error || 'Error' } }));
@@ -534,8 +261,9 @@ export default function GatewayPageClient() {
534
261
  });
535
262
  const json = await res.json();
536
263
  if (json.status === 'ok') {
537
- setMessages((prev) => ({ ...prev, [provider]: { type: 'success', text: 'API key removed.' } }));
264
+ setMessages((prev) => ({ ...prev, [provider]: savedMessage('API key removed.', json.sync) }));
538
265
  setProviders((prev) => prev.map((p) => p.provider === provider ? { ...p, configured: false } : p));
266
+ refreshOfferedModels(); // its models are no longer offered
539
267
  await loadAll();
540
268
  } else {
541
269
  setMessages((prev) => ({ ...prev, [provider]: { type: 'error', text: json.error || 'Error' } }));
@@ -560,7 +288,7 @@ export default function GatewayPageClient() {
560
288
  });
561
289
  const json = await res.json();
562
290
  if (json.status === 'ok') {
563
- setMessages((prev) => ({ ...prev, rev4a: { type: 'success', text: 'REV4A API KEY saved. Agent sync complete.' } }));
291
+ setMessages((prev) => ({ ...prev, rev4a: savedMessage('REV4A API KEY saved.', json.sync) }));
564
292
  setOriginalKeys((prev) => ({ ...prev, rev4a: key }));
565
293
  await loadAll();
566
294
  } else {
@@ -593,6 +321,10 @@ export default function GatewayPageClient() {
593
321
  ),
594
322
  })),
595
323
  );
324
+ refreshOfferedModels();
325
+ if (json.sync && !json.sync.ok) {
326
+ setMessages((prev) => ({ ...prev, [`model-${modelId}`]: savedMessage(`${enabled ? 'Enabled' : 'Disabled'}.`, json.sync) }));
327
+ }
596
328
  } else {
597
329
  setMessages((prev) => ({ ...prev, [`model-${modelId}`]: { type: 'error', text: json.error || 'Error' } }));
598
330
  }
@@ -602,63 +334,9 @@ export default function GatewayPageClient() {
602
334
  });
603
335
  }
604
336
 
605
- /* ---- Agent model update ---- */
606
- async function handleUpdateAgent(key: string, model?: string, fallbacksFromForm?: string[]) {
607
- if (!model && !agentModelInput.trim()) return;
608
- const modelValue = model || agentModelInput.trim();
609
- const fallbacksValue = fallbacksFromForm || (agentFallbackInput.trim()
610
- ? agentFallbackInput.split(',').map((s) => s.trim()).filter(Boolean)
611
- : []);
612
-
613
- // Resolve container name from agentId or containerName
614
- const agent = gatewayData?.agents?.list?.find(
615
- (a) => a.containerName === key || a.agentId === key,
616
- );
617
- const containerName = agent?.containerName || key;
618
-
619
- // Overlay: only during the PUT call
620
- setOverlay({ active: true, message: 'Updating model…' });
621
- try {
622
- const res = await fetch('/api/gateway/agent', {
623
- method: 'PUT',
624
- headers: { 'Content-Type': 'application/json' },
625
- body: JSON.stringify({ containerName, model: modelValue, fallbacks: fallbacksValue }),
626
- });
627
- const json = await res.json();
628
- setOverlay({ active: false, message: '' });
629
-
630
- if (json.status === 'ok') {
631
- setMessages((prev) => ({ ...prev, [`agent-${key}`]: { type: 'success', text: 'Restarting gateway…' } }));
632
- setEditingAgent(null);
633
- setAgentModelInput('');
634
- setAgentFallbackInput('');
635
- // Poll in background: GET /api/gateway every second until it responds
636
- for (let attempt = 0; attempt < 8; attempt++) {
637
- await new Promise((r) => setTimeout(r, 1000));
638
- try {
639
- const gwRes = await fetch('/api/gateway');
640
- if (gwRes.ok) {
641
- const gwJson: GatewayData = await gwRes.json();
642
- setGatewayData(gwJson);
643
- break;
644
- }
645
- } catch { /* retry */ }
646
- }
647
- // Clear the message
648
- setMessages((prev) => ({ ...prev, [`agent-${key}`]: null }));
649
- } else {
650
- setMessages((prev) => ({ ...prev, [`agent-${key}`]: { type: 'error', text: json.error || 'Error' } }));
651
- }
652
- } catch (e: unknown) {
653
- setOverlay({ active: false, message: '' });
654
- setMessages((prev) => ({ ...prev, [`agent-${key}`]: { type: 'error', text: e instanceof Error ? e.message : 'Network error' } }));
655
- }
656
- }
657
-
658
337
  /* ---- Helpers ---- */
659
338
  const configuredCount = providers.filter((p) => p.configured && p.provider !== 'rev4a').length;
660
339
  const rev4aKeyConfigured = providers.find((p) => p.provider === 'rev4a')?.configured ?? false;
661
- const agentCount = gatewayData?.agents?.total ?? 0;
662
340
  const allEnabledModels = providers
663
341
  .filter((p) => p.configured)
664
342
  .flatMap((p) =>
@@ -701,7 +379,7 @@ export default function GatewayPageClient() {
701
379
  <PageHeader
702
380
  eyebrow=""
703
381
  title="Gateway"
704
- description="Provider, models and agents. API keys are stored in .env."
382
+ description="Providers and the model catalogue. API keys are stored in .env."
705
383
  />
706
384
 
707
385
  {/* Sync all agents — propagate provider baseUrl + models to every container */}
@@ -716,7 +394,10 @@ export default function GatewayPageClient() {
716
394
  const data = await res.json();
717
395
  setOverlay({ active: false, message: '' });
718
396
  if (data.status === 'ok') {
719
- setMessages((prev) => ({ ...prev, refresh: { type: 'success', text: `Synced ${data.sync?.length ?? 0} agent(s)` } }));
397
+ setMessages((prev) => ({ ...prev, refresh: { type: 'success', text: data.sync?.summary ?? 'Synced.' } }));
398
+ refreshOfferedModels();
399
+ } else {
400
+ setMessages((prev) => ({ ...prev, refresh: { type: 'error', text: data.error || data.sync?.summary || 'Sync failed' } }));
720
401
  }
721
402
  } catch (e: unknown) {
722
403
  setOverlay({ active: false, message: '' });
@@ -727,7 +408,7 @@ export default function GatewayPageClient() {
727
408
  ↻ Sync All Agents
728
409
  </Button>
729
410
  {messages.refresh && (
730
- <span style={{ fontSize: 11, color: messages.refresh.type === 'success' ? '#34d399' : '#ef4444' }}>
411
+ <span style={{ fontSize: 11, color: messages.refresh.type === 'success' ? '#34d399' : messages.refresh.type === 'warning' ? 'var(--yellow)' : '#ef4444' }}>
731
412
  {messages.refresh.text}
732
413
  </span>
733
414
  )}
@@ -739,13 +420,11 @@ export default function GatewayPageClient() {
739
420
  <>
740
421
  <Skeleton height={72} style={{ border: '1px solid rgba(255,255,255,0.06)' }} />
741
422
  <Skeleton height={72} style={{ border: '1px solid rgba(255,255,255,0.06)' }} />
742
- <Skeleton height={72} style={{ border: '1px solid rgba(255,255,255,0.06)' }} />
743
423
  </>
744
424
  ) : (
745
425
  <>
746
426
  <Metric title="Configured providers" value={`${configuredCount} / ${providers.filter((p) => p.provider !== 'rev4a').length}`} subtitle="DeepSeek, OpenRouter, Codex, …" tone={configuredCount > 0 ? 'success' : 'warning'} />
747
427
  <Metric title="Active models" value={allEnabledModels.length} subtitle="for all configured providers" tone={allEnabledModels.length > 0 ? 'success' : 'warning'} />
748
- <Metric title="Agents" value={agentCount} subtitle="with AGENT_ID label" tone={agentCount > 0 ? 'success' : 'warning'} />
749
428
  </>
750
429
  )}
751
430
  </section>
@@ -785,8 +464,8 @@ export default function GatewayPageClient() {
785
464
  <div style={{
786
465
  flex: '1 1 100%', padding: '6px 10px', borderRadius: 0,
787
466
  fontSize: 12, fontWeight: 500,
788
- background: messages['rev4a']!.type === 'success' ? 'rgba(var(--green-rgb,34,197,94),0.1)' : 'rgba(var(--red-rgb,239,68,68),0.1)',
789
- color: messages['rev4a']!.type === 'success' ? 'var(--green)' : 'var(--red)',
467
+ background: messages['rev4a']!.type === 'success' ? 'rgba(var(--green-rgb,34,197,94),0.1)' : messages['rev4a']!.type === 'warning' ? 'rgba(245,158,11,0.1)' : 'rgba(var(--red-rgb,239,68,68),0.1)',
468
+ color: messages['rev4a']!.type === 'success' ? 'var(--green)' : messages['rev4a']!.type === 'warning' ? 'var(--yellow)' : 'var(--red)',
790
469
  }}>
791
470
  {messages['rev4a']!.text}
792
471
  </div>
@@ -796,331 +475,231 @@ export default function GatewayPageClient() {
796
475
  </Surface>
797
476
  </div>
798
477
 
799
- {/* 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
- />
478
+ {/* Providers */}
479
+ <section style={{ display: 'grid', gap: 16 }}>
480
+ {providers.filter((p) => p.provider !== 'rev4a').map((p) => {
481
+ const enabledCount = p.models.filter((m) => m.enabled).length;
482
+ const msg = messages[p.provider];
483
+ const isExpanded = expandedProviders.has(p.provider);
484
+
485
+ const toggleExpanded = () => {
486
+ setExpandedProviders((prev) => {
487
+ const next = new Set(prev);
488
+ if (next.has(p.provider)) next.delete(p.provider);
489
+ else next.add(p.provider);
490
+ return next;
491
+ });
492
+ };
816
493
 
817
- {/* =========================================================== */}
818
- {/* 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>
494
+ return (
495
+ <Surface key={p.provider} variant="panel">
496
+ {/* Accordion header — always visible, clickable */}
497
+ <div
498
+ onClick={toggleExpanded}
499
+ style={{
500
+ padding: '12px 18px',
501
+ display: 'flex',
502
+ alignItems: 'center',
503
+ gap: 12,
504
+ flexWrap: 'wrap',
505
+ cursor: 'pointer',
506
+ userSelect: 'none',
507
+ }}
508
+ >
509
+ {/* Expand/collapse arrow */}
510
+ <span style={{
511
+ fontSize: 10,
512
+ color: 'var(--text-dim)',
513
+ transition: 'transform 0.15s',
514
+ transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)',
515
+ minWidth: 12,
516
+ textAlign: 'center',
517
+ }}>
518
+ ▶
519
+ </span>
520
+ <div style={{ width: 8, height: 8, borderRadius: '50%', background: p.configured ? 'var(--green)' : 'var(--gray)', flexShrink: 0 }} />
521
+ <div style={{ fontWeight: 600, fontSize: 14 }}>{p.label}</div>
522
+ {p.configured ? <Pill tone="success">Connected</Pill> : <Pill tone="warning">Not configured</Pill>}
523
+ {enabledCount > 0 ? <Pill tone="accent">{enabledCount} active models</Pill> : <Pill>{enabledCount} active models</Pill>}
524
+ <span style={{ marginLeft: 'auto', fontSize: 11, color: 'var(--text-dim)' }}>
525
+ {p.models.length} models
526
+ </span>
527
+ {/* Balance badge — auto-loaded for supported providers with configured key.
528
+ Same squared chip in every state: "..." while loading, "—" (dimmed, tooltip) on error.
529
+ Hidden entirely when the provider has no balance API for the account (permanent). */}
530
+ {p.configured && ['deepseek','openrouter','glm'].includes(p.provider) && !balances[p.provider]?.unsupported && (
531
+ <button
532
+ type="button"
533
+ title={balances[p.provider]?.error || balances[p.provider]?.label || ''}
534
+ onClick={(e) => { e.stopPropagation(); fetchBalance(p.provider); }}
535
+ style={{
536
+ fontSize: 11, fontWeight: 600,
537
+ color: 'var(--green)',
538
+ background: 'rgba(var(--green-rgb,34,197,94),0.1)',
539
+ padding: '2px 8px', borderRadius: 0,
540
+ border: '1px solid rgba(var(--green-rgb,34,197,94),0.2)',
541
+ cursor: 'pointer',
542
+ opacity: balances[p.provider]?.error ? 0.5 : 1,
543
+ }}
544
+ >
545
+ {balances[p.provider] && !balances[p.provider]?.error ? balances[p.provider]!.balance : balances[p.provider]?.error ? '—' : '...'}
546
+ </button>
547
+ )}
548
+ </div>
901
549
 
902
- {/* 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
550
+ {/* Accordion body — expandable */}
551
+ {isExpanded && (
552
+ <div style={{ padding: '0 18px 16px 18px', display: 'grid', gap: 14, borderTop: '1px solid var(--border)', paddingTop: 14 }}>
553
+ {/* API Key input with an inline show/hide toggle */}
554
+ <div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
555
+ <PasswordInput
556
+ value={apiKeyInputs[p.provider] ?? ''}
557
+ onChange={(v) => setApiKeyInputs((prev) => ({ ...prev, [p.provider]: v }))}
558
+ placeholder={`API Key ${p.label}`}
559
+ style={{ flex: 1, minWidth: 200 }}
560
+ />
561
+ <Button variant="primary" size="sm"
562
+ loading={overlay.active}
563
+ disabled={apiKeyInputs[p.provider]?.trim() === originalKeys[p.provider]?.trim()}
564
+ onClick={() => handleSave(p.provider)}>
565
+ Save
566
+ </Button>
567
+ {p.configured && (
568
+ <Button variant="danger" size="sm" disabled={overlay.active} onClick={() => handleRemove(p.provider)}>
569
+ Remove
918
570
  </Button>
919
- {p.configured && (
920
- <Button variant="danger" size="sm" disabled={overlay.active} onClick={() => handleRemove(p.provider)}>
921
- Remove
922
- </Button>
923
- )}
571
+ )}
572
+ </div>
573
+
574
+ {p.baseUrl && (
575
+ <div style={{ fontSize: 11, color: 'var(--text-dim)' }}>
576
+ Endpoint: <code style={{ background: 'rgba(255,255,255,0.06)', padding: '2px 6px', borderRadius: 0 }}>{p.baseUrl}</code>
577
+ {' • '}<a href={p.docsUrl} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--violet)' }}>Get key ↗</a>
924
578
  </div>
579
+ )}
925
580
 
926
- {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>
581
+ {/* Models with checkboxes */}
582
+ {p.models.length > 0 && (() => {
583
+ const query = modelSearch[p.provider] || '';
584
+ const visibleModels = filterModels(p.models, query);
585
+ // Below this many, everything fits on screen and a
586
+ // search box is just noise.
587
+ const showSearch = p.models.length > 6;
588
+ return (
589
+ <div style={{ borderTop: '1px solid var(--border)', paddingTop: 12 }}>
590
+ <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 8, marginBottom: 8, flexWrap: 'wrap' }}>
591
+ <div style={{ fontSize: 11, color: 'var(--text-dim)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
592
+ Models
593
+ <span style={{ marginLeft: 8, textTransform: 'none', letterSpacing: 'normal', color: 'var(--violet)', opacity: 0.7, fontSize: 10 }}>
594
+ in / out per 1M tokens
595
+ </span>
596
+ </div>
597
+ {query && (
598
+ <span style={{ fontSize: 10, color: 'var(--text-dim)' }}>
599
+ {visibleModels.length} of {p.models.length}
600
+ </span>
601
+ )}
930
602
  </div>
931
- )}
932
603
 
933
- {/* 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
- )}
604
+ {showSearch && (
605
+ <div style={{ marginBottom: 8 }}>
606
+ {/* Deliberately type="text": Safari clears type="search"
607
+ on Escape via a `search` event React never sees,
608
+ leaving an empty box over a still-filtered list. */}
609
+ <Input
610
+ type="text"
611
+ value={query}
612
+ placeholder="Filter by name or provider — e.g. kimi, moonshot, flash"
613
+ aria-label={`Filter ${p.label} models`}
614
+ onChange={(e) => setModelSearch((prev) => ({ ...prev, [p.provider]: e.target.value }))}
615
+ />
954
616
  </div>
617
+ )}
955
618
 
956
- {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
- />
619
+ <div style={{ display: 'grid', gap: 4, maxHeight: 360, overflowY: 'auto' }}>
620
+ {visibleModels.length === 0 && (
621
+ <div style={{ fontSize: 12, color: 'var(--text-dim)', padding: '8px 4px' }}>
622
+ No model matches “{query}”.
968
623
  </div>
969
624
  )}
970
-
971
- <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' }}
625
+ {visibleModels.map((m) => {
626
+ const modelMsg = messages[`model-${m.id}`];
627
+ const pricing = m.pricing;
628
+ // -1/-1 is OpenRouter's marker for router models,
629
+ // whose price and capabilities both vary per call.
630
+ const isDynamic = pricing?.input === -1 && pricing?.output === -1;
631
+ return (
632
+ // Only the name/id are inside the <label>: clicking
633
+ // anywhere in a label toggles its checkbox, and the
634
+ // badges below carry tooltips people will click to
635
+ // read — which would silently flip the model and
636
+ // restart every agent container.
637
+ <div key={m.id} style={{
638
+ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 10, padding: '5px 8px',
639
+ background: m.enabled ? 'rgba(var(--green-rgb,34,197,94),0.06)' : 'transparent',
640
+ fontSize: 12,
641
+ }}>
642
+ <input type="checkbox" checked={m.enabled} disabled={overlay.active}
643
+ id={`model-${m.id}`}
644
+ onChange={(e) => handleToggleModel(m.id, e.target.checked)}
645
+ style={{ accentColor: 'var(--green)', cursor: 'pointer' }} />
646
+ <div style={{ flex: 1, minWidth: 0 }}>
647
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
648
+ <label
649
+ htmlFor={`model-${m.id}`}
650
+ style={{ fontWeight: m.enabled ? 500 : 400, cursor: overlay.active ? 'wait' : 'pointer' }}
651
+ >
652
+ {m.name}
653
+ </label>
654
+ <ModalityIcons modality={m.modality} dynamic={isDynamic} />
655
+ </div>
656
+ <div style={{ fontSize: 11, color: 'var(--text-dim)', fontFamily: 'monospace' }}>
657
+ {m.id}
658
+ <span style={{ marginLeft: 8, color: 'var(--violet)', fontWeight: 500 }}>
659
+ {fmtPrice(pricing)}
660
+ </span>
661
+ {m.pricingLive && (
662
+ <span
663
+ title="Live price from the OpenRouter API"
664
+ style={{ marginLeft: 6, display: 'inline-block', verticalAlign: 'middle' }}
1003
665
  >
1004
- {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)}
666
+ <Pill tone="success">LIVE</Pill>
1012
667
  </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>
668
+ )}
1022
669
  </div>
1023
- {modelMsg && (
1024
- <span style={{ fontSize: 11, color: modelMsg.type === 'success' ? 'var(--green)' : 'var(--red)' }}>
1025
- {modelMsg.text}
1026
- </span>
1027
- )}
1028
670
  </div>
1029
- );
1030
- })}
1031
- </div>
1032
- </div>
1033
- );
1034
- })()}
1035
-
1036
- {msg && (
1037
- <div className={msg.type === 'success' ? 'msg-success' : 'msg-error'}>
1038
- {msg.text}
671
+ {modelMsg && (
672
+ // Its own full-width line under the name, not a third column. As an
673
+ // unconstrained flex item a long sync summary took its natural width
674
+ // and crushed the name/id column to a few pixels. Indented past the
675
+ // checkbox so it reads as belonging to this model.
676
+ <div style={{
677
+ flexBasis: '100%', paddingLeft: 23, fontSize: 11, lineHeight: 1.45,
678
+ overflowWrap: 'anywhere',
679
+ color: modelMsg.type === 'success' ? 'var(--green)' : modelMsg.type === 'warning' ? 'var(--yellow)' : 'var(--red)',
680
+ }}>
681
+ {modelMsg.text}
682
+ </div>
683
+ )}
684
+ </div>
685
+ );
686
+ })}
1039
687
  </div>
1040
- )}
1041
- </div>
1042
- )}
1043
- </Surface>
1044
- );
1045
- })}
1046
- </section>
1047
- )}
688
+ </div>
689
+ );
690
+ })()}
1048
691
 
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
- })}
692
+ {msg && (
693
+ <div className={`msg-${msg.type}`}>
694
+ {msg.text}
695
+ </div>
696
+ )}
1065
697
  </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>
698
+ )}
1075
699
  </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
- )}
700
+ );
701
+ })}
702
+ </section>
1124
703
  <ConfirmModal
1125
704
  open={removeConfirm !== null}
1126
705
  onClose={() => setRemoveConfirm(null)}