@flame0510/project-aether 1.2.0 → 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 (49) 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/gateway/agent/route.ts +23 -6
  12. package/app/api/gateway/provider/keys.ts +13 -1
  13. package/app/api/gateway/provider/route.ts +43 -12
  14. package/app/api/gateway/sync.ts +248 -72
  15. package/app/api/models/route.ts +28 -34
  16. package/app/api/provider/auth.ts +65 -0
  17. package/app/api/provider/upstream.ts +9 -2
  18. package/app/api/provider/v1/chat/completions/route.ts +22 -16
  19. package/app/api/provider/v1/models/route.ts +26 -133
  20. package/app/components/PulseChat.tsx +25 -39
  21. package/app/components/ui/RemoveButton.tsx +46 -0
  22. package/app/components/ui/Select.tsx +3 -2
  23. package/app/components/ui/index.ts +1 -0
  24. package/app/credentials/PageClient.tsx +2 -2
  25. package/app/gateway/PageClient.tsx +257 -673
  26. package/app/globals.css +8 -0
  27. package/app/lib/models-context.tsx +43 -7
  28. package/app/wizard/useWizard.ts +6 -1
  29. package/bin/rev4a.js +73 -9
  30. package/docs/ARCHITECTURE.md +16 -4
  31. package/docs/FRONTEND-ARCHITECTURE.md +24 -2
  32. package/docs/REV4A.md +40 -17
  33. package/docs/dev/API-REFERENCE.md +170 -79
  34. package/docs/dev/GATEWAY.md +231 -89
  35. package/docs/dev/PROVIDERS.md +26 -13
  36. package/docs/rag/DATA-FRESHNESS.md +57 -28
  37. package/docs/rag/GLOSSARY.md +16 -14
  38. package/docs/rag/REV4A-OVERVIEW.md +23 -24
  39. package/docs/rag/WHAT-I-CAN-ANSWER.md +5 -7
  40. package/instrumentation.ts +9 -1
  41. package/lib/agent-readiness.ts +110 -0
  42. package/lib/channelManager.ts +64 -22
  43. package/lib/container-file.ts +27 -0
  44. package/lib/model-catalogue.ts +140 -27
  45. package/lib/rev4a-paths.ts +0 -21
  46. package/model-pricing.json +118 -110
  47. package/models.config.json +27 -12
  48. package/package.json +1 -1
  49. package/app/api/gateway/route.ts +0 -191
package/README.md CHANGED
@@ -12,7 +12,7 @@
12
12
  - **Multi-container agent management** — Create, monitor, and manage OpenClaw agents in isolated Docker containers from a single UI
13
13
  - **Agent creation wizard** — Pick a template (Atlas, Prometheus, Argus), choose an LLM model, deploy an agent in seconds
14
14
  - **Provider sync** — Add API keys once, push them to all agent containers with one click
15
- - **Model configuration** — Change the primary model per agent, for every major provider (OpenAI, Anthropic, DeepSeek, Gemini, Ollama, etc.)
15
+ - **Model configuration** — Change the primary model and fallbacks per agent, from the detail panel on the Agents page. Providers: OpenAI, Anthropic, Google, DeepSeek, Groq, Kimi, GLM, Qwen, plus OpenRouter as an aggregator.
16
16
  - **Shared gateway token** — One token to rule all agents. Manage from the dashboard, sync to all containers
17
17
  - **Live event stream** — Real-time session lineage, agent spawning, cost reporting
18
18
  - **Workspace browser** — Browse and edit agent files live from the dashboard
@@ -81,6 +81,7 @@ already filled in — you normally never set these by hand. See
81
81
  | `REV4A_DATA_DIR` | `~/.config/rev4a` | No | Root of all persistent data |
82
82
  | `REV4A_DB` | `<data dir>/data/events.db` | No | SQLite database path |
83
83
  | `REV4A_WORKSPACE_ROOT` | — | No | Root exposed by the local file explorer |
84
+ | `REV4A_DOCKER_WAIT_SECONDS` | `90` | No | How long `rev4a serve` waits for the Docker daemon at start-up before starting without it. Capped at 600; `0` checks once; a negative or non-numeric value means 90. Read from the process environment, not from the `.env` file |
84
85
 
85
86
  ---
86
87
 
