@flame0510/project-aether 1.5.1 → 1.6.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.
Files changed (44) hide show
  1. package/app/agents/ChannelManager.tsx +17 -5
  2. package/app/agents/PageClient.tsx +13 -3
  3. package/app/api/agents/download-image/route.ts +8 -0
  4. package/app/api/gateway/provider/route.ts +16 -10
  5. package/app/api/models/details/route.ts +30 -0
  6. package/app/api/system-health/route.ts +3 -3
  7. package/app/api/update-check/route.ts +23 -10
  8. package/app/components/SessionDrawer.tsx +4 -4
  9. package/app/components/VersionBanner.tsx +50 -33
  10. package/app/components/ui/Metric.tsx +7 -2
  11. package/app/gateway/ModelDetailsModal.tsx +322 -0
  12. package/app/gateway/PageClient.tsx +28 -10
  13. package/app/globals.css +4 -1
  14. package/app/lib/model-format.ts +21 -0
  15. package/bin/rev4a.js +6 -3
  16. package/daemon.js +3 -3
  17. package/docs/ARCHITECTURE.md +25 -3
  18. package/docs/FRONTEND-ARCHITECTURE.md +5 -3
  19. package/docs/REV4A.md +17 -0
  20. package/docs/dev/API-REFERENCE.md +41 -3
  21. package/docs/dev/GATEWAY.md +58 -0
  22. package/docs/rag/REV4A-OVERVIEW.md +7 -1
  23. package/docs/rag/WHAT-I-CAN-ANSWER.md +2 -1
  24. package/instrumentation.ts +11 -0
  25. package/lib/agent-edit-state.ts +25 -66
  26. package/lib/agent-job-state.ts +145 -0
  27. package/lib/agent-jobs-maintenance.ts +34 -0
  28. package/lib/agent-recreate-state.ts +24 -65
  29. package/lib/agent-restore-state.ts +25 -66
  30. package/lib/agent-restore.ts +28 -5
  31. package/lib/agent-update-state.ts +25 -61
  32. package/lib/channelManager.ts +12 -1
  33. package/lib/memory-context.ts +2 -2
  34. package/lib/model-catalogue.ts +17 -0
  35. package/lib/model-details.ts +124 -0
  36. package/lib/provider-labels.ts +21 -0
  37. package/model-details.json +16360 -0
  38. package/model-pricing.json +260 -24
  39. package/models.config.json +494 -10
  40. package/package.json +4 -2
  41. package/scripts/check-language.mjs +76 -0
  42. package/scripts/lib/model-upstream.mjs +61 -0
  43. package/scripts/model-info-suggest.mjs +149 -0
  44. package/scripts/refresh-model-pricing.mjs +180 -21
