@flame0510/project-aether 1.6.1 → 1.7.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/LICENSE +21 -0
- package/README.md +5 -2
- package/agent-templates/atlas/HEARTBEAT.md +1 -1
- package/app/agents/ImageDownloadBanner.tsx +171 -37
- package/app/api/agents/download-image/route.ts +29 -5
- package/app/api/agents/image-status/route.ts +17 -2
- package/app/api/assistant/route.ts +13 -4
- package/app/api/auth/login/route.ts +2 -2
- package/app/api/setup/agent-image/route.ts +6 -4
- package/app/api/system-health/route.ts +3 -3
- package/app/components/DashboardToolbar.tsx +2 -2
- package/app/components/LineageGraphPage.tsx +4 -4
- package/app/components/SessionDrawer.tsx +8 -8
- package/app/components/SystemCockpit.tsx +1 -1
- package/app/globals.css +1 -1
- package/app/setup/PageClient.tsx +1 -1
- package/bin/postinstall.js +5 -1
- package/daemon.js +13 -34
- package/docs/ARCHITECTURE.md +44 -26
- package/docs/CONTAINER-TERMINAL.md +17 -8
- package/docs/DESIGN-SYSTEM.md +21 -12
- package/docs/FRONTEND-ARCHITECTURE.md +2 -2
- package/docs/REV4A.md +35 -85
- package/docs/dev/API-REFERENCE.md +73 -19
- package/docs/dev/DATABASE.md +18 -7
- package/docs/dev/GATEWAY.md +23 -0
- package/docs/dev/SESSION-MAINTENANCE-PLAN.md +6 -6
- package/docs/rag/DATA-FRESHNESS.md +20 -9
- package/docs/rag/GLOSSARY.md +2 -2
- package/docs/rag/REV4A-OVERVIEW.md +10 -6
- package/docs/rag/WHAT-I-CAN-ANSWER.md +3 -3
- package/lib/agent-images.ts +43 -14
- package/lib/buildAgentImage.ts +142 -6
- package/lib/patterns/sessionPresentation.ts +4 -2
- package/lib/rev4a-auth.d.ts +1 -0
- package/lib/rev4a-auth.js +18 -2
- package/models.config.json +937 -45
- package/next.config.mjs +9 -1
- package/package.json +5 -3
- package/scripts/backup.sh +48 -54
- package/scripts/check-language.mjs +21 -3
- package/scripts/model-info-verify.mjs +194 -0
- package/scripts/restore.sh +77 -59
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Michele Tornello
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -13,11 +13,12 @@
|
|
|
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
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
|
+
- **Model details** — Every model row opens a modal with its description, price and provider, architecture and parameter count (mixture of experts, context, vision encoder, precision, weights on disk), reasoning modes, capabilities, benchmarks with sources and licence. Numbers come from the model card on Hugging Face plus curated entries that cite a source; nothing is estimated
|
|
16
17
|
- **Shared gateway token** — One token to rule all agents. Manage from the dashboard, sync to all containers
|
|
17
18
|
- **Live event stream** — Real-time session lineage, agent spawning, cost reporting
|
|
18
19
|
- **Workspace browser** — Browse and edit agent files live from the dashboard
|
|
19
20
|
- **Provider proxy** — OpenAI-compatible chat completions proxy that routes through a shared key vault
|
|
20
|
-
- **System health** —
|
|
21
|
+
- **System health** — runtime sessions, event ingestion and errors, cron jobs and usage cost, with recommendations
|
|
21
22
|
- **Cost tracking** — Per-session, per-model, per-time-range usage and cost reporting
|
|
22
23
|
- **PWA-ready** — Install on phone for mobile monitoring
|
|
23
24
|
|
|
@@ -81,6 +82,8 @@ already filled in — you normally never set these by hand. See
|
|
|
81
82
|
| `REV4A_DATA_DIR` | `~/.config/rev4a` | No | Root of all persistent data |
|
|
82
83
|
| `REV4A_DB` | `<data dir>/data/events.db` | No | SQLite database path |
|
|
83
84
|
| `REV4A_WORKSPACE_ROOT` | — | No | Root exposed by the local file explorer |
|
|
85
|
+
| `REV4A_DEV_ORIGINS` | — | No | Extra comma-separated hostnames or IPs allowed to reach `next dev` (e.g. a LAN address, without scheme or port); no effect on a production build |
|
|
86
|
+
| `REV4A_SKIP_POSTINSTALL_BUILD` | — | No | `1` in CI so `npm ci` skips the post-install `next build`; the workflows build as a separate step. Any non-empty value skips it, `0` included |
|
|
84
87
|
| `REV4A_AGENT_IMAGE_REGISTRY` | `ghcr.io/flame0510/rev4a/openclaw-agent-base` | No | Registry repository agent images are pulled from, as `<repository>:<OpenClaw version>`. A `localhost` registry is reached over plain HTTP |
|
|
85
88
|
| `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 |
|
|
86
89
|
|
|
@@ -161,4 +164,4 @@ at container creation time.
|
|
|
161
164
|
|
|
162
165
|
## License
|
|
163
166
|
|
|
164
|
-
MIT
|
|
167
|
+
MIT — see [LICENSE](./LICENSE).
|
|
@@ -7,6 +7,19 @@
|
|
|
7
7
|
* the registry publishes a newer supported version, and while a download runs.
|
|
8
8
|
* Downloading touches no agent: each agent keeps its OpenClaw version until it is
|
|
9
9
|
* updated on its own.
|
|
10
|
+
*
|
|
11
|
+
* The percent and the status line are real (docker pull's own per-layer lines, via
|
|
12
|
+
* GET /api/agents/image-status — see lib/buildAgentImage.ts); the bar is indeterminate
|
|
13
|
+
* only while no percent can be computed yet (resolving the version, listing the layers,
|
|
14
|
+
* the local-build fallback). Cancel (DELETE /api/agents/download-image) appears once the
|
|
15
|
+
* server reports the download running, and kills the docker process.
|
|
16
|
+
*
|
|
17
|
+
* Every outcome — ready, cancelled, failed — comes from the server's record of the last
|
|
18
|
+
* download (`lastResult`), whether this instance watched it happen or not: a download
|
|
19
|
+
* that ended while the Agents page was closed or reloading is still reported, if it
|
|
20
|
+
* ended less than LATE_RESULT_WINDOW_MS ago (aged with the server's clock). Each outcome
|
|
21
|
+
* is shown once per page load, even though the Agents page renders this component in
|
|
22
|
+
* more than one place (mobile list, mobile detail, desktop).
|
|
10
23
|
*/
|
|
11
24
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
12
25
|
import { Button } from '../components/ui';
|
|
@@ -16,6 +29,21 @@ interface ImageStatus {
|
|
|
16
29
|
needsUpdate: boolean;
|
|
17
30
|
downloading: boolean;
|
|
18
31
|
downloadingVersion: string | null;
|
|
32
|
+
/** Real percent from docker pull's per-layer lines; null while it cannot be computed yet. */
|
|
33
|
+
downloadPercent: number | null;
|
|
34
|
+
/** The latest line of docker's output, shown under the percent. */
|
|
35
|
+
downloadMessage: string | null;
|
|
36
|
+
/** Whether the last finished download ended as cancelled. */
|
|
37
|
+
canceled: boolean;
|
|
38
|
+
/** The server's clock when it answered, used to age `lastResult.at`. */
|
|
39
|
+
serverTime?: number;
|
|
40
|
+
/** The last download's outcome, even one this component instance never watched live. */
|
|
41
|
+
lastResult: {
|
|
42
|
+
version: string;
|
|
43
|
+
result: 'succeeded' | 'failed' | 'canceled';
|
|
44
|
+
message: string;
|
|
45
|
+
at: number;
|
|
46
|
+
} | null;
|
|
19
47
|
localVersions: string[];
|
|
20
48
|
newestLocal: string | null;
|
|
21
49
|
/** Newest supported version on the registry that is not downloaded yet. */
|
|
@@ -23,16 +51,34 @@ interface ImageStatus {
|
|
|
23
51
|
}
|
|
24
52
|
|
|
25
53
|
const POLL_MS = 2000;
|
|
54
|
+
/** How long a late-arriving result (this component instance missed the live transition)
|
|
55
|
+
* is still worth showing. Long enough to cover switching screens and coming right back;
|
|
56
|
+
* short enough that reopening the page an hour later shows nothing stale. */
|
|
57
|
+
const LATE_RESULT_WINDOW_MS = 30_000;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The newest `lastResult.at` already shown or dismissed. Module-level rather than per
|
|
61
|
+
* instance: the Agents page mounts this banner in several places and remounts it when the
|
|
62
|
+
* mobile view switches, and one outcome must not be announced once per mount. A page
|
|
63
|
+
* reload resets it, which is intended — that is one of the cases it exists to cover.
|
|
64
|
+
*/
|
|
65
|
+
let lastShownResultAt = 0;
|
|
26
66
|
|
|
27
67
|
export default function ImageDownloadBanner({ onImageReady }: { onImageReady?: () => void }) {
|
|
28
68
|
const [status, setStatus] = useState<ImageStatus>({
|
|
29
|
-
exists: true, needsUpdate: false, downloading: false, downloadingVersion: null,
|
|
69
|
+
exists: true, needsUpdate: false, downloading: false, downloadingVersion: null,
|
|
70
|
+
downloadPercent: null, downloadMessage: null, canceled: false, lastResult: null,
|
|
71
|
+
localVersions: [], newestLocal: null, available: null,
|
|
30
72
|
});
|
|
31
73
|
const [error, setError] = useState<string | null>(null);
|
|
32
74
|
const [justCompleted, setJustCompleted] = useState<string | null>(null);
|
|
75
|
+
const [justCanceled, setJustCanceled] = useState(false);
|
|
33
76
|
const [actionLoading, setActionLoading] = useState(false);
|
|
77
|
+
const [canceling, setCanceling] = useState(false);
|
|
78
|
+
const [cancelError, setCancelError] = useState<string | null>(null);
|
|
34
79
|
const wasDownloadingRef = useRef<string | null>(null);
|
|
35
80
|
const completedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
81
|
+
const canceledTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
36
82
|
|
|
37
83
|
useEffect(() => {
|
|
38
84
|
let active = true;
|
|
@@ -45,21 +91,48 @@ export default function ImageDownloadBanner({ onImageReady }: { onImageReady?: (
|
|
|
45
91
|
const data: ImageStatus = await res.json();
|
|
46
92
|
setStatus(data);
|
|
47
93
|
|
|
94
|
+
const result = data.lastResult;
|
|
95
|
+
const unseen = !!result && result.at > lastShownResultAt;
|
|
96
|
+
const showOutcome = (r: NonNullable<ImageStatus['lastResult']>) => {
|
|
97
|
+
lastShownResultAt = r.at;
|
|
98
|
+
if (r.result === 'canceled') {
|
|
99
|
+
setJustCanceled(true);
|
|
100
|
+
if (canceledTimerRef.current) clearTimeout(canceledTimerRef.current);
|
|
101
|
+
canceledTimerRef.current = setTimeout(() => setJustCanceled(false), 3000);
|
|
102
|
+
} else if (r.result === 'succeeded') {
|
|
103
|
+
setJustCompleted(r.version);
|
|
104
|
+
onImageReady?.();
|
|
105
|
+
if (completedTimerRef.current) clearTimeout(completedTimerRef.current);
|
|
106
|
+
completedTimerRef.current = setTimeout(() => setJustCompleted(null), 3000);
|
|
107
|
+
} else {
|
|
108
|
+
setError(r.message || `The OpenClaw ${r.version} download did not complete.`);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
48
112
|
if (data.downloading) {
|
|
49
113
|
setError(null);
|
|
50
114
|
wasDownloadingRef.current = data.downloadingVersion ?? wasDownloadingRef.current ?? 'image';
|
|
115
|
+
// The record on file is from before this run: not news any more.
|
|
116
|
+
if (result) lastShownResultAt = Math.max(lastShownResultAt, result.at);
|
|
51
117
|
} else if (wasDownloadingRef.current) {
|
|
118
|
+
// Live transition: this instance watched the download run and stop.
|
|
52
119
|
const finished = wasDownloadingRef.current;
|
|
53
120
|
wasDownloadingRef.current = null;
|
|
54
121
|
setActionLoading(false);
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
completedTimerRef.current = setTimeout(() => setJustCompleted(null), 3000);
|
|
60
|
-
} else {
|
|
122
|
+
setCanceling(false);
|
|
123
|
+
setCancelError(null);
|
|
124
|
+
if (result && unseen) showOutcome(result);
|
|
125
|
+
else if (!result) {
|
|
61
126
|
setError(`The OpenClaw ${finished} download did not complete. Check the Docker connection and try again.`);
|
|
62
127
|
}
|
|
128
|
+
// result present but already seen: another instance (or a late pass) announced it.
|
|
129
|
+
} else if (result && unseen && (data.serverTime ?? Date.now()) - result.at < LATE_RESULT_WINDOW_MS) {
|
|
130
|
+
// Arrived late: it ended while this instance was not mounted or not yet
|
|
131
|
+
// watching (a reload, a return from another page, a very fast download).
|
|
132
|
+
showOutcome(result);
|
|
133
|
+
} else if (result) {
|
|
134
|
+
// Already shown, or too old to matter: just remember it so it cannot retrigger.
|
|
135
|
+
lastShownResultAt = Math.max(lastShownResultAt, result.at);
|
|
63
136
|
}
|
|
64
137
|
} catch { /* ignore */ }
|
|
65
138
|
|
|
@@ -73,6 +146,7 @@ export default function ImageDownloadBanner({ onImageReady }: { onImageReady?: (
|
|
|
73
146
|
useEffect(() => {
|
|
74
147
|
return () => {
|
|
75
148
|
if (completedTimerRef.current) clearTimeout(completedTimerRef.current);
|
|
149
|
+
if (canceledTimerRef.current) clearTimeout(canceledTimerRef.current);
|
|
76
150
|
};
|
|
77
151
|
}, []);
|
|
78
152
|
|
|
@@ -99,39 +173,83 @@ export default function ImageDownloadBanner({ onImageReady }: { onImageReady?: (
|
|
|
99
173
|
}
|
|
100
174
|
}, []);
|
|
101
175
|
|
|
176
|
+
const handleCancel = useCallback(async () => {
|
|
177
|
+
setCanceling(true);
|
|
178
|
+
setCancelError(null);
|
|
179
|
+
try {
|
|
180
|
+
const res = await fetch('/api/agents/download-image', { method: 'DELETE' });
|
|
181
|
+
if (res.status === 404) {
|
|
182
|
+
// Nothing running any more: it ended on its own; its outcome arrives with the poll.
|
|
183
|
+
setCanceling(false);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (!res.ok) {
|
|
187
|
+
const data = await res.json().catch(() => ({ error: 'Failed to cancel' }));
|
|
188
|
+
setCancelError(data.error || 'Failed to cancel');
|
|
189
|
+
setCanceling(false);
|
|
190
|
+
}
|
|
191
|
+
// 202: the poll clears `canceling` once the download has actually stopped.
|
|
192
|
+
} catch (e: unknown) {
|
|
193
|
+
setCancelError((e as Error).message || 'Failed to cancel');
|
|
194
|
+
setCanceling(false);
|
|
195
|
+
}
|
|
196
|
+
}, []);
|
|
197
|
+
|
|
102
198
|
const isDownloading = status.downloading || actionLoading;
|
|
103
|
-
const showBanner = status.needsUpdate || isDownloading || justCompleted || error;
|
|
199
|
+
const showBanner = status.needsUpdate || isDownloading || justCompleted || justCanceled || error;
|
|
104
200
|
|
|
105
201
|
if (!showBanner && status.exists && !isDownloading) return null;
|
|
106
202
|
|
|
107
203
|
// ─── Downloading ───
|
|
108
204
|
if (isDownloading) {
|
|
109
205
|
const version = status.downloadingVersion ?? status.available;
|
|
206
|
+
const percent = status.downloadPercent;
|
|
110
207
|
return (
|
|
111
208
|
<Banner downloading>
|
|
112
|
-
<div style={{ display: 'flex',
|
|
113
|
-
<
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
209
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, flex: 1, minWidth: 0 }}>
|
|
210
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
211
|
+
<span style={{
|
|
212
|
+
display: 'inline-block', width: 8, height: 8,
|
|
213
|
+
borderRadius: '50%',
|
|
214
|
+
background: 'var(--violet)',
|
|
215
|
+
animation: 'pulse 1.5s ease-in-out infinite',
|
|
216
|
+
flexShrink: 0,
|
|
217
|
+
}} />
|
|
218
|
+
<span style={{ color: 'var(--violet)', fontWeight: 500 }}>
|
|
219
|
+
{version ? `Downloading the OpenClaw ${version} agent image…` : 'Downloading the agent image…'}
|
|
220
|
+
{percent !== null ? ` ${percent}%` : ''}
|
|
221
|
+
</span>
|
|
222
|
+
</div>
|
|
223
|
+
{status.downloadMessage && (
|
|
224
|
+
<span style={{ color: 'var(--text-dim)', fontSize: 11, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
225
|
+
{status.downloadMessage}
|
|
226
|
+
</span>
|
|
227
|
+
)}
|
|
228
|
+
{cancelError && <span style={{ color: 'var(--red)', fontSize: 11 }}>{cancelError}</span>}
|
|
123
229
|
</div>
|
|
124
230
|
<div style={{
|
|
125
231
|
flex: 1, maxWidth: 120, height: 3,
|
|
126
|
-
background: 'var(--border)', borderRadius: 2, overflow: 'hidden',
|
|
232
|
+
background: 'var(--border)', borderRadius: 2, overflow: 'hidden', flexShrink: 0,
|
|
127
233
|
}}>
|
|
128
|
-
|
|
129
|
-
height: 3, background: 'var(--violet)',
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
234
|
+
{percent !== null ? (
|
|
235
|
+
<div style={{ height: 3, background: 'var(--violet)', width: `${percent}%`, borderRadius: 2 }} />
|
|
236
|
+
) : (
|
|
237
|
+
<div style={{
|
|
238
|
+
height: 3, background: 'var(--violet)',
|
|
239
|
+
animation: 'progress-indeterminate 1.5s ease-in-out infinite',
|
|
240
|
+
width: '40%', borderRadius: 2,
|
|
241
|
+
}} />
|
|
242
|
+
)}
|
|
133
243
|
</div>
|
|
134
|
-
|
|
244
|
+
{/* Offered only once the server reports the download running: before that there
|
|
245
|
+
is nothing to cancel, and a DELETE would answer 404. */}
|
|
246
|
+
{status.downloading ? (
|
|
247
|
+
<Button variant="secondary" size="sm" onClick={() => { void handleCancel(); }} loading={canceling} disabled={canceling}>
|
|
248
|
+
{canceling ? 'Canceling…' : 'Cancel'}
|
|
249
|
+
</Button>
|
|
250
|
+
) : (
|
|
251
|
+
<Button variant="secondary" size="sm" disabled>Starting…</Button>
|
|
252
|
+
)}
|
|
135
253
|
</Banner>
|
|
136
254
|
);
|
|
137
255
|
}
|
|
@@ -141,25 +259,41 @@ export default function ImageDownloadBanner({ onImageReady }: { onImageReady?: (
|
|
|
141
259
|
return (
|
|
142
260
|
<Banner success>
|
|
143
261
|
<div style={{ flex: 1 }}>
|
|
144
|
-
<span style={{ color: '
|
|
262
|
+
<span style={{ color: 'var(--green)', fontWeight: 500 }}>✓ OpenClaw {justCompleted} agent image ready</span>
|
|
263
|
+
</div>
|
|
264
|
+
</Banner>
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ─── Just canceled ───
|
|
269
|
+
if (justCanceled) {
|
|
270
|
+
return (
|
|
271
|
+
<Banner>
|
|
272
|
+
<div style={{ flex: 1 }}>
|
|
273
|
+
<span style={{ color: 'var(--text-dim)', fontWeight: 500 }}>Download canceled.</span>
|
|
145
274
|
</div>
|
|
146
275
|
</Banner>
|
|
147
276
|
);
|
|
148
277
|
}
|
|
149
278
|
|
|
150
|
-
// ─── Needs download ───
|
|
279
|
+
// ─── Needs download, or an error to report ───
|
|
280
|
+
// With an image present and nothing newer published, only an error brings us here:
|
|
281
|
+
// show just the error, not an "available" line with no version to name.
|
|
282
|
+
const headline = !status.exists
|
|
283
|
+
? '⚠ Agent base image not found.'
|
|
284
|
+
: status.available ? `⚠ OpenClaw ${status.available} is available.` : null;
|
|
151
285
|
return (
|
|
152
286
|
<Banner warning>
|
|
153
287
|
<div style={{ flex: 1 }}>
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
{error && <div style={{ color: '
|
|
288
|
+
{headline && (
|
|
289
|
+
<>
|
|
290
|
+
<strong style={{ color: 'var(--yellow)' }}>{headline}</strong>
|
|
291
|
+
{' '}{status.exists
|
|
292
|
+
? 'Download it for new agents; existing agents keep their version until they are updated.'
|
|
293
|
+
: 'Download it to create agent containers.'}
|
|
294
|
+
</>
|
|
295
|
+
)}
|
|
296
|
+
{error && <div style={{ color: 'var(--red)', fontSize: headline ? 11 : 12, marginTop: headline ? 4 : 0 }}>{headline ? '' : '⚠ '}{error}</div>}
|
|
163
297
|
</div>
|
|
164
298
|
{(status.exists ? status.available : true) && (
|
|
165
299
|
<Button variant="primary" size="sm" onClick={() => { void handleDownload(status.available); }} loading={actionLoading}>
|
|
@@ -5,14 +5,21 @@ import { type NextRequest } from "next/server";
|
|
|
5
5
|
* Downloads one OpenClaw version of the agent base image in the background.
|
|
6
6
|
* Body (optional): `{ "version": "2026.9.3" }` — a supported version; without it, the
|
|
7
7
|
* newest supported version the registry publishes. Returns 202 with the version;
|
|
8
|
-
* poll GET /api/agents/image-status for completion.
|
|
8
|
+
* poll GET /api/agents/image-status for completion, percent and the latest status line.
|
|
9
9
|
*
|
|
10
|
-
* Requires auth. 400 for an unsupported version, 409 if a download is already running
|
|
10
|
+
* Requires auth. 400 for an unsupported version, 409 if a download is already running,
|
|
11
|
+
* 503 when Docker is not available.
|
|
12
|
+
*
|
|
13
|
+
* DELETE /api/agents/download-image
|
|
14
|
+
*
|
|
15
|
+
* Cancels the running download, if any (kills the docker pull/build process).
|
|
16
|
+
* Requires auth. 404 when nothing is running.
|
|
11
17
|
*/
|
|
12
18
|
import { NextResponse } from 'next/server';
|
|
13
|
-
import { defaultDownloadVersion, downloadAgentImage, getIsDownloading } from '@/lib/buildAgentImage';
|
|
19
|
+
import { cancelDownload, defaultDownloadVersion, downloadAgentImage, getIsDownloading } from '@/lib/buildAgentImage';
|
|
14
20
|
import { isSupportedVersion } from '@/lib/agent-versions';
|
|
15
21
|
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
22
|
+
import { dockerAvailable } from '@/lib/docker-socket';
|
|
16
23
|
|
|
17
24
|
export const dynamic = 'force-dynamic';
|
|
18
25
|
|
|
@@ -39,11 +46,28 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
|
|
|
39
46
|
if (getIsDownloading()) {
|
|
40
47
|
return NextResponse.json({ error: 'A download is already in progress' }, { status: 409 });
|
|
41
48
|
}
|
|
49
|
+
// Refused here rather than inside downloadAgentImage: that check throws before any
|
|
50
|
+
// outcome is recorded, so the banner would get a 202 and then nothing to explain it.
|
|
51
|
+
if (!dockerAvailable()) {
|
|
52
|
+
return NextResponse.json({ error: 'Docker is not available — start Docker and try again' }, { status: 503 });
|
|
53
|
+
}
|
|
42
54
|
|
|
43
|
-
// Start download in background — fire and forget
|
|
55
|
+
// Start download in background — fire and forget. A cancel (DELETE below) makes this
|
|
56
|
+
// reject with 'canceled', not a real failure: skip the log.
|
|
44
57
|
downloadAgentImage({ version }).catch((e: unknown) => {
|
|
45
|
-
|
|
58
|
+
if ((e as Error).message !== 'canceled') {
|
|
59
|
+
console.error(`[download-image] OpenClaw ${version}:`, (e as Error).message);
|
|
60
|
+
}
|
|
46
61
|
});
|
|
47
62
|
|
|
48
63
|
return NextResponse.json({ started: true, version }, { status: 202 });
|
|
49
64
|
}
|
|
65
|
+
|
|
66
|
+
export async function DELETE(request: NextRequest): Promise<NextResponse> {
|
|
67
|
+
const denied = await requireAuthJWT(request);
|
|
68
|
+
if (denied) return denied;
|
|
69
|
+
if (!cancelDownload()) {
|
|
70
|
+
return NextResponse.json({ error: 'No download is running' }, { status: 404 });
|
|
71
|
+
}
|
|
72
|
+
return NextResponse.json({ canceling: true }, { status: 202 });
|
|
73
|
+
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* (lib/agent-images.ts), so polling does not hit the network.
|
|
10
10
|
*/
|
|
11
11
|
import { NextResponse, type NextRequest } from 'next/server';
|
|
12
|
-
import { getDownloadingVersion, getIsDownloading } from '@/lib/buildAgentImage';
|
|
12
|
+
import { getDownloadProgress, getDownloadingVersion, getIsDownloading, getLastDownloadResult, wasDownloadCanceled } from '@/lib/buildAgentImage';
|
|
13
13
|
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
14
14
|
import { dockerAvailable } from '@/lib/docker-socket';
|
|
15
15
|
import { listLocalAgentImages, localSupportedVersions, remoteSupportedVersions, type LocalAgentImage } from '@/lib/agent-images';
|
|
@@ -21,7 +21,22 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
|
21
21
|
const denied = await requireAuthJWT(request);
|
|
22
22
|
if (denied) return denied;
|
|
23
23
|
|
|
24
|
-
const
|
|
24
|
+
const progress = getDownloadProgress();
|
|
25
|
+
const base = {
|
|
26
|
+
downloading: getIsDownloading(),
|
|
27
|
+
downloadingVersion: getDownloadingVersion(),
|
|
28
|
+
downloadPercent: progress.percent,
|
|
29
|
+
downloadMessage: progress.message,
|
|
30
|
+
/** Whether the last finished download ended as cancelled (derived from `lastResult`). */
|
|
31
|
+
canceled: wasDownloadCanceled(),
|
|
32
|
+
/**
|
|
33
|
+
* The last download's outcome, whether or not anyone was watching it finish —
|
|
34
|
+
* covers a page reload or a late-opened tab. Overwritten by the next download.
|
|
35
|
+
*/
|
|
36
|
+
lastResult: getLastDownloadResult(),
|
|
37
|
+
/** The server's clock, so the client can age `lastResult.at` without trusting its own. */
|
|
38
|
+
serverTime: Date.now(),
|
|
39
|
+
};
|
|
25
40
|
|
|
26
41
|
if (!dockerAvailable()) {
|
|
27
42
|
return NextResponse.json({
|
|
@@ -24,7 +24,7 @@ const PAGE_CONTEXT: Record<string, PageContext> = {
|
|
|
24
24
|
},
|
|
25
25
|
'/agents': {
|
|
26
26
|
title: 'Agents',
|
|
27
|
-
description: 'List of all Docker containers that run OpenClaw agents. Shows status (running/exited/paused), image, ports, IP, and creation time. Includes a top banner for the agent base image status: yellow warning when
|
|
27
|
+
description: 'List of all Docker containers that run OpenClaw agents. Shows status (running/exited/paused), image, ports, IP, and creation time. Includes a top banner for the agent base image status: yellow warning when a newer version is published or none is downloaded; while downloading, the percent of layers finished, docker\'s latest output line and a Cancel button; afterwards the outcome (ready, canceled or the error).',
|
|
28
28
|
actions: ['Filter by status: all, running, or exited', 'Click an agent to see its details (environment variables, backups, Telegram channels, and the Model section that sets its primary model and fallbacks)', 'Create a new agent using the "Create Agent" button', 'Download the agent base image when the banner shows it missing or outdated'],
|
|
29
29
|
hints: 'The status bullet tells you at a glance if an agent is running (green), exited (red), or paused (yellow). The base image banner shows when a newer supported version is published on the registry — downloading makes it available to create and update agents on; it changes no agent by itself.',
|
|
30
30
|
},
|
|
@@ -58,11 +58,17 @@ const PAGE_CONTEXT: Record<string, PageContext> = {
|
|
|
58
58
|
actions: ['Run shell commands inside the container', 'Inspect files, processes, and logs live', 'Use any CLI tool available inside the container'],
|
|
59
59
|
hints: 'This is a live terminal — changes you make inside the container are real. Be careful with destructive commands.',
|
|
60
60
|
},
|
|
61
|
+
'/credentials': {
|
|
62
|
+
title: 'Credentials',
|
|
63
|
+
description: 'Store third-party service credentials (GitHub, Vercel, Supabase, Trello, Notion) once and install them into agent containers. Secrets are stored unencrypted in credentials.db; provider API keys are managed on the Gateway page, not here.',
|
|
64
|
+
actions: ['Configure a provider, or "+ Add another" credential for one already configured', 'Edit or delete a stored credential', 'Sync a credential to an agent container, or De-sync it to remove it', 'See which credentials are actually installed in each container (detected live)'],
|
|
65
|
+
hints: 'A credential reaches a container only when you sync it, and leaves only when you de-sync it. Any stored credential can be synced to any container.',
|
|
66
|
+
},
|
|
61
67
|
'/config': {
|
|
62
68
|
title: 'Config',
|
|
63
69
|
description: 'Rev4a application configuration page. View and manage settings that control how Rev4a behaves.',
|
|
64
70
|
actions: ['View current configuration values', 'Modify configuration settings', 'Save changes to update Rev4a behavior'],
|
|
65
|
-
hints: '
|
|
71
|
+
hints: 'Saved values are written to the .env file; restart Rev4a for them to take effect.',
|
|
66
72
|
},
|
|
67
73
|
'/lineage': {
|
|
68
74
|
title: 'Lineage',
|
|
@@ -107,7 +113,7 @@ Navigation links inside Rev4a:
|
|
|
107
113
|
- Dashboard → /
|
|
108
114
|
- Agents → /agents
|
|
109
115
|
- Create Agent → /agents/create
|
|
110
|
-
-
|
|
116
|
+
- Credentials → /credentials
|
|
111
117
|
- Gateway → /gateway
|
|
112
118
|
- Cron Jobs → /crons
|
|
113
119
|
- Containers → /containers
|
|
@@ -118,11 +124,14 @@ Navigation links inside Rev4a:
|
|
|
118
124
|
- Tools → /tools
|
|
119
125
|
- Plugins → /plugins
|
|
120
126
|
- Skills → /skills
|
|
127
|
+
- Wizard (first-run guide: providers, first agent) → /wizard
|
|
121
128
|
`.trim();
|
|
122
129
|
|
|
123
130
|
function buildSystemPrompt(page: string): string {
|
|
124
131
|
const path = page.split('?')[0] || '/';
|
|
125
|
-
|
|
132
|
+
// Dynamic routes are keyed by their pattern; the terminal is the only one.
|
|
133
|
+
const key = path.startsWith('/containers/terminal/') ? '/containers/terminal/[id]' : path;
|
|
134
|
+
const ctx = PAGE_CONTEXT[key] ?? {
|
|
126
135
|
title: path,
|
|
127
136
|
description: 'Unknown page.',
|
|
128
137
|
actions: [],
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { NextResponse, type NextRequest } from 'next/server';
|
|
2
2
|
import { SignJWT } from 'jose';
|
|
3
|
-
import { getPassword, getJwtSecret } from '@/lib/rev4a-auth';
|
|
3
|
+
import { getPassword, getJwtSecret, secretEquals } from '@/lib/rev4a-auth';
|
|
4
4
|
|
|
5
5
|
export async function POST(request: NextRequest): Promise<NextResponse> {
|
|
6
6
|
try {
|
|
@@ -27,7 +27,7 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
|
|
|
27
27
|
password?: string;
|
|
28
28
|
};
|
|
29
29
|
|
|
30
|
-
if (!input || input
|
|
30
|
+
if (!input || !secretEquals(input, password)) {
|
|
31
31
|
return NextResponse.json({ error: 'Invalid password' }, { status: 401 });
|
|
32
32
|
}
|
|
33
33
|
|
|
@@ -45,6 +45,8 @@ export async function POST(request: NextRequest): Promise<Response> {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
const encoder = new TextEncoder();
|
|
48
|
+
// Aborted when the client goes away (the stream's cancel()), which stops docker.
|
|
49
|
+
const abort = new AbortController();
|
|
48
50
|
|
|
49
51
|
const stream = new ReadableStream({
|
|
50
52
|
async start(controller) {
|
|
@@ -54,10 +56,8 @@ export async function POST(request: NextRequest): Promise<Response> {
|
|
|
54
56
|
} catch { /* stream closed */ }
|
|
55
57
|
};
|
|
56
58
|
|
|
57
|
-
const ctrl = controller as ReadableStreamDefaultController & { signal?: AbortSignal };
|
|
58
|
-
|
|
59
59
|
try {
|
|
60
|
-
await downloadAgentImage({ onEvent: send, signal:
|
|
60
|
+
await downloadAgentImage({ onEvent: send, signal: abort.signal });
|
|
61
61
|
} catch (e: unknown) {
|
|
62
62
|
send(JSON.stringify({
|
|
63
63
|
type: 'error',
|
|
@@ -66,7 +66,9 @@ export async function POST(request: NextRequest): Promise<Response> {
|
|
|
66
66
|
}
|
|
67
67
|
controller.close();
|
|
68
68
|
},
|
|
69
|
-
|
|
69
|
+
// A ReadableStreamDefaultController has no signal of its own; this is the hook the
|
|
70
|
+
// runtime calls when the client disconnects.
|
|
71
|
+
cancel() { abort.abort(); },
|
|
70
72
|
});
|
|
71
73
|
|
|
72
74
|
return new Response(stream, {
|
|
@@ -118,7 +118,7 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
|
118
118
|
title: 'Check Rev4a runtime heartbeat',
|
|
119
119
|
details: latestMetricAgeSeconds === null
|
|
120
120
|
? 'No heartbeat metrics available from runtime.'
|
|
121
|
-
: `Heartbeat metrics stuck for ${latestMetricAgeSeconds}s (
|
|
121
|
+
: `Heartbeat metrics stuck for ${latestMetricAgeSeconds}s (threshold ${staleThresholdSeconds}s).`,
|
|
122
122
|
actionHref: '/lineage',
|
|
123
123
|
dismissible: false,
|
|
124
124
|
createdAt,
|
|
@@ -133,7 +133,7 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
|
133
133
|
? 'system_metrics table unavailable: freshness runtime not verifiable.'
|
|
134
134
|
: latestMetricAgeSeconds === null
|
|
135
135
|
? 'No heartbeat metrics detected.'
|
|
136
|
-
: `Last heartbeat metrics ${latestMetricAgeSeconds}s
|
|
136
|
+
: `Last heartbeat metrics ${latestMetricAgeSeconds}s ago (threshold ${staleThresholdSeconds}s).`,
|
|
137
137
|
resolvedMessage: 'DB Rev4a fresh again.',
|
|
138
138
|
stale: hasSystemMetrics ? (latestMetricAgeSeconds === null ? true : latestMetricAgeSeconds > staleThresholdSeconds) : false,
|
|
139
139
|
});
|
|
@@ -190,7 +190,7 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
|
190
190
|
severity: 'warning',
|
|
191
191
|
source: 'cost',
|
|
192
192
|
title: 'High usage-based cost',
|
|
193
|
-
details: `Provider usage-based today: $${billing.usageBasedCost.toFixed(2)}.
|
|
193
|
+
details: `Provider usage-based today: $${billing.usageBasedCost.toFixed(2)}. Counts OpenRouter and the other pay-per-use providers, not the fixed plans.`,
|
|
194
194
|
actionHref: '/gateway',
|
|
195
195
|
dismissible: true,
|
|
196
196
|
createdAt,
|
|
@@ -12,7 +12,7 @@ interface DashboardToolbarProps {
|
|
|
12
12
|
export default function DashboardToolbar({ filter, onChange, agents = ['all'] }: DashboardToolbarProps) {
|
|
13
13
|
return (
|
|
14
14
|
<section className="toolbar">
|
|
15
|
-
<span className="toolbar-label">
|
|
15
|
+
<span className="toolbar-label">AGENTS</span>
|
|
16
16
|
|
|
17
17
|
{agents.map((name) => (
|
|
18
18
|
<button
|
|
@@ -44,7 +44,7 @@ export default function DashboardToolbar({ filter, onChange, agents = ['all'] }:
|
|
|
44
44
|
</button>
|
|
45
45
|
|
|
46
46
|
<span className="toolbar-sep">|</span>
|
|
47
|
-
<span className="toolbar-label">
|
|
47
|
+
<span className="toolbar-label">PERIOD</span>
|
|
48
48
|
|
|
49
49
|
{PERIODS.map((p) => (
|
|
50
50
|
<button
|
|
@@ -48,9 +48,9 @@ export default function LineageGraphPage({ initialCosts }: LineageGraphPageProps
|
|
|
48
48
|
{isMobile && (
|
|
49
49
|
<div style={{ display: 'flex', gap: 8, padding: '8px 12px', borderBottom: '1px solid var(--border)', background: 'var(--bg2)' }}>
|
|
50
50
|
{[
|
|
51
|
-
{ key: 'graph', label: '
|
|
51
|
+
{ key: 'graph', label: 'GRAPH' },
|
|
52
52
|
{ key: 'feed', label: 'FEED' },
|
|
53
|
-
{ key: 'costs', label: '
|
|
53
|
+
{ key: 'costs', label: 'COSTS' },
|
|
54
54
|
].map((t) => (
|
|
55
55
|
<button
|
|
56
56
|
key={t.key}
|
|
@@ -101,11 +101,11 @@ export default function LineageGraphPage({ initialCosts }: LineageGraphPageProps
|
|
|
101
101
|
<button
|
|
102
102
|
type="button"
|
|
103
103
|
className="agent-btn"
|
|
104
|
-
aria-label="
|
|
104
|
+
aria-label="Reset graph view"
|
|
105
105
|
onClick={() => topologyRef.current?.resetView()}
|
|
106
106
|
style={resetButtonStyle}
|
|
107
107
|
>
|
|
108
|
-
⊙
|
|
108
|
+
⊙ Reset view
|
|
109
109
|
</button>
|
|
110
110
|
</div>
|
|
111
111
|
{hasSessionsLoaded ? (
|