@@ -0,0 +1,313 @@
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
+ }: {
72
+ agentId: string;
73
+ /** Called after a successful save so the agent list can refresh its chip. */
74
+ onSaved?: () => void;
75
+ }) {
76
+ const [groups, setGroups] = useState<ProviderGroup[]>([]);
77
+ const [current, setCurrent] = useState<AgentModel | null>(null);
78
+ const [primary, setPrimary] = useState<string>('');
79
+ const [fallbacks, setFallbacks] = useState<string[]>([]);
80
+ const [loading, setLoading] = useState(true);
81
+ const [saving, setSaving] = useState(false);
82
+ const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
83
+
84
+ const abortRef = useRef<AbortController | null>(null);
85
+
86
+ const load = useCallback(async () => {
87
+ // Cancel any in-flight load: this fires on mount and after every save, and
88
+ // a stale response arriving late would overwrite fresher state.
89
+ abortRef.current?.abort();
90
+ const controller = new AbortController();
91
+ abortRef.current = controller;
92
+
93
+ setLoading(true);
94
+ try {
95
+ const [mRes, aRes] = await Promise.all([
96
+ fetch('/api/models', { signal: controller.signal }),
97
+ fetch(`/api/agents/${encodeURIComponent(agentId)}/model`, { signal: controller.signal }),
98
+ ]);
99
+ if (!mRes.ok || !aRes.ok) {
100
+ // Say so. An empty picker with no message reads as "no models available",
101
+ // which is a different problem with a different fix.
102
+ setMsg({
103
+ text: `Could not load model configuration (${!mRes.ok ? `catalogue HTTP ${mRes.status}` : `agent HTTP ${aRes.status}`})`,
104
+ ok: false,
105
+ });
106
+ }
107
+ if (mRes.ok) setGroups((await mRes.json()) as ProviderGroup[]);
108
+ if (aRes.ok) {
109
+ const data = (await aRes.json()) as AgentModel;
110
+ setCurrent(data);
111
+ setPrimary(data.primary ? bare(data.primary) : '');
112
+ setFallbacks((data.fallbacks ?? []).map(bare));
113
+ }
114
+ } catch (e) {
115
+ if ((e as Error)?.name !== 'AbortError') setMsg({ text: 'Could not load model configuration', ok: false });
116
+ }
117
+ // Only the load that is still current may end the loading state. A save
118
+ // triggers a reload that aborts the previous one; the aborted request lands in
119
+ // the catch above and used to switch off the spinner the new one had just
120
+ // switched on.
121
+ if (abortRef.current === controller) setLoading(false);
122
+ }, [agentId]);
123
+
124
+ useEffect(() => {
125
+ void load();
126
+ return () => abortRef.current?.abort();
127
+ }, [load]);
128
+
129
+ const allModels = groups.flatMap((g) => g.models);
130
+ const byId = new Map(allModels.map((m) => [m.id, m]));
131
+ const dirty =
132
+ primary !== (current?.primary ? bare(current.primary) : '') ||
133
+ fallbacks.join(',') !== (current?.fallbacks ?? []).map(bare).join(',');
134
+
135
+ /** Return the form to what the container actually holds. */
136
+ const reset = () => {
137
+ setPrimary(current?.primary ? bare(current.primary) : '');
138
+ setFallbacks((current?.fallbacks ?? []).map(bare));
139
+ setMsg(null);
140
+ };
141
+
142
+ const save = async () => {
143
+ setSaving(true);
144
+ setMsg(null);
145
+ try {
146
+ const res = await fetch('/api/gateway/agent', {
147
+ method: 'PUT',
148
+ headers: { 'Content-Type': 'application/json' },
149
+ body: JSON.stringify({ containerName: agentId, model: primary, fallbacks }),
150
+ });
151
+ const data = await res.json().catch(() => ({}));
152
+ if (res.ok && data.success !== false) {
153
+ setMsg({ text: 'Model saved. It applies immediately, no restart needed.', ok: true });
154
+ await load();
155
+ // The card in the list reads a separate summary endpoint; without this it
156
+ // keeps showing the previous model until the next poll.
157
+ onSaved?.();
158
+ } else {
159
+ setMsg({ text: data.error || 'Save failed', ok: false });
160
+ }
161
+ } catch (e) {
162
+ setMsg({ text: (e as Error).message, ok: false });
163
+ }
164
+ setSaving(false);
165
+ };
166
+
167
+ // The current primary may no longer be on offer: unchecked on the Gateway page,
168
+ // or its provider key removed. A native select whose value matches no option
169
+ // renders as though nothing were chosen, contradicting the warning above it.
170
+ // Show the real value, disabled so it cannot be picked again. Guarded on a
171
+ // loaded catalogue so a failed fetch does not flag every model.
172
+ const primaryUnavailable = !!primary && groups.length > 0 && !byId.has(primary);
173
+ const options = [
174
+ { value: '', label: '— select a model —' },
175
+ ...(primaryUnavailable ? [{ value: primary, label: `${primary} (${current?.primaryStatus === 'missing' ? 'no longer in catalogue' : 'not enabled'})`, disabled: true }] : []),
176
+ ...groups.flatMap((g) =>
177
+ g.models.map((m) => ({ value: m.id, label: `${g.provider} · ${optionLabel(m)}` })),
178
+ ),
179
+ ];
180
+
181
+ // Two different failures, two different remedies. The panel used to show one
182
+ // message for both and guess "disabled on the Gateway page" — wrong when the
183
+ // model is enabled and a sync simply never reached this container, which is
184
+ // exactly what a toggle made while Docker was stopped produces.
185
+ const containerLacksIt = !!current?.reachable && current.resolvable === false;
186
+ // The server states the cause; the offered list is only a fallback for when it
187
+ // did not, so the panel and the card chip cannot disagree about why.
188
+ const status = current?.primaryStatus;
189
+ const warnRemoved = status === 'missing';
190
+ const warnNotEnabled = status === 'disabled' || (status == null && primaryUnavailable);
191
+ const warnNotSynced = containerLacksIt && (status === 'offered' || (status == null && groups.length > 0 && !primaryUnavailable));
192
+ const warnUnknown = containerLacksIt && status == null && groups.length === 0;
193
+
194
+ return (
195
+ <div style={{ padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 10 }}>
196
+ {loading && <div style={{ fontSize: 11, color: '#888' }}>Loading…</div>}
197
+
198
+ {!loading && current && !current.reachable && (
199
+ <div style={{ fontSize: 11, color: '#888' }}>
200
+ Container not reachable — start the agent to read or change its model.
201
+ </div>
202
+ )}
203
+
204
+ {!loading && current?.reachable && (
205
+ <>
206
+ {(warnRemoved || warnNotEnabled || warnNotSynced || warnUnknown) && (
207
+ <div style={{
208
+ border: '1px solid rgba(239,68,68,0.35)', background: 'rgba(239,68,68,0.06)',
209
+ padding: '8px 10px', fontSize: 11, color: '#ef4444', lineHeight: 1.5,
210
+ }}>
211
+ {warnRemoved && (
212
+ <>
213
+ <strong>This agent&rsquo;s model is no longer in the catalogue.</strong> It is
214
+ set to <code>{current.primary}</code>, which this version of Rev4a no longer
215
+ offers. The gateway refuses it, so the agent fails on its next turn. Pick a
216
+ model below.
217
+ </>
218
+ )}
219
+ {warnNotEnabled && (
220
+ <>
221
+ <strong>This agent&rsquo;s model is not enabled.</strong> It is set to{' '}
222
+ <code>{current.primary}</code>, which the Gateway does not offer — unchecked
223
+ there, or its provider has no key. The gateway refuses it, so the agent fails
224
+ on its next turn. Pick a model below.
225
+ </>
226
+ )}
227
+ {warnNotSynced && (
228
+ <>
229
+ <strong>This container is out of sync.</strong> Its model{' '}
230
+ <code>{current.primary}</code> is enabled on the Gateway but not among the{' '}
231
+ {current.catalogueSize} models synced to this container — a sync did not reach
232
+ it. Run <em>Sync All Agents</em> on the Gateway page; the model does not need to
233
+ change.
234
+ </>
235
+ )}
236
+ {warnUnknown && (
237
+ <>
238
+ <strong>This agent&rsquo;s model is not in its catalogue.</strong> It is set to{' '}
239
+ <code>{current.primary}</code>, which the {current.catalogueSize} models synced
240
+ to this container do not include. The model list could not be loaded, so the
241
+ cause is unknown.
242
+ </>
243
+ )}
244
+ </div>
245
+ )}
246
+
247
+ <Select
248
+ label="Primary model"
249
+ options={options}
250
+ value={primary}
251
+ onChange={(e) => setPrimary(e.target.value)}
252
+ />
253
+
254
+ <div>
255
+ <div style={{ fontSize: 10, color: '#888', letterSpacing: '0.06em', marginBottom: 4 }}>
256
+ FALLBACKS
257
+ </div>
258
+ {fallbacks.length === 0 && (
259
+ <div style={{ fontSize: 11, color: '#888', marginBottom: 6 }}>
260
+ None set. Without a fallback, the agent goes silent if its primary
261
+ becomes unavailable.
262
+ </div>
263
+ )}
264
+ {fallbacks.map((f, i) => (
265
+ <div key={`${f}-${i}`} style={{ display: 'flex', gap: 6, alignItems: 'center', marginBottom: 4 }}>
266
+ <span style={{ fontSize: 10, color: '#666', minWidth: 12 }}>{i + 1}.</span>
267
+ <span style={{ fontSize: 11, flex: 1, wordBreak: 'break-all' }}>
268
+ {f}
269
+ {byId.get(f)?.deprecated && (
270
+ <span style={{ color: '#eab308' }}> (deprecated)</span>
271
+ )}
272
+ {groups.length > 0 && !byId.has(f) && (
273
+ <span style={{ color: '#ef4444' }}> (not enabled)</span>
274
+ )}
275
+ </span>
276
+ <RemoveButton
277
+ label={`fallback ${f}`}
278
+ title="Remove from the list — applied when you save"
279
+ onClick={() => setFallbacks(fallbacks.filter((_, j) => j !== i))}
280
+ />
281
+ </div>
282
+ ))}
283
+ <Select
284
+ options={[
285
+ { value: '', label: '— add a fallback —' },
286
+ ...allModels
287
+ .filter((m) => m.id !== primary && !fallbacks.includes(m.id))
288
+ .map((m) => ({ value: m.id, label: optionLabel(m) })),
289
+ ]}
290
+ value=""
291
+ onChange={(e) => { if (e.target.value) setFallbacks([...fallbacks, e.target.value]); }}
292
+ />
293
+ </div>
294
+
295
+ <div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
296
+ <Button variant="primary" size="sm" loading={saving} disabled={!dirty || !primary || primaryUnavailable} onClick={save}>
297
+ {saving ? 'Saving…' : 'Save model'}
298
+ </Button>
299
+ {dirty && !saving && (
300
+ <>
301
+ <span style={{ fontSize: 11, color: '#eab308' }}>Unsaved changes</span>
302
+ <Button variant="secondary" size="sm" onClick={reset}>Discard</Button>
303
+ </>
304
+ )}
305
+ {msg && (
306
+ <span style={{ fontSize: 11, color: msg.ok ? '#4ade80' : '#ef4444' }}>{msg.text}</span>
307
+ )}
308
+ </div>
309
+ </>
310
+ )}
311
+ </div>
312
+ );
313
+ }
@@ -7,6 +7,7 @@ import { Button, ListItem, Badge, ConfirmModal, Input, type Tone } from '../comp
7
7
  import PasswordInput from '../components/PasswordInput';
