@flame0510/project-aether 1.1.12 → 1.1.14
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 +4 -1
- package/app/agents/PageClient.tsx +140 -34
- package/app/agents/create/PageClient.tsx +11 -4
- package/app/api/agents/[id]/recreate/route.ts +3 -3
- package/app/api/agents/[id]/route.ts +3 -1
- package/app/api/agents/channels-summary/route.ts +48 -17
- package/app/api/agents/create/route.ts +32 -13
- package/app/api/agents/download-image/route.ts +2 -0
- package/app/api/agents/image-status/route.ts +94 -85
- package/app/api/agents/route.ts +10 -53
- package/app/api/agents/token/route.ts +4 -1
- package/app/api/auth/check/route.ts +2 -23
- package/app/api/auth/login/route.ts +3 -35
- package/app/api/auth/logout/route.ts +1 -1
- package/app/api/auth/status/route.ts +2 -21
- package/app/api/config/env/route.ts +7 -3
- package/app/api/config/restart/route.ts +2 -0
- package/app/api/containers/route.ts +3 -34
- package/app/api/crons/route.ts +2 -0
- package/app/api/debug/route.ts +2 -0
- package/app/api/envcheck/route.ts +3 -1
- package/app/api/gateway/agent/route.ts +8 -6
- package/app/api/gateway/provider/route.ts +43 -5
- package/app/api/gateway/route.ts +104 -136
- package/app/api/gateway/sync.ts +12 -15
- package/app/api/models/route.ts +2 -0
- package/app/api/provider/upstream.ts +12 -3
- package/app/api/setup/agent-image/route.ts +8 -3
- package/app/api/setup/password/route.ts +32 -37
- package/app/api/setup/restart/route.ts +8 -2
- package/app/api/skills/delete/route.js +1 -1
- package/app/api/skills/promote/route.js +1 -1
- package/app/api/skills/route.js +89 -97
- package/app/api/skills/save/route.js +18 -5
- package/app/api/update-check/route.ts +4 -23
- package/app/api/wizard/complete/route.ts +7 -3
- package/app/api/wizard/reset/route.ts +8 -3
- package/app/api/wizard/status/route.ts +8 -5
- package/app/components/AuthGuard.tsx +29 -8
- package/app/components/Skeleton.tsx +71 -0
- package/app/components/ui/ModalityIcons.tsx +157 -0
- package/app/components/ui/index.ts +1 -0
- package/app/crons/PageClient.tsx +32 -7
- package/app/gateway/PageClient.tsx +104 -16
- package/bin/rev4a.js +35 -24
- package/docs/ARCHITECTURE.md +31 -14
- package/docs/FRONTEND-ARCHITECTURE.md +1 -0
- package/docs/REV4A.md +36 -6
- package/docs/dev/API-REFERENCE.md +13 -8
- package/docs/dev/GATEWAY.md +42 -1
- package/docs/dev/PROVIDERS.md +4 -3
- package/docs/rag/REV4A-OVERVIEW.md +1 -1
- package/instrumentation.ts +18 -0
- package/lib/agent-setup.ts +8 -9
- package/lib/buildAgentImage.ts +5 -1
- package/lib/channelManager.ts +23 -16
- package/lib/db.ts +0 -10
- package/lib/docker-exec.ts +195 -0
- package/lib/docker-socket.ts +121 -0
- package/lib/docker-utils.ts +22 -48
- package/lib/openclaw-cron.ts +81 -48
- package/lib/openrouter-pricing.ts +184 -0
- package/lib/rev4a-auth.d.ts +4 -1
- package/lib/rev4a-auth.js +68 -20
- package/lib/rev4a-paths.ts +19 -0
- package/model-pricing.json +50 -50
- package/models.config.json +2 -2
- package/package.json +4 -1
- package/proxy.ts +68 -0
- package/scripts/refresh-model-pricing.mjs +171 -0
- package/lib/auth.ts +0 -28
- package/lib/requireAuth.tsx +0 -38
package/README.md
CHANGED
|
@@ -44,7 +44,10 @@ rev4a serve --port 8080
|
|
|
44
44
|
```
|
|
45
45
|
|
|
46
46
|
> **Production install:** see `CONNECT.md` for systemd deployment on a VPS.
|
|
47
|
-
>
|
|
47
|
+
>
|
|
48
|
+
> **Container deploy:** on hold. The root `Dockerfile` (Docker-in-Docker packaging of
|
|
49
|
+
> the dashboard itself) is an experimental path, not currently built or published —
|
|
50
|
+
> see the file for details. Use the git-clone install above.
|
|
48
51
|
|
|
49
52
|
---
|
|
50
53
|
|
|
@@ -42,11 +42,30 @@ interface Agent {
|
|
|
42
42
|
ip: string | null;
|
|
43
43
|
created: string | null;
|
|
44
44
|
env: string[];
|
|
45
|
-
|
|
45
|
+
controlPort: string | null;
|
|
46
46
|
authToken: string | null;
|
|
47
47
|
imageOutdated?: boolean;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* URL of the agent's OpenClaw Control UI.
|
|
52
|
+
*
|
|
53
|
+
* Composed here rather than on the server on purpose. The hostname the dashboard
|
|
54
|
+
* is currently being reached on is, by definition, one that resolves and routes
|
|
55
|
+
* for this user — `localhost` on a dev machine, a LAN address from a phone, the
|
|
56
|
+
* VPS IP, or a domain. The server can only guess, and it guessed badly: it shelled
|
|
57
|
+
* out to `curl icanhazip.com` on every call and, on macOS, ended up returning the
|
|
58
|
+
* machine's `.local` mDNS name.
|
|
59
|
+
*
|
|
60
|
+
* Returns null when the agent has no published port (nothing to link to) or when
|
|
61
|
+
* called during a server render, where `window` doesn't exist.
|
|
62
|
+
*/
|
|
63
|
+
function agentControlUrl(agent: Agent): string | null {
|
|
64
|
+
if (!agent.controlPort || typeof window === 'undefined') return null;
|
|
65
|
+
const base = `http://${window.location.hostname}:${agent.controlPort}`;
|
|
66
|
+
return agent.authToken ? `${base}#token=${encodeURIComponent(agent.authToken)}` : base;
|
|
67
|
+
}
|
|
68
|
+
|
|
50
69
|
type FilterState = 'all' | 'running' | 'exited';
|
|
51
70
|
|
|
52
71
|
const STATUS_BULLET: Record<string, string> = {
|
|
@@ -180,11 +199,17 @@ function AgentListItem({
|
|
|
180
199
|
)}
|
|
181
200
|
<div style={{ marginLeft: 'auto', flexShrink: 0, display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
182
201
|
{loading && <span style={{ fontSize: 10, color: '#888' }}>...</span>}
|
|
183
|
-
{agent.state === 'running' && agent.
|
|
202
|
+
{agent.state === 'running' && agent.controlPort && (
|
|
184
203
|
<Button
|
|
185
204
|
variant="primary"
|
|
186
205
|
size="sm"
|
|
187
|
-
|
|
206
|
+
// Gate on the port, resolve the URL on click: the condition stays
|
|
207
|
+
// safe to evaluate during a server render, the URL never is.
|
|
208
|
+
onClick={(e) => {
|
|
209
|
+
e.stopPropagation();
|
|
210
|
+
const url = agentControlUrl(agent);
|
|
211
|
+
if (url) window.open(url, '_blank');
|
|
212
|
+
}}
|
|
188
213
|
title="Open agent Control UI"
|
|
189
214
|
>
|
|
190
215
|
Open ↗
|
|
@@ -216,18 +241,23 @@ export default function AgentsPageClient() {
|
|
|
216
241
|
const [loading, setLoading] = useState(true);
|
|
217
242
|
const [error, setError] = useState('');
|
|
218
243
|
const [filter, setFilter] = useState<FilterState>('all');
|
|
219
|
-
const [
|
|
244
|
+
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
220
245
|
const [isMobile, setIsMobile] = useState(false);
|
|
221
246
|
const [showList, setShowList] = useState(true);
|
|
222
247
|
const [chSummary, setChSummary] = useState<Record<string, { telegram: boolean }>>({});
|
|
223
248
|
const [imageReady, setImageReady] = useState(true);
|
|
224
|
-
|
|
249
|
+
// null = not checked yet. Keeping "unknown" distinct from "none configured" is
|
|
250
|
+
// the whole point: `false` as the initial value makes an unanswered check look
|
|
251
|
+
// exactly like a negative answer, so the warning banner renders on no evidence.
|
|
252
|
+
const [providersConfigured, setProvidersConfigured] = useState<boolean | null>(null);
|
|
225
253
|
const [gatewayToken, setGatewayToken] = useState('');
|
|
226
254
|
const [tokenLoading, setTokenLoading] = useState(true);
|
|
227
255
|
const [tokenSaving, setTokenSaving] = useState(false);
|
|
228
256
|
const [tokenStatus, setTokenStatus] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
|
|
229
257
|
const [lifecycleLoading, setLifecycleLoading] = useState<string | null>(null);
|
|
230
258
|
const skipNextPoll = useRef(false);
|
|
259
|
+
/** Guards the un-awaited channel-summary fetch against out-of-order responses. */
|
|
260
|
+
const chSummarySeq = useRef(0);
|
|
231
261
|
useEffect(() => {
|
|
232
262
|
const check = () => setIsMobile(window.innerWidth < 768);
|
|
233
263
|
check();
|
|
@@ -243,11 +273,23 @@ export default function AgentsPageClient() {
|
|
|
243
273
|
if (!Array.isArray(data)) throw new Error('Invalid response');
|
|
244
274
|
setAgents(data);
|
|
245
275
|
setError('');
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
276
|
+
|
|
277
|
+
// Channel summary is deliberately not awaited: it only decorates rows with
|
|
278
|
+
// a TG badge, and awaiting it here held the whole page behind a second
|
|
279
|
+
// round trip — the agent list was ready in ~0.25s but stayed hidden for
|
|
280
|
+
// ~1.15s. It now lands on its own and the badges appear when it does.
|
|
281
|
+
//
|
|
282
|
+
// Sequence-guarded: load() also runs on a 15s poll, so several of these can
|
|
283
|
+
// be in flight at once. Without the check, a slow earlier response landing
|
|
284
|
+
// after a faster later one would overwrite fresh badges with stale ones.
|
|
285
|
+
const seq = ++chSummarySeq.current;
|
|
286
|
+
void (async () => {
|
|
287
|
+
try {
|
|
288
|
+
const chRes = await fetch('/api/agents/channels-summary');
|
|
289
|
+
if (!chRes.ok || seq !== chSummarySeq.current) return;
|
|
290
|
+
setChSummary(await chRes.json());
|
|
291
|
+
} catch { /* badges are optional */ }
|
|
292
|
+
})();
|
|
251
293
|
} catch (e: any) {
|
|
252
294
|
setError(e.message || 'Loading error');
|
|
253
295
|
} finally {
|
|
@@ -274,7 +316,8 @@ export default function AgentsPageClient() {
|
|
|
274
316
|
|
|
275
317
|
// Check if any provider is configured in the Gateway
|
|
276
318
|
useEffect(() => {
|
|
277
|
-
|
|
319
|
+
// summary=1: skips the live-pricing round trip we don't need here.
|
|
320
|
+
fetch('/api/gateway/provider?summary=1')
|
|
278
321
|
.then((r) => r.json())
|
|
279
322
|
.then((d: { providers?: Array<{ configured: boolean }> }) =>
|
|
280
323
|
setProvidersConfigured((d.providers || []).some((p) => p.configured))
|
|
@@ -358,8 +401,36 @@ export default function AgentsPageClient() {
|
|
|
358
401
|
return list;
|
|
359
402
|
}, [agents, filter]);
|
|
360
403
|
|
|
404
|
+
// Derive the selected agent from the live list rather than holding a snapshot.
|
|
405
|
+
// A snapshot goes stale on every 15s poll (status, ports, imageOutdated) and
|
|
406
|
+
// survives deletion; deriving means a rename shows up on the next load() and a
|
|
407
|
+
// deleted agent can't leave a detail panel pointing at something that's gone.
|
|
408
|
+
const selected = useMemo(
|
|
409
|
+
() => (selectedId ? agents.find((a) => a.agentId === selectedId) ?? null : null),
|
|
410
|
+
[agents, selectedId],
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
// The selection can vanish under us — deleted here, or from another tab/CLI
|
|
414
|
+
// and picked up by the poll. Drop it so mobile falls back to the list instead
|
|
415
|
+
// of rendering a dead detail view.
|
|
416
|
+
useEffect(() => {
|
|
417
|
+
if (selectedId && !agents.some((a) => a.agentId === selectedId)) {
|
|
418
|
+
setSelectedId(null);
|
|
419
|
+
setShowList(true);
|
|
420
|
+
}
|
|
421
|
+
}, [agents, selectedId]);
|
|
422
|
+
|
|
423
|
+
/** An agent was deleted: drop it optimistically, then reconcile with the server. */
|
|
424
|
+
const handleAgentDeleted = useCallback((agentId: string) => {
|
|
425
|
+
// The DELETE already succeeded server-side, so removing it here is safe and
|
|
426
|
+
// spares the user the round trip — the API tears down container, volume and
|
|
427
|
+
// backups before it answers, which takes seconds.
|
|
428
|
+
setAgents((prev) => prev.filter((a) => a.agentId !== agentId));
|
|
429
|
+
void load();
|
|
430
|
+
}, [load]);
|
|
431
|
+
|
|
361
432
|
function selectAgent(agent: Agent) {
|
|
362
|
-
|
|
433
|
+
setSelectedId(agent.agentId);
|
|
363
434
|
if (isMobile) setShowList(false);
|
|
364
435
|
}
|
|
365
436
|
|
|
@@ -396,7 +467,11 @@ export default function AgentsPageClient() {
|
|
|
396
467
|
</div>
|
|
397
468
|
);
|
|
398
469
|
|
|
399
|
-
|
|
470
|
+
// Hold the skeleton until the provider check has answered too. Both effects
|
|
471
|
+
// fire on mount, so this costs nothing when the check is warm (~3ms) and only
|
|
472
|
+
// shows up on a cold one (~230ms) — where it's exactly what we want, since
|
|
473
|
+
// that's the window the banner used to flash in.
|
|
474
|
+
if (loading || providersConfigured === null) {
|
|
400
475
|
return (
|
|
401
476
|
<div style={{ padding: 14, display: 'grid', gap: 14 }}>
|
|
402
477
|
{Array.from({ length: 3 }).map((_, i) => (
|
|
@@ -433,10 +508,10 @@ export default function AgentsPageClient() {
|
|
|
433
508
|
<span style={{ fontSize: 12, color: 'var(--violet)' }}>{selected.name}</span>
|
|
434
509
|
</div>
|
|
435
510
|
<ImageDownloadBanner onImageReady={handleImageReady} />
|
|
436
|
-
{
|
|
511
|
+
{providersConfigured === false && <NoProviderBanner />}
|
|
437
512
|
{/* Scrollable content */}
|
|
438
513
|
<div style={{ flex: 1, overflowY: 'auto', WebkitOverflowScrolling: 'touch' }}>
|
|
439
|
-
<DetailPanel agent={selected} />
|
|
514
|
+
<DetailPanel key={selected.agentId} agent={selected} onDeleted={handleAgentDeleted} onChanged={load} />
|
|
440
515
|
</div>
|
|
441
516
|
</div>
|
|
442
517
|
);
|
|
@@ -460,7 +535,7 @@ export default function AgentsPageClient() {
|
|
|
460
535
|
))}
|
|
461
536
|
</div>
|
|
462
537
|
<ImageDownloadBanner onImageReady={handleImageReady} />
|
|
463
|
-
{
|
|
538
|
+
{providersConfigured === false && <NoProviderBanner />}
|
|
464
539
|
{tokenSection}
|
|
465
540
|
<div style={{ flex: 1, overflowY: 'auto', WebkitOverflowScrolling: 'touch' }}>
|
|
466
541
|
{filtered.length === 0 && <div style={{ padding: 14, color: '#888', fontSize: 12 }}>No agents found</div>}
|
|
@@ -499,13 +574,15 @@ export default function AgentsPageClient() {
|
|
|
499
574
|
))}
|
|
500
575
|
</div>
|
|
501
576
|
<ImageDownloadBanner onImageReady={handleImageReady} />
|
|
502
|
-
{
|
|
577
|
+
{providersConfigured === false && <NoProviderBanner />}
|
|
503
578
|
{tokenSection}
|
|
504
579
|
<div style={{ flex: 1, display: 'flex', minHeight: 0 }}>
|
|
505
580
|
<div style={{ width: '38%', minWidth: 300, borderRight: '1px solid var(--border)', overflow: 'auto' }}>
|
|
506
581
|
{filtered.length === 0 && <div style={{ padding: 14, color: '#888', fontSize: 12 }}>No agents found</div>}
|
|
507
582
|
{filtered.map((agent) => {
|
|
508
|
-
|
|
583
|
+
// Match on agentId, not the container id: recreate gives the agent a
|
|
584
|
+
// new container (new id) and would otherwise silently deselect it.
|
|
585
|
+
const isSelected = selectedId === agent.agentId;
|
|
509
586
|
|
|
510
587
|
return (
|
|
511
588
|
<AgentListItem
|
|
@@ -514,7 +591,7 @@ export default function AgentsPageClient() {
|
|
|
514
591
|
loading={lifecycleLoading === agent.agentId}
|
|
515
592
|
channelSummary={chSummary}
|
|
516
593
|
selected={isSelected}
|
|
517
|
-
onClick={() =>
|
|
594
|
+
onClick={() => setSelectedId(agent.agentId)}
|
|
518
595
|
onLifecycle={() => handleLifecycle(agent)}
|
|
519
596
|
/>
|
|
520
597
|
);
|
|
@@ -524,7 +601,10 @@ export default function AgentsPageClient() {
|
|
|
524
601
|
{!selected ? (
|
|
525
602
|
<div style={{ color: '#666', fontSize: 12, padding: 20 }}>Select an agent for details</div>
|
|
526
603
|
) : (
|
|
527
|
-
|
|
604
|
+
// key: without it React reuses one DetailPanel instance across
|
|
605
|
+
// selections, so in-flight state (deleting, recreating, restarting,
|
|
606
|
+
// toasts, open confirms) leaks onto the next agent you click.
|
|
607
|
+
<DetailPanel key={selected.agentId} agent={selected} onDeleted={handleAgentDeleted} onChanged={load} />
|
|
528
608
|
)}
|
|
529
609
|
</div>
|
|
530
610
|
</div>
|
|
@@ -533,7 +613,12 @@ export default function AgentsPageClient() {
|
|
|
533
613
|
}
|
|
534
614
|
|
|
535
615
|
// ─── Edit Agent modal ───
|
|
536
|
-
function EditAgentModal({ agent, onClose }: {
|
|
616
|
+
function EditAgentModal({ agent, onClose, onSaved }: {
|
|
617
|
+
agent: Agent;
|
|
618
|
+
onClose: () => void;
|
|
619
|
+
/** Rename succeeded — the parent refetches so the new name propagates. */
|
|
620
|
+
onSaved: () => void;
|
|
621
|
+
}) {
|
|
537
622
|
const [displayName, setDisplayName] = useState(agent.displayName || agent.name);
|
|
538
623
|
const [saving, setSaving] = useState(false);
|
|
539
624
|
const [error, setError] = useState('');
|
|
@@ -558,8 +643,9 @@ function EditAgentModal({ agent, onClose }: { agent: Agent; onClose: () => void
|
|
|
558
643
|
});
|
|
559
644
|
const data = await res.json();
|
|
560
645
|
if (!res.ok) throw new Error(data.error || 'Failed to update');
|
|
561
|
-
//
|
|
562
|
-
|
|
646
|
+
// Refetch the list so the new display name propagates — no full reload.
|
|
647
|
+
onSaved();
|
|
648
|
+
onClose();
|
|
563
649
|
} catch (e: unknown) {
|
|
564
650
|
setError((e as Error).message);
|
|
565
651
|
setSaving(false);
|
|
@@ -682,25 +768,39 @@ function ConfigModal({ agent, onClose }: { agent: Agent; onClose: () => void })
|
|
|
682
768
|
}
|
|
683
769
|
|
|
684
770
|
// ─── Detail panel (shared between mobile & desktop) ───
|
|
685
|
-
function DetailPanel({ agent
|
|
771
|
+
function DetailPanel({ agent, onDeleted, onChanged }: {
|
|
772
|
+
agent: Agent;
|
|
773
|
+
/** The agent is gone — the parent drops it from the list. */
|
|
774
|
+
onDeleted: (agentId: string) => void;
|
|
775
|
+
/** The agent changed in place (renamed, recreated) — refetch the list. */
|
|
776
|
+
onChanged: () => void;
|
|
777
|
+
}) {
|
|
686
778
|
const [deleting, setDeleting] = useState(false);
|
|
687
779
|
const [deleteError, setDeleteError] = useState('');
|
|
688
780
|
const [showConfig, setShowConfig] = useState(false);
|
|
689
781
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
|
690
782
|
const [showEdit, setShowEdit] = useState(false);
|
|
691
783
|
|
|
784
|
+
// Safe to resolve during render: this panel only mounts once the agent list has
|
|
785
|
+
// been fetched client-side, so it never runs in a server render. The helper
|
|
786
|
+
// guards for a missing `window` regardless.
|
|
787
|
+
const controlUrl = agentControlUrl(agent);
|
|
788
|
+
|
|
692
789
|
const handleDelete = async () => {
|
|
693
790
|
setDeleting(true);
|
|
694
791
|
setDeleteError('');
|
|
695
792
|
try {
|
|
696
|
-
const res = await fetch(`/api/agents/${agent.
|
|
793
|
+
const res = await fetch(`/api/agents/${encodeURIComponent(agent.agentId)}`, { method: 'DELETE' });
|
|
697
794
|
const data = await res.json();
|
|
698
795
|
if (!res.ok || !data.success) {
|
|
699
796
|
setDeleteError(data.error || 'Delete failed');
|
|
700
797
|
setDeleting(false);
|
|
701
798
|
return;
|
|
702
799
|
}
|
|
703
|
-
|
|
800
|
+
setShowDeleteConfirm(false);
|
|
801
|
+
// No setDeleting(false): the parent drops the agent and this panel
|
|
802
|
+
// unmounts. Clearing state on an unmounted component would warn.
|
|
803
|
+
onDeleted(agent.agentId);
|
|
704
804
|
} catch (e: unknown) {
|
|
705
805
|
setDeleteError((e as Error).message || 'Network error');
|
|
706
806
|
setDeleting(false);
|
|
@@ -778,15 +878,15 @@ function DetailPanel({ agent }: { agent: Agent }) {
|
|
|
778
878
|
{/* Backup - persistent volume backup & restore */}
|
|
779
879
|
<BackupSection agentName={agent.name} />
|
|
780
880
|
|
|
781
|
-
{/* Access - agent URL (token inline in link) */}
|
|
782
|
-
{
|
|
881
|
+
{/* Access - agent Control UI URL (token inline in link) */}
|
|
882
|
+
{controlUrl && (
|
|
783
883
|
<div style={{ border: '1px solid var(--border)', borderRadius: 0, background: 'var(--bg)', overflow: 'hidden' }}>
|
|
784
884
|
<SectionHeader icon={<KeyIcon size={12} />} label="ACCESS" />
|
|
785
885
|
<div style={{ padding: '7px 14px', fontSize: 12 }}>
|
|
786
886
|
<div style={{ color: '#888', marginBottom: 2 }}>Control UI</div>
|
|
787
|
-
<a href={
|
|
887
|
+
<a href={controlUrl} target="_blank" rel="noopener noreferrer"
|
|
788
888
|
style={{ color: '#60a5fa', textDecoration: 'underline', fontSize: 12, wordBreak: 'break-all' }}
|
|
789
|
-
>{
|
|
889
|
+
>{controlUrl}</a>
|
|
790
890
|
|
|
791
891
|
</div>
|
|
792
892
|
</div>
|
|
@@ -796,7 +896,7 @@ function DetailPanel({ agent }: { agent: Agent }) {
|
|
|
796
896
|
{showConfig && <ConfigModal agent={agent} onClose={() => setShowConfig(false)} />}
|
|
797
897
|
|
|
798
898
|
{/* Edit agent modal */}
|
|
799
|
-
{showEdit && <EditAgentModal agent={agent} onClose={() => setShowEdit(false)} />}
|
|
899
|
+
{showEdit && <EditAgentModal agent={agent} onClose={() => setShowEdit(false)} onSaved={onChanged} />}
|
|
800
900
|
|
|
801
901
|
{/* Delete confirmation */}
|
|
802
902
|
<ConfirmModal
|
|
@@ -819,7 +919,7 @@ function DetailPanel({ agent }: { agent: Agent }) {
|
|
|
819
919
|
</div>
|
|
820
920
|
</div>
|
|
821
921
|
)}
|
|
822
|
-
<RecreateSection agentName={agent.name} />
|
|
922
|
+
<RecreateSection agentName={agent.name} onRecreated={onChanged} />
|
|
823
923
|
|
|
824
924
|
{/* Delete */}
|
|
825
925
|
<div style={{ border: '1px solid rgba(239,68,68,0.3)', borderRadius: 0, background: 'rgba(239,68,68,0.04)', overflow: 'hidden' }}>
|
|
@@ -1137,7 +1237,11 @@ function RestartSection({ agentName }: { agentName: string }) {
|
|
|
1137
1237
|
|
|
1138
1238
|
/* ─── Recreate section - rebuild container from same image while keeping volume ─── */
|
|
1139
1239
|
|
|
1140
|
-
function RecreateSection({ agentName }: {
|
|
1240
|
+
function RecreateSection({ agentName, onRecreated }: {
|
|
1241
|
+
agentName: string;
|
|
1242
|
+
/** The container was rebuilt — the parent refetches (new id, fresh image). */
|
|
1243
|
+
onRecreated: () => void;
|
|
1244
|
+
}) {
|
|
1141
1245
|
const [recreating, setRecreating] = useState(false);
|
|
1142
1246
|
const [error, setError] = useState('');
|
|
1143
1247
|
const [toast, setToast] = useState<string | null>(null);
|
|
@@ -1157,7 +1261,9 @@ function RecreateSection({ agentName }: { agentName: string }) {
|
|
|
1157
1261
|
const data = await res.json();
|
|
1158
1262
|
if (data.success) {
|
|
1159
1263
|
showToast(data.backup ? `Recreated (backup: ${data.backup})` : 'Container recreated successfully');
|
|
1160
|
-
|
|
1264
|
+
// Refetch instead of reloading: the toast stays readable and the panel
|
|
1265
|
+
// keeps its scroll position while the new container id comes in.
|
|
1266
|
+
onRecreated();
|
|
1161
1267
|
} else {
|
|
1162
1268
|
setError(data.error || 'Recreate failed');
|
|
1163
1269
|
}
|
|
@@ -77,9 +77,16 @@ export default function CreatePageClient({ templates, models, usedNames, usedPor
|
|
|
77
77
|
const [fallbacks] = useState<string[]>([]);
|
|
78
78
|
const [submitting, setSubmitting] = useState(false);
|
|
79
79
|
const [errorMsg, setErrorMsg] = useState('');
|
|
80
|
-
const [result, setResult] = useState<{ name: string; containerId: string;
|
|
80
|
+
const [result, setResult] = useState<{ name: string; containerId: string; port: number; controlToken: string } | null>(null);
|
|
81
81
|
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
|
|
82
82
|
|
|
83
|
+
// The agent's Control UI URL, composed here rather than server-side: whatever
|
|
84
|
+
// hostname reached this dashboard is one that resolves and routes for this
|
|
85
|
+
// user, which the server can only guess at. Null until the create succeeds.
|
|
86
|
+
const controlUrl = result && typeof window !== 'undefined'
|
|
87
|
+
? `http://${window.location.hostname}:${result.port}#token=${encodeURIComponent(result.controlToken)}`
|
|
88
|
+
: null;
|
|
89
|
+
|
|
83
90
|
const handleSelectTemplate = (id: string) => {
|
|
84
91
|
setSelectedTemplate(id);
|
|
85
92
|
setAgentName('');
|
|
@@ -389,10 +396,10 @@ export default function CreatePageClient({ templates, models, usedNames, usedPor
|
|
|
389
396
|
<div style={{ fontSize: 13, color: '#888', marginBottom: 4 }}>
|
|
390
397
|
Name: <span style={{ color: 'var(--text)' }}>{result.name}</span>
|
|
391
398
|
</div>
|
|
392
|
-
{
|
|
399
|
+
{controlUrl && (
|
|
393
400
|
<div style={{ fontSize: 13, color: '#888' }}>
|
|
394
|
-
Access: <a href={
|
|
395
|
-
style={{ color: '#60a5fa', textDecoration: 'underline' }}>{
|
|
401
|
+
Access: <a href={controlUrl} target="_blank" rel="noopener noreferrer"
|
|
402
|
+
style={{ color: '#60a5fa', textDecoration: 'underline' }}>{controlUrl}</a>
|
|
396
403
|
</div>
|
|
397
404
|
)}
|
|
398
405
|
</div>
|
|
@@ -181,9 +181,9 @@ export async function POST(request: Request,
|
|
|
181
181
|
{ timeout: 15000, stdio: 'pipe', input: providerJson },
|
|
182
182
|
);
|
|
183
183
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
184
|
+
// No gateway restart: `config patch` reports "Change will apply without
|
|
185
|
+
// restarting the gateway" for a models-only patch, and the Gateway watches
|
|
186
|
+
// openclaw.json with gateway.reload defaulting to hybrid.
|
|
187
187
|
} catch {
|
|
188
188
|
// config patch is best-effort on recreate
|
|
189
189
|
}
|
|
@@ -265,7 +265,9 @@ export async function PATCH(
|
|
|
265
265
|
`docker exec -i ${id} sh -c 'openclaw config patch --stdin'`,
|
|
266
266
|
{ timeout: 15000, stdio: 'pipe', input: providerJson },
|
|
267
267
|
);
|
|
268
|
-
|
|
268
|
+
// No gateway restart: `config patch` reports "Change will apply without
|
|
269
|
+
// restarting the gateway" for a models-only patch, and the Gateway watches
|
|
270
|
+
// openclaw.json with gateway.reload defaulting to hybrid.
|
|
269
271
|
} catch { /* non-fatal */ }
|
|
270
272
|
|
|
271
273
|
return NextResponse.json({
|
|
@@ -1,36 +1,67 @@
|
|
|
1
1
|
import { NextResponse, type NextRequest } from 'next/server';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { execFile } from 'child_process';
|
|
3
|
+
import { promisify } from 'util';
|
|
4
|
+
import { parseTelegramChannel } from '@/lib/channelManager';
|
|
5
|
+
import { dockerExecNoFail, mapWithConcurrency } from '@/lib/docker-exec';
|
|
4
6
|
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
5
7
|
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
|
|
6
10
|
export const dynamic = 'force-dynamic';
|
|
7
11
|
export const maxDuration = 15; // 15-second timeout for channels summary
|
|
8
12
|
|
|
13
|
+
/** Agents read at once — each is one `docker exec … cat`. */
|
|
14
|
+
const AGENT_QUERY_CONCURRENCY = 8;
|
|
15
|
+
|
|
16
|
+
const AGENT_CONFIG_PATH = '/root/.openclaw/openclaw.json';
|
|
17
|
+
|
|
9
18
|
/**
|
|
10
19
|
* GET /api/agents/channels-summary
|
|
11
20
|
*
|
|
12
21
|
* Returns a lightweight channel status map for all running agents.
|
|
13
22
|
* { [agentId]: { telegram: boolean } }
|
|
23
|
+
*
|
|
24
|
+
* This used to call getChannels(agentId) in a loop, which re-ran
|
|
25
|
+
* `docker ps --filter label=AGENT_ID=<id>` for every agent just to recover the
|
|
26
|
+
* container name — a name this route's own `docker ps` had already returned and
|
|
27
|
+
* then discarded. So it was 1 + 2N synchronous process spawns to read N files.
|
|
28
|
+
* Now the discovery call carries the container name through, and the configs are
|
|
29
|
+
* read concurrently.
|
|
14
30
|
*/
|
|
15
|
-
export async function GET(request: NextRequest
|
|
16
|
-
): Promise<NextResponse> {
|
|
31
|
+
export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
17
32
|
const denied = await requireAuthJWT(request); if (denied) return denied as any;
|
|
18
33
|
try {
|
|
19
|
-
//
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
)
|
|
34
|
+
// Keep the container name alongside the label — the follow-up lookups per
|
|
35
|
+
// agent existed only because this call used to throw the name away.
|
|
36
|
+
const { stdout } = await execFileAsync('docker', [
|
|
37
|
+
'ps', '--filter', 'label=AGENT_ID', '--format', '{{.Label "AGENT_ID"}}\t{{.Names}}',
|
|
38
|
+
], { encoding: 'utf-8', timeout: 5000 });
|
|
24
39
|
|
|
25
|
-
const
|
|
26
|
-
|
|
40
|
+
const agents = stdout.trim().split('\n')
|
|
41
|
+
.map((line) => {
|
|
42
|
+
const [agentId, containerName] = line.split('\t');
|
|
43
|
+
if (!agentId?.trim() || !containerName?.trim()) return null;
|
|
44
|
+
return { agentId: agentId.trim(), containerName: containerName.trim() };
|
|
45
|
+
})
|
|
46
|
+
.filter(Boolean) as { agentId: string; containerName: string }[];
|
|
47
|
+
|
|
48
|
+
const results = await mapWithConcurrency(agents, AGENT_QUERY_CONCURRENCY, async (agent) => {
|
|
49
|
+
const raw = await dockerExecNoFail(
|
|
50
|
+
agent.containerName,
|
|
51
|
+
['cat', AGENT_CONFIG_PATH],
|
|
52
|
+
{ timeoutMs: 8000 },
|
|
53
|
+
);
|
|
54
|
+
if (!raw) return { agentId: agent.agentId, telegram: false };
|
|
55
|
+
try {
|
|
56
|
+
const config = JSON.parse(raw) as Record<string, unknown>;
|
|
57
|
+
return { agentId: agent.agentId, telegram: parseTelegramChannel(config)?.connected === true };
|
|
58
|
+
} catch {
|
|
59
|
+
return { agentId: agent.agentId, telegram: false };
|
|
60
|
+
}
|
|
61
|
+
});
|
|
27
62
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
summary[agentId] = {
|
|
31
|
-
telegram: channels.telegram?.connected === true,
|
|
32
|
-
};
|
|
33
|
-
}
|
|
63
|
+
const summary: Record<string, { telegram: boolean }> = {};
|
|
64
|
+
for (const r of results) summary[r.agentId] = { telegram: r.telegram };
|
|
34
65
|
|
|
35
66
|
return NextResponse.json(summary);
|
|
36
67
|
} catch {
|
|
@@ -4,7 +4,6 @@ import * as crypto from 'crypto';
|
|
|
4
4
|
import * as fs from 'fs';
|
|
5
5
|
import * as path from 'path';
|
|
6
6
|
import { buildRev4aProviderConfig } from '../../gateway/sync';
|
|
7
|
-
import { detectPublicHost } from '@/lib/docker-utils';
|
|
8
7
|
import { DB_FILE, AGENTS_TOKEN_FILE, PROVIDER_KEYS_FILE, templatesDir } from '@/lib/rev4a-paths';
|
|
9
8
|
import { getMountFlags, applyRuntimeConfig } from '@/lib/agent-setup';
|
|
10
9
|
import { loadModelsConfig } from '@/lib/model-catalogue';
|
|
@@ -44,11 +43,23 @@ const DEFAULT_BLOCK_SIZE = 10;
|
|
|
44
43
|
|
|
45
44
|
function getUsedHostPorts(): Set<number> {
|
|
46
45
|
try {
|
|
46
|
+
// Parse host ports in JS rather than piping through grep. The previous
|
|
47
|
+
// form used `grep -oP '\d+(?=->)'` inside a template literal, where \d
|
|
48
|
+
// loses its backslash — the shell received `d+(?=->)`, which never
|
|
49
|
+
// matches Docker's `0.0.0.0:3700->3000/tcp`, so this always returned an
|
|
50
|
+
// empty set and every agent was offered port 3700. `grep -P` is also
|
|
51
|
+
// GNU-only and absent on macOS. This mirrors the client-side parser in
|
|
52
|
+
// app/agents/create/page.tsx.
|
|
47
53
|
const raw = execSync(
|
|
48
|
-
`docker ps --format '{{.Ports}}'
|
|
54
|
+
`docker ps --format '{{.Ports}}'`,
|
|
49
55
|
{ encoding: 'utf-8', timeout: 3000 },
|
|
50
|
-
)
|
|
51
|
-
|
|
56
|
+
);
|
|
57
|
+
const used = new Set<number>();
|
|
58
|
+
for (const [, port] of raw.matchAll(/(\d+)->/g)) {
|
|
59
|
+
const n = Number(port);
|
|
60
|
+
if (Number.isInteger(n)) used.add(n);
|
|
61
|
+
}
|
|
62
|
+
return used;
|
|
52
63
|
} catch {
|
|
53
64
|
return new Set();
|
|
54
65
|
}
|
|
@@ -237,17 +248,23 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
|
|
|
237
248
|
);
|
|
238
249
|
}
|
|
239
250
|
|
|
240
|
-
// Every agent gets a host port range for direct access
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
251
|
+
// Every agent gets a host port range for direct access. The URL is composed
|
|
252
|
+
// by the browser from `port` + `controlToken` in the response — only the
|
|
253
|
+
// browser knows a hostname that actually reaches this machine.
|
|
254
|
+
//
|
|
255
|
+
// There used to be an AGENT_HOSTNAME env var here, holding `<detected-ip>:<port>`.
|
|
256
|
+
// Nothing ever read it: verified across this repo and across the whole
|
|
257
|
+
// filesystem of a running agent container, binaries included, with a control
|
|
258
|
+
// search proving the method finds AGENT_ID and OPENCLAW_GATEWAY_TOKEN in the
|
|
259
|
+
// same sweep. Worse, a container's env is immutable and recreate copies every
|
|
260
|
+
// AGENT_* var forward, so a wrong address was frozen in and propagated for the
|
|
261
|
+
// life of the agent, with no code path anywhere that could refresh it.
|
|
244
262
|
const labels: string[] = [`AGENT_ID=${containerName}`];
|
|
245
263
|
const envVars: string[] = [
|
|
246
264
|
`AGENT_ID=${containerName}`,
|
|
247
265
|
`AGENT_NAME=${displayName.trim()}`,
|
|
248
266
|
`AGENT_TEMPLATE=${template}`,
|
|
249
267
|
`OPENCLAW_GATEWAY_TOKEN=${agentsToken}`,
|
|
250
|
-
`AGENT_HOSTNAME=${agentHostname}`,
|
|
251
268
|
`TZ=Europe/Rome`,
|
|
252
269
|
];
|
|
253
270
|
|
|
@@ -425,9 +442,11 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
|
|
|
425
442
|
{ timeout: 15000, stdio: 'pipe', input: providerJson },
|
|
426
443
|
);
|
|
427
444
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
445
|
+
// No gateway restart: `config patch` reports "Change will apply without
|
|
446
|
+
// restarting the gateway" for a models-only patch, and the Gateway watches
|
|
447
|
+
// openclaw.json with gateway.reload defaulting to hybrid. The earlier
|
|
448
|
+
// restart in this route stays — that one follows a full-file rewrite that
|
|
449
|
+
// includes gateway.*, which is the one section that genuinely needs it.
|
|
431
450
|
} catch (e: unknown) {
|
|
432
451
|
// runtime config patch is best-effort; log but don't fail the create
|
|
433
452
|
console.error('[agent:create] runtime config patch failed:', (e as Error).message || String(e));
|
|
@@ -440,7 +459,7 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
|
|
|
440
459
|
displayName: displayName.trim(),
|
|
441
460
|
image,
|
|
442
461
|
network,
|
|
443
|
-
|
|
462
|
+
controlToken: agentsToken,
|
|
444
463
|
port: portStart,
|
|
445
464
|
portEnd,
|
|
446
465
|
isBlock,
|
|
@@ -16,6 +16,8 @@ import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
|
16
16
|
export const dynamic = 'force-dynamic';
|
|
17
17
|
|
|
18
18
|
export async function POST(request: NextRequest): Promise<NextResponse> {
|
|
19
|
+
const denied = await requireAuthJWT(request);
|
|
20
|
+
if (denied) return denied;
|
|
19
21
|
if (getIsDownloading()) {
|
|
20
22
|
return NextResponse.json(
|
|
21
23
|
{ error: 'A download is already in progress' },
|