@flame0510/project-aether 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/agent-templates/README.md +42 -22
- package/agent-templates/base-image/Dockerfile +42 -33
- package/agent-templates/base-image/entrypoint.sh +67 -12
- package/app/agents/BrowserAccessSection.tsx +510 -0
- package/app/agents/ChannelManager.tsx +19 -11
- package/app/agents/ImageDownloadBanner.tsx +53 -19
- package/app/agents/ModelSection.tsx +316 -0
- package/app/agents/PageClient.tsx +708 -167
- package/app/agents/UpdateSection.tsx +300 -0
- package/app/agents/create/PageClient.tsx +11 -49
- package/app/agents/create/page.tsx +8 -21
- package/app/api/agents/[id]/backup/route.ts +26 -69
- package/app/api/agents/[id]/channels/pairing/route.ts +3 -3
- package/app/api/agents/[id]/channels/telegram/route.ts +2 -2
- package/app/api/agents/[id]/cold-backup/route.ts +56 -0
- package/app/api/agents/[id]/devices/route.ts +126 -0
- package/app/api/agents/[id]/invite-link/route.ts +53 -0
- package/app/api/agents/[id]/lifecycle/route.ts +3 -0
- package/app/api/agents/[id]/model/route.ts +113 -0
- package/app/api/agents/[id]/open-control-ui/route.ts +58 -0
- package/app/api/agents/[id]/recreate/route.ts +33 -187
- package/app/api/agents/[id]/restart/route.ts +5 -0
- package/app/api/agents/[id]/restore/route.ts +40 -70
- package/app/api/agents/[id]/route.ts +38 -169
- package/app/api/agents/[id]/update/rollback/route.ts +30 -0
- package/app/api/agents/[id]/update/route.ts +50 -0
- package/app/api/agents/activity-summary/route.ts +67 -0
- package/app/api/agents/create/route.ts +91 -145
- package/app/api/agents/devices-summary/route.ts +37 -0
- package/app/api/agents/download-image/route.ts +16 -9
- package/app/api/agents/image-status/route.ts +31 -111
- package/app/api/agents/models-summary/route.ts +163 -0
- package/app/api/agents/route.ts +25 -49
- package/app/api/agents/token/route.ts +33 -10
- package/app/api/assistant/route.ts +37 -16
- package/app/api/gateway/agent/route.ts +37 -6
- package/app/api/gateway/provider/balance/route.ts +5 -2
- package/app/api/gateway/provider/keys.ts +13 -1
- package/app/api/gateway/provider/route.ts +43 -12
- package/app/api/gateway/sync.ts +335 -76
- package/app/api/models/route.ts +28 -34
- package/app/api/provider/auth.ts +65 -0
- package/app/api/provider/upstream.ts +9 -2
- package/app/api/provider/v1/chat/completions/route.ts +22 -16
- package/app/api/provider/v1/models/route.ts +26 -133
- package/app/api/setup/agent-image/route.ts +14 -42
- package/app/components/DashboardToolbar.tsx +1 -1
- package/app/components/PulseChat.tsx +25 -39
- package/app/components/ui/RemoveButton.tsx +46 -0
- package/app/components/ui/Select.tsx +3 -2
- package/app/components/ui/index.ts +1 -0
- package/app/credentials/PageClient.tsx +2 -2
- package/app/gateway/PageClient.tsx +253 -674
- package/app/globals.css +8 -0
- package/app/lib/models-context.tsx +43 -7
- package/app/wizard/useWizard.ts +6 -1
- package/bin/rev4a.js +116 -50
- package/daemon.js +6 -6
- package/docs/ARCHITECTURE.md +110 -12
- package/docs/FRONTEND-ARCHITECTURE.md +31 -2
- package/docs/REV4A.md +93 -33
- package/docs/dev/API-REFERENCE.md +723 -178
- package/docs/dev/DATABASE.md +96 -0
- package/docs/dev/GATEWAY.md +250 -93
- package/docs/dev/PROVIDERS.md +26 -13
- package/docs/rag/DATA-FRESHNESS.md +59 -28
- package/docs/rag/GLOSSARY.md +27 -16
- package/docs/rag/REV4A-OVERVIEW.md +37 -25
- package/docs/rag/WHAT-I-CAN-ANSWER.md +10 -8
- package/instrumentation.ts +52 -1
- package/lib/agent-busy.ts +21 -0
- package/lib/agent-devices.ts +361 -0
- package/lib/agent-edit-state.ts +108 -0
- package/lib/agent-edit.ts +157 -0
- package/lib/agent-images.ts +375 -0
- package/lib/agent-ports-server.ts +27 -0
- package/lib/agent-ports.ts +68 -0
- package/lib/agent-readiness.ts +110 -0
- package/lib/agent-recreate-state.ts +108 -0
- package/lib/agent-recreate.ts +305 -0
- package/lib/agent-restore-state.ts +107 -0
- package/lib/agent-restore.ts +135 -0
- package/lib/agent-setup.ts +66 -17
- package/lib/agent-update-state.ts +122 -0
- package/lib/agent-update.ts +448 -0
- package/lib/agent-versions.json +14 -0
- package/lib/agent-versions.ts +80 -0
- package/lib/buildAgentImage.ts +88 -290
- package/lib/channelManager.ts +153 -64
- package/lib/cold-backup.ts +354 -0
- package/lib/container-file.ts +27 -0
- package/lib/credentials/delivery.ts +3 -3
- package/lib/db-bootstrap.mjs +76 -0
- package/lib/docker-utils.ts +3 -3
- package/lib/model-catalogue.ts +140 -27
- package/lib/provider-balance.ts +33 -12
- package/lib/rev4a-paths.ts +0 -21
- package/model-pricing.json +118 -110
- package/models.config.json +27 -12
- package/package.json +1 -1
- package/app/api/gateway/route.ts +0 -191
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Agent base image banner on the Agents page.
|
|
5
|
+
*
|
|
6
|
+
* Shown when no supported OpenClaw version of the agent image is downloaded, when
|
|
7
|
+
* the registry publishes a newer supported version, and while a download runs.
|
|
8
|
+
* Downloading touches no agent: each agent keeps its OpenClaw version until it is
|
|
9
|
+
* updated on its own.
|
|
10
|
+
*/
|
|
3
11
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
4
12
|
import { Button } from '../components/ui';
|
|
5
13
|
|
|
@@ -7,16 +15,23 @@ interface ImageStatus {
|
|
|
7
15
|
exists: boolean;
|
|
8
16
|
needsUpdate: boolean;
|
|
9
17
|
downloading: boolean;
|
|
18
|
+
downloadingVersion: string | null;
|
|
19
|
+
localVersions: string[];
|
|
20
|
+
newestLocal: string | null;
|
|
21
|
+
/** Newest supported version on the registry that is not downloaded yet. */
|
|
22
|
+
available: string | null;
|
|
10
23
|
}
|
|
11
24
|
|
|
12
25
|
const POLL_MS = 2000;
|
|
13
26
|
|
|
14
27
|
export default function ImageDownloadBanner({ onImageReady }: { onImageReady?: () => void }) {
|
|
15
|
-
const [status, setStatus] = useState<ImageStatus>({
|
|
28
|
+
const [status, setStatus] = useState<ImageStatus>({
|
|
29
|
+
exists: true, needsUpdate: false, downloading: false, downloadingVersion: null, localVersions: [], newestLocal: null, available: null,
|
|
30
|
+
});
|
|
16
31
|
const [error, setError] = useState<string | null>(null);
|
|
17
|
-
const [justCompleted, setJustCompleted] = useState(
|
|
32
|
+
const [justCompleted, setJustCompleted] = useState<string | null>(null);
|
|
18
33
|
const [actionLoading, setActionLoading] = useState(false);
|
|
19
|
-
const wasDownloadingRef = useRef(
|
|
34
|
+
const wasDownloadingRef = useRef<string | null>(null);
|
|
20
35
|
const completedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
21
36
|
|
|
22
37
|
useEffect(() => {
|
|
@@ -32,15 +47,20 @@ export default function ImageDownloadBanner({ onImageReady }: { onImageReady?: (
|
|
|
32
47
|
|
|
33
48
|
if (data.downloading) {
|
|
34
49
|
setError(null);
|
|
50
|
+
wasDownloadingRef.current = data.downloadingVersion ?? wasDownloadingRef.current ?? 'image';
|
|
35
51
|
} else if (wasDownloadingRef.current) {
|
|
36
|
-
|
|
52
|
+
const finished = wasDownloadingRef.current;
|
|
53
|
+
wasDownloadingRef.current = null;
|
|
37
54
|
setActionLoading(false);
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
55
|
+
if (data.localVersions?.includes(finished)) {
|
|
56
|
+
setJustCompleted(finished);
|
|
57
|
+
onImageReady?.();
|
|
58
|
+
if (completedTimerRef.current) clearTimeout(completedTimerRef.current);
|
|
59
|
+
completedTimerRef.current = setTimeout(() => setJustCompleted(null), 3000);
|
|
60
|
+
} else {
|
|
61
|
+
setError(`The OpenClaw ${finished} download did not complete. Check the Docker connection and try again.`);
|
|
62
|
+
}
|
|
41
63
|
}
|
|
42
|
-
|
|
43
|
-
wasDownloadingRef.current = data.downloading;
|
|
44
64
|
} catch { /* ignore */ }
|
|
45
65
|
|
|
46
66
|
if (active) setTimeout(tick, POLL_MS);
|
|
@@ -56,16 +76,23 @@ export default function ImageDownloadBanner({ onImageReady }: { onImageReady?: (
|
|
|
56
76
|
};
|
|
57
77
|
}, []);
|
|
58
78
|
|
|
59
|
-
const handleDownload = useCallback(async () => {
|
|
79
|
+
const handleDownload = useCallback(async (version: string | null) => {
|
|
60
80
|
setActionLoading(true);
|
|
61
81
|
setError(null);
|
|
62
82
|
try {
|
|
63
|
-
const res = await fetch('/api/agents/download-image', {
|
|
83
|
+
const res = await fetch('/api/agents/download-image', {
|
|
84
|
+
method: 'POST',
|
|
85
|
+
headers: { 'Content-Type': 'application/json' },
|
|
86
|
+
body: JSON.stringify(version ? { version } : {}),
|
|
87
|
+
});
|
|
64
88
|
if (!res.ok) {
|
|
65
89
|
const data = await res.json().catch(() => ({ error: 'Failed to start download' }));
|
|
66
90
|
setError(data.error || 'Failed to start download');
|
|
67
91
|
setActionLoading(false);
|
|
92
|
+
return;
|
|
68
93
|
}
|
|
94
|
+
const data = await res.json().catch(() => ({})) as { version?: string };
|
|
95
|
+
wasDownloadingRef.current = data.version ?? version ?? 'image';
|
|
69
96
|
} catch (e: unknown) {
|
|
70
97
|
setError((e as Error).message || 'Download failed');
|
|
71
98
|
setActionLoading(false);
|
|
@@ -79,6 +106,7 @@ export default function ImageDownloadBanner({ onImageReady }: { onImageReady?: (
|
|
|
79
106
|
|
|
80
107
|
// ─── Downloading ───
|
|
81
108
|
if (isDownloading) {
|
|
109
|
+
const version = status.downloadingVersion ?? status.available;
|
|
82
110
|
return (
|
|
83
111
|
<Banner downloading>
|
|
84
112
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1 }}>
|
|
@@ -89,7 +117,9 @@ export default function ImageDownloadBanner({ onImageReady }: { onImageReady?: (
|
|
|
89
117
|
animation: 'pulse 1.5s ease-in-out infinite',
|
|
90
118
|
flexShrink: 0,
|
|
91
119
|
}} />
|
|
92
|
-
<span style={{ color: 'var(--violet)', fontWeight: 500 }}>
|
|
120
|
+
<span style={{ color: 'var(--violet)', fontWeight: 500 }}>
|
|
121
|
+
{version ? `Downloading the OpenClaw ${version} agent image…` : 'Downloading the agent image…'}
|
|
122
|
+
</span>
|
|
93
123
|
</div>
|
|
94
124
|
<div style={{
|
|
95
125
|
flex: 1, maxWidth: 120, height: 3,
|
|
@@ -107,11 +137,11 @@ export default function ImageDownloadBanner({ onImageReady }: { onImageReady?: (
|
|
|
107
137
|
}
|
|
108
138
|
|
|
109
139
|
// ─── Just completed ───
|
|
110
|
-
if (justCompleted
|
|
140
|
+
if (justCompleted) {
|
|
111
141
|
return (
|
|
112
142
|
<Banner success>
|
|
113
143
|
<div style={{ flex: 1 }}>
|
|
114
|
-
<span style={{ color: '#22c55e', fontWeight: 500 }}>✓
|
|
144
|
+
<span style={{ color: '#22c55e', fontWeight: 500 }}>✓ OpenClaw {justCompleted} agent image ready</span>
|
|
115
145
|
</div>
|
|
116
146
|
</Banner>
|
|
117
147
|
);
|
|
@@ -122,16 +152,20 @@ export default function ImageDownloadBanner({ onImageReady }: { onImageReady?: (
|
|
|
122
152
|
<Banner warning>
|
|
123
153
|
<div style={{ flex: 1 }}>
|
|
124
154
|
<strong style={{ color: '#fbbf24' }}>
|
|
125
|
-
{status.exists
|
|
155
|
+
{status.exists
|
|
156
|
+
? `⚠ OpenClaw ${status.available} is available.`
|
|
157
|
+
: '⚠ Agent base image not found.'}
|
|
126
158
|
</strong>
|
|
127
159
|
{' '}{status.exists
|
|
128
|
-
? '
|
|
160
|
+
? 'Download it for new agents; existing agents keep their version until they are updated.'
|
|
129
161
|
: 'Download it to create agent containers.'}
|
|
130
162
|
{error && <div style={{ color: '#ef4444', fontSize: 11, marginTop: 4 }}>{error}</div>}
|
|
131
163
|
</div>
|
|
132
|
-
|
|
133
|
-
{
|
|
134
|
-
|
|
164
|
+
{(status.exists ? status.available : true) && (
|
|
165
|
+
<Button variant="primary" size="sm" onClick={() => { void handleDownload(status.available); }} loading={actionLoading}>
|
|
166
|
+
{actionLoading ? 'Starting…' : status.exists ? `Download ${status.available}` : 'Download Image'}
|
|
167
|
+
</Button>
|
|
168
|
+
)}
|
|
135
169
|
</Banner>
|
|
136
170
|
);
|
|
137
171
|
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Model section of the agent detail panel.
|
|
5
|
+
*
|
|
6
|
+
* Primary model and fallbacks used to live on the Gateway page, which mixed two
|
|
7
|
+
* unrelated jobs: provider keys and catalogue (system configuration) and
|
|
8
|
+
* per-agent model assignment (one agent's configuration). This is the second.
|
|
9
|
+
*
|
|
10
|
+
* Fallbacks matter more than they look. The create wizard has never set them: it
|
|
11
|
+
* holds `const [fallbacks] = useState<string[]>([])` and nothing writes to that
|
|
12
|
+
* state. The only UI that could was the Gateway Agents tab this panel replaced,
|
|
13
|
+
* and in practice no agent carries any. When a
|
|
14
|
+
* model is disabled on the Gateway page while some agent still has it as
|
|
15
|
+
* primary, that agent has nothing to fall back to and goes silent on its next
|
|
16
|
+
* turn. This panel surfaces exactly that condition.
|
|
17
|
+
*
|
|
18
|
+
* `agents` is in OpenClaw's no-restart column, so saving applies immediately
|
|
19
|
+
* with no container restart.
|
|
20
|
+
*/
|
|
21
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
22
|
+
import { Button, RemoveButton, Select } from '../components/ui';
|
|
23
|
+
|
|
24
|
+
interface ModelOption {
|
|
25
|
+
id: string;
|
|
26
|
+
label: string;
|
|
27
|
+
input: number | null;
|
|
28
|
+
output: number | null;
|
|
29
|
+
/** Upstream retired the name. It still answers, so this warns rather than blocks. */
|
|
30
|
+
deprecated: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface ProviderGroup {
|
|
34
|
+
provider: string;
|
|
35
|
+
emoji: string;
|
|
36
|
+
models: ModelOption[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface AgentModel {
|
|
40
|
+
reachable: boolean;
|
|
41
|
+
primary: string | null;
|
|
42
|
+
fallbacks: string[];
|
|
43
|
+
resolvable: boolean | null;
|
|
44
|
+
catalogueSize?: number;
|
|
45
|
+
/** Server-side cause for a failing primary; absent on the unreachable branch. */
|
|
46
|
+
primaryStatus?: 'offered' | 'disabled' | 'missing' | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Strip the `rev4a/` prefix the container config stores, for comparison with catalogue ids. */
|
|
50
|
+
const bare = (id: string) => id.replace(/^rev4a\//, '');
|
|
51
|
+
|
|
52
|
+
function priceLabel(m: ModelOption): string {
|
|
53
|
+
if (m.input === null && m.output === null) return '';
|
|
54
|
+
const fmt = (n: number | null) => (n === null ? '?' : `$${n}`);
|
|
55
|
+
return ` · ${fmt(m.input)} / ${fmt(m.output)} per 1M`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A deprecated model is still selectable — it works — but picking one by accident
|
|
60
|
+
* is exactly how an agent ends up on a name upstream no longer honours. The tag
|
|
61
|
+
* sits next to the name it qualifies, not at the head of the row, so the
|
|
62
|
+
* provider grouping stays scannable.
|
|
63
|
+
*/
|
|
64
|
+
function optionLabel(m: ModelOption): string {
|
|
65
|
+
return `${m.label}${m.deprecated ? ' (deprecated)' : ''}${priceLabel(m)}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export default function ModelSection({
|
|
69
|
+
agentId,
|
|
70
|
+
onSaved,
|
|
71
|
+
agentBusy,
|
|
72
|
+
}: {
|
|
73
|
+
agentId: string;
|
|
74
|
+
/** Called after a successful save so the agent list can refresh its chip. */
|
|
75
|
+
onSaved?: () => void;
|
|
76
|
+
/** A long operation is running on this agent: saving would race it. */
|
|
77
|
+
agentBusy?: boolean;
|
|
78
|
+
}) {
|
|
79
|
+
const [groups, setGroups] = useState<ProviderGroup[]>([]);
|
|
80
|
+
const [current, setCurrent] = useState<AgentModel | null>(null);
|
|
81
|
+
const [primary, setPrimary] = useState<string>('');
|
|
82
|
+
const [fallbacks, setFallbacks] = useState<string[]>([]);
|
|
83
|
+
const [loading, setLoading] = useState(true);
|
|
84
|
+
const [saving, setSaving] = useState(false);
|
|
85
|
+
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
|
|
86
|
+
|
|
87
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
88
|
+
|
|
89
|
+
const load = useCallback(async () => {
|
|
90
|
+
// Cancel any in-flight load: this fires on mount and after every save, and
|
|
91
|
+
// a stale response arriving late would overwrite fresher state.
|
|
92
|
+
abortRef.current?.abort();
|
|
93
|
+
const controller = new AbortController();
|
|
94
|
+
abortRef.current = controller;
|
|
95
|
+
|
|
96
|
+
setLoading(true);
|
|
97
|
+
try {
|
|
98
|
+
const [mRes, aRes] = await Promise.all([
|
|
99
|
+
fetch('/api/models', { signal: controller.signal }),
|
|
100
|
+
fetch(`/api/agents/${encodeURIComponent(agentId)}/model`, { signal: controller.signal }),
|
|
101
|
+
]);
|
|
102
|
+
if (!mRes.ok || !aRes.ok) {
|
|
103
|
+
// Say so. An empty picker with no message reads as "no models available",
|
|
104
|
+
// which is a different problem with a different fix.
|
|
105
|
+
setMsg({
|
|
106
|
+
text: `Could not load model configuration (${!mRes.ok ? `catalogue HTTP ${mRes.status}` : `agent HTTP ${aRes.status}`})`,
|
|
107
|
+
ok: false,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
if (mRes.ok) setGroups((await mRes.json()) as ProviderGroup[]);
|
|
111
|
+
if (aRes.ok) {
|
|
112
|
+
const data = (await aRes.json()) as AgentModel;
|
|
113
|
+
setCurrent(data);
|
|
114
|
+
setPrimary(data.primary ? bare(data.primary) : '');
|
|
115
|
+
setFallbacks((data.fallbacks ?? []).map(bare));
|
|
116
|
+
}
|
|
117
|
+
} catch (e) {
|
|
118
|
+
if ((e as Error)?.name !== 'AbortError') setMsg({ text: 'Could not load model configuration', ok: false });
|
|
119
|
+
}
|
|
120
|
+
// Only the load that is still current may end the loading state. A save
|
|
121
|
+
// triggers a reload that aborts the previous one; the aborted request lands in
|
|
122
|
+
// the catch above and used to switch off the spinner the new one had just
|
|
123
|
+
// switched on.
|
|
124
|
+
if (abortRef.current === controller) setLoading(false);
|
|
125
|
+
}, [agentId]);
|
|
126
|
+
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
void load();
|
|
129
|
+
return () => abortRef.current?.abort();
|
|
130
|
+
}, [load]);
|
|
131
|
+
|
|
132
|
+
const allModels = groups.flatMap((g) => g.models);
|
|
133
|
+
const byId = new Map(allModels.map((m) => [m.id, m]));
|
|
134
|
+
const dirty =
|
|
135
|
+
primary !== (current?.primary ? bare(current.primary) : '') ||
|
|
136
|
+
fallbacks.join(',') !== (current?.fallbacks ?? []).map(bare).join(',');
|
|
137
|
+
|
|
138
|
+
/** Return the form to what the container actually holds. */
|
|
139
|
+
const reset = () => {
|
|
140
|
+
setPrimary(current?.primary ? bare(current.primary) : '');
|
|
141
|
+
setFallbacks((current?.fallbacks ?? []).map(bare));
|
|
142
|
+
setMsg(null);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const save = async () => {
|
|
146
|
+
setSaving(true);
|
|
147
|
+
setMsg(null);
|
|
148
|
+
try {
|
|
149
|
+
const res = await fetch('/api/gateway/agent', {
|
|
150
|
+
method: 'PUT',
|
|
151
|
+
headers: { 'Content-Type': 'application/json' },
|
|
152
|
+
body: JSON.stringify({ containerName: agentId, model: primary, fallbacks }),
|
|
153
|
+
});
|
|
154
|
+
const data = await res.json().catch(() => ({}));
|
|
155
|
+
if (res.ok && data.success !== false) {
|
|
156
|
+
setMsg({ text: 'Model saved. It applies immediately, no restart needed.', ok: true });
|
|
157
|
+
await load();
|
|
158
|
+
// The card in the list reads a separate summary endpoint; without this it
|
|
159
|
+
// keeps showing the previous model until the next poll.
|
|
160
|
+
onSaved?.();
|
|
161
|
+
} else {
|
|
162
|
+
setMsg({ text: data.error || 'Save failed', ok: false });
|
|
163
|
+
}
|
|
164
|
+
} catch (e) {
|
|
165
|
+
setMsg({ text: (e as Error).message, ok: false });
|
|
166
|
+
}
|
|
167
|
+
setSaving(false);
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
// The current primary may no longer be on offer: unchecked on the Gateway page,
|
|
171
|
+
// or its provider key removed. A native select whose value matches no option
|
|
172
|
+
// renders as though nothing were chosen, contradicting the warning above it.
|
|
173
|
+
// Show the real value, disabled so it cannot be picked again. Guarded on a
|
|
174
|
+
// loaded catalogue so a failed fetch does not flag every model.
|
|
175
|
+
const primaryUnavailable = !!primary && groups.length > 0 && !byId.has(primary);
|
|
176
|
+
const options = [
|
|
177
|
+
{ value: '', label: '— select a model —' },
|
|
178
|
+
...(primaryUnavailable ? [{ value: primary, label: `${primary} (${current?.primaryStatus === 'missing' ? 'no longer in catalogue' : 'not enabled'})`, disabled: true }] : []),
|
|
179
|
+
...groups.flatMap((g) =>
|
|
180
|
+
g.models.map((m) => ({ value: m.id, label: `${g.provider} · ${optionLabel(m)}` })),
|
|
181
|
+
),
|
|
182
|
+
];
|
|
183
|
+
|
|
184
|
+
// Two different failures, two different remedies. The panel used to show one
|
|
185
|
+
// message for both and guess "disabled on the Gateway page" — wrong when the
|
|
186
|
+
// model is enabled and a sync simply never reached this container, which is
|
|
187
|
+
// exactly what a toggle made while Docker was stopped produces.
|
|
188
|
+
const containerLacksIt = !!current?.reachable && current.resolvable === false;
|
|
189
|
+
// The server states the cause; the offered list is only a fallback for when it
|
|
190
|
+
// did not, so the panel and the card chip cannot disagree about why.
|
|
191
|
+
const status = current?.primaryStatus;
|
|
192
|
+
const warnRemoved = status === 'missing';
|
|
193
|
+
const warnNotEnabled = status === 'disabled' || (status == null && primaryUnavailable);
|
|
194
|
+
const warnNotSynced = containerLacksIt && (status === 'offered' || (status == null && groups.length > 0 && !primaryUnavailable));
|
|
195
|
+
const warnUnknown = containerLacksIt && status == null && groups.length === 0;
|
|
196
|
+
|
|
197
|
+
return (
|
|
198
|
+
<div style={{ padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
|
199
|
+
{loading && <div style={{ fontSize: 11, color: '#888' }}>Loading…</div>}
|
|
200
|
+
|
|
201
|
+
{!loading && current && !current.reachable && (
|
|
202
|
+
<div style={{ fontSize: 11, color: '#888' }}>
|
|
203
|
+
Container not reachable — start the agent to read or change its model.
|
|
204
|
+
</div>
|
|
205
|
+
)}
|
|
206
|
+
|
|
207
|
+
{!loading && current?.reachable && (
|
|
208
|
+
<>
|
|
209
|
+
{(warnRemoved || warnNotEnabled || warnNotSynced || warnUnknown) && (
|
|
210
|
+
<div style={{
|
|
211
|
+
border: '1px solid rgba(239,68,68,0.35)', background: 'rgba(239,68,68,0.06)',
|
|
212
|
+
padding: '8px 10px', fontSize: 11, color: '#ef4444', lineHeight: 1.5,
|
|
213
|
+
}}>
|
|
214
|
+
{warnRemoved && (
|
|
215
|
+
<>
|
|
216
|
+
<strong>This agent’s model is no longer in the catalogue.</strong> It is
|
|
217
|
+
set to <code>{current.primary}</code>, which this version of Rev4a no longer
|
|
218
|
+
offers. The gateway refuses it, so the agent fails on its next turn. Pick a
|
|
219
|
+
model below.
|
|
220
|
+
</>
|
|
221
|
+
)}
|
|
222
|
+
{warnNotEnabled && (
|
|
223
|
+
<>
|
|
224
|
+
<strong>This agent’s model is not enabled.</strong> It is set to{' '}
|
|
225
|
+
<code>{current.primary}</code>, which the Gateway does not offer — unchecked
|
|
226
|
+
there, or its provider has no key. The gateway refuses it, so the agent fails
|
|
227
|
+
on its next turn. Pick a model below.
|
|
228
|
+
</>
|
|
229
|
+
)}
|
|
230
|
+
{warnNotSynced && (
|
|
231
|
+
<>
|
|
232
|
+
<strong>This container is out of sync.</strong> Its model{' '}
|
|
233
|
+
<code>{current.primary}</code> is enabled on the Gateway but not among the{' '}
|
|
234
|
+
{current.catalogueSize} models synced to this container — a sync did not reach
|
|
235
|
+
it. Run <em>Sync All Agents</em> on the Gateway page; the model does not need to
|
|
236
|
+
change.
|
|
237
|
+
</>
|
|
238
|
+
)}
|
|
239
|
+
{warnUnknown && (
|
|
240
|
+
<>
|
|
241
|
+
<strong>This agent’s model is not in its catalogue.</strong> It is set to{' '}
|
|
242
|
+
<code>{current.primary}</code>, which the {current.catalogueSize} models synced
|
|
243
|
+
to this container do not include. The model list could not be loaded, so the
|
|
244
|
+
cause is unknown.
|
|
245
|
+
</>
|
|
246
|
+
)}
|
|
247
|
+
</div>
|
|
248
|
+
)}
|
|
249
|
+
|
|
250
|
+
<Select
|
|
251
|
+
label="Primary model"
|
|
252
|
+
options={options}
|
|
253
|
+
value={primary}
|
|
254
|
+
onChange={(e) => setPrimary(e.target.value)}
|
|
255
|
+
/>
|
|
256
|
+
|
|
257
|
+
<div>
|
|
258
|
+
<div style={{ fontSize: 10, color: '#888', letterSpacing: '0.06em', marginBottom: 4 }}>
|
|
259
|
+
FALLBACKS
|
|
260
|
+
</div>
|
|
261
|
+
{fallbacks.length === 0 && (
|
|
262
|
+
<div style={{ fontSize: 11, color: '#888', marginBottom: 6 }}>
|
|
263
|
+
None set. Without a fallback, the agent goes silent if its primary
|
|
264
|
+
becomes unavailable.
|
|
265
|
+
</div>
|
|
266
|
+
)}
|
|
267
|
+
{fallbacks.map((f, i) => (
|
|
268
|
+
<div key={`${f}-${i}`} style={{ display: 'flex', gap: 6, alignItems: 'center', marginBottom: 4 }}>
|
|
269
|
+
<span style={{ fontSize: 10, color: '#666', minWidth: 12 }}>{i + 1}.</span>
|
|
270
|
+
<span style={{ fontSize: 11, flex: 1, wordBreak: 'break-all' }}>
|
|
271
|
+
{f}
|
|
272
|
+
{byId.get(f)?.deprecated && (
|
|
273
|
+
<span style={{ color: '#eab308' }}> (deprecated)</span>
|
|
274
|
+
)}
|
|
275
|
+
{groups.length > 0 && !byId.has(f) && (
|
|
276
|
+
<span style={{ color: '#ef4444' }}> (not enabled)</span>
|
|
277
|
+
)}
|
|
278
|
+
</span>
|
|
279
|
+
<RemoveButton
|
|
280
|
+
label={`fallback ${f}`}
|
|
281
|
+
title="Remove from the list — applied when you save"
|
|
282
|
+
onClick={() => setFallbacks(fallbacks.filter((_, j) => j !== i))}
|
|
283
|
+
/>
|
|
284
|
+
</div>
|
|
285
|
+
))}
|
|
286
|
+
<Select
|
|
287
|
+
options={[
|
|
288
|
+
{ value: '', label: '— add a fallback —' },
|
|
289
|
+
...allModels
|
|
290
|
+
.filter((m) => m.id !== primary && !fallbacks.includes(m.id))
|
|
291
|
+
.map((m) => ({ value: m.id, label: optionLabel(m) })),
|
|
292
|
+
]}
|
|
293
|
+
value=""
|
|
294
|
+
onChange={(e) => { if (e.target.value) setFallbacks([...fallbacks, e.target.value]); }}
|
|
295
|
+
/>
|
|
296
|
+
</div>
|
|
297
|
+
|
|
298
|
+
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
|
299
|
+
<Button variant="primary" size="sm" loading={saving} disabled={!dirty || !primary || primaryUnavailable || !!agentBusy} onClick={save}>
|
|
300
|
+
{saving ? 'Saving…' : 'Save model'}
|
|
301
|
+
</Button>
|
|
302
|
+
{dirty && !saving && (
|
|
303
|
+
<>
|
|
304
|
+
<span style={{ fontSize: 11, color: '#eab308' }}>Unsaved changes</span>
|
|
305
|
+
<Button variant="secondary" size="sm" onClick={reset}>Discard</Button>
|
|
306
|
+
</>
|
|
307
|
+
)}
|
|
308
|
+
{msg && (
|
|
309
|
+
<span style={{ fontSize: 11, color: msg.ok ? '#4ade80' : '#ef4444' }}>{msg.text}</span>
|
|
310
|
+
)}
|
|
311
|
+
</div>
|
|
312
|
+
</>
|
|
313
|
+
)}
|
|
314
|
+
</div>
|
|
315
|
+
);
|
|
316
|
+
}
|