@@ -0,0 +1,322 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Everything known about one model: what it is, what it costs, what it can do and how it
5
+ * scores. Opened from the Gateway's model row and **fetched when it opens**
6
+ * (`GET /api/models/details`) — descriptions are long and one model is looked at at a
7
+ * time, so inlining them in `/api/models` would weigh down every page poll.
8
+ *
9
+ * Absent data is not shown at all: no "n/a" blocks, and no number without its source.
10
+ * Prices come from the owner of the model (`model-pricing.json`), specs, description and
11
+ * OpenRouter benchmarks from the generated `model-details.json`, and anything else from
12
+ * the catalogue's hand-written `info` block.
13
+ */
14
+ import { useEffect, useState } from 'react';
15
+ import { Button, LoadingSpinner, Metric, Modal, ModalityIcons, Pill, PropertyList } from '../components/ui';
16
+ import { formatParams, formatPrice } from '../lib/model-format';
17
+
18
+ /** Mirrors lib/model-details.ts: a client component cannot import a module that reads files. */
19
+ interface DetailsView {
20
+ id: string;
21
+ name: string;
22
+ provider: string;
23
+ providerLabel: string;
24
+ modality?: string;
25
+ enabled: boolean;
26
+ deprecated: boolean;
27
+ price: { input: number; output: number; source: 'openrouter' | 'vendor' } | null;
28
+ details: {
29
+ description?: string;
30
+ created?: string;
31
+ context?: number;
32
+ providerContext?: number;
33
+ maxOutput?: number;
34
+ tokenizer?: string;
35
+ instructType?: string;
36
+ /** Total parameter count, from the model's Hugging Face card (open weights only). */
37
+ params?: number;
38
+ paramsSource?: string;
39
+ /** Architecture facts from the model card (Hugging Face); open weights only. */
40
+ hf?: {
41
+ id: string;
42
+ total?: number;
43
+ byDtype?: Record<string, number>;
44
+ family?: string;
45
+ modelType?: string;
46
+ moe?: { experts?: number; perToken?: number; shared?: number };
47
+ layers?: number;
48
+ hidden?: number;
49
+ heads?: number;
50
+ kvHeads?: number;
51
+ vocab?: number;
52
+ context?: number;
53
+ vision?: boolean;
54
+ quantization?: string;
55
+ task?: string;
56
+ languages?: string[];
57
+ license?: string;
58
+ licenseName?: string;
59
+ published?: string;
60
+ updated?: string;
61
+ weightsBytes?: number;
62
+ tags?: string[];
63
+ downloads?: number;
64
+ likes?: number;
65
+ };
66
+ knowledgeCutoff?: string;
67
+ huggingFaceId?: string;
68
+ canonicalSlug?: string;
69
+ url?: string;
70
+ reasoning?: { mandatory?: boolean; default_enabled?: boolean; supported_efforts?: string[]; default_effort?: string };
71
+ supportedParameters?: string[];
72
+ moderated?: boolean;
73
+ benchmarks?: {
74
+ design_arena?: { arena?: string; category?: string; elo?: number; win_rate?: number; rank?: number }[];
75
+ artificial_analysis?: Record<string, number>;
76
+ };
77
+ } | null;
78
+ info: {
79
+ params?: string;
80
+ released?: string;
81
+ docUrl?: string;
82
+ knowledgeCutoff?: string;
83
+ benchmarks?: { name: string; value: number; source: string; asOf?: string }[];
84
+ notes?: string;
85
+ } | null;
86
+ asOf: string | null;
87
+ detailsSource: 'openrouter' | 'none';
88
+ }
89
+
90
+ const count = (n?: number): string | undefined => (typeof n === 'number' ? n.toLocaleString('en-US') : undefined);
91
+
92
+ /** "1,048,576" is unreadable in a small metric: 1.05M reads at a glance. */
93
+ function compactCount(n: number): string {
94
+ if (n >= 1e9) return `${(n / 1e9).toFixed(1)}B`;
95
+ if (n >= 1e6) return `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 2)}M`;
96
+ if (n >= 1e3) return `${Math.round(n / 1e3)}k`;
97
+ return String(n);
98
+ }
99
+
100
+ /** "BF16 6.9B · FP8 314.4B" — the weights' precision mix, biggest first. */
101
+ function dtypeMix(byDtype: Record<string, number>): string {
102
+ return Object.entries(byDtype)
103
+ .filter(([, n]) => n > 1e6)
104
+ .sort((a, b) => b[1] - a[1])
105
+ .map(([k, n]) => `${k.replace('F8_E4M3', 'FP8').replace('F32', 'FP32')} ${formatParams(n)}`)
106
+ .join(' · ');
107
+ }
108
+ /** `parallel_tool_calls` reads better as words in a chip. */
109
+ const human = (p: string): string => p.replace(/_/g, ' ');
110
+
111
+ export default function ModelDetailsModal({ modelId, onClose }: { modelId: string; onClose: () => void }) {
112
+ const [view, setView] = useState<DetailsView | null>(null);
113
+ const [error, setError] = useState<string | null>(null);
114
+ const [fullDescription, setFullDescription] = useState(false);
115
+
116
+ useEffect(() => {
117
+ const controller = new AbortController();
118
+ setView(null);
119
+ setError(null);
120
+ fetch(`/api/models/details?id=${encodeURIComponent(modelId)}`, { signal: controller.signal })
121
+ .then(async (res) => {
122
+ const data = await res.json().catch(() => null);
123
+ if (!res.ok) throw new Error((data as { error?: string } | null)?.error || `HTTP ${res.status}`);
124
+ return data as DetailsView;
125
+ })
126
+ .then(setView)
127
+ .catch((e: unknown) => { if ((e as Error)?.name !== 'AbortError') setError((e as Error).message); });
128
+ return () => controller.abort();
129
+ }, [modelId]);
130
+
131
+ const d = view?.details ?? null;
132
+ const info = view?.info ?? null;
133
+ const hf = d?.hf ?? null;
134
+ const reasoning = d?.reasoning;
135
+ const arenas = d?.benchmarks?.design_arena ?? [];
136
+ const aa = d?.benchmarks?.artificial_analysis;
137
+ const released = info?.released ?? hf?.published ?? d?.created;
138
+
139
+ return (
140
+ <Modal open onClose={onClose} title={view ? view.name : modelId} maxWidth={720}>
141
+ {error ? (
142
+ <div style={{ fontSize: 12, color: 'var(--red)' }}>{error}</div>
143
+ ) : !view ? (
144
+ <LoadingSpinner label="Loading model details…" />
145
+ ) : (
146
+ <div style={{ display: 'grid', gap: 4, fontSize: 12 }}>
147
+ {/* Identity */}
148
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
149
+ <Pill>{view.provider}</Pill>
150
+ {view.deprecated && <Pill tone="warning">deprecated</Pill>}
151
+ {view.enabled ? <Pill tone="success">enabled</Pill> : <Pill>disabled</Pill>}
152
+ {view.modality && <ModalityIcons modality={view.modality} />}
153
+ </div>
154
+ <div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-dim)', overflowWrap: 'anywhere' }}>
155
+ {view.id}
156
+ </div>
157
+
158
+ {d?.description && (
159
+ <div style={{ margin: '8px 0 0' }}>
160
+ <p style={{
161
+ fontSize: 12, lineHeight: 1.5, color: 'var(--text)', margin: 0,
162
+ ...(fullDescription ? {} : { display: '-webkit-box', WebkitLineClamp: 4, WebkitBoxOrient: 'vertical' as const, overflow: 'hidden' }),
163
+ }}>{d.description}</p>
164
+ {d.description.length > 340 && (
165
+ <Button variant="ghost" size="sm" onClick={() => setFullDescription(!fullDescription)}>
166
+ {fullDescription ? 'Show less' : 'Show more'}
167
+ </Button>
168
+ )}
169
+ </div>
170
+ )}
171
+
172
+ {(info?.notes || view.deprecated) && (
173
+ <>
174
+ <div style={{ fontSize: 10, color: 'var(--violet)', textTransform: 'uppercase', letterSpacing: '0.08em', margin: '12px 0 4px' }}>Status</div>
175
+ <div style={{ fontSize: 11, color: 'var(--yellow)' }}>
176
+ {info?.notes ?? 'Upstream has retired this id: it still answers, but is redirected.'}
177
+ </div>
178
+ </>
179
+ )}
180
+
181
+ {/* Price */}
182
+ <div style={{ fontSize: 10, color: 'var(--violet)', textTransform: 'uppercase', letterSpacing: '0.08em', margin: '12px 0 4px' }}>Price</div>
183
+ <PropertyList rows={[
184
+ { label: 'Input / output', value: formatPrice(view.price) },
185
+ ...(view.price ? [{ label: 'Per 1M tokens, from', value: view.providerLabel, dim: true }] : []),
186
+ ]} />
187
+
188
+ {/* Architecture: the factory facts from the model card, open weights only */}
189
+ {hf && (hf.total || hf.moe || hf.layers) ? (
190
+ <>
191
+ <div style={{ fontSize: 10, color: 'var(--violet)', textTransform: 'uppercase', letterSpacing: '0.08em', margin: '12px 0 4px' }}>Architecture</div>
192
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(108px, 1fr))', gap: 8, marginBottom: 8 }}>
193
+ {hf.total ? <Metric size="sm" title="Parameters" value={formatParams(hf.total)} /> : null}
194
+ {hf.moe ? <Metric size="sm" title="Experts" value={`${count(hf.moe.experts) ?? '?'}${hf.moe.perToken ? ` · ${hf.moe.perToken}/tok` : ''}`} /> : null}
195
+ {hf.layers ? <Metric size="sm" title="Layers" value={hf.layers} /> : null}
196
+ {hf.context ? <Metric size="sm" title="Max context" value={`${compactCount(hf.context)} tok`} /> : null}
197
+ </div>
198
+ <PropertyList rows={[
199
+ ...(hf.family ? [{ label: 'Family', value: hf.family }] : []),
200
+ ...(hf.modelType ? [{ label: 'Model type', value: hf.modelType, dim: true }] : []),
201
+ ...(hf.hidden ? [{ label: 'Hidden size', value: count(hf.hidden) ?? '' }] : []),
202
+ ...(hf.heads ? [{ label: 'Attention heads', value: `${hf.heads}${hf.kvHeads && hf.kvHeads !== hf.heads ? ` (${hf.kvHeads} KV)` : ''}` }] : []),
203
+ ...(hf.vocab ? [{ label: 'Vocabulary', value: count(hf.vocab) ?? '' }] : []),
204
+ ...(hf.vision ? [{ label: 'Vision encoder', value: 'yes (multimodal)' }] : []),
205
+ ...(hf.quantization ? [{ label: 'Precision', value: hf.quantization.toUpperCase() }] : []),
206
+ ...(hf.weightsBytes ? [{ label: 'Weights on disk', value: `~${hf.weightsBytes >= 1e12 ? `${(hf.weightsBytes / 1e12).toFixed(1)} TB` : `${Math.round(hf.weightsBytes / 1e9)} GB`}` }] : []),
207
+ ...(hf.license ? [{ label: 'Licence', value: hf.license === 'other' ? 'custom — see the model card' : `${hf.license.toUpperCase()}${hf.licenseName ? ` (${hf.licenseName})` : ''}` }] : []),
208
+ ...(hf.byDtype ? [{ label: 'Weights by dtype', value: dtypeMix(hf.byDtype), dim: true }] : []),
209
+ ...(hf.task ? [{ label: 'Task', value: human(hf.task) }] : []),
210
+ ...(hf.languages?.length ? [{ label: 'Languages', value: hf.languages.join(', ') }] : []),
211
+ ...(hf.downloads || hf.likes ? [{ label: 'Popularity', value: `${count(hf.downloads) ?? '—'} downloads · ${count(hf.likes) ?? '—'} likes`, dim: true }] : []),
212
+ ]} />
213
+ <div style={{ fontSize: 10, color: 'var(--text-dim)', marginTop: 4 }}>
214
+ From the model card on Hugging Face ({hf.id}).
215
+ </div>
216
+ </>
217
+ ) : null}
218
+
219
+ {/* Specs */}
220
+ <div style={{ fontSize: 10, color: 'var(--violet)', textTransform: 'uppercase', letterSpacing: '0.08em', margin: '12px 0 4px' }}>Specs</div>
221
+ <PropertyList rows={[
222
+ ...(released ? [{ label: 'Released', value: released }] : []),
223
+ ...(hf?.updated && hf.updated !== released ? [{ label: 'Card updated', value: hf.updated, dim: true }] : []),
224
+ ...(count(d?.context) ? [{ label: 'Context', value: `${count(d?.context)} tokens` }] : []),
225
+ ...(d?.providerContext && d.providerContext !== d.context ? [{ label: 'Provider limit', value: `${count(d.providerContext)} tokens`, dim: true }] : []),
226
+ ...(count(d?.maxOutput) ? [{ label: 'Max output', value: `${count(d?.maxOutput)} tokens` }] : []),
227
+ // The curated value can be more precise than the card (MoE total vs active);
228
+ // when there is none, the Hugging Face count is the honest number we have.
229
+ ...(info?.params
230
+ // The vendor's own framing (total/active), next to the card's total below:
231
+ // labelled, or the two numbers read as a contradiction.
232
+ ? [{ label: 'Parameters (vendor)', value: info.params }]
233
+ : []),
234
+ ...(info?.knowledgeCutoff || d?.knowledgeCutoff ? [{ label: 'Knowledge cutoff', value: info?.knowledgeCutoff ?? d?.knowledgeCutoff ?? '' }] : []),
235
+ ...(d?.tokenizer ? [{ label: 'Tokenizer', value: d.tokenizer, dim: true }] : []),
236
+ ...(d?.instructType ? [{ label: 'Instruct type', value: d.instructType, dim: true }] : []),
237
+ ...(d?.moderated ? [{ label: 'Moderated', value: 'yes' }] : []),
238
+ ]} />
239
+
240
+ {/* Reasoning */}
241
+ {reasoning && (
242
+ <>
243
+ <div style={{ fontSize: 10, color: 'var(--violet)', textTransform: 'uppercase', letterSpacing: '0.08em', margin: '12px 0 4px' }}>Reasoning</div>
244
+ <PropertyList rows={[
245
+ { label: 'Mode', value: reasoning.mandatory ? 'always reasons' : reasoning.default_enabled ? 'on by default' : 'optional' },
246
+ ...(reasoning.default_effort ? [{ label: 'Default effort', value: reasoning.default_effort }] : []),
247
+ ...(reasoning.supported_efforts?.length ? [{ label: 'Efforts', value: reasoning.supported_efforts.join(', ') }] : []),
248
+ ]} />
249
+ </>
250
+ )}
251
+
252
+ {/* Capabilities */}
253
+ {(d?.supportedParameters?.length || hf?.tags?.length) ? (
254
+ <>
255
+ <div style={{ fontSize: 10, color: 'var(--violet)', textTransform: 'uppercase', letterSpacing: '0.08em', margin: '12px 0 6px' }}>Capabilities</div>
256
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
257
+ {[...new Set([...(d?.supportedParameters ?? []), ...(hf?.tags ?? [])])].map((c) => <Pill key={c}>{human(c)}</Pill>)}
258
+ </div>
259
+ </>
260
+ ) : null}
261
+
262
+ {/* Benchmarks: OpenRouter's, then the curated ones */}
263
+ {aa || arenas.length > 0 || info?.benchmarks?.length ? (
264
+ <>
265
+ <div style={{ fontSize: 10, color: 'var(--violet)', textTransform: 'uppercase', letterSpacing: '0.08em', margin: '14px 0 6px' }}>Benchmarks</div>
266
+ {aa && (
267
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))', gap: 8, marginBottom: 8 }}>
268
+ {Object.entries(aa).map(([k, v]) => <Metric key={k} title={human(k)} value={v} />)}
269
+ </div>
270
+ )}
271
+ {arenas.length > 0 && (
272
+ <div style={{ display: 'grid', gap: 2 }}>
273
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 60px 50px 60px', gap: 6, fontSize: 10, color: 'var(--text-dim)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
274
+ <span>Design arena</span><span style={{ textAlign: 'right' }}>Elo</span><span style={{ textAlign: 'right' }}>Rank</span><span style={{ textAlign: 'right' }}>Win rate</span>
275
+ </div>
276
+ {arenas.map((a, i) => (
277
+ <div key={`${a.category ?? i}`} style={{ display: 'grid', gridTemplateColumns: '1fr 60px 50px 60px', gap: 6, fontSize: 11 }}>
278
+ <span>{human(String(a.category ?? a.arena ?? '—'))}</span>
279
+ <span style={{ textAlign: 'right', fontFamily: 'var(--font-mono)' }}>{a.elo ?? '—'}</span>
280
+ <span style={{ textAlign: 'right', fontFamily: 'var(--font-mono)' }}>{a.rank ? `#${a.rank}` : '—'}</span>
281
+ <span style={{ textAlign: 'right', fontFamily: 'var(--font-mono)' }}>{a.win_rate !== undefined ? `${a.win_rate}%` : '—'}</span>
282
+ </div>
283
+ ))}
284
+ <div style={{ fontSize: 10, color: 'var(--text-dim)', marginTop: 2 }}>OpenRouter arenas (community votes), as of the file's date below.</div>
285
+ </div>
286
+ )}
287
+ {info?.benchmarks?.length ? (
288
+ <div style={{ display: 'grid', gap: 3, marginTop: 8 }}>
289
+ {info.benchmarks.map((b) => (
290
+ <div key={b.name} style={{ fontSize: 11 }}>
291
+ <span style={{ fontFamily: 'var(--font-mono)' }}>{b.value}</span>
292
+ <span style={{ marginLeft: 6 }}>{b.name}</span>
293
+ <a href={b.source} target="_blank" rel="noreferrer" style={{ marginLeft: 6, color: 'var(--violet)', fontSize: 10 }}>source</a>
294
+ {b.asOf && <span style={{ marginLeft: 6, color: 'var(--text-dim)', fontSize: 10 }}>({b.asOf})</span>}
295
+ </div>
296
+ ))}
297
+ </div>
298
+ ) : null}
299
+ </>
300
+ ) : null}
301
+
302
+ {/* Links + provenance */}
303
+ <div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center', marginTop: 14, fontSize: 10, color: 'var(--text-dim)' }}>
304
+ {d?.url && <a href={d.url} target="_blank" rel="noreferrer" style={{ color: 'var(--violet)' }}>OpenRouter page</a>}
305
+ {d?.huggingFaceId && <span>HF: {d.huggingFaceId}</span>}
306
+ {info?.docUrl && <a href={info.docUrl} target="_blank" rel="noreferrer" style={{ color: 'var(--violet)' }}>Vendor docs</a>}
307
+ </div>
308
+ <div style={{ marginTop: 6, fontSize: 10, color: 'var(--text-dim)' }}>
309
+ {view.detailsSource === 'openrouter'
310
+ ? `Specs and benchmarks via OpenRouter${d?.paramsSource === 'huggingface' ? ', size via Hugging Face' : ''}`
311
+ : 'No generated details for this model'}
312
+ {view.asOf ? ` · data as of ${view.asOf.slice(0, 10)}` : ''}
313
+ </div>
314
+
315
+ <div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 12 }}>
316
+ <Button variant="secondary" size="sm" onClick={onClose}>Close</Button>
317
+ </div>
318
+ </div>
319
+ )}
320
+ </Modal>
321
+ );
322
+ }
@@ -2,6 +2,8 @@
2
2
 
