@flame0510/project-aether 1.1.15 → 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/credentials/[id]/sync/route.ts +3 -3
- package/app/api/credentials/detect/route.ts +126 -176
- package/app/api/credentials/route.ts +3 -0
- 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/Skeleton.tsx +132 -0
- 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 +461 -140
- package/app/credentials/loading.tsx +19 -5
- 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 +92 -33
- package/docs/FRONTEND-ARCHITECTURE.md +36 -6
- package/docs/REV4A.md +62 -30
- package/docs/dev/API-REFERENCE.md +490 -227
- package/docs/dev/DATABASE.md +8 -3
- package/docs/dev/GATEWAY.md +236 -92
- package/docs/dev/PROVIDERS.md +44 -44
- package/docs/rag/DATA-FRESHNESS.md +57 -28
- package/docs/rag/GLOSSARY.md +20 -18
- package/docs/rag/REV4A-OVERVIEW.md +28 -32
- package/docs/rag/WHAT-I-CAN-ANSWER.md +10 -12
- 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/credentials/delivery.ts +212 -119
- package/lib/credentials/detect.ts +229 -97
- package/lib/credentials/providers.ts +38 -7
- package/lib/credentials/vault.ts +78 -13
- package/lib/docker-exec.ts +50 -14
- 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/app/globals.css
CHANGED
|
@@ -1296,6 +1296,14 @@ html, body { height: 100%; height: 100dvh; background: var(--bg); color: var(--t
|
|
|
1296
1296
|
background: rgba(var(--red-rgb,239,68,68),0.1);
|
|
1297
1297
|
color: var(--red);
|
|
1298
1298
|
}
|
|
1299
|
+
/* Saved, but not fully applied — e.g. a change stored while an agent sync failed. */
|
|
1300
|
+
.msg-warning {
|
|
1301
|
+
padding: 8px 12px;
|
|
1302
|
+
border-radius: 0;
|
|
1303
|
+
font-size: 12px;
|
|
1304
|
+
background: rgba(245,158,11,0.1);
|
|
1305
|
+
color: var(--yellow);
|
|
1306
|
+
}
|
|
1299
1307
|
|
|
1300
1308
|
/* --- Mobile Bottom Navigation --- */
|
|
1301
1309
|
.mobile-bottom-nav {
|
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* The model list, fetched once and shared by every client that needs it.
|
|
5
|
+
*
|
|
6
|
+
* There used to be two independent copies: this provider, and a private fetch
|
|
7
|
+
* inside PulseChat. Both ran on mount with an empty dependency list, and neither
|
|
8
|
+
* component ever unmounts — so unchecking a model on the Gateway page left the
|
|
9
|
+
* chat panel offering it for the rest of the session, and only a full page reload
|
|
10
|
+
* cleared it.
|
|
11
|
+
*
|
|
12
|
+
* The fix is not to refetch more often. It is to refetch when the answer
|
|
13
|
+
* changes: `refresh()` is called by whoever changes it — the Gateway page after a
|
|
14
|
+
* model toggle, a key save or removal, and a sync; the first-run wizard after
|
|
15
|
+
* saving keys. Everything else just reads.
|
|
16
|
+
*/
|
|
17
|
+
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
|
|
4
18
|
|
|
5
19
|
interface ProviderEntry {
|
|
6
20
|
provider: string;
|
|
@@ -11,26 +25,48 @@ interface ProviderEntry {
|
|
|
11
25
|
interface ModelsContextValue {
|
|
12
26
|
providers: ProviderEntry[];
|
|
13
27
|
loaded: boolean;
|
|
28
|
+
/** Re-read the catalogue. Call after changing which models are enabled. */
|
|
29
|
+
refresh: () => void;
|
|
14
30
|
}
|
|
15
31
|
|
|
16
|
-
const ModelsContext = createContext<ModelsContextValue>({
|
|
32
|
+
const ModelsContext = createContext<ModelsContextValue>({
|
|
33
|
+
providers: [],
|
|
34
|
+
loaded: false,
|
|
35
|
+
refresh: () => {},
|
|
36
|
+
});
|
|
17
37
|
|
|
18
38
|
export function ModelsProvider({ children }: { children: React.ReactNode }) {
|
|
19
39
|
const [providers, setProviders] = useState<ProviderEntry[]>([]);
|
|
20
40
|
const [loaded, setLoaded] = useState(false);
|
|
21
41
|
|
|
22
|
-
|
|
42
|
+
// Refreshes can overlap — two quick toggles — and responses can arrive out of
|
|
43
|
+
// order. Only the most recent request may write.
|
|
44
|
+
const seq = useRef(0);
|
|
45
|
+
|
|
46
|
+
const refresh = useCallback(() => {
|
|
47
|
+
const mine = ++seq.current;
|
|
23
48
|
fetch('/api/models')
|
|
24
|
-
.then((r) =>
|
|
49
|
+
.then((r) => {
|
|
50
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
51
|
+
return r.json();
|
|
52
|
+
})
|
|
25
53
|
.then((data) => {
|
|
26
|
-
if (
|
|
54
|
+
if (mine !== seq.current) return;
|
|
55
|
+
// An empty array is a real answer — every model disabled, or no provider
|
|
56
|
+
// key — so it must replace the list rather than be discarded as a failure.
|
|
57
|
+
if (Array.isArray(data)) setProviders(data);
|
|
27
58
|
setLoaded(true);
|
|
28
59
|
})
|
|
29
|
-
.catch(() =>
|
|
60
|
+
.catch(() => {
|
|
61
|
+
// Keep the previous list: a failed refresh is not an empty catalogue.
|
|
62
|
+
if (mine === seq.current) setLoaded(true);
|
|
63
|
+
});
|
|
30
64
|
}, []);
|
|
31
65
|
|
|
66
|
+
useEffect(() => { refresh(); }, [refresh]);
|
|
67
|
+
|
|
32
68
|
return (
|
|
33
|
-
<ModelsContext.Provider value={{ providers, loaded }}>
|
|
69
|
+
<ModelsContext.Provider value={{ providers, loaded, refresh }}>
|
|
34
70
|
{children}
|
|
35
71
|
</ModelsContext.Provider>
|
|
36
72
|
);
|
package/app/wizard/useWizard.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useState, useCallback, useEffect } from 'react';
|
|
4
|
+
import { useModels } from '../lib/models-context';
|
|
4
5
|
import {
|
|
5
6
|
isWizardComplete,
|
|
6
7
|
markWizardComplete,
|
|
@@ -45,6 +46,9 @@ export function useWizard() {
|
|
|
45
46
|
// Step tracking is in-memory only; the cookie is the source of truth
|
|
46
47
|
}, []);
|
|
47
48
|
|
|
49
|
+
// Saving a key changes which models are offered; the shared list must follow.
|
|
50
|
+
const { refresh: refreshOfferedModels } = useModels();
|
|
51
|
+
|
|
48
52
|
const saveProviders = useCallback(async () => {
|
|
49
53
|
const entries = Object.entries(providerKeys).filter(([, v]) => v.trim());
|
|
50
54
|
if (entries.length === 0) {
|
|
@@ -60,10 +64,11 @@ export function useWizard() {
|
|
|
60
64
|
body: JSON.stringify({ provider, apiKey: apiKey.trim() }),
|
|
61
65
|
});
|
|
62
66
|
}
|
|
67
|
+
refreshOfferedModels();
|
|
63
68
|
markStep('providers');
|
|
64
69
|
} catch { /* API call failed — do not mark step as complete */ }
|
|
65
70
|
setSaving(false);
|
|
66
|
-
}, [providerKeys, markStep]);
|
|
71
|
+
}, [providerKeys, markStep, refreshOfferedModels]);
|
|
67
72
|
|
|
68
73
|
const goNext = useCallback(async () => {
|
|
69
74
|
if (step === 'welcome') { markStep('welcome'); setStep('providers'); }
|
package/bin/rev4a.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* rev4a serve:
|
|
11
11
|
* 1. Ensures .env exists (auto-generates on first run)
|
|
12
12
|
* 2. Ensures .next build exists (builds if missing)
|
|
13
|
-
* 3. Ensures Docker is installed (installs if missing + root)
|
|
13
|
+
* 3. Ensures Docker is installed (installs if missing + root) and waits for its daemon
|
|
14
14
|
* 4. Ensures agent base image is built (builds if missing)
|
|
15
15
|
* 5. Starts Next.js + daemon + terminal WS as a single process group
|
|
16
16
|
*
|
|
@@ -44,6 +44,17 @@ const DOCKERFILE = join(ROOT, 'agent-templates', 'base-image', 'Dockerfile');
|
|
|
44
44
|
const AGENT_IMAGE = 'openclaw-agent-base:latest';
|
|
45
45
|
const AGENT_IMAGE_REGISTRY = 'ghcr.io/flame0510/rev4a/openclaw-agent-base:latest';
|
|
46
46
|
const DOCKER_NETWORK = 'rev4a-network';
|
|
47
|
+
// How long `serve` waits for the Docker daemon before starting without it. Like
|
|
48
|
+
// REV4A_DATA_DIR it is read from the real process environment (a systemd
|
|
49
|
+
// Environment= line, a shell export), because the .env file is loaded later.
|
|
50
|
+
const DOCKER_WAIT_SECONDS = (() => {
|
|
51
|
+
const raw = (process.env.REV4A_DOCKER_WAIT_SECONDS || '').trim();
|
|
52
|
+
if (!raw) return 90;
|
|
53
|
+
const n = Number(raw);
|
|
54
|
+
// 0 means a single check. Non-numeric or infinite values fall back to the
|
|
55
|
+
// default, and the ceiling keeps a typo from holding start-up for hours.
|
|
56
|
+
return Number.isFinite(n) && n >= 0 ? Math.min(n, 600) : 90;
|
|
57
|
+
})();
|
|
47
58
|
|
|
48
59
|
// Fallback used only when package.json cannot be read — the published npm
|
|
49
60
|
// package name is the single source of truth, so never hardcode it elsewhere.
|
|
@@ -97,25 +108,70 @@ function whichDistro() {
|
|
|
97
108
|
}
|
|
98
109
|
}
|
|
99
110
|
|
|
100
|
-
// ── Docker auto-install
|
|
111
|
+
// ── Docker auto-install and daemon wait ─────────────────────────────────────
|
|
101
112
|
|
|
113
|
+
/** Block this thread for `ms` without spawning a process (`sleep` is not on every OS). */
|
|
114
|
+
function sleepSync(ms) {
|
|
115
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Wait until the Docker daemon answers, up to DOCKER_WAIT_SECONDS.
|
|
120
|
+
*
|
|
121
|
+
* Finding the `docker` command only proves the CLI is installed. At boot the
|
|
122
|
+
* daemon can still be starting — on the production host systemd started this
|
|
123
|
+
* service a second before Docker. Start-up still worked, by accident: the first
|
|
124
|
+
* `docker` call blocked on docker.socket until the daemon was up. With a daemon
|
|
125
|
+
* slower than a later call's 10 s timeout, the image check would have concluded
|
|
126
|
+
* the image was missing and tried to pull and rebuild it. Asking the daemon
|
|
127
|
+
* directly, with a short per-attempt timeout, makes the wait explicit, bounded
|
|
128
|
+
* and logged, whatever kind of Docker install this is.
|
|
129
|
+
*/
|
|
130
|
+
function waitForDockerDaemon() {
|
|
131
|
+
const deadline = Date.now() + DOCKER_WAIT_SECONDS * 1000;
|
|
132
|
+
let lastError = '';
|
|
133
|
+
let announced = false;
|
|
134
|
+
for (;;) {
|
|
135
|
+
try {
|
|
136
|
+
const version = execSync('docker info --format "{{.ServerVersion}}"', { stdio: 'pipe', timeout: 10_000 })
|
|
137
|
+
.toString().trim();
|
|
138
|
+
if (version) {
|
|
139
|
+
log('DOCKER', `Docker daemon ready (server ${version})`);
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
} catch (err) {
|
|
143
|
+
lastError = ((err.stderr && err.stderr.toString()) || err.message || '').trim().split('\n')[0];
|
|
144
|
+
}
|
|
145
|
+
if (Date.now() >= deadline) break;
|
|
146
|
+
if (!announced) {
|
|
147
|
+
log('DOCKER', `Waiting for the Docker daemon (up to ${DOCKER_WAIT_SECONDS}s)…`);
|
|
148
|
+
announced = true;
|
|
149
|
+
}
|
|
150
|
+
sleepSync(2000);
|
|
151
|
+
}
|
|
152
|
+
log('DOCKER', `⚠ Docker daemon not reachable after ${DOCKER_WAIT_SECONDS}s: ${lastError || 'no answer'}`);
|
|
153
|
+
log('DOCKER', ' Starting without it. Agents are unavailable and the startup sync reaches none; run Sync All Agents once Docker is up.');
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** True when the Docker daemon is reachable and the Docker-dependent steps can run. */
|
|
102
158
|
function ensureDocker() {
|
|
103
159
|
if (hasCmd('docker')) {
|
|
104
|
-
log('DOCKER', 'Docker
|
|
105
|
-
return;
|
|
160
|
+
log('DOCKER', 'Docker CLI found');
|
|
161
|
+
return waitForDockerDaemon();
|
|
106
162
|
}
|
|
107
163
|
|
|
108
164
|
if (!isLinux()) {
|
|
109
165
|
log('DOCKER', '⚠ Docker not found. Rev4a needs Docker to create agent containers.');
|
|
110
166
|
log('DOCKER', ' Install Docker: https://docs.docker.com/get-docker/');
|
|
111
167
|
log('DOCKER', ' Then run: rev4a serve');
|
|
112
|
-
return;
|
|
168
|
+
return false;
|
|
113
169
|
}
|
|
114
170
|
|
|
115
171
|
if (!isRoot()) {
|
|
116
172
|
log('DOCKER', '⚠ Docker not found. Run Rev4a as root to auto-install:');
|
|
117
173
|
log('DOCKER', ' sudo rev4a serve');
|
|
118
|
-
return;
|
|
174
|
+
return false;
|
|
119
175
|
}
|
|
120
176
|
|
|
121
177
|
const distro = whichDistro();
|
|
@@ -134,10 +190,12 @@ function ensureDocker() {
|
|
|
134
190
|
);
|
|
135
191
|
|
|
136
192
|
log('DOCKER', 'Docker installed successfully');
|
|
193
|
+
return waitForDockerDaemon();
|
|
137
194
|
} catch (err) {
|
|
138
195
|
log('DOCKER', '⚠ Docker auto-install failed. Install manually:');
|
|
139
196
|
log('DOCKER', ' https://docs.docker.com/engine/install/');
|
|
140
197
|
log('DOCKER', ' Then run: rev4a serve');
|
|
198
|
+
return false;
|
|
141
199
|
}
|
|
142
200
|
}
|
|
143
201
|
|
|
@@ -414,9 +472,15 @@ function checkVersion() {
|
|
|
414
472
|
function preflight() {
|
|
415
473
|
log('PREFLIGHT', 'Running pre-flight checks…');
|
|
416
474
|
ensureEnv();
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
475
|
+
// Network and image need the daemon. Without it the image check fails and
|
|
476
|
+
// would start a pull and then a local build — up to fifteen minutes, before the
|
|
477
|
+
// dashboard is even up — for an image that is most likely already there.
|
|
478
|
+
if (ensureDocker()) {
|
|
479
|
+
ensureNetwork();
|
|
480
|
+
ensureAgentImage();
|
|
481
|
+
} else {
|
|
482
|
+
log('PREFLIGHT', 'Skipping network and agent image checks: Docker daemon not reachable.');
|
|
483
|
+
}
|
|
420
484
|
ensureRev4aRules();
|
|
421
485
|
|
|
422
486
|
if (!existsSync(NEXT_DIR)) {
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Rev4a Architecture — Design & Vision
|
|
2
2
|
|
|
3
3
|
> **Status:** Active — `main` branch
|
|
4
|
-
> **Last updated:** 2026-
|
|
4
|
+
> **Last updated:** 2026-09-14
|
|
5
5
|
> **Goal:** Transform Rev4a from a monitoring dashboard into a central orchestrator for a distributed multi-container agency.
|
|
6
6
|
|
|
7
7
|
---
|
|
@@ -64,15 +64,15 @@
|
|
|
64
64
|
|
|
65
65
|
---
|
|
66
66
|
|
|
67
|
-
## 2. Current Architecture (as of 2026-
|
|
67
|
+
## 2. Current Architecture (as of 2026-08-28)
|
|
68
68
|
|
|
69
69
|
### What's already been implemented
|
|
70
70
|
|
|
71
71
|
**Container separation:** `openclaw-atlas` already runs as a standalone container with `AGENT_ID=atlas`, discovered dynamically by Rev4a. This proves the container-per-agent model works.
|
|
72
72
|
|
|
73
|
-
**
|
|
73
|
+
**Credentials vault:** The `/credentials` page stores third-party service tokens in `credentials.db` and installs them into containers on explicit sync. Provider API keys live separately in `provider-keys.json`, managed from the Gateway UI.
|
|
74
74
|
|
|
75
|
-
**Container management UI:** The `/containers` page lists all running Docker containers with resource usage
|
|
75
|
+
**Container management UI:** The `/containers` page lists all running Docker containers with resource usage and quick links, and opens a web terminal into any of them. Fully functional.
|
|
76
76
|
|
|
77
77
|
**Workspace API:** Lazy-loaded file tree explorer with real-time reads (no caching), supports both host and container workspaces via `docker exec`.
|
|
78
78
|
|
|
@@ -80,8 +80,8 @@
|
|
|
80
80
|
|
|
81
81
|
| Feature | Status | Notes |
|
|
82
82
|
|---|---|---|
|
|
83
|
-
|
|
|
84
|
-
| Container page | 🟢 Functional | Lists all containers,
|
|
83
|
+
| Credentials (UI + API) | 🟢 Implemented | SQLite, no encryption yet |
|
|
84
|
+
| Container page | 🟢 Functional | Lists all containers, resources, web terminal |
|
|
85
85
|
| Agent creation wizard | 🟢 Implemented | One-click create with model/template selection |
|
|
86
86
|
| Provider proxy | 🟢 Implemented | Agents route through Rev4a provider gateway |
|
|
87
87
|
| Shared volumes | 🟢 Implemented | Skills + repos mounted on all agents |
|
|
@@ -103,7 +103,7 @@ The central container, running the Next.js dashboard + orchestration API.
|
|
|
103
103
|
- Centralized credential management
|
|
104
104
|
- Docker socket access for container management
|
|
105
105
|
- SQLite DB for cross-container event monitoring
|
|
106
|
-
- **
|
|
106
|
+
- **First-run wizard** — setup flow at `/wizard`; completion is recorded in `<data dir>/data/wizard.json` and read through `GET /api/wizard/status`
|
|
107
107
|
|
|
108
108
|
**Volume mounts (target):**
|
|
109
109
|
```
|
|
@@ -119,6 +119,61 @@ The central container, running the Next.js dashboard + orchestration API.
|
|
|
119
119
|
- Injected into every session via the `bootstrap-extra-files` hook (glob `.rev4a/*.md`)
|
|
120
120
|
- Immutable by agents — enforced by the `:ro` mount
|
|
121
121
|
- See `lib/agent-setup.ts` for the centralized volume + config guarantee logic
|
|
122
|
+
- **Waiting for a new container** is `waitForGatewayReady()` in
|
|
123
|
+
`lib/agent-readiness.ts`, used by create, recreate and the agent route. On
|
|
124
|
+
OpenClaw 9.x it waits for `/startupz` to report `started` (503 while starting).
|
|
125
|
+
The 2026.7.1-2 image has no `/startupz` — its gateway serves the web UI with 200
|
|
126
|
+
for unknown paths — so there it falls back to `/health`, which only shows the
|
|
127
|
+
server is listening. Do not reimplement the wait in a route.
|
|
128
|
+
- **The model catalogue** is read only through `lib/model-catalogue.ts`:
|
|
129
|
+
`loadModelsConfig()`, `loadOfferedModels()` (enabled after overrides, provider has
|
|
130
|
+
a key), `isModelOffered()` (enforced by the proxy and the assistant) and
|
|
131
|
+
`catalogueStatus()`. A failed read of `models.config.json` falls back to the last
|
|
132
|
+
good copy and never deletes overrides.
|
|
133
|
+
|
|
134
|
+
**Running commands inside containers** (`lib/docker-exec.ts`):
|
|
135
|
+
|
|
136
|
+
This module is how a route reaches an agent container. The migration to it is
|
|
137
|
+
not finished — routes predating it still shell out directly — so treat these as
|
|
138
|
+
the rules for anything you touch, not as a description of the whole tree:
|
|
139
|
+
|
|
140
|
+
- **Never `execSync`/`execFileSync` in a request path.** They block Node's single
|
|
141
|
+
thread: while one runs, no other request is served and no agent's stream
|
|
142
|
+
advances. A `docker exec` costs ~90 ms warm and the OpenClaw CLI costs
|
|
143
|
+
seconds, so a route that walks the fleet freezes the event loop for the sum of
|
|
144
|
+
all of them. Use `dockerExec` / `dockerExecShell` and their `…NoFail` variants.
|
|
145
|
+
- **`mapWithConcurrency` is fail-fast, like `Promise.all`.** If the mapped
|
|
146
|
+
function rejects for one item, the whole call rejects and results already
|
|
147
|
+
computed for other containers are discarded. Fanning out over a fleet requires
|
|
148
|
+
the `…NoFail` variants, or an explicit per-item try/catch, so one unreachable
|
|
149
|
+
container cannot blank an entire page. Callers: `app/api/skills/route.js`,
|
|
150
|
+
`app/api/agents/models-summary/route.ts`,
|
|
151
|
+
`app/api/agents/channels-summary/route.ts`,
|
|
152
|
+
`lib/openclaw-cron.ts`, `app/api/credentials/detect/route.ts`,
|
|
153
|
+
`lib/credentials/delivery.ts`.
|
|
154
|
+
- **Never interpolate a secret into a command string.** Pass it through the `env`
|
|
155
|
+
option and reference it as `"$KEY"`; command substitution inside an
|
|
156
|
+
interpolated value would otherwise execute on the host. Only the *name* reaches
|
|
157
|
+
argv, as `docker exec -e KEY` — docker forwards the value from its own
|
|
158
|
+
environment, so it appears neither in the host process table nor in any
|
|
159
|
+
rejection built from that argv.
|
|
160
|
+
- **A timeout does not reject.** `docker exec` exits 0 on the SIGTERM Node sends
|
|
161
|
+
when the deadline passes, so a command killed halfway *resolves* with whatever
|
|
162
|
+
it had already printed. A `try/catch` therefore cannot tell a truncated read
|
|
163
|
+
from a complete one. Where that distinction matters, make the script prove it
|
|
164
|
+
finished — `detect.ts` emits a `probe:complete` sentinel before its slow calls,
|
|
165
|
+
`containerRead` prints a terminator after the file — and treat its absence as
|
|
166
|
+
failure. Anything that writes back what it read must refuse to write when the
|
|
167
|
+
proof is missing.
|
|
168
|
+
- **A rejection carries `stderr`, never the command.** Node's own message is
|
|
169
|
+
`Command failed: <argv…>`, which reproduces whatever was interpolated into the
|
|
170
|
+
script. `dockerExec` replaces it with `docker exec <container>: <cause> —
|
|
171
|
+
<stderr>`, because these messages travel: `syncProfileToAgents` puts one into
|
|
172
|
+
`SyncResult.error` and the sync route returns it to the browser.
|
|
173
|
+
|
|
174
|
+
`dockerExecWithInput` uses `spawn` rather than `execFile` because
|
|
175
|
+
`promisify(exec)` silently ignores an `input` option: the process starts, stdin
|
|
176
|
+
is never written, and the command hangs with no error to point at.
|
|
122
177
|
|
|
123
178
|
### 3.2 Agent Container Template (`openclaw-agent-base`)
|
|
124
179
|
|
|
@@ -164,7 +219,7 @@ Agent Container Rev4a Gateway Provider API
|
|
|
164
219
|
│ │ │
|
|
165
220
|
│ Authorization: Bearer <gateway-token> │
|
|
166
221
|
│ POST /api/provider/v1/chat/completions │
|
|
167
|
-
│ model: rev4a/deepseek-
|
|
222
|
+
│ model: rev4a/deepseek-flash │
|
|
168
223
|
│────────────────────────>│ │
|
|
169
224
|
│ │ POST /v1/chat/completions│
|
|
170
225
|
│ │ Authorization: Bearer <real-key>
|
|
@@ -196,30 +251,34 @@ detailed documentation.
|
|
|
196
251
|
|
|
197
252
|
## 4. Credential Management (Vault)
|
|
198
253
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
254
|
+
Two separate stores:
|
|
255
|
+
|
|
256
|
+
- **Provider API keys** — `<data dir>/data/provider-keys.json`, used by the Rev4a
|
|
257
|
+
provider proxy.
|
|
258
|
+
- **Third-party service credentials** — `<data dir>/data/credentials.db` (SQLite),
|
|
259
|
+
managed from the Credentials page. Five providers: `github-pat`, `trello`,
|
|
260
|
+
`vercel`, `supabase`, `notion` (`lib/credentials/providers.ts`).
|
|
261
|
+
|
|
262
|
+
**Secrets are stored unencrypted.** `credential_secrets.payload` holds raw JSON.
|
|
263
|
+
Anything able to read that file, or to reach `POST /api/credentials/[id]/reveal`
|
|
264
|
+
with a valid session, obtains them in the clear. Encryption (AES-256-GCM, or an
|
|
265
|
+
external vault) is not implemented.
|
|
266
|
+
|
|
267
|
+
**Delivery is explicit, not automatic.** Creating an agent installs nothing: a
|
|
268
|
+
credential reaches a container only when the user syncs it
|
|
269
|
+
(`POST /api/credentials/[id]/sync`), and leaves only on de-sync
|
|
270
|
+
(`DELETE /api/credentials/[id]/sync`). There is no per-agent permission model —
|
|
271
|
+
any stored credential can be synced to any container. Delivery writes each CLI's
|
|
272
|
+
own config inside the container (`lib/credentials/delivery.ts`); OpenClaw's
|
|
273
|
+
`secrets` subsystem covers only OpenClaw's own configuration credentials and
|
|
274
|
+
offers nothing for third-party CLIs.
|
|
275
|
+
|
|
276
|
+
`GET /api/credentials/detect` reports which credentials are actually installed,
|
|
277
|
+
by SHA-256 hashing the token found in each container and matching it against the
|
|
278
|
+
stored profiles.
|
|
221
279
|
|
|
222
|
-
**
|
|
280
|
+
**Planned:** encryption at rest, and a per-agent permission model so a credential
|
|
281
|
+
can be restricted to a subset of agents.
|
|
223
282
|
|
|
224
283
|
---
|
|
225
284
|
|
|
@@ -479,7 +538,7 @@ ALTER TABLE sessions ADD COLUMN ended_at INTEGER;
|
|
|
479
538
|
### Phase 2 — Provider Gateway
|
|
480
539
|
- [x] Implement proxy API on Rev4a (`/api/provider/v1/`)
|
|
481
540
|
- [x] Gateway Page (`/gateway`) for provider key management
|
|
482
|
-
- [x] Agent model configuration
|
|
541
|
+
- [x] Agent model configuration in the agent detail panel
|
|
483
542
|
- [x] Provider sync to agent containers (`PUT /api/gateway/provider`)
|
|
484
543
|
- [x] Auth via rev4a token in `data/provider-keys.json`
|
|
485
544
|
- [ ] Rate limiting per agent
|
|
@@ -568,7 +627,7 @@ for the full rationale.
|
|
|
568
627
|
| Database | SQLite (WAL mode) | Current `events.db`, may need to scale |
|
|
569
628
|
| Memory | Per-agent SQLite + central index | New |
|
|
570
629
|
| Config | Generated YAML/JSON | New |
|
|
571
|
-
|
|
|
630
|
+
| Credential stores | SQLite + JSON | credentials.db for service tokens, provider-keys.json for provider API keys. Neither is encrypted |
|
|
572
631
|
| Reverse proxy | Traefik | Already in use |
|
|
573
632
|
| Monitoring | Rev4a daemon (extended) | Evolution of current daemon |
|
|
574
633
|
| Version control | Git + GitHub | `github.com/Flame0510/rev4a.git` |
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Rev4a Frontend Architecture
|
|
2
2
|
|
|
3
|
-
> **Last updated:** 2026-
|
|
3
|
+
> **Last updated:** 2026-09-14
|
|
4
4
|
|
|
5
5
|
## Layering
|
|
6
6
|
|
|
@@ -24,7 +24,8 @@ All shared UI primitives live in `app/components/ui/` and are exported from `app
|
|
|
24
24
|
|-----------|------|---------|
|
|
25
25
|
| `Button` | `Button.tsx` | Action button, 5 variants (`primary`, `secondary`, `danger`, `ghost`, `success`), 2 sizes (`sm`, `md`), loading spinner. |
|
|
26
26
|
| `Input` | `Input.tsx` | Text input with label, error state, placeholder. |
|
|
27
|
-
| `Select` | `Select.tsx` | Native select with typed options, label, error state. |
|
|
27
|
+
| `Select` | `Select.tsx` | Native select with typed options, label, error state. An option can be `disabled`, to display a current value that may not be chosen again. |
|
|
28
|
+
| `RemoveButton` | `RemoveButton.tsx` | The `×` that removes an item from a list — one `danger` style everywhere. Whether the removal is immediate or pending goes in `title`, not in the colour. Not for dismissing dialogs — see rule 8. |
|
|
28
29
|
| `Modal` | `Modal.tsx` | Overlay modal, Escape-to-close, maxWidth prop, `type="button"` on close. |
|
|
29
30
|
| `Toast` | `Toast.tsx` | Lightweight toast notification with auto-dismiss (4s), `success` / `error` variants. |
|
|
30
31
|
| `LoadingSpinner` | `LoadingSpinner.tsx` | Inline or fullscreen spinner. |
|
|
@@ -41,11 +42,14 @@ All shared UI primitives live in `app/components/ui/` and are exported from `app
|
|
|
41
42
|
| `Page` / `PageHeader` | `Page.tsx` | Full-page layout shell. |
|
|
42
43
|
| `Icons` | `Icons.tsx` | SVG icons (`EyeIcon`, `EyeOffIcon`), 16/20px shared. |
|
|
43
44
|
| `PasswordInput` | `PasswordInput.tsx` | Password input with inline show/hide toggle (`<button type="button">` with `aria-label`). |
|
|
44
|
-
| `
|
|
45
|
-
| `
|
|
46
|
-
| `
|
|
45
|
+
| `ModelSection` | `app/agents/ModelSection.tsx` | Primary model and fallbacks for one agent, in its detail panel. Explicit save, no restart. The model is a property of the agent, not of the gateway. Tags models the catalogue marks `deprecated`. |
|
|
46
|
+
| `ModelsProvider` / `useModels` | `app/lib/models-context.tsx` | The client's single model list, from `/api/models`. Whatever changes what is offered calls `refresh()`: the Gateway page after a toggle, a key save or removal, or a sync, and the first-run wizard after saving keys. Everything else only reads, PulseChat included. |
|
|
47
|
+
| `WizardPageClient` + step components | `app/wizard/PageClient.tsx` | Multi-step first-run wizard plus its frame shell, with mobile-first CSS. |
|
|
48
|
+
| `useWizard` | `app/wizard/useWizard.ts` | Shared hook: wizard state, step transitions, provider save, restart. Receives server-side initial state to avoid a loading flash. |
|
|
49
|
+
| Wizard icons | `app/wizard/icons.tsx` | Shared SVG icons (flyweight pattern): `ArrowRightIcon`, `CheckIcon`, `CheckCircleIcon`, `DockerIcon`, `GatewayIcon`, `AgentIcon`, `TemplateIcon`, `LinkIcon`, `ConfigIcon`, `InformationIcon`. |
|
|
47
50
|
| `tokens` | `tokens.ts` | TypeScript types for `Tone` and related token values. |
|
|
48
51
|
| `Badge` | `Badge.tsx` | Inline status tag with tone variants (success, danger, warning, neutral). Used for channel chips, pairing labels, error/success messages. |
|
|
52
|
+
| `Skeleton` + shape helpers | `app/components/Skeleton.tsx` | Shimmer placeholders. `Skeleton` is the primitive; the rest mirror one specific layout each: `TreeSkeleton`, `CodeSkeleton`, `CronJobsSkeleton`, `CronRunsSkeleton`, `CredentialCardsSkeleton`, `AgentSyncRowsSkeleton`, `SkeletonLines`, `SkeletonMetric`, `CardRowSkeleton`. A shape helper must match the real markup it stands in for — same row structure, same paddings, same element count where the count is known — so nothing reflows when data replaces it. |
|
|
49
53
|
|
|
50
54
|
### Rules
|
|
51
55
|
|
|
@@ -53,10 +57,33 @@ All shared UI primitives live in `app/components/ui/` and are exported from `app
|
|
|
53
57
|
2. **No inline styles on interactive elements.** Buttons, inputs, selects use the `app/components/ui/*` component with component props. Only layout/wrapping containers use inline style.
|
|
54
58
|
3. **No Italian in code, labels, or comments.** UI strings, error messages, aria-labels — all English.
|
|
55
59
|
4. **Clickable/custom interactive elements must be `<button type="button">`.** Not `<span onClick>`, not `<div onClick>`. Always include `aria-label` for icon-only buttons.
|
|
60
|
+
4a. **A control gated on another field stays visible and disabled** — never conditionally unmounted. Rendering it only once its dependency is filled makes it appear out of nowhere, and any hint that references it is pointing at something not on screen. Show it disabled, with the reason beside it, so the shape of the form is stable from the first render.
|
|
56
61
|
5. **Modals inside forms** — close button has `type="button"` to prevent accidental form submission.
|
|
57
|
-
6. **Loading buttons** — set `loading={true}` on `Button`, do not render separate loaders next to the button. The spinner is built-in.
|
|
62
|
+
6. **Loading buttons** — set `loading={true}` on `Button`, do not render separate loaders next to the button. The spinner is built-in. The label must also change for the duration (`Saving…`, `Deleting…`, `Syncing…`).
|
|
63
|
+
6a. **One in-flight mutation, keyed.** Hold a single `busy` string identifying the running action (`delete:<id>`, `sync:<id>:<container>`), plus a `useRef` mirror as the authoritative guard — state is read from the render that produced the click, so two fast clicks would otherwise both pass. `loading` compares against the exact key; `disabled` is set for any busy value, so a control that would be refused looks refused. Never key a mutation on one dimension when the same control is rendered per row of another: a key holding only a container name puts every profile's button for that container into a spinner.
|
|
64
|
+
6b. **Do not hold the lock across a slow refresh.** Release it when the mutation itself completes and refresh derived state afterwards, unawaited. Show the pending state by fading the stale values only — never the buttons, since `opacity` on an ancestor cannot be undone by a child and makes live controls read as disabled.
|
|
65
|
+
6c. **An optimistic update moves every field the render derives from, together.** A row that decides its state by reading two fields against each other flips to a third, wrong state if the update touches only one of them — and holds it until the slow refresh lands. Update the whole set the derivation reads, or none of it.
|
|
58
66
|
7. **New UI component** — add it to `app/components/ui/`, export from `index.ts`, document it here. If it's specific to one page, keep it page-local unless another page needs it.
|
|
59
67
|
|
|
68
|
+
8. **`×` means remove, `✕` means dismiss.** They look alike and are not the same
|
|
69
|
+
action. Removing an item from a list uses `RemoveButton`; dismissing a dialog
|
|
70
|
+
is the `Modal` component's own close control. Do not build a third variant of
|
|
71
|
+
either, and do not reuse one for the other.
|
|
72
|
+
|
|
73
|
+
*Outstanding:* four dialogs predate the shared `Modal` and roll their own close
|
|
74
|
+
control — the edit dialog and the `openclaw.json` dialog in
|
|
75
|
+
`app/agents/PageClient.tsx` (both a `ghost` Button with `✕`), the drawer in
|
|
76
|
+
`app/components/SessionDrawer.tsx` (a bare `<button>`), and
|
|
77
|
+
`app/components/ModelPickerModal.tsx` (`model-picker__close`, which compounds
|
|
78
|
+
the problem by using `×`, the *remove* glyph, to dismiss, and omits
|
|
79
|
+
`type="button"`). The fix is to move them onto `Modal`, not to extract a
|
|
80
|
+
`CloseButton`: they also reimplement Escape-to-close and overlay behaviour.
|
|
81
|
+
`ModelPickerModal` has no importers at all, so deleting it is the cheaper fix
|
|
82
|
+
there.
|
|
83
|
+
|
|
84
|
+
Separately, `app/agents/PageClient.tsx` has a raw `×` delete-backup button with
|
|
85
|
+
no accessible name that should be `RemoveButton`.
|
|
86
|
+
|
|
60
87
|
## GoF pattern mapping
|
|
61
88
|
|
|
62
89
|
Already used:
|
|
@@ -108,6 +135,9 @@ For interactive pages that can change view/file/tab quickly:
|
|
|
108
135
|
2. Guard against stale updates after `await` boundaries.
|
|
109
136
|
3. On navigation/context switch, kill in-flight requests before starting new ones.
|
|
110
137
|
4. Mobile layout changes must not wait on network completion.
|
|
138
|
+
5. After a mutation, refresh the cheap endpoint and await it; fire the expensive one without awaiting. A page that re-reads everything makes the user wait on work their change did not need.
|
|
139
|
+
6. A refresh helper invoked from a mutation must never reject. The mutation has already succeeded by then, so a failed re-read surfacing as the caller's error reports a completed action as failed.
|
|
140
|
+
7. Check `res.ok` before reading the body. An error response is still valid JSON, so `json.items ?? []` silently turns a 401 into an empty result presented as fact.
|
|
111
141
|
|
|
112
142
|
## Migration rule
|
|
113
143
|
|