@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.
- package/README.md +2 -1
- package/app/agents/ModelSection.tsx +313 -0
- package/app/agents/PageClient.tsx +83 -4
- package/app/agents/create/page.tsx +8 -21
- package/app/api/agents/[id]/model/route.ts +113 -0
- package/app/api/agents/[id]/recreate/route.ts +10 -34
- package/app/api/agents/[id]/route.ts +10 -29
- package/app/api/agents/create/route.ts +59 -57
- package/app/api/agents/models-summary/route.ts +163 -0
- package/app/api/assistant/route.ts +36 -15
- package/app/api/gateway/agent/route.ts +23 -6
- package/app/api/gateway/provider/keys.ts +13 -1
- package/app/api/gateway/provider/route.ts +43 -12
- package/app/api/gateway/sync.ts +248 -72
- package/app/api/models/route.ts +28 -34
- package/app/api/provider/auth.ts +65 -0
- package/app/api/provider/upstream.ts +9 -2
- package/app/api/provider/v1/chat/completions/route.ts +22 -16
- package/app/api/provider/v1/models/route.ts +26 -133
- package/app/components/PulseChat.tsx +25 -39
- package/app/components/ui/RemoveButton.tsx +46 -0
- package/app/components/ui/Select.tsx +3 -2
- package/app/components/ui/index.ts +1 -0
- package/app/credentials/PageClient.tsx +2 -2
- package/app/gateway/PageClient.tsx +257 -673
- package/app/globals.css +8 -0
- package/app/lib/models-context.tsx +43 -7
- package/app/wizard/useWizard.ts +6 -1
- package/bin/rev4a.js +73 -9
- package/docs/ARCHITECTURE.md +16 -4
- package/docs/FRONTEND-ARCHITECTURE.md +24 -2
- package/docs/REV4A.md +40 -17
- package/docs/dev/API-REFERENCE.md +170 -79
- package/docs/dev/GATEWAY.md +231 -89
- package/docs/dev/PROVIDERS.md +26 -13
- package/docs/rag/DATA-FRESHNESS.md +57 -28
- package/docs/rag/GLOSSARY.md +16 -14
- package/docs/rag/REV4A-OVERVIEW.md +23 -24
- package/docs/rag/WHAT-I-CAN-ANSWER.md +5 -7
- package/instrumentation.ts +9 -1
- package/lib/agent-readiness.ts +110 -0
- package/lib/channelManager.ts +64 -22
- package/lib/container-file.ts +27 -0
- package/lib/model-catalogue.ts +140 -27
- package/lib/rev4a-paths.ts +0 -21
- package/model-pricing.json +118 -110
- package/models.config.json +27 -12
- package/package.json +1 -1
- package/app/api/gateway/route.ts +0 -191
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,
|
|
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’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’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’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:
|
|
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,
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
+
}
|