@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
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Modality badges for a model.
|
|
5
|
+
*
|
|
6
|
+
* Reads the `modality` string that ships in models.config.json (and matches
|
|
7
|
+
* OpenRouter's `architecture.modality` format verbatim), e.g.
|
|
8
|
+
* "text+image+file->text" → accepts text, images, files
|
|
9
|
+
* "text+image+file+audio+video->text+image" → also *produces* images
|
|
10
|
+
*
|
|
11
|
+
* Inputs are shown as icons; a non-text output is called out separately,
|
|
12
|
+
* since "can read an image" and "can generate an image" are very different
|
|
13
|
+
* capabilities and collapsing them would be misleading.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
interface ModalitySpec {
|
|
17
|
+
label: string;
|
|
18
|
+
icon: React.ReactNode;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const ICONS: Record<string, ModalitySpec> = {
|
|
22
|
+
text: {
|
|
23
|
+
label: 'Text',
|
|
24
|
+
icon: (
|
|
25
|
+
<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round">
|
|
26
|
+
<path d="M3 4h10M3 8h10M3 12h6" />
|
|
27
|
+
</svg>
|
|
28
|
+
),
|
|
29
|
+
},
|
|
30
|
+
image: {
|
|
31
|
+
label: 'Images',
|
|
32
|
+
icon: (
|
|
33
|
+
<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.4">
|
|
34
|
+
<rect x="2" y="3" width="12" height="10" rx="1.5" />
|
|
35
|
+
<circle cx="6" cy="6.5" r="1.2" />
|
|
36
|
+
<path d="M3 11.5l3.2-3 2.3 2.2 2-1.8 2.5 2.6" strokeLinecap="round" strokeLinejoin="round" />
|
|
37
|
+
</svg>
|
|
38
|
+
),
|
|
39
|
+
},
|
|
40
|
+
file: {
|
|
41
|
+
label: 'Files / PDF',
|
|
42
|
+
icon: (
|
|
43
|
+
<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinejoin="round">
|
|
44
|
+
<path d="M9 2H4.5A1.5 1.5 0 0 0 3 3.5v9A1.5 1.5 0 0 0 4.5 14h7a1.5 1.5 0 0 0 1.5-1.5V6z" />
|
|
45
|
+
<path d="M9 2v4h4" />
|
|
46
|
+
</svg>
|
|
47
|
+
),
|
|
48
|
+
},
|
|
49
|
+
audio: {
|
|
50
|
+
label: 'Audio',
|
|
51
|
+
icon: (
|
|
52
|
+
<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round">
|
|
53
|
+
<path d="M8 2.5v11" />
|
|
54
|
+
<path d="M5 5.5v5M11 5.5v5" />
|
|
55
|
+
<path d="M2.5 7.5v1M13.5 7.5v1" />
|
|
56
|
+
</svg>
|
|
57
|
+
),
|
|
58
|
+
},
|
|
59
|
+
video: {
|
|
60
|
+
label: 'Video',
|
|
61
|
+
icon: (
|
|
62
|
+
<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinejoin="round">
|
|
63
|
+
<rect x="2" y="4" width="8.5" height="8" rx="1.5" />
|
|
64
|
+
<path d="M10.5 8l3.5-2v6l-3.5-2z" />
|
|
65
|
+
</svg>
|
|
66
|
+
),
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export interface ParsedModality {
|
|
71
|
+
inputs: string[];
|
|
72
|
+
outputs: string[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Split "text+image->text+image" into its input and output lists. */
|
|
76
|
+
export function parseModality(modality: string | undefined | null): ParsedModality | null {
|
|
77
|
+
if (!modality || typeof modality !== 'string') return null;
|
|
78
|
+
const [rawIn, rawOut] = modality.split('->');
|
|
79
|
+
if (!rawIn) return null;
|
|
80
|
+
const split = (s: string) => s.split('+').map((p) => p.trim()).filter(Boolean);
|
|
81
|
+
return { inputs: split(rawIn), outputs: rawOut ? split(rawOut) : [] };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Own-property lookup: a token like "constructor" must not resolve to Object.prototype. */
|
|
85
|
+
function iconFor(token: string): ModalitySpec | null {
|
|
86
|
+
return Object.hasOwn(ICONS, token) ? ICONS[token] : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface ModalityIconsProps {
|
|
90
|
+
modality?: string | null;
|
|
91
|
+
/**
|
|
92
|
+
* Router models (OpenRouter Auto) advertise the union of everything they
|
|
93
|
+
* might route to, which reads as a guarantee it isn't. They already show
|
|
94
|
+
* "Dynamic" for price; capabilities get the same treatment rather than
|
|
95
|
+
* concrete icons.
|
|
96
|
+
*/
|
|
97
|
+
dynamic?: boolean;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export default function ModalityIcons({ modality, dynamic }: ModalityIconsProps) {
|
|
101
|
+
if (dynamic) {
|
|
102
|
+
return (
|
|
103
|
+
<span
|
|
104
|
+
title="Routed to a different model per request — capabilities vary"
|
|
105
|
+
style={{ fontSize: 10, color: 'var(--text-dim)', fontStyle: 'italic' }}
|
|
106
|
+
>
|
|
107
|
+
varies
|
|
108
|
+
</span>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const parsed = parseModality(modality);
|
|
113
|
+
if (!parsed) return null;
|
|
114
|
+
|
|
115
|
+
// Anything the model can produce beyond plain text is worth surfacing:
|
|
116
|
+
// only 15 of 422 upstream models do, and "reads images" vs "makes images"
|
|
117
|
+
// are very different capabilities.
|
|
118
|
+
const generates = parsed.outputs.filter((o) => o !== 'text');
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 5, verticalAlign: 'middle' }}>
|
|
122
|
+
{parsed.inputs.map((input) => {
|
|
123
|
+
const spec = iconFor(input);
|
|
124
|
+
if (!spec) return null;
|
|
125
|
+
return (
|
|
126
|
+
<span
|
|
127
|
+
key={input}
|
|
128
|
+
title={`Accepts ${spec.label.toLowerCase()}`}
|
|
129
|
+
aria-label={`Accepts ${spec.label.toLowerCase()}`}
|
|
130
|
+
style={{ display: 'inline-flex', color: 'var(--text-dim)', opacity: 0.75 }}
|
|
131
|
+
>
|
|
132
|
+
{spec.icon}
|
|
133
|
+
</span>
|
|
134
|
+
);
|
|
135
|
+
})}
|
|
136
|
+
|
|
137
|
+
{generates.map((out) => {
|
|
138
|
+
const spec = iconFor(out);
|
|
139
|
+
if (!spec) return null;
|
|
140
|
+
return (
|
|
141
|
+
<span
|
|
142
|
+
key={`out-${out}`}
|
|
143
|
+
title={`Generates ${spec.label.toLowerCase()}`}
|
|
144
|
+
aria-label={`Generates ${spec.label.toLowerCase()}`}
|
|
145
|
+
style={{
|
|
146
|
+
display: 'inline-flex', alignItems: 'center', gap: 2,
|
|
147
|
+
color: 'var(--violet)', fontSize: 9, fontWeight: 600,
|
|
148
|
+
}}
|
|
149
|
+
>
|
|
150
|
+
<span aria-hidden="true">→</span>
|
|
151
|
+
{spec.icon}
|
|
152
|
+
</span>
|
|
153
|
+
);
|
|
154
|
+
})}
|
|
155
|
+
</span>
|
|
156
|
+
);
|
|
157
|
+
}
|
package/app/crons/PageClient.tsx
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { useEffect, useState, useRef } from 'react';
|
|
4
4
|
import { useResponsive } from '../design-system';
|
|
5
5
|
import { Button, Pill } from '../components/ui';
|
|
6
|
-
import {
|
|
6
|
+
import { CronJobsSkeleton, CronRunsSkeleton } from '../components/Skeleton';
|
|
7
7
|
import { apiFetch } from '@/lib/apiFetch';
|
|
8
8
|
import { useRev4aTimezone } from '@/lib/hooks/useRev4aTimezone';
|
|
9
9
|
import { formatDateTimeInTimezone } from '@/lib/timezone';
|
|
@@ -266,9 +266,28 @@ export default function CronsPage() {
|
|
|
266
266
|
|
|
267
267
|
useEffect(() => {
|
|
268
268
|
const controller = new AbortController();
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
269
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
270
|
+
|
|
271
|
+
// Chained setTimeout rather than setInterval, deliberately.
|
|
272
|
+
//
|
|
273
|
+
// GET /api/crons runs `openclaw cron list --json` inside every agent
|
|
274
|
+
// container. That command takes ~5s per agent and the route walks them in
|
|
275
|
+
// sequence, so the response time scales with the number of agents and is
|
|
276
|
+
// already well past 15s on a multi-agent host. setInterval fires on schedule
|
|
277
|
+
// whether or not the previous call has come back, so those requests pile up
|
|
278
|
+
// — and since the route blocks the server's event loop while it runs, the
|
|
279
|
+
// whole dashboard stops answering for as long as this page stays open.
|
|
280
|
+
//
|
|
281
|
+
// Scheduling the next run only once the current one has settled turns the
|
|
282
|
+
// delay into a gap between polls instead of a deadline, so the loop can
|
|
283
|
+
// never outrun the endpoint no matter how slow it gets.
|
|
284
|
+
const poll = async () => {
|
|
285
|
+
await load(controller.signal);
|
|
286
|
+
if (!controller.signal.aborted) timer = setTimeout(poll, 15_000);
|
|
287
|
+
};
|
|
288
|
+
void poll();
|
|
289
|
+
|
|
290
|
+
return () => { controller.abort(); if (timer) clearTimeout(timer); };
|
|
272
291
|
}, []);
|
|
273
292
|
|
|
274
293
|
return (
|
|
@@ -278,7 +297,9 @@ export default function CronsPage() {
|
|
|
278
297
|
<span className="crons-header__meta">{runs.length} runs loaded</span>
|
|
279
298
|
</div>
|
|
280
299
|
|
|
281
|
-
{
|
|
300
|
+
{/* No loader here: the skeleton lives inside each column, so the page keeps
|
|
301
|
+
its real layout while loading instead of showing a stack of bars above
|
|
302
|
+
two empty panels. */}
|
|
282
303
|
|
|
283
304
|
{/* Agent filter bar */}
|
|
284
305
|
{agentFilters.length > 1 && (
|
|
@@ -307,9 +328,12 @@ export default function CronsPage() {
|
|
|
307
328
|
{/* Scheduled jobs */}
|
|
308
329
|
<section style={{ width: isMobile ? '100%' : '40%', borderRight: isMobile ? 'none' : '1px solid var(--border)', display: isMobile && tab !== 'jobs' ? 'none' : 'flex', flexDirection: 'column', minHeight: 0 }}>
|
|
309
330
|
<div style={{ padding: '8px 12px', borderBottom: '1px solid var(--border)', fontSize: 10, color: '#555', flexShrink: 0 }}>
|
|
310
|
-
|
|
331
|
+
{/* Hide the count on the cold load — "(0)" beside a skeleton reads as
|
|
332
|
+
"no jobs", which is precisely what isn't known yet. */}
|
|
333
|
+
SCHEDULED JOBS{loading && filteredJobs.length === 0 ? '' : ` (${filteredJobs.length})`}
|
|
311
334
|
</div>
|
|
312
335
|
<div style={{ flex: 1, overflowY: 'auto' }}>
|
|
336
|
+
{loading && filteredJobs.length === 0 && <CronJobsSkeleton />}
|
|
313
337
|
{filteredJobs.length === 0 && !loading && (
|
|
314
338
|
<div style={{ padding: '20px 12px', fontSize: 11, color: '#555' }}>No scheduled jobs found</div>
|
|
315
339
|
)}
|
|
@@ -471,10 +495,11 @@ export default function CronsPage() {
|
|
|
471
495
|
RUN HISTORY {selectedJob ? `— ${selectedJob.name ?? selectedJob.id?.slice(0, 8) ?? ''}` : ''}
|
|
472
496
|
</div>
|
|
473
497
|
<div style={{ flex: 1, overflowY: 'auto' }}>
|
|
498
|
+
{loading && !selectedJob && <CronRunsSkeleton />}
|
|
474
499
|
{!selectedJob && !loading && (
|
|
475
500
|
<div style={{ padding: '20px 12px', fontSize: 11, color: '#555' }}>Select a cron job to view its run history</div>
|
|
476
501
|
)}
|
|
477
|
-
{selectedJob && runsLoading && <
|
|
502
|
+
{selectedJob && runsLoading && <CronRunsSkeleton />}
|
|
478
503
|
{selectedJob && !runsLoading && runsError && (
|
|
479
504
|
<div style={{ padding: '20px 12px', fontSize: 11, color: 'var(--red)' }}>{runsError}</div>
|
|
480
505
|
)}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
4
|
-
import { Button, ConfirmModal, LoadingSpinner, Metric, Page, PageHeader, Pill, Surface, Tabs, type Tone } from '../components/ui';
|
|
4
|
+
import { Button, ConfirmModal, Input, LoadingSpinner, Metric, ModalityIcons, Page, PageHeader, Pill, Surface, Tabs, type Tone } from '../components/ui';
|
|
5
5
|
import PasswordInput from '../components/PasswordInput';
|
|
6
6
|
import { Skeleton } from '../components/Skeleton';
|
|
7
7
|
|
|
@@ -14,6 +14,12 @@ interface ModelInfo {
|
|
|
14
14
|
id: string;
|
|
15
15
|
name: string;
|
|
16
16
|
enabled: boolean;
|
|
17
|
+
/** USD per 1M tokens. -1/-1 means the model routes dynamically. */
|
|
18
|
+
pricing?: { input: number; output: number };
|
|
19
|
+
/** Set when the price came from the live OpenRouter catalogue, not the static file. */
|
|
20
|
+
pricingLive?: boolean;
|
|
21
|
+
/** e.g. "text+image+file->text" — curated, matches what sync.ts provisions. */
|
|
22
|
+
modality?: string;
|
|
17
23
|
}
|
|
18
24
|
|
|
19
25
|
interface GatewayProvider {
|
|
@@ -345,6 +351,9 @@ export default function GatewayPageClient() {
|
|
|
345
351
|
const [revealedKeys, setRevealedKeys] = useState<Record<string, string | null>>({});
|
|
346
352
|
const [revealing, setRevealing] = useState<Record<string, boolean>>({});
|
|
347
353
|
|
|
354
|
+
// Per-provider model filter (purely local, no requests)
|
|
355
|
+
const [modelSearch, setModelSearch] = useState<Record<string, string>>({});
|
|
356
|
+
|
|
348
357
|
// Agent edit state
|
|
349
358
|
const [editingAgent, setEditingAgent] = useState<string | null>(null);
|
|
350
359
|
const [agentModelInput, setAgentModelInput] = useState('');
|
|
@@ -431,6 +440,24 @@ export default function GatewayPageClient() {
|
|
|
431
440
|
return `${f(p.input)} / ${f(p.output)}`;
|
|
432
441
|
}
|
|
433
442
|
|
|
443
|
+
/**
|
|
444
|
+
* Local model filter. Matches against both the display name and the id,
|
|
445
|
+
* because the upstream vendor often only appears in one of them — e.g.
|
|
446
|
+
* "Kimi K2 (OR)" has id `openrouter/moonshotai/kimi-k2`, so searching
|
|
447
|
+
* either "kimi" or "moonshot" has to find it.
|
|
448
|
+
*
|
|
449
|
+
* Every whitespace-separated term must match somewhere, so "kimi code"
|
|
450
|
+
* narrows rather than widening.
|
|
451
|
+
*/
|
|
452
|
+
function filterModels<T extends { id: string; name: string }>(models: T[], query: string): T[] {
|
|
453
|
+
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
454
|
+
if (terms.length === 0) return models;
|
|
455
|
+
return models.filter((m) => {
|
|
456
|
+
const haystack = `${m.name} ${m.id}`.toLowerCase();
|
|
457
|
+
return terms.every((t) => haystack.includes(t));
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
|
|
434
461
|
/* ---- Overlay helper ---- */
|
|
435
462
|
async function withOverlay<T>(message: string, fn: () => Promise<T>): Promise<T> {
|
|
436
463
|
setOverlay({ active: true, message });
|
|
@@ -904,33 +931,93 @@ export default function GatewayPageClient() {
|
|
|
904
931
|
)}
|
|
905
932
|
|
|
906
933
|
{/* Models with checkboxes */}
|
|
907
|
-
{p.models.length > 0 && (
|
|
934
|
+
{p.models.length > 0 && (() => {
|
|
935
|
+
const query = modelSearch[p.provider] || '';
|
|
936
|
+
const visibleModels = filterModels(p.models, query);
|
|
937
|
+
// Below this many, everything fits on screen and a
|
|
938
|
+
// search box is just noise.
|
|
939
|
+
const showSearch = p.models.length > 6;
|
|
940
|
+
return (
|
|
908
941
|
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 12 }}>
|
|
909
|
-
<div style={{
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
942
|
+
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 8, marginBottom: 8, flexWrap: 'wrap' }}>
|
|
943
|
+
<div style={{ fontSize: 11, color: 'var(--text-dim)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
|
944
|
+
Models
|
|
945
|
+
<span style={{ marginLeft: 8, textTransform: 'none', letterSpacing: 'normal', color: 'var(--violet)', opacity: 0.7, fontSize: 10 }}>
|
|
946
|
+
in / out per 1M tokens
|
|
947
|
+
</span>
|
|
948
|
+
</div>
|
|
949
|
+
{query && (
|
|
950
|
+
<span style={{ fontSize: 10, color: 'var(--text-dim)' }}>
|
|
951
|
+
{visibleModels.length} of {p.models.length}
|
|
952
|
+
</span>
|
|
953
|
+
)}
|
|
914
954
|
</div>
|
|
955
|
+
|
|
956
|
+
{showSearch && (
|
|
957
|
+
<div style={{ marginBottom: 8 }}>
|
|
958
|
+
{/* Deliberately type="text": Safari clears type="search"
|
|
959
|
+
on Escape via a `search` event React never sees,
|
|
960
|
+
leaving an empty box over a still-filtered list. */}
|
|
961
|
+
<Input
|
|
962
|
+
type="text"
|
|
963
|
+
value={query}
|
|
964
|
+
placeholder="Filter by name or provider — e.g. kimi, moonshot, flash"
|
|
965
|
+
aria-label={`Filter ${p.label} models`}
|
|
966
|
+
onChange={(e) => setModelSearch((prev) => ({ ...prev, [p.provider]: e.target.value }))}
|
|
967
|
+
/>
|
|
968
|
+
</div>
|
|
969
|
+
)}
|
|
970
|
+
|
|
915
971
|
<div style={{ display: 'grid', gap: 4, maxHeight: 360, overflowY: 'auto' }}>
|
|
916
|
-
{
|
|
972
|
+
{visibleModels.length === 0 && (
|
|
973
|
+
<div style={{ fontSize: 12, color: 'var(--text-dim)', padding: '8px 4px' }}>
|
|
974
|
+
No model matches “{query}”.
|
|
975
|
+
</div>
|
|
976
|
+
)}
|
|
977
|
+
{visibleModels.map((m) => {
|
|
917
978
|
const modelMsg = messages[`model-${m.id}`];
|
|
979
|
+
const pricing = m.pricing;
|
|
980
|
+
// -1/-1 is OpenRouter's marker for router models,
|
|
981
|
+
// whose price and capabilities both vary per call.
|
|
982
|
+
const isDynamic = pricing?.input === -1 && pricing?.output === -1;
|
|
918
983
|
return (
|
|
919
|
-
<label
|
|
920
|
-
|
|
984
|
+
// Only the name/id are inside the <label>: clicking
|
|
985
|
+
// anywhere in a label toggles its checkbox, and the
|
|
986
|
+
// badges below carry tooltips people will click to
|
|
987
|
+
// read — which would silently flip the model and
|
|
988
|
+
// restart every agent container.
|
|
989
|
+
<div key={m.id} style={{
|
|
990
|
+
display: 'flex', alignItems: 'center', gap: 10, padding: '5px 8px',
|
|
921
991
|
background: m.enabled ? 'rgba(var(--green-rgb,34,197,94),0.06)' : 'transparent',
|
|
922
|
-
|
|
992
|
+
fontSize: 12,
|
|
923
993
|
}}>
|
|
924
994
|
<input type="checkbox" checked={m.enabled} disabled={overlay.active}
|
|
995
|
+
id={`model-${m.id}`}
|
|
925
996
|
onChange={(e) => handleToggleModel(m.id, e.target.checked)}
|
|
926
997
|
style={{ accentColor: 'var(--green)', cursor: 'pointer' }} />
|
|
927
|
-
<div style={{ flex: 1 }}>
|
|
928
|
-
<div style={{
|
|
998
|
+
<div style={{ flex: 1, minWidth: 0 }}>
|
|
999
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
|
1000
|
+
<label
|
|
1001
|
+
htmlFor={`model-${m.id}`}
|
|
1002
|
+
style={{ fontWeight: m.enabled ? 500 : 400, cursor: overlay.active ? 'wait' : 'pointer' }}
|
|
1003
|
+
>
|
|
1004
|
+
{m.name}
|
|
1005
|
+
</label>
|
|
1006
|
+
<ModalityIcons modality={m.modality} dynamic={isDynamic} />
|
|
1007
|
+
</div>
|
|
929
1008
|
<div style={{ fontSize: 11, color: 'var(--text-dim)', fontFamily: 'monospace' }}>
|
|
930
1009
|
{m.id}
|
|
931
1010
|
<span style={{ marginLeft: 8, color: 'var(--violet)', fontWeight: 500 }}>
|
|
932
|
-
{fmtPrice(
|
|
1011
|
+
{fmtPrice(pricing)}
|
|
933
1012
|
</span>
|
|
1013
|
+
{m.pricingLive && (
|
|
1014
|
+
<span
|
|
1015
|
+
title="Live price from the OpenRouter API"
|
|
1016
|
+
style={{ marginLeft: 6, display: 'inline-block', verticalAlign: 'middle' }}
|
|
1017
|
+
>
|
|
1018
|
+
<Pill tone="success">LIVE</Pill>
|
|
1019
|
+
</span>
|
|
1020
|
+
)}
|
|
934
1021
|
</div>
|
|
935
1022
|
</div>
|
|
936
1023
|
{modelMsg && (
|
|
@@ -938,12 +1025,13 @@ export default function GatewayPageClient() {
|
|
|
938
1025
|
{modelMsg.text}
|
|
939
1026
|
</span>
|
|
940
1027
|
)}
|
|
941
|
-
</
|
|
1028
|
+
</div>
|
|
942
1029
|
);
|
|
943
1030
|
})}
|
|
944
1031
|
</div>
|
|
945
1032
|
</div>
|
|
946
|
-
|
|
1033
|
+
);
|
|
1034
|
+
})()}
|
|
947
1035
|
|
|
948
1036
|
{msg && (
|
|
949
1037
|
<div className={msg.type === 'success' ? 'msg-success' : 'msg-error'}>
|
package/bin/rev4a.js
CHANGED
|
@@ -29,8 +29,14 @@ const os = require('os');
|
|
|
29
29
|
const ROOT = join(__dirname, '..');
|
|
30
30
|
const NEXT_DIR = join(ROOT, '.next');
|
|
31
31
|
const PKG_JSON = join(ROOT, 'package.json');
|
|
32
|
-
// Config lives outside the npm package so it survives npm update / reinstall
|
|
33
|
-
|
|
32
|
+
// Config lives outside the npm package so it survives npm update / reinstall.
|
|
33
|
+
// REV4A_DATA_DIR must be honoured here exactly as rev4aDataDir() honours it in
|
|
34
|
+
// lib/rev4a-paths.ts: this file creates the shared directories that the app then
|
|
35
|
+
// bind-mounts into agents, so a divergence would have serve writing the system
|
|
36
|
+
// rules to one path while every agent mounted another — silently empty.
|
|
37
|
+
// It can only come from the real process environment, never from the .env file:
|
|
38
|
+
// that file is looked up under this very directory.
|
|
39
|
+
const CONFIG_DIR = process.env.REV4A_DATA_DIR || join(os.homedir(), '.config', 'rev4a');
|
|
34
40
|
const DATA_DIR = join(CONFIG_DIR, 'data');
|
|
35
41
|
const ENV_SOURCE = join(CONFIG_DIR, '.env');
|
|
36
42
|
const ENV_LINK = join(ROOT, '.env');
|
|
@@ -213,14 +219,26 @@ function ensureNetwork() {
|
|
|
213
219
|
// ── Rev4a system rules ───────────────────────────────────────────────────
|
|
214
220
|
|
|
215
221
|
function ensureRev4aRules() {
|
|
216
|
-
//
|
|
217
|
-
//
|
|
222
|
+
// These must mirror SHARED_SKILLS_DIR / REV4A_RULES_DIR in lib/rev4a-paths.ts.
|
|
223
|
+
// This file is CommonJS and runs before .next exists (it is what builds it),
|
|
224
|
+
// so it cannot import from lib/ — same reason CONFIG_DIR is recomputed above.
|
|
225
|
+
//
|
|
226
|
+
// It previously tried `require('.next/server/lib/agent-setup.js')` to reuse
|
|
227
|
+
// ensureHostDirs(). Next bundles route code and never emits that path, so the
|
|
228
|
+
// require always threw into an empty catch and the directories were never
|
|
229
|
+
// created here at all.
|
|
230
|
+
const sharedDir = join(CONFIG_DIR, 'shared');
|
|
231
|
+
const sharedSkillsDir = join(sharedDir, 'shared-skills');
|
|
232
|
+
const rev4aRulesDir = join(sharedDir, 'rev4a-rules');
|
|
233
|
+
|
|
218
234
|
try {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
} catch {
|
|
235
|
+
mkdirSync(sharedSkillsDir, { recursive: true });
|
|
236
|
+
mkdirSync(rev4aRulesDir, { recursive: true });
|
|
237
|
+
} catch (err) {
|
|
238
|
+
log('RULES', `⚠ Could not create shared directories: ${err.message}`);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
222
241
|
|
|
223
|
-
const rev4aRulesDir = '/docker/rev4a-rules';
|
|
224
242
|
const ruleFile = join(rev4aRulesDir, 'AGENTS.md');
|
|
225
243
|
const source = join(ROOT, 'rev4a-rules', 'AGENTS.md');
|
|
226
244
|
|
|
@@ -241,14 +259,10 @@ function ensureRev4aRules() {
|
|
|
241
259
|
}
|
|
242
260
|
|
|
243
261
|
try {
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
}
|
|
248
|
-
const tmpFile = `/tmp/rev4a-rules-${Date.now()}.md`;
|
|
249
|
-
writeFileSync(tmpFile, sourceContent, 'utf-8');
|
|
250
|
-
execSync(`${sudo}cp ${tmpFile} ${ruleFile} && ${sudo}rm ${tmpFile}`);
|
|
251
|
-
log('RULES', 'Deployed rev4a system rules to /docker/rev4a-rules/');
|
|
262
|
+
// Direct write — the old /tmp + `sudo cp` + `sudo rm` dance was only
|
|
263
|
+
// needed because the destination sat outside the user's home.
|
|
264
|
+
writeFileSync(ruleFile, sourceContent, 'utf-8');
|
|
265
|
+
log('RULES', `Deployed rev4a system rules to ${rev4aRulesDir}/`);
|
|
252
266
|
} catch (err) {
|
|
253
267
|
log('RULES', `⚠ Failed to deploy rev4a rules: ${err.message}`);
|
|
254
268
|
}
|
|
@@ -539,14 +553,11 @@ const args = process.argv.slice(2);
|
|
|
539
553
|
const cmd = args[0];
|
|
540
554
|
|
|
541
555
|
const BANNER = `
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
███████╔╝ ██████╗ ██║ ██║
|
|
548
|
-
╚══════╝ ╚═════╝ ╚═╝ ╚═╝
|
|
549
|
-
|
|
556
|
+
████ █████ █ █ █ █ ███
|
|
557
|
+
█ █ █ █ █ █ █ █ █
|
|
558
|
+
████ ████ █ █ █████ █████
|
|
559
|
+
█ █ █ █ █ █ █ █
|
|
560
|
+
█ █ █████ █ █ █ █
|
|
550
561
|
`;
|
|
551
562
|
|
|
552
563
|
const HELP_TEXT = `
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
│ │
|
|
56
56
|
│ ┌──────────────────────────────────────────────────────────┐ │
|
|
57
57
|
│ │ SHARED VOLUMES │ │
|
|
58
|
-
│ │ • /
|
|
58
|
+
│ │ • ~/.config/rev4a/shared/shared-skills → all agents (ro) │ │
|
|
59
59
|
│ │ • /docker/shared-repos → shared project repos │ │
|
|
60
60
|
│ └──────────────────────────────────────────────────────────┘ │
|
|
61
61
|
│ └──────────────────────────────────────────────────────────┘ │
|
|
@@ -107,14 +107,14 @@ The central container, running the Next.js dashboard + orchestration API.
|
|
|
107
107
|
|
|
108
108
|
**Volume mounts (target):**
|
|
109
109
|
```
|
|
110
|
-
/path/to/rev4a/data
|
|
111
|
-
/var/run/docker.sock
|
|
112
|
-
/
|
|
113
|
-
/docker/shared-repos
|
|
110
|
+
/path/to/rev4a/data → /data (persistent)
|
|
111
|
+
/var/run/docker.sock → /var/run/docker.sock
|
|
112
|
+
~/.config/rev4a/shared/shared-skills → /data/.openclaw/shared-skills (ro)
|
|
113
|
+
/docker/shared-repos → /data/repos (rw)
|
|
114
114
|
```
|
|
115
115
|
|
|
116
116
|
**Rev4a System Rules** (`rev4a-rules/AGENTS.md`):
|
|
117
|
-
- Versioned in the repo, deployed to
|
|
117
|
+
- Versioned in the repo, deployed to `~/.config/rev4a/shared/rev4a-rules/` on every `rev4a serve` start
|
|
118
118
|
- Mounted read-only into every agent at `/root/.openclaw/workspace/.rev4a/AGENTS.md`
|
|
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
|
|
@@ -132,9 +132,9 @@ copies the entrypoint script, and applies baseline gateway config patches.
|
|
|
132
132
|
|
|
133
133
|
**Volume mounts (per agent):**
|
|
134
134
|
```
|
|
135
|
-
/
|
|
136
|
-
/
|
|
137
|
-
agent-{name}-data (named vol)
|
|
135
|
+
~/.config/rev4a/shared/shared-skills → /data/.openclaw/shared-skills (ro)
|
|
136
|
+
~/.config/rev4a/shared/rev4a-rules → /root/.openclaw/workspace/.rev4a (ro)
|
|
137
|
+
agent-{name}-data (named vol) → /root (rw, per-agent persistent state)
|
|
138
138
|
```
|
|
139
139
|
|
|
140
140
|
All shared volumes are defined centrally in `lib/agent-setup.ts`
|
|
@@ -147,7 +147,6 @@ and guaranteed at runtime by `applyRuntimeConfig()`.
|
|
|
147
147
|
AGENT_NAME=<agent-name>
|
|
148
148
|
AGENT_TEMPLATE=<template>
|
|
149
149
|
OPENCLAW_GATEWAY_TOKEN=<shared agent token>
|
|
150
|
-
AGENT_HOSTNAME=<host-ip>:<portStart>
|
|
151
150
|
TZ=Europe/Rome
|
|
152
151
|
```
|
|
153
152
|
|
|
@@ -287,9 +286,9 @@ GET /api/agents List all agents (Docker containers with AGENT
|
|
|
287
286
|
|
|
288
287
|
## 6. Shared Volumes
|
|
289
288
|
|
|
290
|
-
### Shared Skills (
|
|
289
|
+
### Shared Skills (`~/.config/rev4a/shared/shared-skills`)
|
|
291
290
|
```
|
|
292
|
-
/
|
|
291
|
+
~/.config/rev4a/shared/shared-skills/
|
|
293
292
|
my-custom-skill/ # promoted from an agent workspace
|
|
294
293
|
SKILL.md
|
|
295
294
|
another-skill/
|
|
@@ -306,12 +305,30 @@ This keeps the layout simple and avoids hierarchy debates at this stage.
|
|
|
306
305
|
**Auto-configuration:**
|
|
307
306
|
- The agent base image (`openclaw-agent-base`) pre-configures `skills.load.extraDirs: ["/data/.openclaw/shared-skills"]` so every new agent discovers shared skills automatically.
|
|
308
307
|
- Both create and recreate routes guarantee this via `applyRuntimeConfig()` from `lib/agent-setup.ts`.
|
|
309
|
-
-
|
|
308
|
+
- No manual setup: `rev4a serve` creates both shared directories during
|
|
309
|
+
preflight (`ensureRev4aRules()` in `bin/rev4a.js`). They live under
|
|
310
|
+
`REV4A_DATA`, so a plain `mkdir` suffices — no `sudo`, no permission
|
|
311
|
+
bits to set.
|
|
312
|
+
|
|
313
|
+
> These directories used to live at `/docker/shared-skills` and
|
|
314
|
+
> `/docker/rev4a-rules`, which needed `sudo` on Linux and could not exist at
|
|
315
|
+
> all on macOS — the root volume is a sealed APFS system volume, so `/docker`
|
|
316
|
+
> is not creatable even as root, and agent creation failed with
|
|
317
|
+
> `mounts denied`. Being under the user's home also puts them inside the
|
|
318
|
+
> directories Docker Desktop shares with its Linux VM by default, so bind
|
|
319
|
+
> mounts work with no extra configuration.
|
|
320
|
+
>
|
|
321
|
+
> Upgrading an existing install: move any promoted skills from
|
|
322
|
+
> `/docker/shared-skills` into the new location, then hit **Recreate** on each
|
|
323
|
+
> agent so its container picks up the new mount source. Containers freeze
|
|
324
|
+
> their mounts at creation time, so running agents keep using the old path
|
|
325
|
+
> until recreated. `rev4a-rules/AGENTS.md` needs no migration — it is
|
|
326
|
+
> regenerated from the package on every start.
|
|
310
327
|
|
|
311
328
|
### Rev4a System Rules (`rev4a-rules/`)
|
|
312
329
|
|
|
313
330
|
Versioned in the repo at `rev4a-rules/AGENTS.md`. Deployed to
|
|
314
|
-
|
|
331
|
+
`~/.config/rev4a/shared/rev4a-rules/` on every `rev4a serve` startup via
|
|
315
332
|
`ensureRev4aRules()` in `bin/rev4a.js`. Mounted read-only into every
|
|
316
333
|
agent container and injected into sessions through the
|
|
317
334
|
`bootstrap-extra-files` hook (glob `.rev4a/*.md`).
|
|
@@ -34,6 +34,7 @@ All shared UI primitives live in `app/components/ui/` and are exported from `app
|
|
|
34
34
|
| `PropertyList` | `PropertyList.tsx` | 2-column grid for label/value pairs. Children rendered as `<Fragment>` — do not wrap in extra `<div>`. |
|
|
35
35
|
| `TemplateOption` | `TemplateOption.tsx` | Agent template selection card with avatar, description, optional badge. Centralises create-agent card styling. |
|
|
36
36
|
| `Pill` | `Pill.tsx` | Small status/attribute label. Variants: `default`, `accent`. |
|
|
37
|
+
| `ModalityIcons` | `ModalityIcons.tsx` | Capability badges parsed from a model's `modality` string (`"text+image->text"`). Input types render as icons; a non-text **output** is called out separately, since reading an image and generating one are different capabilities. Pass `dynamic` for router models, which advertise the union of everything they might route to and so show "varies" instead. |
|
|
37
38
|
| `Metric` | `Metric.tsx` | Metric card with title, value, subtitle, tone. |
|
|
38
39
|
| `StatusCard` | `StatusCard.tsx` | Health/status report card. |
|
|
39
40
|
| `Surface` | `Surface.tsx` | Shared panel/card surface, variant prop. |
|