@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,5 +1,13 @@
1
1
  'use client';
2
2
 
3
+ /**
4
+ * Agent base image banner on the Agents page.
5
+ *
6
+ * Shown when no supported OpenClaw version of the agent image is downloaded, when
7
+ * the registry publishes a newer supported version, and while a download runs.
8
+ * Downloading touches no agent: each agent keeps its OpenClaw version until it is
9
+ * updated on its own.
10
+ */
3
11
  import { useState, useEffect, useCallback, useRef } from 'react';
4
12
  import { Button } from '../components/ui';
5
13
 
@@ -7,16 +15,23 @@ interface ImageStatus {
7
15
  exists: boolean;
8
16
  needsUpdate: boolean;
9
17
  downloading: boolean;
18
+ downloadingVersion: string | null;
19
+ localVersions: string[];
20
+ newestLocal: string | null;
21
+ /** Newest supported version on the registry that is not downloaded yet. */
22
+ available: string | null;
10
23
  }
11
24
 
12
25
  const POLL_MS = 2000;
13
26
 
14
27
  export default function ImageDownloadBanner({ onImageReady }: { onImageReady?: () => void }) {
15
- const [status, setStatus] = useState<ImageStatus>({ exists: true, needsUpdate: false, downloading: false });
28
+ const [status, setStatus] = useState<ImageStatus>({
29
+ exists: true, needsUpdate: false, downloading: false, downloadingVersion: null, localVersions: [], newestLocal: null, available: null,
30
+ });
16
31
  const [error, setError] = useState<string | null>(null);
17
- const [justCompleted, setJustCompleted] = useState(false);
32
+ const [justCompleted, setJustCompleted] = useState<string | null>(null);
18
33
  const [actionLoading, setActionLoading] = useState(false);
19
- const wasDownloadingRef = useRef(false);
34
+ const wasDownloadingRef = useRef<string | null>(null);
20
35
  const completedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
21
36
 
22
37
  useEffect(() => {
@@ -32,15 +47,20 @@ export default function ImageDownloadBanner({ onImageReady }: { onImageReady?: (
32
47
 
33
48
  if (data.downloading) {
34
49
  setError(null);
50
+ wasDownloadingRef.current = data.downloadingVersion ?? wasDownloadingRef.current ?? 'image';
35
51
  } else if (wasDownloadingRef.current) {
36
- setJustCompleted(true);
52
+ const finished = wasDownloadingRef.current;
53
+ wasDownloadingRef.current = null;
37
54
  setActionLoading(false);
38
- onImageReady?.();
39
- if (completedTimerRef.current) clearTimeout(completedTimerRef.current);
40
- completedTimerRef.current = setTimeout(() => setJustCompleted(false), 3000);
55
+ if (data.localVersions?.includes(finished)) {
56
+ setJustCompleted(finished);
57
+ onImageReady?.();
58
+ if (completedTimerRef.current) clearTimeout(completedTimerRef.current);
59
+ completedTimerRef.current = setTimeout(() => setJustCompleted(null), 3000);
60
+ } else {
61
+ setError(`The OpenClaw ${finished} download did not complete. Check the Docker connection and try again.`);
62
+ }
41
63
  }
42
-
43
- wasDownloadingRef.current = data.downloading;
44
64
  } catch { /* ignore */ }
45
65
 
46
66
  if (active) setTimeout(tick, POLL_MS);
@@ -56,16 +76,23 @@ export default function ImageDownloadBanner({ onImageReady }: { onImageReady?: (
56
76
  };
57
77
  }, []);
58
78
 
59
- const handleDownload = useCallback(async () => {
79
+ const handleDownload = useCallback(async (version: string | null) => {
60
80
  setActionLoading(true);
61
81
  setError(null);
62
82
  try {
63
- const res = await fetch('/api/agents/download-image', { method: 'POST' });
83
+ const res = await fetch('/api/agents/download-image', {
84
+ method: 'POST',
85
+ headers: { 'Content-Type': 'application/json' },
86
+ body: JSON.stringify(version ? { version } : {}),
87
+ });
64
88
  if (!res.ok) {
65
89
  const data = await res.json().catch(() => ({ error: 'Failed to start download' }));
66
90
  setError(data.error || 'Failed to start download');
67
91
  setActionLoading(false);
92
+ return;
68
93
  }
94
+ const data = await res.json().catch(() => ({})) as { version?: string };
95
+ wasDownloadingRef.current = data.version ?? version ?? 'image';
69
96
  } catch (e: unknown) {
70
97
  setError((e as Error).message || 'Download failed');
71
98
  setActionLoading(false);
@@ -79,6 +106,7 @@ export default function ImageDownloadBanner({ onImageReady }: { onImageReady?: (
79
106
 
80
107
  // ─── Downloading ───
81
108
  if (isDownloading) {
109
+ const version = status.downloadingVersion ?? status.available;
82
110
  return (
83
111
  <Banner downloading>
84
112
  <div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1 }}>
@@ -89,7 +117,9 @@ export default function ImageDownloadBanner({ onImageReady }: { onImageReady?: (
89
117
  animation: 'pulse 1.5s ease-in-out infinite',
90
118
  flexShrink: 0,
91
119
  }} />
92
- <span style={{ color: 'var(--violet)', fontWeight: 500 }}>Downloading agent base image…</span>
120
+ <span style={{ color: 'var(--violet)', fontWeight: 500 }}>
121
+ {version ? `Downloading the OpenClaw ${version} agent image…` : 'Downloading the agent image…'}
122
+ </span>
93
123
  </div>
94
124
  <div style={{
95
125
  flex: 1, maxWidth: 120, height: 3,
@@ -107,11 +137,11 @@ export default function ImageDownloadBanner({ onImageReady }: { onImageReady?: (
107
137
  }
108
138
 
109
139
  // ─── Just completed ───
110
- if (justCompleted && !isDownloading) {
140
+ if (justCompleted) {
111
141
  return (
112
142
  <Banner success>
113
143
  <div style={{ flex: 1 }}>
114
- <span style={{ color: '#22c55e', fontWeight: 500 }}>✓ Agent base image ready</span>
144
+ <span style={{ color: '#22c55e', fontWeight: 500 }}>✓ OpenClaw {justCompleted} agent image ready</span>
115
145
  </div>
116
146
  </Banner>
117
147
  );
@@ -122,16 +152,20 @@ export default function ImageDownloadBanner({ onImageReady }: { onImageReady?: (
122
152
  <Banner warning>
123
153
  <div style={{ flex: 1 }}>
124
154
  <strong style={{ color: '#fbbf24' }}>
125
- {status.exists ? '⚠ Agent base image is outdated.' : '⚠ Agent base image not found.'}
155
+ {status.exists
156
+ ? `⚠ OpenClaw ${status.available} is available.`
157
+ : '⚠ Agent base image not found.'}
126
158
  </strong>
127
159
  {' '}{status.exists
128
- ? 'A newer version is available on the registry.'
160
+ ? 'Download it for new agents; existing agents keep their version until they are updated.'
129
161
  : 'Download it to create agent containers.'}
130
162
  {error && <div style={{ color: '#ef4444', fontSize: 11, marginTop: 4 }}>{error}</div>}
131
163
  </div>
132
- <Button variant="primary" size="sm" onClick={handleDownload} loading={actionLoading}>
133
- {actionLoading ? 'Starting…' : status.exists ? 'Download Update' : 'Download Image'}
134
- </Button>
164
+ {(status.exists ? status.available : true) && (
165
+ <Button variant="primary" size="sm" onClick={() => { void handleDownload(status.available); }} loading={actionLoading}>
166
+ {actionLoading ? 'Starting…' : status.exists ? `Download ${status.available}` : 'Download Image'}
167
+ </Button>
168
+ )}
135
169
  </Banner>
136
170
  );
137
171
  }
@@ -0,0 +1,316 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Model section of the agent detail panel.
5
+ *
6
+ * Primary model and fallbacks used to live on the Gateway page, which mixed two
7
+ * unrelated jobs: provider keys and catalogue (system configuration) and
8
+ * per-agent model assignment (one agent's configuration). This is the second.
9
+ *
10
+ * Fallbacks matter more than they look. The create wizard has never set them: it
11
+ * holds `const [fallbacks] = useState<string[]>([])` and nothing writes to that
12
+ * state. The only UI that could was the Gateway Agents tab this panel replaced,
13
+ * and in practice no agent carries any. When a
14
+ * model is disabled on the Gateway page while some agent still has it as
15
+ * primary, that agent has nothing to fall back to and goes silent on its next
16
+ * turn. This panel surfaces exactly that condition.
17
+ *
18
+ * `agents` is in OpenClaw's no-restart column, so saving applies immediately
19
+ * with no container restart.
20
+ */
21
+ import { useCallback, useEffect, useRef, useState } from 'react';
22
+ import { Button, RemoveButton, Select } from '../components/ui';
23
+
24
+ interface ModelOption {
25
+ id: string;
26
+ label: string;
27
+ input: number | null;
28
+ output: number | null;
29
+ /** Upstream retired the name. It still answers, so this warns rather than blocks. */
30
+ deprecated: boolean;
31
+ }
32
+
33
+ interface ProviderGroup {
34
+ provider: string;
35
+ emoji: string;
36
+ models: ModelOption[];
37
+ }
38
+
39
+ interface AgentModel {
40
+ reachable: boolean;
41
+ primary: string | null;
42
+ fallbacks: string[];
43
+ resolvable: boolean | null;
44
+ catalogueSize?: number;
45
+ /** Server-side cause for a failing primary; absent on the unreachable branch. */
46
+ primaryStatus?: 'offered' | 'disabled' | 'missing' | null;
47
+ }
48
+
49
+ /** Strip the `rev4a/` prefix the container config stores, for comparison with catalogue ids. */
50
+ const bare = (id: string) => id.replace(/^rev4a\//, '');
51
+
52
+ function priceLabel(m: ModelOption): string {
53
+ if (m.input === null && m.output === null) return '';
54
+ const fmt = (n: number | null) => (n === null ? '?' : `$${n}`);
55
+ return ` · ${fmt(m.input)} / ${fmt(m.output)} per 1M`;
56
+ }
57
+
58
+ /**
59
+ * A deprecated model is still selectable — it works — but picking one by accident
60
+ * is exactly how an agent ends up on a name upstream no longer honours. The tag
61
+ * sits next to the name it qualifies, not at the head of the row, so the
62
+ * provider grouping stays scannable.
63
+ */
64
+ function optionLabel(m: ModelOption): string {
65
+ return `${m.label}${m.deprecated ? ' (deprecated)' : ''}${priceLabel(m)}`;
66
+ }
67
+
68
+ export default function ModelSection({
69
+ agentId,
70
+ onSaved,
71
+ agentBusy,
72
+ }: {
73
+ agentId: string;
74
+ /** Called after a successful save so the agent list can refresh its chip. */
75
+ onSaved?: () => void;
76
+ /** A long operation is running on this agent: saving would race it. */
77
+ agentBusy?: boolean;
78
+ }) {
79
+ const [groups, setGroups] = useState<ProviderGroup[]>([]);
80
+ const [current, setCurrent] = useState<AgentModel | null>(null);
81
+ const [primary, setPrimary] = useState<string>('');
82
+ const [fallbacks, setFallbacks] = useState<string[]>([]);
83
+ const [loading, setLoading] = useState(true);
84
+ const [saving, setSaving] = useState(false);
85
+ const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
86
+
87
+ const abortRef = useRef<AbortController | null>(null);
88
+
89
+ const load = useCallback(async () => {
90
+ // Cancel any in-flight load: this fires on mount and after every save, and
91
+ // a stale response arriving late would overwrite fresher state.
92
+ abortRef.current?.abort();
93
+ const controller = new AbortController();
94
+ abortRef.current = controller;
95
+
96
+ setLoading(true);
97
+ try {
98
+ const [mRes, aRes] = await Promise.all([
99
+ fetch('/api/models', { signal: controller.signal }),
100
+ fetch(`/api/agents/${encodeURIComponent(agentId)}/model`, { signal: controller.signal }),
101
+ ]);
102
+ if (!mRes.ok || !aRes.ok) {
103
+ // Say so. An empty picker with no message reads as "no models available",
104
+ // which is a different problem with a different fix.
105
+ setMsg({
106
+ text: `Could not load model configuration (${!mRes.ok ? `catalogue HTTP ${mRes.status}` : `agent HTTP ${aRes.status}`})`,
107
+ ok: false,
108
+ });
109
+ }
110
+ if (mRes.ok) setGroups((await mRes.json()) as ProviderGroup[]);
111
+ if (aRes.ok) {
112
+ const data = (await aRes.json()) as AgentModel;
113
+ setCurrent(data);
114
+ setPrimary(data.primary ? bare(data.primary) : '');
115
+ setFallbacks((data.fallbacks ?? []).map(bare));
116
+ }
117
+ } catch (e) {
118
+ if ((e as Error)?.name !== 'AbortError') setMsg({ text: 'Could not load model configuration', ok: false });
119
+ }
120
+ // Only the load that is still current may end the loading state. A save
121
+ // triggers a reload that aborts the previous one; the aborted request lands in
122
+ // the catch above and used to switch off the spinner the new one had just
123
+ // switched on.
124
+ if (abortRef.current === controller) setLoading(false);
125
+ }, [agentId]);
126
+
127
+ useEffect(() => {
128
+ void load();
129
+ return () => abortRef.current?.abort();
130
+ }, [load]);
131
+
132
+ const allModels = groups.flatMap((g) => g.models);
133
+ const byId = new Map(allModels.map((m) => [m.id, m]));
134
+ const dirty =
135
+ primary !== (current?.primary ? bare(current.primary) : '') ||
136
+ fallbacks.join(',') !== (current?.fallbacks ?? []).map(bare).join(',');
137
+
138
+ /** Return the form to what the container actually holds. */
139
+ const reset = () => {
140
+ setPrimary(current?.primary ? bare(current.primary) : '');
141
+ setFallbacks((current?.fallbacks ?? []).map(bare));
142
+ setMsg(null);
143
+ };
144
+
145
+ const save = async () => {
146
+ setSaving(true);
147
+ setMsg(null);
148
+ try {
149
+ const res = await fetch('/api/gateway/agent', {
150
+ method: 'PUT',
151
+ headers: { 'Content-Type': 'application/json' },
152
+ body: JSON.stringify({ containerName: agentId, model: primary, fallbacks }),
153
+ });
154
+ const data = await res.json().catch(() => ({}));
155
+ if (res.ok && data.success !== false) {
156
+ setMsg({ text: 'Model saved. It applies immediately, no restart needed.', ok: true });
157
+ await load();
158
+ // The card in the list reads a separate summary endpoint; without this it
159
+ // keeps showing the previous model until the next poll.
160
+ onSaved?.();
161
+ } else {
162
+ setMsg({ text: data.error || 'Save failed', ok: false });
163
+ }
164
+ } catch (e) {
165
+ setMsg({ text: (e as Error).message, ok: false });
166
+ }
167
+ setSaving(false);
168
+ };
169
+
170
+ // The current primary may no longer be on offer: unchecked on the Gateway page,
171
+ // or its provider key removed. A native select whose value matches no option
172
+ // renders as though nothing were chosen, contradicting the warning above it.
173
+ // Show the real value, disabled so it cannot be picked again. Guarded on a
174
+ // loaded catalogue so a failed fetch does not flag every model.
175
+ const primaryUnavailable = !!primary && groups.length > 0 && !byId.has(primary);
176
+ const options = [
177
+ { value: '', label: '— select a model —' },
178
+ ...(primaryUnavailable ? [{ value: primary, label: `${primary} (${current?.primaryStatus === 'missing' ? 'no longer in catalogue' : 'not enabled'})`, disabled: true }] : []),
179
+ ...groups.flatMap((g) =>
180
+ g.models.map((m) => ({ value: m.id, label: `${g.provider} · ${optionLabel(m)}` })),
181
+ ),
182
+ ];
183
+
184
+ // Two different failures, two different remedies. The panel used to show one
185
+ // message for both and guess "disabled on the Gateway page" — wrong when the
186
+ // model is enabled and a sync simply never reached this container, which is
187
+ // exactly what a toggle made while Docker was stopped produces.
188
+ const containerLacksIt = !!current?.reachable && current.resolvable === false;
189
+ // The server states the cause; the offered list is only a fallback for when it
190
+ // did not, so the panel and the card chip cannot disagree about why.
191
+ const status = current?.primaryStatus;
192
+ const warnRemoved = status === 'missing';
193
+ const warnNotEnabled = status === 'disabled' || (status == null && primaryUnavailable);
194
+ const warnNotSynced = containerLacksIt && (status === 'offered' || (status == null && groups.length > 0 && !primaryUnavailable));
195
+ const warnUnknown = containerLacksIt && status == null && groups.length === 0;
196
+
197
+ return (
198
+ <div style={{ padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 10 }}>
199
+ {loading && <div style={{ fontSize: 11, color: '#888' }}>Loading…</div>}
200
+
201
+ {!loading && current && !current.reachable && (
202
+ <div style={{ fontSize: 11, color: '#888' }}>
203
+ Container not reachable — start the agent to read or change its model.
204
+ </div>
205
+ )}
206
+
207
+ {!loading && current?.reachable && (
208
+ <>
209
+ {(warnRemoved || warnNotEnabled || warnNotSynced || warnUnknown) && (
210
+ <div style={{
211
+ border: '1px solid rgba(239,68,68,0.35)', background: 'rgba(239,68,68,0.06)',
212
+ padding: '8px 10px', fontSize: 11, color: '#ef4444', lineHeight: 1.5,
213
+ }}>
214
+ {warnRemoved && (
215
+ <>
216
+ <strong>This agent&rsquo;s model is no longer in the catalogue.</strong> It is
217
+ set to <code>{current.primary}</code>, which this version of Rev4a no longer
218
+ offers. The gateway refuses it, so the agent fails on its next turn. Pick a
219
+ model below.
220
+ </>
221
+ )}
222
+ {warnNotEnabled && (
223
+ <>
224
+ <strong>This agent&rsquo;s model is not enabled.</strong> It is set to{' '}
225
+ <code>{current.primary}</code>, which the Gateway does not offer — unchecked
226
+ there, or its provider has no key. The gateway refuses it, so the agent fails
227
+ on its next turn. Pick a model below.
228
+ </>
229
+ )}
230
+ {warnNotSynced && (
231
+ <>
232
+ <strong>This container is out of sync.</strong> Its model{' '}
233
+ <code>{current.primary}</code> is enabled on the Gateway but not among the{' '}
234
+ {current.catalogueSize} models synced to this container — a sync did not reach
235
+ it. Run <em>Sync All Agents</em> on the Gateway page; the model does not need to
236
+ change.
237
+ </>
238
+ )}
239
+ {warnUnknown && (
240
+ <>
241
+ <strong>This agent&rsquo;s model is not in its catalogue.</strong> It is set to{' '}
242
+ <code>{current.primary}</code>, which the {current.catalogueSize} models synced
243
+ to this container do not include. The model list could not be loaded, so the
244
+ cause is unknown.
245
+ </>
246
+ )}
247
+ </div>
248
+ )}
249
+
250
+ <Select
251
+ label="Primary model"
252
+ options={options}
253
+ value={primary}
254
+ onChange={(e) => setPrimary(e.target.value)}
255
+ />
256
+
257
+ <div>
258
+ <div style={{ fontSize: 10, color: '#888', letterSpacing: '0.06em', marginBottom: 4 }}>
259
+ FALLBACKS
260
+ </div>
261
+ {fallbacks.length === 0 && (
262
+ <div style={{ fontSize: 11, color: '#888', marginBottom: 6 }}>
263
+ None set. Without a fallback, the agent goes silent if its primary
264
+ becomes unavailable.
265
+ </div>
266
+ )}
267
+ {fallbacks.map((f, i) => (
268
+ <div key={`${f}-${i}`} style={{ display: 'flex', gap: 6, alignItems: 'center', marginBottom: 4 }}>
269
+ <span style={{ fontSize: 10, color: '#666', minWidth: 12 }}>{i + 1}.</span>
270
+ <span style={{ fontSize: 11, flex: 1, wordBreak: 'break-all' }}>
271
+ {f}
272
+ {byId.get(f)?.deprecated && (
273
+ <span style={{ color: '#eab308' }}> (deprecated)</span>
274
+ )}
275
+ {groups.length > 0 && !byId.has(f) && (
276
+ <span style={{ color: '#ef4444' }}> (not enabled)</span>
277
+ )}
278
+ </span>
279
+ <RemoveButton
280
+ label={`fallback ${f}`}
281
+ title="Remove from the list — applied when you save"
282
+ onClick={() => setFallbacks(fallbacks.filter((_, j) => j !== i))}
283
+ />
284
+ </div>
285
+ ))}
286
+ <Select
287
+ options={[
288
+ { value: '', label: '— add a fallback —' },
289
+ ...allModels
290
+ .filter((m) => m.id !== primary && !fallbacks.includes(m.id))
291
+ .map((m) => ({ value: m.id, label: optionLabel(m) })),
292
+ ]}
293
+ value=""
294
+ onChange={(e) => { if (e.target.value) setFallbacks([...fallbacks, e.target.value]); }}
295
+ />
296
+ </div>
297
+
298
+ <div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
299
+ <Button variant="primary" size="sm" loading={saving} disabled={!dirty || !primary || primaryUnavailable || !!agentBusy} onClick={save}>
300
+ {saving ? 'Saving…' : 'Save model'}
301
+ </Button>
302
+ {dirty && !saving && (
303
+ <>
304
+ <span style={{ fontSize: 11, color: '#eab308' }}>Unsaved changes</span>
305
+ <Button variant="secondary" size="sm" onClick={reset}>Discard</Button>
306
+ </>
307
+ )}
308
+ {msg && (
309
+ <span style={{ fontSize: 11, color: msg.ok ? '#4ade80' : '#ef4444' }}>{msg.text}</span>
310
+ )}
311
+ </div>
312
+ </>
313
+ )}
314
+ </div>
315
+ );
316
+ }