3
3
  import { useCallback, useEffect, useRef, useState } from 'react';
4
4
  import { Button, ConfirmModal, Input, LoadingSpinner, Metric, ModalityIcons, Page, PageHeader, Pill, Surface } from '../components/ui';
5
+ import ModelDetailsModal from './ModelDetailsModal';
6
+ import { formatParams, formatPrice } from '../lib/model-format';
5
7
  import PasswordInput from '../components/PasswordInput';
6
8
  import { Skeleton } from '../components/Skeleton';
7
9
  import { useModels } from '../lib/models-context';
@@ -19,6 +21,8 @@ interface ModelInfo {
19
21
  pricingLive?: boolean;
20
22
  /** e.g. "text+image+file->text" — curated, matches what sync.ts provisions. */
21
23
  modality?: string;
24
+ /** Total parameters, when the model's card publishes them (open weights only). */
25
+ params?: number;
22
26
  }
23
27
 
24
28
  interface GatewayProvider {
@@ -89,6 +93,8 @@ export default function GatewayPageClient() {
89
93
 
90
94
  // Balance fetch state (per provider)
91
95
  const [balances, setBalances] = useState<Record<string, { balance: string; label?: string; loading?: boolean; error?: string; unsupported?: boolean } | null>>({});
96
+ /** Model whose details modal is open, null when none. */
97
+ const [detailsId, setDetailsId] = useState<string | null>(null);
92
98
  const [fetchingBalance, setFetchingBalance] = useState<Record<string, boolean>>({});
93
99
 
94
100
  // Accordion state — which provider cards are expanded
@@ -155,14 +161,8 @@ export default function GatewayPageClient() {
155
161
 
156
162
  useEffect(() => { void loadAll(); }, [loadAll]);
157
163
 
158
- /** Format pricing for display: "$0.44 / $0.87" or "Free" or "Dynamic" or "—" */
159
- function fmtPrice(p: { input: number; output: number } | null | undefined): string {
160
- if (!p) return '—';
161
- if (p.input === -1 && p.output === -1) return 'Dynamic';
162
- if (p.input === 0 && p.output === 0) return 'Free';
163
- const f = (n: number) => n < 0.01 ? `$${n.toFixed(3)}` : `$${n.toFixed(2)}`;
164
- return `${f(p.input)} / ${f(p.output)}`;
165
- }
164
+ /** Format pricing for display — shared with the details modal (app/lib/model-format.ts). */
165
+ const fmtPrice = formatPrice;
166
166
 
167
167
  /**
168
168
  * Local model filter. Matches against both the display name and the id,
@@ -430,7 +430,7 @@ export default function GatewayPageClient() {
430
430
  </section>
431
431
 
432
432
  {/* =========================================================== */}
433
- {/* REV4A API KEY — sezione speciale */}
433
+ {/* REV4A API KEY — special section */}
434
434
  {/* =========================================================== */}
435
435
  <div style={{ marginBottom: 20 }}>
436
436
  <Surface variant="panel">
@@ -658,6 +658,14 @@ export default function GatewayPageClient() {
658
658
  <span style={{ marginLeft: 8, color: 'var(--violet)', fontWeight: 500 }}>
659
659
  {fmtPrice(pricing)}
660
660
  </span>
661
+ {typeof m.params === 'number' && (
662
+ <span
663
+ style={{ marginLeft: 8, color: 'var(--text-dim)' }}
664
+ title={`${m.params.toLocaleString('en-US')} parameters (Hugging Face)`}
665
+ >
666
+ {formatParams(m.params)}
667
+ </span>
668
+ )}
661
669
  {m.pricingLive && (
662
670
  <span
663
671
  title="Live price from the OpenRouter API"
@@ -668,6 +676,15 @@ export default function GatewayPageClient() {
668
676
  )}
669
677
  </div>
670
678
  </div>
679
+ <Button
680
+ variant="ghost"
681
+ size="sm"
682
+ aria-label={`Details for ${m.name}`}
683
+ title="Details"
684
+ onClick={() => setDetailsId(m.id)}
685
+ >
686
+ ⋯
687
+ </Button>
671
688
  {modelMsg && (
672
689
  // Its own full-width line under the name, not a third column. As an
673
690
  // unconstrained flex item a long sync summary took its natural width
@@ -710,6 +727,7 @@ export default function GatewayPageClient() {
710
727
  cancelLabel="Cancel"
711
728
  tone="danger"
712
729
  />
713
- </Page>
730
+ {detailsId && <ModelDetailsModal modelId={detailsId} onClose={() => setDetailsId(null)} />}
731
+ </Page>
714
732
  );
715
733
  }
package/app/globals.css CHANGED
@@ -615,6 +615,9 @@ html, body { height: 100%; height: 100dvh; background: var(--bg); color: var(--t
615
615
  .ui-tone--danger { border-color: #7f1d1d; }
616
616
  .ui-muted { color: var(--text-dim); }
617
617
  .ui-metric-value { margin-top: 14px; font-size: 26px; font-weight: 800; color: var(--text); }
618
+ /* Compact metric: for grids inside dialogs, where the card value would wrap. */
619
+ .ui-metric--sm .ui-metric-value { margin-top: 6px; font-size: 15px; font-weight: 700; line-height: 1.25; word-break: break-word; }
620
+ .ui-metric--sm .ui-kicker { font-size: 9px; }
618
621
  .ui-tone--danger .ui-metric-value { color: var(--red); }
619
622
  .ui-metric-subtitle { margin-top: 8px; font-size: 11px; }
620
623
  .ui-card-head { display: flex; justify-content: space-between; gap: 10px; margin-bottom: 12px; }
@@ -1386,7 +1389,7 @@ html, body { height: 100%; height: 100dvh; background: var(--bg); color: var(--t
1386
1389
 
1387
1390
  /* Padding bottom per non coprire contenuti — handled by .app-shell__content */
1388
1391
 
1389
- /* Chat button alzato sopra la bottom navbar */
1392
+ /* Chat button raised above the bottom navbar */
1390
1393
  .ochat__trigger {
1391
1394
  bottom: 70px !important;
1392
1395
  }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Formatting shared by the Gateway model row and the details modal, so the same number
3
+ * never reads two ways in the same page.
4
+ */
5
+
6
+ /** "1.60T", "321.3B", "9.7B", "300M" — the size people actually ask about. */
7
+ export function formatParams(n: number): string {
8
+ if (n >= 1e12) return `${(n / 1e12).toFixed(2)}T`;
9
+ if (n >= 1e9) return `${(n / 1e9).toFixed(1)}B`;
10
+ if (n >= 1e6) return `${(n / 1e6).toFixed(0)}M`;
11
+ return String(n);
12
+ }
13
+
14
+ /** USD per 1M tokens: "$0.15 / $0.50", or "Free", "Dynamic", "—". */
15
+ export function formatPrice(p: { input: number; output: number } | null | undefined): string {
16
+ if (!p) return '—';
17
+ if (p.input === -1 && p.output === -1) return 'Dynamic';
18
+ if (p.input === 0 && p.output === 0) return 'Free';
19
+ const f = (n: number) => (n < 0.01 ? `$${n.toFixed(3)}` : `$${n.toFixed(2)}`);
20
+ return `${f(p.input)} / ${f(p.output)}`;
21
+ }
package/bin/rev4a.js CHANGED
@@ -428,7 +428,9 @@ function seedAgentToken() {
428
428
  function build() {
429
429
  log('BUILD', 'Building Next.js app…');
430
430
  try {
431
- execSync('npx next build', { stdio: 'inherit', cwd: ROOT, timeout: 120_000 });
431
+ // Generous on purpose: a build is the slowest step of an install, and killing it
432
+ // because it passed two minutes takes the server down over slowness alone.
433
+ execSync('npx next build', { stdio: 'inherit', cwd: ROOT, timeout: 30 * 60_000 });
432
434
  log('BUILD', 'Build complete');
433
435
  } catch (err) {
434
436
  log('BUILD', `Build failed: ${err.message}`);
@@ -754,13 +756,14 @@ if (cmd === 'update') {
754
756
  console.log('Detected git repo — pulling and building…');
755
757
  execSync(`cd ${root} && git pull && npm install && npm run build`, {
756
758
  stdio: 'inherit',
757
- timeout: 120_000,
759
+ // npm install + a full build: minutes, not two.
760
+ timeout: 30 * 60_000,
758
761
  });
759
762
  } else {
760
763
  console.log('Using npm global install…');
761
764
  execSync(`npm install -g ${PKG_NAME}@latest`, {
762
765
  stdio: 'inherit',
763
- timeout: 120_000,
766
+ timeout: 20 * 60_000,
764
767
  });
765
768
  // Wipe old build so rev4a serve rebuilds with the new version
766
769
  const nextDir = join(ROOT, '.next');
package/daemon.js CHANGED
@@ -28,7 +28,7 @@ const MODEL_PRICING = {
28
28
  'default': { in: 3.00, out: 15.00 },
29
29
  };
30
30
 
31
- // Alias diretti per modelli che non matchano per substring
31
+ // Direct aliases for models that do not match by substring
32
32
  const MODEL_ALIASES = {
33
33
  'cheap': 'flash',
34
34
  'fast': 'claude-sonnet-4',
@@ -69,7 +69,7 @@ try {
69
69
  process.on('uncaughtException', (err) => {
70
70
  console.error(`[UNCAUGHT] ${err.message}\n${err.stack}`);
71
71
  console.error('Daemon will attempt restart via watchdog cron');
72
- // NON uscire — il watchdog lo vede da healthcheck
72
+ // Do NOT exit — the watchdog reads this from the healthcheck
73
73
  });
74
74
 
75
75
  db.exec(`
@@ -749,7 +749,7 @@ function pollSessions() {
749
749
  }
750
750
  }
751
751
 
752
- // WAL checkpoint FULL ogni 10 poll
752
+ // FULL WAL checkpoint every 10 polls
753
753
  pollCount = (pollCount || 0) + 1;
754
754
  if (pollCount % 10 === 0) {
755
755
  db.pragma('wal_checkpoint(FULL)');
@@ -1,7 +1,7 @@
1
1
  # Rev4a Architecture — Design & Vision
2
2
 
3
3
  > **Status:** Active — `main` branch
4
- > **Last updated:** 2026-09-15
4
+ > **Last updated:** 2026-09-22
5
5
  > **Goal:** Transform Rev4a from a monitoring dashboard into a central orchestrator for a distributed multi-container agency.
6
6
 
7
7
  ---
@@ -207,8 +207,17 @@ The central container, running the Next.js dashboard + orchestration API.
207
207
  table (`lib/agent-restore-state.ts`): the archive replaces the volume (stop, clear,
208
208
  extract, start), up to 30 minutes. Same 202-and-background shape as a recreate, and the
209
209
  same guarantees — a reload or a Rev4a restart does not lose the job, a second restore
210
- is refused, an interrupted one starts the container again. The extract has no
211
- percentage (it is a single `tar xzf`); the panel and the agent card show `RESTORING`.
210
+ is refused, and an interrupted one is **run again from the same archive** at startup: a
211
+ half-written volume must never be presented as a restore, and the archive was already
212
+ checked before the job started (when it is gone the container is started on whatever the
213
+ volume holds, and the row says so). The extract has no percentage (it is a single
214
+ `tar xzf`); the panel and the agent card show `RESTORING`.
215
+ - **The four job tables share one implementation**: `lib/agent-job-state.ts` builds the
216
+ insert/update/latest/active/`markInterrupted` machinery for `agent_upgrades`,
217
+ `agent_recreates`, `agent_restores` and `agent_edits`; each `lib/agent-*-state.ts` keeps
218
+ only its table's types and function names. They were four near-identical copies that had
219
+ started to drift. At startup `lib/agent-jobs-maintenance.ts` prunes each table to the
220
+ newest 20 rows per agent — the panel reads the latest row only, so the rest is history.
212
221
  - **Browser access** to an agent's Control UI goes through `lib/agent-devices.ts`:
213
222
  `openclaw devices list | approve | reject | rename | remove` and
214
223
  `openclaw dashboard --json`, run inside the container with the async `dockerExec`.
@@ -279,6 +288,19 @@ the rules for anything you touch, not as a description of the whole tree:
279
288
  `promisify(exec)` silently ignores an `input` option: the process starts, stdin
280
289
  is never written, and the command hangs with no error to point at.
281
290
 
291
+ - **The process model and self-update.** `rev4a serve` (the systemd unit's process) is a
292
+ supervisor: it spawns `rev4a _run <port>`, which in turn runs Next, the daemon and the
293
+ terminal WebSocket. `rev4a update` installs the new version, then `rev4a restart` writes
294
+ `.restart-flag` in the data directory; `_run` sees it, stops its children, waits for the
295
+ port to come free and exits with code **42**, and the supervisor relaunches `_run` on the
296
+ new code — so the supervisor itself never goes down (the unit's `NRestarts` stays 0).
297
+ The dashboard's **Update now** is only a detached `rev4a update` (`POST
298
+ /api/update-check`): it must not try to restart anything itself, because the update
299
+ stops the very process that spawned it, and the CLI's own flag is the mechanism that
300
+ matters. Its output goes to `update.log` in the data directory, since nothing else can
301
+ report the outcome: the banner records what it asked for, waits for the installed
302
+ version to change and only then reloads (see `VersionBanner`).
303
+
282
304
  ### 3.2 Agent Container Template (`openclaw-agent-base`)
283
305
 
284
306
  Docker image for every agent container.
@@ -1,6 +1,6 @@
1
1
  # Rev4a Frontend Architecture
2
2
 
3
- > **Last updated:** 2026-09-15
3
+ > **Last updated:** 2026-09-22
4
4
 
5
5
  ## Layering
6
6
 
@@ -36,7 +36,7 @@ All shared UI primitives live in `app/components/ui/` and are exported from `app
36
36
  | `TemplateOption` | `TemplateOption.tsx` | Agent template selection card with avatar, description, optional badge. Centralises create-agent card styling. |
37
37
  | `Pill` | `Pill.tsx` | Small status/attribute label. Variants: `default`, `accent`. |
38
38
  | `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. |
39
- | `Metric` | `Metric.tsx` | Metric card with title, value, subtitle, tone. |
39
+ | `Metric` | `Metric.tsx` | Metric card with title, value, subtitle, tone, and a `size` (`md` default, `sm` for a grid inside a dialog: smaller value, tighter spacing). |
40
40
  | `StatusCard` | `StatusCard.tsx` | Health/status report card. |
41
41
  | `Surface` | `Surface.tsx` | Shared panel/card surface, variant prop. |
42
42
  | `Page` / `PageHeader` | `Page.tsx` | Full-page layout shell. |
@@ -46,10 +46,12 @@ All shared UI primitives live in `app/components/ui/` and are exported from `app
46
46
  | `BackupSection` | inline in `app/agents/PageClient.tsx` | BACKUP section of the agent detail panel, on the cold backup and the restore. **Backup Now** starts `POST /api/agents/[id]/cold-backup`; while the job runs a banner shows the file and its live percent with **Cancel** (`DELETE /cold-backup`). **Restore** (after a confirm) starts `POST /restore` and a banner shows `Restoring <file>…` (no percent: the extract is a single `tar xzf`, and there is no Cancel). Both sections poll their `GET` every 2 s while running, and on mount pick up a job that is already running — a backup lives in a Docker helper, a restore in `agent_restores`, so reloading the page or navigating away never loses them nor allows a second one (the server answers 409 anyway). Delete per row; all actions disabled while one runs; keyed busy state `{ kind, file }` so only the row in action shows the spinner. On the agent list, an activity Badge (fed by `/api/agents/activity-summary`, polled at 2 s only while something runs, otherwise riding the 15 s list poll) reads `BACKUP nn%`, `RESTORING`, `RECREATING`, `EDITING` or `UPDATING`. |
47
47
  | `RecreateSection` | inline in `app/agents/PageClient.tsx` | RECREATE section of the agent detail panel. **Recreate Container** starts `POST /api/agents/[id]/recreate` (202) after a confirm; a banner then shows the phase — *Backing up … nn%* while the cold backup runs, *Recreating container…* while the container is rebuilt and the gateway starts. The section polls `GET /recreate` every 2 s, and on mount picks up a recreate that is already running, so a reload or navigation never loses it; it refetches the agent once the job reports `done`. |
48
48
  | `ImageDownloadBanner` | `app/agents/ImageDownloadBanner.tsx` | Agent image banner on the Agents page. Polls `/api/agents/image-status` every 2 s; offers **Download Image** when no supported version is downloaded, **Download <version>** when the registry publishes a newer one, and shows the download in progress and its completion. Downloading changes no agent. |
49
+ | `VersionBanner` | `app/components/VersionBanner.tsx` | "Update available" banner for Rev4a itself, when `GET /api/update-check?check=1` reports a newer published version (dismissable per version, remembered in `localStorage`). **Update now** starts `POST /api/update-check`; because that update restarts the server, the banner cannot be told the outcome by the response: it records what it asked for in `sessionStorage`, polls `/api/update-check` until the installed version moves (two minutes at most) and reloads, then on the next mount either confirms "Updated to vX" or reports that the update did not complete and points at `update.log` (the update's own output). It never reloads blindly onto the same version. |
49
50
  | `BrowserAccessSection` / `OpenControlUiButton` | `app/agents/BrowserAccessSection.tsx` | Browser access to one agent's Control UI, in its detail panel: requests waiting for approval (Approve / Reject) and approved browsers (Rename / Revoke), refreshed every 5 s while mounted. A successful approve, reject, rename or revoke updates the list at once, since the refresh behind it runs the OpenClaw CLI and takes seconds; a read started before the mutation is discarded. On agents that require approval, "Invite link" fetches `/api/agents/[id]/invite-link` and shows the link in a read-only field with Copy, which uses the Clipboard API in a secure context and the field's selection over plain HTTP, plus a warning when the link uses localhost. `OpenControlUiButton` opens `/api/agents/[id]/open-control-ui` in a new tab inside the click; that route redirects to a one-time link that pairs the browser with no approval, or to the plain token link when none can be issued. Used on the agent cards and in the panel. |
50
- | `ChannelManager` / `ChannelSection` | `app/agents/ChannelManager.tsx`, `ChannelSection` inline in `app/agents/PageClient.tsx` | Telegram, in the agent detail panel (`ChannelSection` is the card that opens the modal; the modal title is the agent's display name). Reads `GET /channels`; lists pending pairing requests with **Approve** (`POST /channels/pairing`) and approved senders with **Revoke** after a confirm (`DELETE /channels/pairing?senderId=`). Pending comes from `openclaw pairing list`, approved from OpenClaw's pairing store (`lib/channelManager.ts`). Polls pairings every 5 s while open; one keyed busy state per action. |
51
+ | `ChannelManager` / `ChannelSection` | `app/agents/ChannelManager.tsx`, `ChannelSection` inline in `app/agents/PageClient.tsx` | Telegram, in the agent detail panel (`ChannelSection` is the card that opens the modal; the modal title is the agent's display name). Reads `GET /channels`; lists pending pairing requests with **Approve** (`POST /channels/pairing`) and approved senders with **Revoke** after a confirm (`DELETE /channels/pairing?senderId=`). Pending comes from `openclaw pairing list`, approved from OpenClaw's pairing store (`lib/channelManager.ts`). When that store cannot be read the panel shows the reason instead of "No approved senders" (`PairingState.error`), so an empty list is never a guess. Polls pairings every 5 s while open; one keyed busy state per action. |
51
52
  | `EditAgentModal` / `EditBanner` | `app/agents/PageClient.tsx` | Rename/ports editing. The modal has **Display Name** and **Host Port** (same input and validation messages as the create wizard, from `lib/agent-ports.ts`), sends `PATCH /api/agents/[id]` and closes on `202`; `EditBanner` (top of the agent detail) polls `GET /api/agents/[id]` every 2 s while an edit is `rebuilding`, resumes on mount, and refetches the agent once it finishes, so a reload or a navigation shows the running edit instead of allowing a second (the server answers 409). No backup is taken: the volume is untouched. The port range is validated **as it is typed**: the modal fetches `GET /api/agents/ports` when it opens (the same set the create form uses, every container, running or stopped) and excludes the agent's own block, so an occupied range shows the API's message inline and the Save button stays disabled — the create wizard does the same with the same endpoint. |
52
53
  | `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`. |
54
+ | `ModelDetailsModal` | `app/gateway/ModelDetailsModal.tsx` | Details for one model, opened by the `⋯` button on a Gateway model row (`aria-label="Details for <name>"`). Fetches `GET /api/models/details?id=` **when it opens** (AbortController, shared spinner, error text) instead of inlining descriptions in the polled list. Sections appear only when their source has data: identity (provider, modality through the shared `ModalityIcons`, enabled and deprecated chips, the id), price with its origin (the provider's display label, from `lib/provider-labels.ts`), specs (release date, context and the provider's own limit when it differs, max output, knowledge cutoff, tokenizer, instruct type, moderation, **Architecture** (a metric row — parameters, experts, layers, max context — over the facts from the model card: family, model type, hidden size, attention heads with KV heads, vocabulary, vision encoder, precision and the weight mix by dtype, task, languages, popularity, attributed to Hugging Face), the description truncated to four lines with a **Show more** toggle, reasoning (mode, default effort, efforts), capabilities as `Pill`s, benchmarks (Artificial Analysis indices as `Metric`s, the design-arena table, then the curated ones with source and date), status (deprecated + notes) and a footer with the generated part's date. Never shows "n/a": an absent field is an absent block. |
53
55
  | `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. |
54
56
  | `WizardPageClient` + step components | `app/wizard/PageClient.tsx` | Multi-step first-run wizard plus its frame shell, with mobile-first CSS. |
55
57
  | `useWizard` | `app/wizard/useWizard.ts` | Shared hook: wizard state, step transitions, provider save, restart. Receives server-side initial state to avoid a loading flash. |
package/docs/REV4A.md CHANGED
@@ -201,6 +201,23 @@ If the daemon never answers, `serve` starts anyway, skips the network and image
201
201
  checks, and says so in the log. The startup sync then reaches no agent, and logs
202
202
  that too; run Sync All Agents once Docker is up.
203
203
 
204
+ ### Memory (a host that runs several agents)
205
+
206
+ `rev4a update` installs the new version and the server rebuilds Next before it comes
207
+ back (`rev4a` wipes `.next`, so the build always runs). That build is the memory peak
208
+ of the whole installation: with several agent Gateways resident (roughly 0.5-0.8 GB
209
+ each) a host with no swap can hit the kernel OOM killer during the update — it killed
210
+ `next-server` on a 7.7 GB VPS, which then stayed unreachable until a reboot.
211
+
212
+ Give such a host swap (4-8 GB is enough for the build) and a conservative
213
+ `vm.swappiness`:
214
+
215
+ ```bash
216
+ fallocate -l 4G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile
217
+ echo '/swapfile none swap sw 0 0' >> /etc/fstab
218
+ echo 'vm.swappiness=10' > /etc/sysctl.d/99-rev4a-swap.conf
219
+ ```
220
+
204
221
  ### Systemd environment override
205
222
 
206
223
  File: `/etc/systemd/system/rev4a-next.service` (EnvironmentFile)