@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.
- package/app/agents/ChannelManager.tsx +17 -5
- package/app/agents/PageClient.tsx +13 -3
- package/app/api/agents/download-image/route.ts +8 -0
- package/app/api/gateway/provider/route.ts +16 -10
- package/app/api/models/details/route.ts +30 -0
- package/app/api/system-health/route.ts +3 -3
- package/app/api/update-check/route.ts +23 -10
- package/app/components/SessionDrawer.tsx +4 -4
- package/app/components/VersionBanner.tsx +50 -33
- package/app/components/ui/Metric.tsx +7 -2
- package/app/gateway/ModelDetailsModal.tsx +322 -0
- package/app/gateway/PageClient.tsx +28 -10
- package/app/globals.css +4 -1
- package/app/lib/model-format.ts +21 -0
- package/bin/rev4a.js +6 -3
- package/daemon.js +3 -3
- package/docs/ARCHITECTURE.md +25 -3
- package/docs/FRONTEND-ARCHITECTURE.md +5 -3
- package/docs/REV4A.md +17 -0
- package/docs/dev/API-REFERENCE.md +41 -3
- package/docs/dev/GATEWAY.md +58 -0
- package/docs/rag/REV4A-OVERVIEW.md +7 -1
- package/docs/rag/WHAT-I-CAN-ANSWER.md +2 -1
- package/instrumentation.ts +11 -0
- package/lib/agent-edit-state.ts +25 -66
- package/lib/agent-job-state.ts +145 -0
- package/lib/agent-jobs-maintenance.ts +34 -0
- package/lib/agent-recreate-state.ts +24 -65
- package/lib/agent-restore-state.ts +25 -66
- package/lib/agent-restore.ts +28 -5
- package/lib/agent-update-state.ts +25 -61
- package/lib/channelManager.ts +12 -1
- package/lib/memory-context.ts +2 -2
- package/lib/model-catalogue.ts +17 -0
- package/lib/model-details.ts +124 -0
- package/lib/provider-labels.ts +21 -0
- package/model-details.json +16360 -0
- package/model-pricing.json +260 -24
- package/models.config.json +494 -10
- package/package.json +4 -2
- package/scripts/check-language.mjs +76 -0
- package/scripts/lib/model-upstream.mjs +61 -0
- package/scripts/model-info-suggest.mjs +149 -0
- package/scripts/refresh-model-pricing.mjs +180 -21
|
@@ -52,6 +52,7 @@ export default function ChannelManager({ agentId, displayName, open, onClose }:
|
|
|
52
52
|
|
|
53
53
|
const [pending, setPending] = useState<PendingPairing[]>([]);
|
|
54
54
|
const [approved, setApproved] = useState<ApprovedPairing[]>([]);
|
|
55
|
+
const [pairingError, setPairingError] = useState<string | null>(null);
|
|
55
56
|
const pollRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
56
57
|
const pairingsAbortRef = useRef<AbortController | null>(null);
|
|
57
58
|
const channelsAbortRef = useRef<AbortController | null>(null);
|
|
@@ -85,6 +86,7 @@ export default function ChannelManager({ agentId, displayName, open, onClose }:
|
|
|
85
86
|
const data = await res.json();
|
|
86
87
|
setPending(data.pending ?? []);
|
|
87
88
|
setApproved(data.approved ?? []);
|
|
89
|
+
setPairingError(data.error ?? null);
|
|
88
90
|
}
|
|
89
91
|
} catch (e: any) {
|
|
90
92
|
if (e?.name !== 'AbortError') { /* ignore aborted */ }
|
|
@@ -288,6 +290,7 @@ export default function ChannelManager({ agentId, displayName, open, onClose }:
|
|
|
288
290
|
restarting={restarting}
|
|
289
291
|
pending={pending}
|
|
290
292
|
approved={approved}
|
|
293
|
+
pairingError={pairingError}
|
|
291
294
|
approvingCode={approvingCode}
|
|
292
295
|
revokingId={revokingId}
|
|
293
296
|
onConnect={handleTelegramConnect}
|
|
@@ -305,7 +308,7 @@ export default function ChannelManager({ agentId, displayName, open, onClose }:
|
|
|
305
308
|
|
|
306
309
|
function TelegramTab({
|
|
307
310
|
tg, tgToken, setTgToken, loading, disconnecting, pairingsLoading, restarting,
|
|
308
|
-
pending, approved, approvingCode, revokingId,
|
|
311
|
+
pending, approved, pairingError, approvingCode, revokingId,
|
|
309
312
|
onConnect, onDisconnect, onRestart, onApprove, onRevoke,
|
|
310
313
|
}: {
|
|
311
314
|
tg: TelegramChannel | null;
|
|
@@ -317,6 +320,7 @@ function TelegramTab({
|
|
|
317
320
|
restarting: boolean;
|
|
318
321
|
pending: PendingPairing[];
|
|
319
322
|
approved: ApprovedPairing[];
|
|
323
|
+
pairingError: string | null;
|
|
320
324
|
approvingCode: string | null;
|
|
321
325
|
revokingId: string | null;
|
|
322
326
|
onConnect: () => void;
|
|
@@ -343,6 +347,7 @@ function TelegramTab({
|
|
|
343
347
|
<PairingSection
|
|
344
348
|
pending={pending}
|
|
345
349
|
approved={approved}
|
|
350
|
+
pairingError={pairingError}
|
|
346
351
|
pairingsLoading={pairingsLoading}
|
|
347
352
|
approvingCode={approvingCode}
|
|
348
353
|
revokingId={revokingId}
|
|
@@ -389,11 +394,12 @@ function TelegramTab({
|
|
|
389
394
|
// ── Pairing Section ──────────────────────────
|
|
390
395
|
|
|
391
396
|
function PairingSection({
|
|
392
|
-
pending, approved, pairingsLoading, approvingCode, revokingId,
|
|
397
|
+
pending, approved, pairingError, pairingsLoading, approvingCode, revokingId,
|
|
393
398
|
onApprove, onRevoke,
|
|
394
399
|
}: {
|
|
395
400
|
pending: PendingPairing[];
|
|
396
401
|
approved: ApprovedPairing[];
|
|
402
|
+
pairingError: string | null;
|
|
397
403
|
pairingsLoading: boolean;
|
|
398
404
|
approvingCode: string | null;
|
|
399
405
|
revokingId: string | null;
|
|
@@ -462,9 +468,15 @@ function PairingSection({
|
|
|
462
468
|
)}
|
|
463
469
|
|
|
464
470
|
{approved.length === 0 && (
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
471
|
+
pairingError ? (
|
|
472
|
+
<div style={{ fontSize: 10, color: 'var(--red, #ef4444)' }}>
|
|
473
|
+
{pairingError}
|
|
474
|
+
</div>
|
|
475
|
+
) : (
|
|
476
|
+
<div style={{ fontSize: 10, color: 'var(--text-dim)' }}>
|
|
477
|
+
No approved senders
|
|
478
|
+
</div>
|
|
479
|
+
)
|
|
468
480
|
)}
|
|
469
481
|
</>
|
|
470
482
|
)}
|
|
@@ -343,9 +343,19 @@ export default function AgentsPageClient() {
|
|
|
343
343
|
const res = await fetch('/api/agents/devices-summary');
|
|
344
344
|
if (!res.ok || seq !== dvSummarySeq.current) return;
|
|
345
345
|
const payload = await res.json();
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
setDvSummary(
|
|
346
|
+
// A row whose read failed carries `error: true` and no count: keep the previous
|
|
347
|
+
// value instead of writing zero, which would hide a request that is really waiting.
|
|
348
|
+
setDvSummary((prev) => {
|
|
349
|
+
const next: Record<string, { pending: number }> = {};
|
|
350
|
+
for (const row of payload.agents ?? []) {
|
|
351
|
+
if (row.error === true) {
|
|
352
|
+
if (prev[row.agentId]) next[row.agentId] = prev[row.agentId];
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
next[row.agentId] = { pending: row.pending ?? 0 };
|
|
356
|
+
}
|
|
357
|
+
return next;
|
|
358
|
+
});
|
|
349
359
|
} catch { /* badges are optional */ }
|
|
350
360
|
}, []);
|
|
351
361
|
|
|
@@ -32,6 +32,14 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
|
|
|
32
32
|
}
|
|
33
33
|
const version = (body?.version as string | undefined) ?? (await defaultDownloadVersion());
|
|
34
34
|
|
|
35
|
+
// Checked again here, next to the start: the first check and this one are separated by
|
|
36
|
+
// awaits, so two requests can pass it together. The loser of the race is refused inside
|
|
37
|
+
// downloadAgentImage, and that rejection would be swallowed by the catch below — the
|
|
38
|
+
// route would answer 202 for a download it never started.
|
|
39
|
+
if (getIsDownloading()) {
|
|
40
|
+
return NextResponse.json({ error: 'A download is already in progress' }, { status: 409 });
|
|
41
|
+
}
|
|
42
|
+
|
|
35
43
|
// Start download in background — fire and forget
|
|
36
44
|
downloadAgentImage({ version }).catch((e: unknown) => {
|
|
37
45
|
console.error(`[download-image] OpenClaw ${version}:`, (e as Error).message);
|
|
@@ -11,6 +11,8 @@ import { readProviderKeys, writeProviderKeys } from './keys';
|
|
|
11
11
|
import { dockerUnreachableReason, syncAllAgents, type SyncOutcome } from '../sync';
|
|
12
12
|
import { detectProviderGatewayUrl } from '@/lib/docker-utils';
|
|
13
13
|
import { getPricing } from '@/lib/model-pricing';
|
|
14
|
+
import { getModelParams } from '@/lib/model-details';
|
|
15
|
+
import { PROVIDER_LABELS } from '@/lib/provider-labels';
|
|
14
16
|
import { loadModelsConfig, toggleModelOverride, type ModelConfigEntry } from '@/lib/model-catalogue';
|
|
15
17
|
import { getLivePricing } from '@/lib/openrouter-pricing';
|
|
16
18
|
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
@@ -33,70 +35,70 @@ interface ProviderEnvConfig {
|
|
|
33
35
|
const PROVIDER_CONFIGS: Record<string, ProviderEnvConfig> = {
|
|
34
36
|
google: {
|
|
35
37
|
envKey: 'PROVIDER_GOOGLE_API_KEY',
|
|
36
|
-
label:
|
|
38
|
+
label: PROVIDER_LABELS.google,
|
|
37
39
|
baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai',
|
|
38
40
|
authHeader: 'x-goog-api-key',
|
|
39
41
|
docsUrl: 'https://aistudio.google.com/app/apikey',
|
|
40
42
|
},
|
|
41
43
|
deepseek: {
|
|
42
44
|
envKey: 'PROVIDER_DEEPSEEK_API_KEY',
|
|
43
|
-
label:
|
|
45
|
+
label: PROVIDER_LABELS.deepseek,
|
|
44
46
|
baseUrl: 'https://api.deepseek.com',
|
|
45
47
|
authHeader: 'Authorization',
|
|
46
48
|
docsUrl: 'https://platform.deepseek.com/api_keys',
|
|
47
49
|
},
|
|
48
50
|
openrouter: {
|
|
49
51
|
envKey: 'PROVIDER_OPENROUTER_API_KEY',
|
|
50
|
-
label:
|
|
52
|
+
label: PROVIDER_LABELS.openrouter,
|
|
51
53
|
baseUrl: 'https://openrouter.ai/api',
|
|
52
54
|
authHeader: 'Authorization',
|
|
53
55
|
docsUrl: 'https://openrouter.ai/keys',
|
|
54
56
|
},
|
|
55
57
|
openai: {
|
|
56
58
|
envKey: 'PROVIDER_OPENAI_API_KEY',
|
|
57
|
-
label:
|
|
59
|
+
label: PROVIDER_LABELS.openai,
|
|
58
60
|
baseUrl: 'https://api.openai.com',
|
|
59
61
|
authHeader: 'Authorization',
|
|
60
62
|
docsUrl: 'https://platform.openai.com/api-keys',
|
|
61
63
|
},
|
|
62
64
|
anthropic: {
|
|
63
65
|
envKey: 'PROVIDER_ANTHROPIC_API_KEY',
|
|
64
|
-
label:
|
|
66
|
+
label: PROVIDER_LABELS.anthropic,
|
|
65
67
|
baseUrl: 'https://api.anthropic.com',
|
|
66
68
|
authHeader: 'x-api-key',
|
|
67
69
|
docsUrl: 'https://console.anthropic.com/settings/keys',
|
|
68
70
|
},
|
|
69
71
|
groq: {
|
|
70
72
|
envKey: 'PROVIDER_GROQ_API_KEY',
|
|
71
|
-
label:
|
|
73
|
+
label: PROVIDER_LABELS.groq,
|
|
72
74
|
baseUrl: 'https://api.groq.com/openai',
|
|
73
75
|
authHeader: 'Authorization',
|
|
74
76
|
docsUrl: 'https://console.groq.com/keys',
|
|
75
77
|
},
|
|
76
78
|
kimi: {
|
|
77
79
|
envKey: 'PROVIDER_KIMI_API_KEY',
|
|
78
|
-
label:
|
|
80
|
+
label: PROVIDER_LABELS.kimi,
|
|
79
81
|
baseUrl: 'https://api.moonshot.cn',
|
|
80
82
|
authHeader: 'Authorization',
|
|
81
83
|
docsUrl: 'https://platform.kimi.com/console/api-keys',
|
|
82
84
|
},
|
|
83
85
|
glm: {
|
|
84
86
|
envKey: 'PROVIDER_GLM_API_KEY',
|
|
85
|
-
label:
|
|
87
|
+
label: PROVIDER_LABELS.glm,
|
|
86
88
|
baseUrl: 'https://api.z.ai',
|
|
87
89
|
authHeader: 'Authorization',
|
|
88
90
|
docsUrl: 'https://z.ai/manage-apikey/apikey-list',
|
|
89
91
|
},
|
|
90
92
|
qwen: {
|
|
91
93
|
envKey: 'PROVIDER_QWEN_API_KEY',
|
|
92
|
-
label:
|
|
94
|
+
label: PROVIDER_LABELS.qwen,
|
|
93
95
|
baseUrl: 'https://dashscope.aliyuncs.com',
|
|
94
96
|
authHeader: 'Authorization',
|
|
95
97
|
docsUrl: 'https://bailian.console.aliyun.com/cn-beijing?tab=model#/api-key',
|
|
96
98
|
},
|
|
97
99
|
rev4a: {
|
|
98
100
|
envKey: 'REV4A_API_KEY',
|
|
99
|
-
label:
|
|
101
|
+
label: PROVIDER_LABELS.rev4a,
|
|
100
102
|
baseUrl: detectProviderGatewayUrl(),
|
|
101
103
|
authHeader: 'Authorization',
|
|
102
104
|
docsUrl: '',
|
|
@@ -167,6 +169,9 @@ export async function GET(request: NextRequest) {
|
|
|
167
169
|
// containers from it, so taking a different one upstream would advertise
|
|
168
170
|
// inputs the agent was never configured to accept.
|
|
169
171
|
const livePricing = await getLivePricing(allModels.map((m) => m.id));
|
|
172
|
+
// Sizes for the row's "next to the price" hint: open-weight models only (see
|
|
173
|
+
// docs/dev/GATEWAY.md), absent for a model whose vendor does not publish one.
|
|
174
|
+
const paramsByModel = getModelParams();
|
|
170
175
|
|
|
171
176
|
const providers = Object.entries(PROVIDER_CONFIGS).map(([key, cfg]) => {
|
|
172
177
|
const providerModels = allModels.filter((m) => m.provider === key);
|
|
@@ -184,6 +189,7 @@ export async function GET(request: NextRequest) {
|
|
|
184
189
|
if (staticPrice) entry.pricing = staticPrice;
|
|
185
190
|
}
|
|
186
191
|
if (m.modality) entry.modality = m.modality;
|
|
192
|
+
if (paramsByModel[m.id]) entry.params = paramsByModel[m.id];
|
|
187
193
|
return entry;
|
|
188
194
|
}),
|
|
189
195
|
...(providerKeys[key] ? { apiKey: providerKeys[key] } : {}),
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/models/details?id=<model id>
|
|
3
|
+
*
|
|
4
|
+
* Everything the details modal shows for one model: the curated catalogue entry, the
|
|
5
|
+
* price (and where it is from) and the generated specs, description and benchmarks
|
|
6
|
+
* (`model-details.json`). Fetched when the modal opens rather than inlined in
|
|
7
|
+
* `/api/models`, which pages poll: descriptions are long and only one model is looked at
|
|
8
|
+
* at a time.
|
|
9
|
+
*
|
|
10
|
+
* Absent fields are simply absent — the modal hides those blocks instead of printing
|
|
11
|
+
* "n/a" — and a model the catalogue does not carry is a plain 404.
|
|
12
|
+
*/
|
|
13
|
+
import { NextResponse, type NextRequest } from 'next/server';
|
|
14
|
+
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
15
|
+
import { getModelDetails } from '@/lib/model-details';
|
|
16
|
+
|
|
17
|
+
export const dynamic = 'force-dynamic';
|
|
18
|
+
|
|
19
|
+
export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
20
|
+
const denied = await requireAuthJWT(request);
|
|
21
|
+
if (denied) return denied;
|
|
22
|
+
|
|
23
|
+
const id = request.nextUrl.searchParams.get('id');
|
|
24
|
+
if (!id) return NextResponse.json({ error: 'id query param required' }, { status: 400 });
|
|
25
|
+
|
|
26
|
+
const details = getModelDetails(id);
|
|
27
|
+
if (!details) return NextResponse.json({ error: `No model '${id}' in the catalogue` }, { status: 404 });
|
|
28
|
+
|
|
29
|
+
return NextResponse.json(details, { headers: { 'Cache-Control': 'no-store' } });
|
|
30
|
+
}
|
|
@@ -106,7 +106,7 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
|
106
106
|
? 'Heartbeat metrics unavailable in this DB'
|
|
107
107
|
: latestMetricAgeSeconds === null
|
|
108
108
|
? 'No heartbeat metrics detected'
|
|
109
|
-
: `
|
|
109
|
+
: `last heartbeat metrics ${latestMetricAgeSeconds}s ago`,
|
|
110
110
|
source: 'runtime',
|
|
111
111
|
});
|
|
112
112
|
|
|
@@ -157,7 +157,7 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
|
157
157
|
severity: 'warning',
|
|
158
158
|
source: 'runtime',
|
|
159
159
|
title: 'Review recent errors',
|
|
160
|
-
details: `${eventErrorCount} error events
|
|
160
|
+
details: `${eventErrorCount} error events in the last 24h.`,
|
|
161
161
|
actionHref: '/lineage',
|
|
162
162
|
dismissible: true,
|
|
163
163
|
createdAt,
|
|
@@ -181,7 +181,7 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
|
181
181
|
label: 'Usage-based cost today',
|
|
182
182
|
health: billing.usageBasedCost > 10 ? 'warning' : 'ok',
|
|
183
183
|
value: `$${billing.usageBasedCost.toFixed(2)}`,
|
|
184
|
-
details: `DB estimate $${billing.dbEstimatedCost.toFixed(2)}; fixed ${billing.buckets.fixed.sessions}
|
|
184
|
+
details: `DB estimate $${billing.dbEstimatedCost.toFixed(2)}; fixed ${billing.buckets.fixed.sessions} sessions; credits ${billing.buckets.credits.sessions} sessions`,
|
|
185
185
|
source: 'cost',
|
|
186
186
|
});
|
|
187
187
|
if (billing.usageBasedCost > 10) {
|
|
@@ -8,15 +8,26 @@ import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
|
8
8
|
// package name is the single source of truth, so never hardcode it elsewhere.
|
|
9
9
|
const FALLBACK_PKG_NAME = '@flame0510/project-aether';
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
11
|
+
/**
|
|
12
|
+
* The installed package's name and version. `version` is **null when it cannot be read**
|
|
13
|
+
* — never a placeholder like `0.0.0`: during `rev4a update` npm rewrites this very file,
|
|
14
|
+
* and a transient read failure must not look like a version that changed.
|
|
15
|
+
*/
|
|
16
|
+
function getPackageInfo(): { version: string | null; name: string } {
|
|
17
|
+
// cwd first (systemd sets WorkingDirectory), then relative to this module: a global
|
|
18
|
+
// install started from elsewhere still finds its own package.json.
|
|
19
|
+
const candidates = [join(process.cwd(), 'package.json'), join(__dirname, '..', '..', '..', 'package.json')];
|
|
20
|
+
for (const pkgPath of candidates) {
|
|
21
|
+
try {
|
|
22
|
+
const parsed = JSON.parse(readFileSync(pkgPath, 'utf-8')) as { version?: unknown; name?: unknown };
|
|
23
|
+
const version = typeof parsed.version === 'string' && parsed.version ? parsed.version : null;
|
|
24
|
+
const name = typeof parsed.name === 'string' && parsed.name ? parsed.name : FALLBACK_PKG_NAME;
|
|
25
|
+
if (version) return { version, name };
|
|
26
|
+
} catch {
|
|
27
|
+
// try the next candidate
|
|
28
|
+
}
|
|
19
29
|
}
|
|
30
|
+
return { version: null, name: FALLBACK_PKG_NAME };
|
|
20
31
|
}
|
|
21
32
|
|
|
22
33
|
interface CachedResult {
|
|
@@ -38,11 +49,13 @@ export async function GET(request: Request): Promise<NextResponse> {
|
|
|
38
49
|
const check = url.searchParams.get('check') === '1';
|
|
39
50
|
|
|
40
51
|
const pkg = getPackageInfo();
|
|
41
|
-
const installed = pkg.version
|
|
52
|
+
const installed = pkg.version;
|
|
42
53
|
let latest: string | null = null;
|
|
43
54
|
let updateAvailable = false;
|
|
44
55
|
|
|
45
|
-
|
|
56
|
+
// Nothing to compare when the installed version is unknown: answer that, rather than
|
|
57
|
+
// offering an update against a placeholder.
|
|
58
|
+
if (check && installed) {
|
|
46
59
|
const now = Date.now();
|
|
47
60
|
if (cache && now - cache.checkedAt < CACHE_TTL_MS) {
|
|
48
61
|
latest = cache.latest;
|
|
@@ -146,7 +146,7 @@ export default function SessionDrawer({ sessionId, onClose }: SessionDrawerProps
|
|
|
146
146
|
<button
|
|
147
147
|
type="button"
|
|
148
148
|
onClick={onClose}
|
|
149
|
-
aria-label="
|
|
149
|
+
aria-label="Close"
|
|
150
150
|
style={{
|
|
151
151
|
position: 'absolute',
|
|
152
152
|
top: isMobile ? 16 : 14,
|
|
@@ -185,9 +185,9 @@ export default function SessionDrawer({ sessionId, onClose }: SessionDrawerProps
|
|
|
185
185
|
{session.status ?? 'idle'}
|
|
186
186
|
</Row>
|
|
187
187
|
<Row label="TIPO">
|
|
188
|
-
{session.session_id.includes(':cron:') ? 'cron' : '
|
|
188
|
+
{session.session_id.includes(':cron:') ? 'cron' : 'session'}
|
|
189
189
|
</Row>
|
|
190
|
-
<Row label="
|
|
190
|
+
<Row label="MODEL">{session.model ?? '-'}</Row>
|
|
191
191
|
<Row label="COSTO">{formatUsdOrDash(session.cost_usd)}</Row>
|
|
192
192
|
<Row label="TOKEN">
|
|
193
193
|
in: {formatTokens(session.tokens_in)} out: {formatTokens(session.tokens_out)}
|
|
@@ -215,7 +215,7 @@ export default function SessionDrawer({ sessionId, onClose }: SessionDrawerProps
|
|
|
215
215
|
))}
|
|
216
216
|
</Section>
|
|
217
217
|
|
|
218
|
-
<Section title="
|
|
218
|
+
<Section title="CHILD SESSIONS">
|
|
219
219
|
{children.length === 0 && <Muted>No child sessions</Muted>}
|
|
220
220
|
{children.map((child) => (
|
|
221
221
|
<div key={child.session_id} style={{ fontSize: 12 }}>
|
|
@@ -5,14 +5,22 @@
|
|
|
5
5
|
*
|
|
6
6
|
* The update is spawned by `POST /api/update-check` and restarts the server, so this
|
|
7
7
|
* component cannot be told the outcome by the response. It records what it asked for,
|
|
8
|
-
* waits for the server to answer again, and on the next mount compares
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* waits for the server to answer again, and on the next mount compares.
|
|
9
|
+
*
|
|
10
|
+
* Two rules learned the hard way (2026-09-23):
|
|
11
|
+
* - a **success** is only a version that is *readable* and different. During the update
|
|
12
|
+
* npm rewrites `package.json`, and a read that fails used to answer `0.0.0`, which read
|
|
13
|
+
* as "the version moved" — the banner then said "Updated to v0.0.0" and reloaded in the
|
|
14
|
+
* middle of the install.
|
|
15
|
+
* - the banner never reloads **blindly**: if the version has not moved by the time the
|
|
16
|
+
* wait is over, it says the update has not finished (it may still be running, or it
|
|
17
|
+
* failed) and offers a Reload, instead of pretending.
|
|
11
18
|
*/
|
|
12
19
|
import { useEffect, useState } from 'react';
|
|
20
|
+
import { Button } from '../components/ui';
|
|
13
21
|
|
|
14
22
|
interface VersionInfo {
|
|
15
|
-
installed: string;
|
|
23
|
+
installed: string | null;
|
|
16
24
|
latest: string | null;
|
|
17
25
|
updateAvailable: boolean;
|
|
18
26
|
log?: string;
|
|
@@ -22,7 +30,7 @@ const DISMISS_KEY = 'rev4a-update-dismissed';
|
|
|
22
30
|
/** What we asked for and from which version, so the next mount can judge the outcome. */
|
|
23
31
|
const PENDING_KEY = 'rev4a-update-pending';
|
|
24
32
|
|
|
25
|
-
/** How long to
|
|
33
|
+
/** How long to wait for the restart before saying the update has not finished. */
|
|
26
34
|
const WAIT_MS = 120_000;
|
|
27
35
|
|
|
28
36
|
interface Pending { target: string; from: string }
|
|
@@ -38,6 +46,11 @@ function readPending(): Pending | null {
|
|
|
38
46
|
}
|
|
39
47
|
}
|
|
40
48
|
|
|
49
|
+
/** A version we can believe: present and not a placeholder. */
|
|
50
|
+
function usableVersion(v: string | null | undefined): v is string {
|
|
51
|
+
return typeof v === 'string' && v.length > 0 && v !== '0.0.0';
|
|
52
|
+
}
|
|
53
|
+
|
|
41
54
|
export default function VersionBanner() {
|
|
42
55
|
const [info, setInfo] = useState<VersionInfo | null>(null);
|
|
43
56
|
const [dismissed, setDismissed] = useState(true);
|
|
@@ -52,27 +65,26 @@ export default function VersionBanner() {
|
|
|
52
65
|
.then((d: VersionInfo) => {
|
|
53
66
|
if (!active) return;
|
|
54
67
|
const pending = readPending();
|
|
55
|
-
// Back from an update: did
|
|
56
|
-
if (pending && d.installed !== pending.from) {
|
|
68
|
+
// Back from an update: did a *readable* version actually replace the old one?
|
|
69
|
+
if (pending && usableVersion(d.installed) && d.installed !== pending.from) {
|
|
57
70
|
sessionStorage.removeItem(PENDING_KEY);
|
|
58
71
|
setUpdateOk(`Updated to v${d.installed}`);
|
|
59
|
-
setTimeout(() => { if (active) window.location.reload(); },
|
|
72
|
+
setTimeout(() => { if (active) window.location.reload(); }, 2500);
|
|
60
73
|
return;
|
|
61
74
|
}
|
|
62
75
|
if (pending) {
|
|
63
|
-
|
|
76
|
+
// Keep the record: a reload asks again. Say what we know, claim nothing.
|
|
64
77
|
setInfo(d);
|
|
65
78
|
setDismissed(false);
|
|
66
79
|
setUpdateError(
|
|
67
|
-
`The update to v${pending.target}
|
|
68
|
-
+ `
|
|
80
|
+
`The update to v${pending.target} has not finished: ${usableVersion(d.installed) ? `Rev4a is still on v${d.installed}` : 'Rev4a is not answering with a version'}.`
|
|
81
|
+
+ ` It may still be running, or it failed — see ${d.log ?? 'update.log'} in the Rev4a data directory, then reload.`,
|
|
69
82
|
);
|
|
70
83
|
return;
|
|
71
84
|
}
|
|
72
|
-
if (d.updateAvailable) {
|
|
85
|
+
if (d.updateAvailable && usableVersion(d.latest)) {
|
|
73
86
|
setInfo(d);
|
|
74
|
-
|
|
75
|
-
setDismissed(saved === d.latest);
|
|
87
|
+
setDismissed(localStorage.getItem(DISMISS_KEY) === d.latest);
|
|
76
88
|
}
|
|
77
89
|
})
|
|
78
90
|
.catch(() => {});
|
|
@@ -80,14 +92,12 @@ export default function VersionBanner() {
|
|
|
80
92
|
}, []);
|
|
81
93
|
|
|
82
94
|
function handleDismiss() {
|
|
83
|
-
if (info?.latest)
|
|
84
|
-
localStorage.setItem(DISMISS_KEY, info.latest);
|
|
85
|
-
}
|
|
95
|
+
if (info?.latest) localStorage.setItem(DISMISS_KEY, info.latest);
|
|
86
96
|
setDismissed(true);
|
|
87
97
|
}
|
|
88
98
|
|
|
89
99
|
async function handleUpdate() {
|
|
90
|
-
if (!info) return;
|
|
100
|
+
if (!info || !usableVersion(info.installed)) return;
|
|
91
101
|
setUpdating(true);
|
|
92
102
|
setUpdateError(null);
|
|
93
103
|
setUpdateOk(null);
|
|
@@ -106,16 +116,22 @@ export default function VersionBanner() {
|
|
|
106
116
|
sessionStorage.setItem(PENDING_KEY, JSON.stringify({ target: info.latest ?? '', from: info.installed }));
|
|
107
117
|
localStorage.removeItem(DISMISS_KEY);
|
|
108
118
|
setUpdateOk('Update started. Rev4a is restarting…');
|
|
109
|
-
// The server goes down and comes back on the new version:
|
|
110
|
-
//
|
|
119
|
+
// The server goes down and comes back on the new version: reload once it answers
|
|
120
|
+
// with a different, readable version. If it never does, say so rather than reloading
|
|
121
|
+
// into a state the operator cannot interpret.
|
|
111
122
|
const deadline = Date.now() + WAIT_MS;
|
|
112
123
|
const poll = async () => {
|
|
113
|
-
if (Date.now() > deadline) { window.location.reload(); return; }
|
|
114
124
|
try {
|
|
115
125
|
const r = await fetch('/api/update-check', { cache: 'no-store' });
|
|
116
126
|
const d = (await r.json()) as VersionInfo;
|
|
117
|
-
if (d.installed !== info.installed) { window.location.reload(); return; }
|
|
127
|
+
if (usableVersion(d.installed) && d.installed !== info.installed) { window.location.reload(); return; }
|
|
118
128
|
} catch { /* the server is down for the update: expected */ }
|
|
129
|
+
if (Date.now() > deadline) {
|
|
130
|
+
setUpdating(false);
|
|
131
|
+
setUpdateOk(null);
|
|
132
|
+
setUpdateError('The update has not finished after two minutes. It may still be running — reload in a moment, or check update.log.');
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
119
135
|
setTimeout(poll, 3000);
|
|
120
136
|
};
|
|
121
137
|
setTimeout(poll, 3000);
|
|
@@ -138,30 +154,31 @@ export default function VersionBanner() {
|
|
|
138
154
|
<span className="update-banner__ok">{updateOk}</span>
|
|
139
155
|
) : updateError ? (
|
|
140
156
|
<span className="update-banner__err">{updateError}</span>
|
|
141
|
-
) :
|
|
157
|
+
) : (
|
|
142
158
|
<>
|
|
143
159
|
<span className="update-banner__label">Update available</span>
|
|
144
160
|
<span className="update-banner__sep">—</span>
|
|
145
|
-
<span className="update-banner__version">v{info
|
|
146
|
-
<span className="update-banner__sep">(installed: v{info
|
|
161
|
+
<span className="update-banner__version">v{info?.latest}</span>
|
|
162
|
+
<span className="update-banner__sep">(installed: v{info?.installed})</span>
|
|
147
163
|
</>
|
|
148
|
-
)
|
|
164
|
+
)}
|
|
149
165
|
</span>
|
|
150
166
|
|
|
151
167
|
{!updateOk && (
|
|
152
168
|
<div className="update-banner__actions">
|
|
153
169
|
{updateError ? (
|
|
154
|
-
<
|
|
170
|
+
<Button variant="primary" size="sm" onClick={handleUpdate} disabled={updating}>
|
|
155
171
|
{updating ? 'Updating…' : 'Retry'}
|
|
156
|
-
</
|
|
172
|
+
</Button>
|
|
157
173
|
) : (
|
|
158
|
-
<
|
|
174
|
+
<Button variant="primary" size="sm" onClick={handleUpdate} disabled={updating}>
|
|
159
175
|
{updating ? 'Updating…' : 'Update now'}
|
|
160
|
-
</
|
|
176
|
+
</Button>
|
|
177
|
+
)}
|
|
178
|
+
{updateError && (
|
|
179
|
+
<Button variant="secondary" size="sm" onClick={() => window.location.reload()}>Reload</Button>
|
|
161
180
|
)}
|
|
162
|
-
<
|
|
163
|
-
Dismiss
|
|
164
|
-
</button>
|
|
181
|
+
<Button variant="ghost" size="sm" onClick={handleDismiss}>Dismiss</Button>
|
|
165
182
|
</div>
|
|
166
183
|
)}
|
|
167
184
|
</div>
|
|
@@ -9,11 +9,16 @@ interface MetricProps {
|
|
|
9
9
|
subtitle?: ReactNode;
|
|
10
10
|
tone?: Tone;
|
|
11
11
|
loading?: boolean;
|
|
12
|
+
/**
|
|
13
|
+
* `sm` is the compact form for grids inside a dialog or a dense panel: same anatomy,
|
|
14
|
+
* smaller value and tighter spacing, so four of them fit on one line.
|
|
15
|
+
*/
|
|
16
|
+
size?: 'md' | 'sm';
|
|
12
17
|
}
|
|
13
18
|
|
|
14
|
-
export function Metric({ title, value, subtitle, tone = 'neutral', loading = false }: MetricProps) {
|
|
19
|
+
export function Metric({ title, value, subtitle, tone = 'neutral', loading = false, size = 'md' }: MetricProps) {
|
|
15
20
|
return (
|
|
16
|
-
<Surface as="article" tone={tone}>
|
|
21
|
+
<Surface as="article" tone={tone} className={size === 'sm' ? 'ui-metric--sm' : undefined}>
|
|
17
22
|
<div className="ui-kicker">{title}</div>
|
|
18
23
|
<div className="ui-metric-value">{loading ? <SkeletonMetric /> : value}</div>
|
|
19
24
|
{subtitle !== undefined && <div className="ui-muted ui-metric-subtitle">{loading ? <SkeletonLines count={1} /> : subtitle}</div>}
|