8
8
  import ImageDownloadBanner from './ImageDownloadBanner';
9
9
  import ChannelManager from './ChannelManager';
10
+ import ModelSection from './ModelSection';
10
11
  import {
11
12
  InfoIcon, BracesIcon, KeyIcon, TrashIcon, TerminalIcon,
12
13
  LinkIcon, ArchiveIcon, RotateIcon, PackageIcon,
@@ -174,6 +175,7 @@ function AgentListItem({
174
175
  loading,
175
176
  selected,
176
177
  channelSummary,
178
+ modelSummary,
177
179
  onClick,
178
180
  onLifecycle,
179
181
  }: {
@@ -181,6 +183,7 @@ function AgentListItem({
181
183
  loading: boolean;
182
184
  selected?: boolean;
183
185
  channelSummary: Record<string, { telegram: boolean }>;
186
+ modelSummary: Record<string, { primary: string | null; health: string; fallbackCount: number; firstFallback?: string | null }>;
184
187
  onClick: () => void;
185
188
  onLifecycle: () => void;
186
189
  }) {
@@ -221,6 +224,41 @@ function AgentListItem({
221
224
  {agent.image?.includes('/') ? agent.image.split('/').pop() : agent.image}
222
225
  {agent.ports && <span style={{ marginLeft: 8 }}>{agent.ports}</span>}
223
226
  </div>
227
+ {(() => {
228
+ const m = modelSummary[agent.agentId];
229
+ if (!m || !m.primary) return null;
230
+ // Tone follows what the operator has to do about it: red means the agent
231
+ // will fail on its next turn, amber means it still works on a retired name.
232
+ // The red states differ in remedy — pick another model for the first two,
233
+ // run Sync All on the Gateway page for OUT OF SYNC.
234
+ const failing = m.health === 'missing' || m.health === 'disabled' || m.health === 'out-of-sync';
235
+ const tone = failing ? 'danger' : m.health === 'deprecated' ? 'warning' : undefined;
236
+ const chip = m.health === 'missing' ? 'NOT IN CATALOGUE'
237
+ : m.health === 'disabled' ? 'NOT ENABLED'
238
+ : m.health === 'out-of-sync' ? 'OUT OF SYNC'
239
+ : m.health === 'deprecated' ? 'DEPRECATED'
240
+ : m.fallbackCount === 0 ? 'NO FALLBACK' : null;
241
+ return (
242
+ <div style={{ display: 'flex', gap: 6, alignItems: 'center', marginTop: 4, flexWrap: 'wrap' }}>
243
+ <span style={{ fontSize: 11, color: '#888', wordBreak: 'break-all' }}>
244
+ {m.primary.replace(/^rev4a\//, '')}
245
+ {/* Only the first fallback: a list here would drown the row, and the
246
+ full set is editable in the detail panel anyway. */}
247
+ {m.firstFallback && (
248
+ <span style={{ color: '#666' }}>
249
+ {' ↳ '}{m.firstFallback.replace(/^rev4a\//, '')}
250
+ {m.fallbackCount > 1 && ` +${m.fallbackCount - 1}`}
251
+ </span>
252
+ )}
253
+ </span>
254
+ {chip && (
255
+ <Badge tone={(tone ?? 'neutral') as Tone}>
256
+ <span style={{ fontSize: 8, padding: '1px 6px' }}>{chip}</span>
257
+ </Badge>
258
+ )}
259
+ </div>
260
+ );
261
+ })()}
224
262
  {channelSummary[agent.agentId]?.telegram && (
225
263
  <div style={{ display: 'flex', gap: 4, marginTop: 4 }}>
226
264
  <Badge tone="success"><span style={{ fontSize: 8, padding: '1px 6px' }}>TG</span></Badge>
@@ -245,6 +283,7 @@ export default function AgentsPageClient() {
245
283
  const [isMobile, setIsMobile] = useState(false);
246
284
  const [showList, setShowList] = useState(true);
247
285
  const [chSummary, setChSummary] = useState<Record<string, { telegram: boolean }>>({});
286
+ const [mdSummary, setMdSummary] = useState<Record<string, { primary: string | null; health: string; fallbackCount: number; firstFallback?: string | null }>>({});
248
287
  const [imageReady, setImageReady] = useState(true);
249
288
  // null = not checked yet. Keeping "unknown" distinct from "none configured" is
250
289
  // the whole point: `false` as the initial value makes an unanswered check look
@@ -258,6 +297,28 @@ export default function AgentsPageClient() {
258
297
  const skipNextPoll = useRef(false);
259
298
  /** Guards the un-awaited channel-summary fetch against out-of-order responses. */
260
299
  const chSummarySeq = useRef(0);
300
+ const mdSummarySeq = useRef(0);
301
+
302
+ /**
303
+ * Refresh the per-agent model rows that feed the card chips.
304
+ *
305
+ * Shared by the 15s poll and by a save in the detail panel: without the second
306
+ * caller the chip kept showing the previous model until the next poll, which
307
+ * reads as "the save did not work".
308
+ *
309
+ * Sequence-guarded because several can overlap; the last one issued wins.
310
+ */
311
+ const refreshModelSummary = useCallback(async () => {
312
+ const seq = ++mdSummarySeq.current;
313
+ try {
314
+ const res = await fetch('/api/agents/models-summary');
315
+ if (!res.ok || seq !== mdSummarySeq.current) return;
316
+ const payload = await res.json();
317
+ const byId: Record<string, { primary: string | null; health: string; fallbackCount: number; firstFallback?: string | null }> = {};
318
+ for (const row of payload.agents ?? []) byId[row.agentId] = row;
319
+ setMdSummary(byId);
320
+ } catch { /* badges are optional */ }
321
+ }, []);
261
322
  useEffect(() => {
262
323
  const check = () => setIsMobile(window.innerWidth < 768);
263
324
  check();
@@ -290,6 +351,10 @@ export default function AgentsPageClient() {
290
351
  setChSummary(await chRes.json());
291
352
  } catch { /* badges are optional */ }
292
353
  })();
354
+
355
+ // Same treatment for the model summary: it decorates rows, so it must not
356
+ // hold the list, and a slow earlier response must not overwrite a newer one.
357
+ void refreshModelSummary();
293
358
  } catch (e: any) {
294
359
  setError(e.message || 'Loading error');
295
360
  } finally {
@@ -511,13 +576,13 @@ export default function AgentsPageClient() {
511
576
  {providersConfigured === false && <NoProviderBanner />}
512
577
  {/* Scrollable content */}
513
578
  <div style={{ flex: 1, overflowY: 'auto', WebkitOverflowScrolling: 'touch' }}>
514
- <DetailPanel key={selected.agentId} agent={selected} onDeleted={handleAgentDeleted} onChanged={load} />
579
+ <DetailPanel key={selected.agentId} agent={selected} onDeleted={handleAgentDeleted} onChanged={load} onModelSaved={refreshModelSummary} />
515
580
  </div>
516
581
  </div>
517
582
  );
518
583
  }
519
584
 
520
- // Mobile: lista
585
+ // Mobile: list
521
586
  return (
522
587
  <div style={{ flex: 1, background: 'var(--bg)', color: 'var(--text)', fontFamily: 'var(--font-mono-stack)', display: 'flex', flexDirection: 'column', }}>
523
588
  <div style={{ height: 48, padding: '0 14px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
@@ -545,6 +610,7 @@ export default function AgentsPageClient() {
545
610
  agent={agent}
546
611
  loading={lifecycleLoading === agent.agentId}
547
612
  channelSummary={chSummary}
613
+ modelSummary={mdSummary}
548
614
  onClick={() => selectAgent(agent)}
549
615
  onLifecycle={() => handleLifecycle(agent)}
550
616
  />
@@ -590,6 +656,7 @@ export default function AgentsPageClient() {
590
656
  agent={agent}
591
657
  loading={lifecycleLoading === agent.agentId}
592
658
  channelSummary={chSummary}
659
+ modelSummary={mdSummary}
593
660
  selected={isSelected}
594
661
  onClick={() => setSelectedId(agent.agentId)}
595
662
  onLifecycle={() => handleLifecycle(agent)}
@@ -604,7 +671,7 @@ export default function AgentsPageClient() {
604
671
  // key: without it React reuses one DetailPanel instance across
605
672
  // selections, so in-flight state (deleting, recreating, restarting,
606
673
  // toasts, open confirms) leaks onto the next agent you click.
607
- <DetailPanel key={selected.agentId} agent={selected} onDeleted={handleAgentDeleted} onChanged={load} />
674
+ <DetailPanel key={selected.agentId} agent={selected} onDeleted={handleAgentDeleted} onChanged={load} onModelSaved={refreshModelSummary} />
608
675
  )}
609
676
  </div>
610
677
  </div>
@@ -768,12 +835,18 @@ function ConfigModal({ agent, onClose }: { agent: Agent; onClose: () => void })
768
835
  }
769
836
 
770
837
  // ─── Detail panel (shared between mobile & desktop) ───
771
- function DetailPanel({ agent, onDeleted, onChanged }: {
838
+ function DetailPanel({ agent, onDeleted, onChanged, onModelSaved }: {
772
839
  agent: Agent;
773
840
  /** The agent is gone — the parent drops it from the list. */
774
841
  onDeleted: (agentId: string) => void;
775
842
  /** The agent changed in place (renamed, recreated) — refetch the list. */
776
843
  onChanged: () => void;
844
+ /**
845
+ * The model changed. Narrower than `onChanged` on purpose: only the card chip
846
+ * needs refreshing, so this avoids refetching the whole list plus the channel
847
+ * summary for a field the parent already knows how to reload on its own.
848
+ */
849
+ onModelSaved: () => void;
777
850
  }) {
778
851
  const [deleting, setDeleting] = useState(false);
779
852
  const [deleteError, setDeleteError] = useState('');
@@ -856,6 +929,12 @@ function DetailPanel({ agent, onDeleted, onChanged }: {
856
929
  {/* Channels - Telegram */}
857
930
  <ChannelSection agentId={agent.agentId} agentName={agent.name} />
858
931
 
932
+ {/* Model - primary + fallbacks (moved here from the Gateway page) */}
933
+ <div style={{ border: '1px solid var(--border)', borderRadius: 0, background: 'var(--bg)', overflow: 'hidden' }}>
934
+ <SectionHeader icon={<BracesIcon size={12} />} label="MODEL" />
935
+ <ModelSection agentId={agent.agentId} onSaved={onModelSaved} />
936
+ </div>
937
+
859
938
  {/* Config - View openclaw.json */}
860
939
  <div style={{ border: '1px solid var(--border)', borderRadius: 0, background: 'var(--bg)', overflow: 'hidden' }}>
861
940
  <SectionHeader icon={<BracesIcon size={12} />} label="CONFIG" />
@@ -1,7 +1,8 @@
1
1
  import { execSync } from 'child_process';
2
2
  import * as fs from 'fs';
3
3
  import * as path from 'path';
4
- import { DB_FILE, AGENTS_TOKEN_FILE, MODELS_CONFIG_FILE, PROVIDER_KEYS_FILE, templatesDir } from '@/lib/rev4a-paths';
4
+ import { DB_FILE, AGENTS_TOKEN_FILE, templatesDir } from '@/lib/rev4a-paths';
5
+ import { loadOfferedModels } from '@/lib/model-catalogue';
5
6
  import CreatePageClient from './PageClient';
6
7
 
7
8
  export const dynamic = 'force-dynamic';
@@ -33,26 +34,12 @@ async function getTemplates(): Promise<TemplateInfo[]> {
33
34
  }
34
35
 
35
36
  async function getModels(): Promise<ModelInfo[]> {
36
- try {
37
- const raw = fs.readFileSync(MODELS_CONFIG_FILE, 'utf-8');
38
- const config = JSON.parse(raw);
39
- const allModels: ModelInfo[] = (config.models || []).filter((m: { enabled: boolean }) => m.enabled !== false);
40
-
41
- let configuredProviders: string[] = [];
42
- try {
43
- const keysRaw = fs.readFileSync(PROVIDER_KEYS_FILE, 'utf-8');
44
- configuredProviders = Object.keys(JSON.parse(keysRaw));
45
- } catch { /* no keys configured yet */ }
46
-
47
- if (configuredProviders.length > 0) {
48
- return allModels.filter((m) => configuredProviders.includes(m.provider));
49
- }
50
-
51
- // No providers configured — show no models
52
- return [];
53
- } catch {
54
- return [];
55
- }
37
+ // Through the shared definition, not a raw file read. Reading the bundle
38
+ // directly skipped model-overrides.json, so a model the operator had switched
39
+ // off on the Gateway page was still offered here — and one they had switched
40
+ // on was missing. On the production host that was 58 models listed against 62
41
+ // correct: two offered that should not be, six absent that should be.
42
+ return loadOfferedModels().map((m) => ({ id: m.id, name: m.name, provider: m.provider }));
56
43
  }
57
44
 
58
45
  async function getUsedNames(): Promise<string[]> {
@@ -0,0 +1,113 @@
1
+ /**
2
+ * GET /api/agents/[id]/model
3
+ *
4
+ * The model configuration of one agent: primary, fallbacks, and whether the
5
+ * primary is actually resolvable from the catalogue the agent was given.
6
+ *
7
+ * This replaced `GET /api/gateway`, which reported the same two fields but walked
8
+ * every container to do it — on a seven-agent host, seven `docker exec` calls at
9
+ * ~90ms each, paid to open one agent's detail panel. This reads one container.
10
+ *
11
+ * Writes still go to `PUT /api/gateway/agent`, which already handles the
12
+ * `rev4a/` prefixing and fallback de-duplication.
13
+ */
14
+ import { NextResponse } from 'next/server';
15
+ import { resolveAgentedContainer, isValidAgentId } from '@/lib/container';
16
+ import { dockerExecNoFail } from '@/lib/docker-exec';
17
+ import { catalogueStatus } from '@/lib/model-catalogue';
18
+ import { requireAuthJWT } from '@/lib/rev4a-auth';
19
+
20
+ export const dynamic = 'force-dynamic';
21
+
22
+ interface ModelRef {
23
+ primary: string | null;
24
+ fallbacks: string[];
25
+ }
26
+
27
+ function readModelRef(config: Record<string, unknown>): ModelRef {
28
+ const agents = (config.agents ?? {}) as Record<string, unknown>;
29
+ const defaults = (agents.defaults ?? {}) as Record<string, unknown>;
30
+ const model = defaults.model;
31
+
32
+ // `model` accepts either a bare string or { primary, fallbacks }.
33
+ if (typeof model === 'string') return { primary: model, fallbacks: [] };
34
+ if (model && typeof model === 'object') {
35
+ const m = model as Record<string, unknown>;
36
+ return {
37
+ primary: typeof m.primary === 'string' ? m.primary : null,
38
+ fallbacks: Array.isArray(m.fallbacks) ? m.fallbacks.filter((f): f is string => typeof f === 'string') : [],
39
+ };
40
+ }
41
+ return { primary: null, fallbacks: [] };
42
+ }
43
+
44
+ /** Model ids the agent's own catalogue offers, as written by gateway/sync. */
45
+ function readCatalogueIds(config: Record<string, unknown>): string[] {
46
+ const models = (config.models ?? {}) as Record<string, unknown>;
47
+ const providers = (models.providers ?? {}) as Record<string, unknown>;
48
+ const rev4a = (providers.rev4a ?? {}) as Record<string, unknown>;
49
+ const list = Array.isArray(rev4a.models) ? rev4a.models : [];
50
+ return list
51
+ .map((m) => (m && typeof m === 'object' ? (m as Record<string, unknown>).id : null))
52
+ .filter((id): id is string => typeof id === 'string');
53
+ }
54
+
55
+ export async function GET(
56
+ request: Request,
57
+ { params }: { params: Promise<{ id: string }> },
58
+ ): Promise<NextResponse> {
59
+ const denied = await requireAuthJWT(request);
60
+ if (denied) return denied as any;
61
+
62
+ const { id } = await params;
63
+ if (!id || !isValidAgentId(id)) {
64
+ return NextResponse.json({ error: 'Invalid agent id' }, { status: 400 });
65
+ }
66
+
67
+ const container = resolveAgentedContainer(id);
68
+ if (!container) {
69
+ return NextResponse.json({ error: `No container found with AGENT_ID '${id}'` }, { status: 404 });
70
+ }
71
+
72
+ const raw = await dockerExecNoFail(container, ['cat', '/root/.openclaw/openclaw.json'], { timeoutMs: 8000 });
73
+ if (!raw) {
74
+ // A stopped container, or a read that timed out. `docker exec` exits 0 on
75
+ // the SIGTERM sent at the deadline, so an empty body is the only signal
76
+ // either way — report unknown rather than inventing an empty config.
77
+ return NextResponse.json({ agentId: id, reachable: false, primary: null, fallbacks: [], resolvable: null });
78
+ }
79
+
80
+ let config: Record<string, unknown>;
81
+ try {
82
+ config = JSON.parse(raw);
83
+ } catch {
84
+ return NextResponse.json({ error: 'Container config is not valid JSON' }, { status: 502 });
85
+ }
86
+
87
+ const { primary, fallbacks } = readModelRef(config);
88
+ const catalogue = readCatalogueIds(config);
89
+
90
+ // The primary is stored prefixed (`rev4a/<id>`); the catalogue stores bare ids.
91
+ const bare = primary ? primary.replace(/^rev4a\//, '') : null;
92
+
93
+ return NextResponse.json({
94
+ agentId: id,
95
+ reachable: true,
96
+ primary,
97
+ fallbacks,
98
+ /**
99
+ * False means the agent is configured for a model its own catalogue does not
100
+ * carry — it will fail on its next turn, and with no fallbacks it fails hard.
101
+ * This happens when a model is disabled in the Gateway while some agent still
102
+ * has it as primary.
103
+ */
104
+ resolvable: bare ? catalogue.includes(bare) : null,
105
+ catalogueSize: catalogue.length,
106
+ /**
107
+ * Why the primary would fail regardless of this container: `offered`,
108
+ * `disabled` (unchecked, or no provider key) or `missing` (no longer in the
109
+ * catalogue). Lets the panel name the right cause instead of guessing it.
110
+ */
111
+ primaryStatus: bare ? catalogueStatus(bare) : null,
112
+ });
113
+ }