@flame0510/project-aether 1.1.9
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 +150 -0
- package/agent-templates/README.md +37 -0
- package/agent-templates/argus/AGENTS.md +52 -0
- package/agent-templates/argus/IDENTITY.md +7 -0
- package/agent-templates/argus/MEMORY.md +9 -0
- package/agent-templates/argus/SOUL.md +24 -0
- package/agent-templates/argus/TOOLS.md +8 -0
- package/agent-templates/atlas/AGENTS.md +47 -0
- package/agent-templates/atlas/HEARTBEAT.md +10 -0
- package/agent-templates/atlas/IDENTITY.md +6 -0
- package/agent-templates/atlas/MEMORY.md +9 -0
- package/agent-templates/atlas/SOUL.md +21 -0
- package/agent-templates/atlas/TOOLS.md +4 -0
- package/agent-templates/base-image/.trigger +0 -0
- package/agent-templates/base-image/Dockerfile +90 -0
- package/agent-templates/base-image/entrypoint.sh +37 -0
- package/agent-templates/custom/AGENTS.md +29 -0
- package/agent-templates/custom/BOOTSTRAP.md +56 -0
- package/agent-templates/custom/MEMORY.md +8 -0
- package/agent-templates/prometheus/AGENTS.md +37 -0
- package/agent-templates/prometheus/IDENTITY.md +6 -0
- package/agent-templates/prometheus/MEMORY.md +9 -0
- package/agent-templates/prometheus/SOUL.md +21 -0
- package/agent-templates/prometheus/TOOLS.md +4 -0
- package/app/agents/ChannelManager.tsx +465 -0
- package/app/agents/ImageDownloadBanner.tsx +159 -0
- package/app/agents/PageClient.tsx +1285 -0
- package/app/agents/create/PageClient.tsx +437 -0
- package/app/agents/create/page.tsx +126 -0
- package/app/agents/loading.tsx +14 -0
- package/app/agents/page.tsx +5 -0
- package/app/api/agents/[id]/backup/route.ts +169 -0
- package/app/api/agents/[id]/channels/pairing/route.ts +69 -0
- package/app/api/agents/[id]/channels/route.ts +14 -0
- package/app/api/agents/[id]/channels/telegram/route.ts +50 -0
- package/app/api/agents/[id]/lifecycle/route.ts +94 -0
- package/app/api/agents/[id]/recreate/route.ts +199 -0
- package/app/api/agents/[id]/restart/route.ts +33 -0
- package/app/api/agents/[id]/restore/route.ts +81 -0
- package/app/api/agents/[id]/route.ts +280 -0
- package/app/api/agents/channels-summary/route.ts +39 -0
- package/app/api/agents/config-json/route.ts +58 -0
- package/app/api/agents/create/route.ts +488 -0
- package/app/api/agents/download-image/route.ts +32 -0
- package/app/api/agents/image-status/route.ts +126 -0
- package/app/api/agents/route.ts +213 -0
- package/app/api/agents/token/route.ts +94 -0
- package/app/api/agents-active/route.ts +190 -0
- package/app/api/agents-config/route.ts +629 -0
- package/app/api/alerts/smoke/route.ts +26 -0
- package/app/api/aliases/add/route.ts +28 -0
- package/app/api/aliases/remove/route.ts +27 -0
- package/app/api/assistant/route.ts +204 -0
- package/app/api/auth/check/route.ts +38 -0
- package/app/api/auth/login/route.ts +85 -0
- package/app/api/auth/logout/route.ts +7 -0
- package/app/api/auth/status/route.ts +33 -0
- package/app/api/config/env/route.ts +132 -0
- package/app/api/config/restart/route.ts +26 -0
- package/app/api/containers/route.ts +75 -0
- package/app/api/cost-override/route.ts +46 -0
- package/app/api/costs/route.ts +84 -0
- package/app/api/credentials/[id]/reveal/route.ts +21 -0
- package/app/api/credentials/[id]/route.ts +52 -0
- package/app/api/credentials/[id]/sync/route.ts +58 -0
- package/app/api/credentials/detect/route.ts +246 -0
- package/app/api/credentials/route.ts +33 -0
- package/app/api/crons/[id]/route.ts +69 -0
- package/app/api/crons/route.ts +12 -0
- package/app/api/crons/runs/route.ts +31 -0
- package/app/api/debug/route.ts +11 -0
- package/app/api/envcheck/route.ts +18 -0
- package/app/api/events/route.ts +24 -0
- package/app/api/gateway/agent/route.ts +154 -0
- package/app/api/gateway/provider/balance/route.ts +38 -0
- package/app/api/gateway/provider/keys.ts +38 -0
- package/app/api/gateway/provider/oauth/route.ts +304 -0
- package/app/api/gateway/provider/route.ts +209 -0
- package/app/api/gateway/route.ts +223 -0
- package/app/api/gateway/sync.ts +189 -0
- package/app/api/lineage/route.ts +54 -0
- package/app/api/memory-context/route.ts +15 -0
- package/app/api/metrics/route.ts +35 -0
- package/app/api/models/route.ts +77 -0
- package/app/api/plugins/route.js +37 -0
- package/app/api/provider/upstream.ts +142 -0
- package/app/api/provider/v1/chat/completions/route.ts +146 -0
- package/app/api/provider/v1/models/route.ts +167 -0
- package/app/api/provider-usage/route.ts +629 -0
- package/app/api/session/route.ts +39 -0
- package/app/api/sessions/[id]/route.ts +38 -0
- package/app/api/sessions/route.ts +23 -0
- package/app/api/setup/agent-image/route.ts +102 -0
- package/app/api/setup/password/route.ts +111 -0
- package/app/api/setup/restart/route.ts +76 -0
- package/app/api/skills/delete/route.js +105 -0
- package/app/api/skills/promote/route.js +129 -0
- package/app/api/skills/route.js +203 -0
- package/app/api/skills/save/route.js +142 -0
- package/app/api/stats/route.ts +34 -0
- package/app/api/stats-since/route.ts +20 -0
- package/app/api/stream/route.ts +70 -0
- package/app/api/system-health/route.ts +233 -0
- package/app/api/tool-calls/route.ts +24 -0
- package/app/api/tools-config/route.ts +163 -0
- package/app/api/update-check/route.ts +127 -0
- package/app/api/version/route.ts +25 -0
- package/app/api/wizard/complete/route.ts +32 -0
- package/app/api/wizard/reset/route.ts +28 -0
- package/app/api/wizard/status/route.ts +28 -0
- package/app/api/workspace/route.ts +392 -0
- package/app/api/workspace/stream/route.ts +136 -0
- package/app/components/AppShell.tsx +33 -0
- package/app/components/AuthGuard.tsx +91 -0
- package/app/components/CostBreakdown.tsx +37 -0
- package/app/components/DashboardHeader.tsx +73 -0
- package/app/components/DashboardLayout.tsx +76 -0
- package/app/components/DashboardToolbar.tsx +61 -0
- package/app/components/FloatingActions.tsx +33 -0
- package/app/components/Icons.tsx +78 -0
- package/app/components/LineageGraphPage.tsx +142 -0
- package/app/components/LiveFeed.tsx +48 -0
- package/app/components/MobileBottomNav.tsx +84 -0
- package/app/components/ModelPickerModal.tsx +94 -0
- package/app/components/PasswordInput.tsx +86 -0
- package/app/components/PulseChat.tsx +362 -0
- package/app/components/Rev4aLoader.tsx +48 -0
- package/app/components/SessionDrawer.tsx +263 -0
- package/app/components/SessionTopology.tsx +289 -0
- package/app/components/Sidebar.tsx +294 -0
- package/app/components/Skeleton.tsx +133 -0
- package/app/components/SystemCockpit.tsx +169 -0
- package/app/components/VersionBanner.tsx +105 -0
- package/app/components/WizardButton.tsx +30 -0
- package/app/components/ui/Badge.tsx +30 -0
- package/app/components/ui/Button.tsx +59 -0
- package/app/components/ui/ConfirmModal.tsx +46 -0
- package/app/components/ui/FilterBar.tsx +33 -0
- package/app/components/ui/Input.tsx +25 -0
- package/app/components/ui/ItemList.tsx +83 -0
- package/app/components/ui/LoadingSpinner.tsx +30 -0
- package/app/components/ui/Metric.tsx +22 -0
- package/app/components/ui/Modal.tsx +42 -0
- package/app/components/ui/Page.tsx +18 -0
- package/app/components/ui/Pill.tsx +6 -0
- package/app/components/ui/PropertyList.tsx +24 -0
- package/app/components/ui/Select.tsx +26 -0
- package/app/components/ui/StatusCard.tsx +32 -0
- package/app/components/ui/Surface.tsx +14 -0
- package/app/components/ui/Tabs.tsx +55 -0
- package/app/components/ui/TemplateOption.tsx +59 -0
- package/app/components/ui/Toast.tsx +44 -0
- package/app/components/ui/index.ts +19 -0
- package/app/components/ui/tokens.ts +18 -0
- package/app/config/PageClient.tsx +293 -0
- package/app/config/loading.tsx +10 -0
- package/app/config/page.tsx +7 -0
- package/app/containers/ContainersClient.tsx +129 -0
- package/app/containers/loading.tsx +21 -0
- package/app/containers/page.tsx +5 -0
- package/app/containers/terminal/[id]/TerminalClient.tsx +313 -0
- package/app/containers/terminal/[id]/page.tsx +6 -0
- package/app/credentials/PageClient.tsx +598 -0
- package/app/credentials/loading.tsx +10 -0
- package/app/credentials/page.tsx +7 -0
- package/app/crons/PageClient.tsx +538 -0
- package/app/crons/loading.tsx +10 -0
- package/app/crons/page.tsx +5 -0
- package/app/design-system.ts +57 -0
- package/app/gateway/PageClient.tsx +1048 -0
- package/app/gateway/loading.tsx +24 -0
- package/app/gateway/page.tsx +5 -0
- package/app/globals.css +1474 -0
- package/app/layout.tsx +45 -0
- package/app/lib/models-context.tsx +41 -0
- package/app/lineage/loading.tsx +10 -0
- package/app/lineage/page.tsx +13 -0
- package/app/loading.tsx +16 -0
- package/app/login/page.tsx +160 -0
- package/app/memory/MemoryContextPageClient.tsx +196 -0
- package/app/memory/loading.tsx +10 -0
- package/app/memory/page.tsx +8 -0
- package/app/page.tsx +31 -0
- package/app/plugins/PageClient.tsx +296 -0
- package/app/plugins/loading.tsx +10 -0
- package/app/plugins/page.tsx +5 -0
- package/app/setup/PageClient.tsx +227 -0
- package/app/setup/loading.tsx +21 -0
- package/app/setup/page.tsx +7 -0
- package/app/skills/PageClient.tsx +499 -0
- package/app/skills/loading.tsx +10 -0
- package/app/skills/page.tsx +5 -0
- package/app/tools/ToolsPageClient.tsx +308 -0
- package/app/tools/loading.tsx +10 -0
- package/app/tools/page.tsx +62 -0
- package/app/tools/tools-catalog.ts +141 -0
- package/app/wizard/PageClient.tsx +485 -0
- package/app/wizard/icons.tsx +89 -0
- package/app/wizard/loading.tsx +24 -0
- package/app/wizard/page.tsx +7 -0
- package/app/wizard/storage.ts +33 -0
- package/app/wizard/useWizard.ts +101 -0
- package/app/workspace/WorkspaceClient.tsx +633 -0
- package/app/workspace/loading.tsx +36 -0
- package/app/workspace/page.tsx +8 -0
- package/bin/docker-entrypoint.sh +21 -0
- package/bin/postinstall.js +20 -0
- package/bin/rev4a.js +696 -0
- package/daemon.js +783 -0
- package/docs/ARCHITECTURE.md +557 -0
- package/docs/CONTAINER-TERMINAL.md +280 -0
- package/docs/DESIGN-SYSTEM.md +99 -0
- package/docs/FRONTEND-ARCHITECTURE.md +119 -0
- package/docs/REV4A.md +504 -0
- package/docs/dev/API-REFERENCE.md +1835 -0
- package/docs/dev/DATABASE.md +224 -0
- package/docs/dev/GATEWAY.md +300 -0
- package/docs/dev/PROVIDERS.md +229 -0
- package/docs/dev/SESSION-MAINTENANCE-PLAN.md +607 -0
- package/docs/dev/WORKSPACE.md +199 -0
- package/docs/rag/DATA-FRESHNESS.md +109 -0
- package/docs/rag/GLOSSARY.md +125 -0
- package/docs/rag/REV4A-OVERVIEW.md +135 -0
- package/docs/rag/WHAT-I-CAN-ANSWER.md +124 -0
- package/lib/agent-setup.ts +130 -0
- package/lib/alerts.ts +115 -0
- package/lib/apiFetch.ts +7 -0
- package/lib/auth.ts +28 -0
- package/lib/billing.ts +100 -0
- package/lib/buildAgentImage.ts +346 -0
- package/lib/channelManager.ts +386 -0
- package/lib/container.ts +60 -0
- package/lib/credentials/db.ts +86 -0
- package/lib/credentials/delivery.ts +448 -0
- package/lib/credentials/detect.ts +149 -0
- package/lib/credentials/hash.ts +9 -0
- package/lib/credentials/providers.ts +110 -0
- package/lib/credentials/vault.ts +195 -0
- package/lib/db-bootstrap.d.ts +3 -0
- package/lib/db-bootstrap.mjs +144 -0
- package/lib/db.ts +25 -0
- package/lib/docker-utils.ts +60 -0
- package/lib/hooks/useDashboard.ts +264 -0
- package/lib/hooks/useRev4aTimezone.ts +28 -0
- package/lib/hooks/useScreenshot.ts +30 -0
- package/lib/memory-context.ts +385 -0
- package/lib/model-catalogue.ts +131 -0
- package/lib/model-pricing.ts +63 -0
- package/lib/openclaw-cron.ts +350 -0
- package/lib/patterns/ApiAdapter.ts +73 -0
- package/lib/patterns/ApiClient.ts +65 -0
- package/lib/patterns/EventBus.ts +113 -0
- package/lib/patterns/FilterStrategy.ts +79 -0
- package/lib/patterns/SessionFactory.ts +117 -0
- package/lib/patterns/sessionPresentation.ts +117 -0
- package/lib/provider-balance.ts +163 -0
- package/lib/requireAuth.tsx +38 -0
- package/lib/rev4a-auth.d.ts +3 -0
- package/lib/rev4a-auth.js +36 -0
- package/lib/rev4a-paths.ts +145 -0
- package/lib/timezone.ts +46 -0
- package/lib/types/index.ts +96 -0
- package/lib/utils/format.ts +82 -0
- package/lineage.js +56 -0
- package/model-pricing.json +482 -0
- package/models.config.json +844 -0
- package/next-env.d.ts +6 -0
- package/next.config.mjs +7 -0
- package/package.json +82 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/favicon-64.png +0 -0
- package/public/favicon.svg +58 -0
- package/public/icon-192-maskable.png +0 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512-maskable.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/manifest.json +38 -0
- package/public/rev4a-logo.png +0 -0
- package/public/rev4a-logo.svg +58 -0
- package/rev4a-rules/AGENTS.md +37 -0
- package/scripts/add-auth.cjs +185 -0
- package/scripts/backup.sh +66 -0
- package/scripts/check-providers.mjs +143 -0
- package/scripts/docker/traefik/docker-compose.yml +32 -0
- package/scripts/docker/traefik/dynamic/.gitkeep +18 -0
- package/scripts/docker/traefik/traefik.yml +37 -0
- package/scripts/remove-auth-profile.js +56 -0
- package/scripts/restore.sh +82 -0
- package/terminal-ws-server.js +139 -0
- package/tsconfig.json +42 -0
|
@@ -0,0 +1,1048 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
4
|
+
import { Button, ConfirmModal, LoadingSpinner, Metric, Page, PageHeader, Pill, Surface, Tabs, type Tone } from '../components/ui';
|
|
5
|
+
import PasswordInput from '../components/PasswordInput';
|
|
6
|
+
import { Skeleton } from '../components/Skeleton';
|
|
7
|
+
|
|
8
|
+
/* ------------------------------------------------------------------ */
|
|
9
|
+
/* Types */
|
|
10
|
+
/* ------------------------------------------------------------------ */
|
|
11
|
+
type Tab = 'providers' | 'agents';
|
|
12
|
+
|
|
13
|
+
interface ModelInfo {
|
|
14
|
+
id: string;
|
|
15
|
+
name: string;
|
|
16
|
+
enabled: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface GatewayProvider {
|
|
20
|
+
provider: string;
|
|
21
|
+
label: string;
|
|
22
|
+
configured: boolean;
|
|
23
|
+
baseUrl: string;
|
|
24
|
+
docsUrl: string;
|
|
25
|
+
models: ModelInfo[];
|
|
26
|
+
apiKey?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface GatewayAgent {
|
|
30
|
+
agentId: string;
|
|
31
|
+
containerName: string;
|
|
32
|
+
state: string;
|
|
33
|
+
defaultModel: string;
|
|
34
|
+
fallbacks: string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface GatewayData {
|
|
38
|
+
timestamp: number;
|
|
39
|
+
gateway: string;
|
|
40
|
+
status: string;
|
|
41
|
+
agents: { total: number; list: GatewayAgent[] };
|
|
42
|
+
apiKeys: { configured: string[]; all: { provider: string; configured: boolean }[] };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface ProviderApiResponse {
|
|
46
|
+
providers: GatewayProvider[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/* ------------------------------------------------------------------ */
|
|
50
|
+
/* Overlay Loader Component */
|
|
51
|
+
/* ------------------------------------------------------------------ */
|
|
52
|
+
function LoadingOverlay({ message }: { message: string }) {
|
|
53
|
+
return <LoadingSpinner label={message} />;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/* ------------------------------------------------------------------ */
|
|
57
|
+
/* FallbackSelector — pick one or more fallback models from list */
|
|
58
|
+
/* ------------------------------------------------------------------ */
|
|
59
|
+
function FallbackSelector({
|
|
60
|
+
availableModels,
|
|
61
|
+
selected,
|
|
62
|
+
onChange,
|
|
63
|
+
}: {
|
|
64
|
+
availableModels: { id: string; name: string; providerLabel: string }[];
|
|
65
|
+
selected: string[];
|
|
66
|
+
onChange: (models: string[]) => void;
|
|
67
|
+
}) {
|
|
68
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
69
|
+
|
|
70
|
+
const toggleModel = (id: string) => {
|
|
71
|
+
if (selected.includes(id)) {
|
|
72
|
+
onChange(selected.filter((s) => s !== id));
|
|
73
|
+
} else {
|
|
74
|
+
onChange([...selected, id]);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div style={{ position: 'relative' }}>
|
|
80
|
+
<div
|
|
81
|
+
onClick={() => setIsOpen(!isOpen)}
|
|
82
|
+
style={{
|
|
83
|
+
cursor: 'pointer',
|
|
84
|
+
padding: '8px 10px',
|
|
85
|
+
borderRadius: 0,
|
|
86
|
+
border: '1px solid var(--border)',
|
|
87
|
+
background: 'var(--surface)',
|
|
88
|
+
fontSize: 12,
|
|
89
|
+
minHeight: 36,
|
|
90
|
+
display: 'flex',
|
|
91
|
+
flexWrap: 'wrap',
|
|
92
|
+
gap: 4,
|
|
93
|
+
alignItems: 'center',
|
|
94
|
+
}}
|
|
95
|
+
>
|
|
96
|
+
{selected.length === 0 && (
|
|
97
|
+
<span style={{ color: 'var(--text-dim)' }}>No fallbacks</span>
|
|
98
|
+
)}
|
|
99
|
+
{selected.map((id) => {
|
|
100
|
+
const m = availableModels.find((a) => a.id === id);
|
|
101
|
+
return (
|
|
102
|
+
<span
|
|
103
|
+
key={id}
|
|
104
|
+
onClick={(e) => {
|
|
105
|
+
e.stopPropagation();
|
|
106
|
+
toggleModel(id);
|
|
107
|
+
}}
|
|
108
|
+
style={{
|
|
109
|
+
display: 'inline-flex',
|
|
110
|
+
alignItems: 'center',
|
|
111
|
+
gap: 4,
|
|
112
|
+
padding: '3px 8px',
|
|
113
|
+
borderRadius: 0,
|
|
114
|
+
background: 'rgba(167,139,250,0.14)',
|
|
115
|
+
border: '1px solid rgba(167,139,250,0.24)',
|
|
116
|
+
fontSize: 11,
|
|
117
|
+
cursor: 'pointer',
|
|
118
|
+
}}
|
|
119
|
+
>
|
|
120
|
+
{m?.name || id}
|
|
121
|
+
<span style={{ marginLeft: 2, opacity: 0.6 }}>✕</span>
|
|
122
|
+
</span>
|
|
123
|
+
);
|
|
124
|
+
})}
|
|
125
|
+
<span style={{ color: 'var(--text-dim)', fontSize: 11, marginLeft: 'auto' }}>▼</span>
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
{isOpen && (
|
|
129
|
+
<div
|
|
130
|
+
style={{
|
|
131
|
+
position: 'absolute',
|
|
132
|
+
top: '100%',
|
|
133
|
+
left: 0,
|
|
134
|
+
right: 0,
|
|
135
|
+
zIndex: 100,
|
|
136
|
+
marginTop: 4,
|
|
137
|
+
background: 'var(--surface)',
|
|
138
|
+
border: '1px solid var(--border)',
|
|
139
|
+
borderRadius: 0,
|
|
140
|
+
maxHeight: 220,
|
|
141
|
+
overflowY: 'auto',
|
|
142
|
+
boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
|
|
143
|
+
}}
|
|
144
|
+
>
|
|
145
|
+
{availableModels.map((m) => {
|
|
146
|
+
const isSel = selected.includes(m.id);
|
|
147
|
+
return (
|
|
148
|
+
<div
|
|
149
|
+
key={m.id}
|
|
150
|
+
onClick={() => toggleModel(m.id)}
|
|
151
|
+
style={{
|
|
152
|
+
padding: '8px 12px',
|
|
153
|
+
cursor: 'pointer',
|
|
154
|
+
fontSize: 12,
|
|
155
|
+
display: 'flex',
|
|
156
|
+
alignItems: 'center',
|
|
157
|
+
gap: 8,
|
|
158
|
+
background: isSel ? 'rgba(var(--green-rgb,34,197,94),0.06)' : 'transparent',
|
|
159
|
+
}}
|
|
160
|
+
>
|
|
161
|
+
<input
|
|
162
|
+
type="checkbox"
|
|
163
|
+
checked={isSel}
|
|
164
|
+
readOnly
|
|
165
|
+
style={{ accentColor: 'var(--green)', pointerEvents: 'none' }}
|
|
166
|
+
/>
|
|
167
|
+
<div>
|
|
168
|
+
<div style={{ fontWeight: isSel ? 500 : 400 }}>{m.name}</div>
|
|
169
|
+
<div style={{ fontSize: 10, color: 'var(--text-dim)', fontFamily: 'monospace' }}>{m.id}</div>
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
);
|
|
173
|
+
})}
|
|
174
|
+
{availableModels.length === 0 && (
|
|
175
|
+
<div style={{ padding: '12px', color: 'var(--text-dim)', fontSize: 12, textAlign: 'center' }}>
|
|
176
|
+
No models available
|
|
177
|
+
</div>
|
|
178
|
+
)}
|
|
179
|
+
</div>
|
|
180
|
+
)}
|
|
181
|
+
|
|
182
|
+
{isOpen && (
|
|
183
|
+
<div
|
|
184
|
+
onClick={() => setIsOpen(false)}
|
|
185
|
+
style={{ position: 'fixed', inset: 0, zIndex: 99 }}
|
|
186
|
+
/>
|
|
187
|
+
)}
|
|
188
|
+
</div>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/* ------------------------------------------------------------------ */
|
|
193
|
+
/* AgentEditForm — select + fallback pills (inline) */
|
|
194
|
+
/* ------------------------------------------------------------------ */
|
|
195
|
+
function AgentEditForm({
|
|
196
|
+
agentId,
|
|
197
|
+
allModels,
|
|
198
|
+
initialModel,
|
|
199
|
+
initialFallbacks,
|
|
200
|
+
saving,
|
|
201
|
+
onSave,
|
|
202
|
+
onCancel,
|
|
203
|
+
}: {
|
|
204
|
+
agentId: string;
|
|
205
|
+
allModels: { id: string; name: string; providerLabel: string }[];
|
|
206
|
+
initialModel: string;
|
|
207
|
+
initialFallbacks: string;
|
|
208
|
+
saving: boolean;
|
|
209
|
+
onSave: (model: string, fallbacks: string[]) => void;
|
|
210
|
+
onCancel: () => void;
|
|
211
|
+
}) {
|
|
212
|
+
const [modelInput, setModelInput] = useState(initialModel || '');
|
|
213
|
+
const [fallbacks, setFallbacks] = useState<string[]>(
|
|
214
|
+
[...new Set(initialFallbacks ? initialFallbacks.split(',').map((s) => s.trim()).filter(Boolean) : [])],
|
|
215
|
+
);
|
|
216
|
+
const fallbackCandidates = allModels.filter((m) => m.id !== modelInput);
|
|
217
|
+
|
|
218
|
+
const toggleFallback = (id: string) => {
|
|
219
|
+
setFallbacks((prev) => prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id]);
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
return (
|
|
223
|
+
<div style={{ display: 'grid', gap: 10, borderTop: '1px solid var(--border)', paddingTop: 12 }}>
|
|
224
|
+
<div style={{ fontSize: 12, color: 'var(--text-dim)' }}>
|
|
225
|
+
Update model for <strong>{agentId}</strong>.
|
|
226
|
+
</div>
|
|
227
|
+
|
|
228
|
+
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
|
229
|
+
{/* Primary model — styled native select */}
|
|
230
|
+
<div style={{ flex: 1, minWidth: 220 }}>
|
|
231
|
+
<div style={{ fontSize: 11, color: 'var(--text-dim)', marginBottom: 4 }}>Primary model</div>
|
|
232
|
+
<select
|
|
233
|
+
value={modelInput}
|
|
234
|
+
onChange={(e) => setModelInput(e.target.value)}
|
|
235
|
+
style={{
|
|
236
|
+
width: '100%',
|
|
237
|
+
background: 'var(--bg3)',
|
|
238
|
+
color: 'var(--text)',
|
|
239
|
+
border: '1px solid var(--border)',
|
|
240
|
+
borderRadius: 0,
|
|
241
|
+
fontSize: 11,
|
|
242
|
+
padding: '6px 28px 6px 8px',
|
|
243
|
+
fontFamily: 'var(--font-mono-stack)',
|
|
244
|
+
cursor: 'pointer',
|
|
245
|
+
outline: 'none',
|
|
246
|
+
WebkitAppearance: 'none',
|
|
247
|
+
MozAppearance: 'none',
|
|
248
|
+
appearance: 'none',
|
|
249
|
+
backgroundImage: "url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12'><path fill='%238a8a92' d='M6 8L1 3h10z'/></svg>\")",
|
|
250
|
+
backgroundRepeat: 'no-repeat',
|
|
251
|
+
backgroundPosition: 'right 8px center',
|
|
252
|
+
}}
|
|
253
|
+
>
|
|
254
|
+
<option value="" disabled>Select model…</option>
|
|
255
|
+
{allModels.map((m) => (
|
|
256
|
+
<option key={m.id} value={m.id}>
|
|
257
|
+
{m.name} — {m.providerLabel}
|
|
258
|
+
</option>
|
|
259
|
+
))}
|
|
260
|
+
</select>
|
|
261
|
+
</div>
|
|
262
|
+
|
|
263
|
+
{/* Fallback pills selector */}
|
|
264
|
+
<div style={{ flex: 1, minWidth: 220 }}>
|
|
265
|
+
<div style={{ fontSize: 11, color: 'var(--text-dim)', marginBottom: 4 }}>Fallback</div>
|
|
266
|
+
<div style={{
|
|
267
|
+
display: 'flex',
|
|
268
|
+
flexWrap: 'wrap',
|
|
269
|
+
gap: 4,
|
|
270
|
+
padding: 4,
|
|
271
|
+
minHeight: 36,
|
|
272
|
+
borderRadius: 0,
|
|
273
|
+
border: '1px solid var(--border)',
|
|
274
|
+
background: 'var(--surface)',
|
|
275
|
+
}}>
|
|
276
|
+
{fallbackCandidates.length === 0 ? (
|
|
277
|
+
<span style={{ color: 'var(--text-dim)', fontSize: 11, padding: '6px 4px' }}>No models available</span>
|
|
278
|
+
) : fallbacks.length === 0 ? (
|
|
279
|
+
<span style={{ color: 'var(--text-dim)', fontSize: 11, padding: '6px 4px' }}>Click a model to add as fallback</span>
|
|
280
|
+
) : null}
|
|
281
|
+
{fallbackCandidates.map((m) => {
|
|
282
|
+
const isSelected = fallbacks.includes(m.id);
|
|
283
|
+
return (
|
|
284
|
+
<span
|
|
285
|
+
key={m.id}
|
|
286
|
+
onClick={() => toggleFallback(m.id)}
|
|
287
|
+
style={{
|
|
288
|
+
display: 'inline-flex',
|
|
289
|
+
alignItems: 'center',
|
|
290
|
+
gap: 3,
|
|
291
|
+
padding: '3px 8px',
|
|
292
|
+
borderRadius: 0,
|
|
293
|
+
fontSize: 11,
|
|
294
|
+
cursor: 'pointer',
|
|
295
|
+
background: isSelected
|
|
296
|
+
? 'rgba(167,139,250,0.18)'
|
|
297
|
+
: 'rgba(255,255,255,0.05)',
|
|
298
|
+
border: isSelected
|
|
299
|
+
? '1px solid rgba(167,139,250,0.35)'
|
|
300
|
+
: '1px solid rgba(255,255,255,0.1)',
|
|
301
|
+
transition: 'all 0.12s',
|
|
302
|
+
}}
|
|
303
|
+
>
|
|
304
|
+
{m.name}
|
|
305
|
+
{isSelected && <span style={{ marginLeft: 2, opacity: 0.6 }}>✕</span>}
|
|
306
|
+
</span>
|
|
307
|
+
);
|
|
308
|
+
})}
|
|
309
|
+
</div>
|
|
310
|
+
</div>
|
|
311
|
+
</div>
|
|
312
|
+
|
|
313
|
+
<div style={{ display: 'flex', gap: 8 }}>
|
|
314
|
+
<Button variant="primary" size="sm" loading={saving} disabled={!modelInput.trim()} onClick={() => onSave(modelInput, fallbacks)}>
|
|
315
|
+
{saving ? 'Saving…' : 'Save'}
|
|
316
|
+
</Button>
|
|
317
|
+
<Button variant="secondary" size="sm" onClick={onCancel}>
|
|
318
|
+
Cancel
|
|
319
|
+
</Button>
|
|
320
|
+
</div>
|
|
321
|
+
</div>
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/* ------------------------------------------------------------------ */
|
|
326
|
+
/* Component (main) */
|
|
327
|
+
/* ------------------------------------------------------------------ */
|
|
328
|
+
export default function GatewayPageClient() {
|
|
329
|
+
const [tab, setTab] = useState<Tab>('providers');
|
|
330
|
+
const [gatewayData, setGatewayData] = useState<GatewayData | null>(null);
|
|
331
|
+
const [providers, setProviders] = useState<GatewayProvider[]>([]);
|
|
332
|
+
const [loading, setLoading] = useState(true);
|
|
333
|
+
const [error, setError] = useState<string | null>(null);
|
|
334
|
+
|
|
335
|
+
// Overlay loader
|
|
336
|
+
const [overlay, setOverlay] = useState<{ active: boolean; message: string }>({ active: false, message: '' });
|
|
337
|
+
|
|
338
|
+
// Show skeleton inline instead of blocking whole page
|
|
339
|
+
const skeletonRows = loading;
|
|
340
|
+
|
|
341
|
+
// API key form state
|
|
342
|
+
const [apiKeyInputs, setApiKeyInputs] = useState<Record<string, string>>({});
|
|
343
|
+
const [originalKeys, setOriginalKeys] = useState<Record<string, string>>({});
|
|
344
|
+
const [messages, setMessages] = useState<Record<string, { type: 'success' | 'error'; text: string } | null>>({});
|
|
345
|
+
const [revealedKeys, setRevealedKeys] = useState<Record<string, string | null>>({});
|
|
346
|
+
const [revealing, setRevealing] = useState<Record<string, boolean>>({});
|
|
347
|
+
|
|
348
|
+
// Agent edit state
|
|
349
|
+
const [editingAgent, setEditingAgent] = useState<string | null>(null);
|
|
350
|
+
const [agentModelInput, setAgentModelInput] = useState('');
|
|
351
|
+
const [agentFallbackInput, setAgentFallbackInput] = useState('');
|
|
352
|
+
|
|
353
|
+
// Balance fetch state (per provider)
|
|
354
|
+
const [balances, setBalances] = useState<Record<string, { balance: string; label?: string; loading?: boolean; error?: string } | null>>({});
|
|
355
|
+
const [fetchingBalance, setFetchingBalance] = useState<Record<string, boolean>>({});
|
|
356
|
+
|
|
357
|
+
// Accordion state — which provider cards are expanded
|
|
358
|
+
const [expandedProviders, setExpandedProviders] = useState<Set<string>>(new Set());
|
|
359
|
+
const expandedInitRef = useRef(false);
|
|
360
|
+
|
|
361
|
+
/* ---- Data loading ---- */
|
|
362
|
+
const loadAll = useCallback(async () => {
|
|
363
|
+
setLoading(true);
|
|
364
|
+
try {
|
|
365
|
+
const [provRes, gwRes] = await Promise.all([
|
|
366
|
+
fetch('/api/gateway/provider'),
|
|
367
|
+
fetch('/api/gateway'),
|
|
368
|
+
]);
|
|
369
|
+
if (provRes.ok) {
|
|
370
|
+
const provJson: ProviderApiResponse = await provRes.json();
|
|
371
|
+
setProviders(provJson.providers);
|
|
372
|
+
|
|
373
|
+
// Auto-expand configured providers on first load
|
|
374
|
+
if (!expandedInitRef.current) {
|
|
375
|
+
expandedInitRef.current = true;
|
|
376
|
+
const configured = new Set<string>();
|
|
377
|
+
provJson.providers.forEach((p: any) => {
|
|
378
|
+
if (p.configured) configured.add(p.provider);
|
|
379
|
+
});
|
|
380
|
+
if (configured.size > 0) setExpandedProviders(configured);
|
|
381
|
+
}
|
|
382
|
+
// Load keys from provider response (all configured providers now return apiKey)
|
|
383
|
+
const keyEntries: Record<string, string> = {};
|
|
384
|
+
const origEntries: Record<string, string> = {};
|
|
385
|
+
provJson.providers.forEach((p: any) => {
|
|
386
|
+
if (p.configured && p.apiKey) {
|
|
387
|
+
keyEntries[p.provider] = p.apiKey;
|
|
388
|
+
origEntries[p.provider] = p.apiKey;
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
setApiKeyInputs((prev) => ({ ...prev, ...keyEntries }));
|
|
392
|
+
setOriginalKeys((prev) => ({ ...prev, ...origEntries }));
|
|
393
|
+
|
|
394
|
+
// Auto-fetch balances for supported providers that have keys configured
|
|
395
|
+
const balanceProvs = provJson.providers
|
|
396
|
+
.filter((p: any) => p.configured && ['deepseek','openrouter','glm'].includes(p.provider))
|
|
397
|
+
.map((p: any) => p.provider);
|
|
398
|
+
for (const bp of balanceProvs) {
|
|
399
|
+
try {
|
|
400
|
+
const bRes = await fetch(`/api/gateway/provider/balance?provider=${bp}`);
|
|
401
|
+
const bJson = await bRes.json();
|
|
402
|
+
if (bRes.ok && bJson.balance) {
|
|
403
|
+
setBalances((prev) => ({ ...prev, [bp]: { balance: bJson.balance, label: bJson.label } }));
|
|
404
|
+
} else {
|
|
405
|
+
setBalances((prev) => ({ ...prev, [bp]: { balance: '—', error: bJson.error || 'Failed' } }));
|
|
406
|
+
}
|
|
407
|
+
} catch (e: unknown) {
|
|
408
|
+
setBalances((prev) => ({ ...prev, [bp]: { balance: '—', error: e instanceof Error ? e.message : String(e) } }));
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
if (gwRes.ok) {
|
|
413
|
+
const gwJson: GatewayData = await gwRes.json();
|
|
414
|
+
setGatewayData(gwJson);
|
|
415
|
+
}
|
|
416
|
+
} catch (e: unknown) {
|
|
417
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
418
|
+
} finally {
|
|
419
|
+
setLoading(false);
|
|
420
|
+
}
|
|
421
|
+
}, []);
|
|
422
|
+
|
|
423
|
+
useEffect(() => { void loadAll(); }, [loadAll]);
|
|
424
|
+
|
|
425
|
+
/** Format pricing for display: "$0.44 / $0.87" or "Free" or "Dynamic" or "—" */
|
|
426
|
+
function fmtPrice(p: { input: number; output: number } | null | undefined): string {
|
|
427
|
+
if (!p) return '—';
|
|
428
|
+
if (p.input === -1 && p.output === -1) return 'Dynamic';
|
|
429
|
+
if (p.input === 0 && p.output === 0) return 'Free';
|
|
430
|
+
const f = (n: number) => n < 0.01 ? `$${n.toFixed(3)}` : `$${n.toFixed(2)}`;
|
|
431
|
+
return `${f(p.input)} / ${f(p.output)}`;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/* ---- Overlay helper ---- */
|
|
435
|
+
async function withOverlay<T>(message: string, fn: () => Promise<T>): Promise<T> {
|
|
436
|
+
setOverlay({ active: true, message });
|
|
437
|
+
try {
|
|
438
|
+
return await fn();
|
|
439
|
+
} finally {
|
|
440
|
+
setOverlay({ active: false, message: '' });
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/* ---- Balance fetch ---- */
|
|
445
|
+
async function fetchBalance(provider: string) {
|
|
446
|
+
setFetchingBalance((prev) => ({ ...prev, [provider]: true }));
|
|
447
|
+
setBalances((prev) => ({ ...prev, [provider]: null }));
|
|
448
|
+
try {
|
|
449
|
+
const res = await fetch(`/api/gateway/provider/balance?provider=${provider}`);
|
|
450
|
+
const json = await res.json();
|
|
451
|
+
if (res.ok && json.balance) {
|
|
452
|
+
setBalances((prev) => ({ ...prev, [provider]: { balance: json.balance, label: json.label } }));
|
|
453
|
+
} else {
|
|
454
|
+
setBalances((prev) => ({ ...prev, [provider]: { balance: '—', error: json.error || 'Failed' } }));
|
|
455
|
+
}
|
|
456
|
+
} catch (e: unknown) {
|
|
457
|
+
setBalances((prev) => ({ ...prev, [provider]: { balance: '—', error: e instanceof Error ? e.message : String(e) } }));
|
|
458
|
+
} finally {
|
|
459
|
+
setFetchingBalance((prev) => ({ ...prev, [provider]: false }));
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/* ---- Save / Remove API key ---- */
|
|
464
|
+
async function handleSave(provider: string) {
|
|
465
|
+
const key = apiKeyInputs[provider]?.trim();
|
|
466
|
+
if (!key) return;
|
|
467
|
+
await withOverlay(`Saving API key for ${provider}…`, async () => {
|
|
468
|
+
setMessages((prev) => ({ ...prev, [provider]: null }));
|
|
469
|
+
try {
|
|
470
|
+
const res = await fetch('/api/gateway/provider', {
|
|
471
|
+
method: 'POST',
|
|
472
|
+
headers: { 'Content-Type': 'application/json' },
|
|
473
|
+
body: JSON.stringify({ provider, apiKey: key }),
|
|
474
|
+
});
|
|
475
|
+
const json = await res.json();
|
|
476
|
+
if (json.status === 'ok') {
|
|
477
|
+
setMessages((prev) => ({ ...prev, [provider]: { type: 'success', text: 'API key saved.' } }));
|
|
478
|
+
setOriginalKeys((prev) => ({ ...prev, [provider]: key }));
|
|
479
|
+
setProviders((prev) => prev.map((p) => p.provider === provider ? { ...p, configured: true } : p));
|
|
480
|
+
await loadAll();
|
|
481
|
+
} else {
|
|
482
|
+
setMessages((prev) => ({ ...prev, [provider]: { type: 'error', text: json.error || 'Error' } }));
|
|
483
|
+
}
|
|
484
|
+
} catch (e: unknown) {
|
|
485
|
+
setMessages((prev) => ({ ...prev, [provider]: { type: 'error', text: e instanceof Error ? e.message : 'Network error' } }));
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const [removeConfirm, setRemoveConfirm] = useState<string | null>(null);
|
|
491
|
+
|
|
492
|
+
async function handleRemove(provider: string) {
|
|
493
|
+
setRemoveConfirm(provider);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
async function executeRemove() {
|
|
497
|
+
const provider = removeConfirm;
|
|
498
|
+
if (!provider) return;
|
|
499
|
+
setRemoveConfirm(null);
|
|
500
|
+
await withOverlay(`Removing API key ${provider}…`, async () => {
|
|
501
|
+
setMessages((prev) => ({ ...prev, [provider]: null }));
|
|
502
|
+
try {
|
|
503
|
+
const res = await fetch('/api/gateway/provider', {
|
|
504
|
+
method: 'POST',
|
|
505
|
+
headers: { 'Content-Type': 'application/json' },
|
|
506
|
+
body: JSON.stringify({ provider }),
|
|
507
|
+
});
|
|
508
|
+
const json = await res.json();
|
|
509
|
+
if (json.status === 'ok') {
|
|
510
|
+
setMessages((prev) => ({ ...prev, [provider]: { type: 'success', text: 'API key removed.' } }));
|
|
511
|
+
setProviders((prev) => prev.map((p) => p.provider === provider ? { ...p, configured: false } : p));
|
|
512
|
+
await loadAll();
|
|
513
|
+
} else {
|
|
514
|
+
setMessages((prev) => ({ ...prev, [provider]: { type: 'error', text: json.error || 'Error' } }));
|
|
515
|
+
}
|
|
516
|
+
} catch (e: unknown) {
|
|
517
|
+
setMessages((prev) => ({ ...prev, [provider]: { type: 'error', text: e instanceof Error ? e.message : 'Network error' } }));
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/* ---- Save Rev4a API Key ---- */
|
|
523
|
+
async function handleSaveRev4aKey() {
|
|
524
|
+
const key = apiKeyInputs['rev4a']?.trim();
|
|
525
|
+
if (!key) return;
|
|
526
|
+
await withOverlay('Saving REV4A API KEY…', async () => {
|
|
527
|
+
setMessages((prev) => ({ ...prev, rev4a: null }));
|
|
528
|
+
try {
|
|
529
|
+
const res = await fetch('/api/gateway/provider', {
|
|
530
|
+
method: 'POST',
|
|
531
|
+
headers: { 'Content-Type': 'application/json' },
|
|
532
|
+
body: JSON.stringify({ provider: 'rev4a', apiKey: key }),
|
|
533
|
+
});
|
|
534
|
+
const json = await res.json();
|
|
535
|
+
if (json.status === 'ok') {
|
|
536
|
+
setMessages((prev) => ({ ...prev, rev4a: { type: 'success', text: 'REV4A API KEY saved. Agent sync complete.' } }));
|
|
537
|
+
setOriginalKeys((prev) => ({ ...prev, rev4a: key }));
|
|
538
|
+
await loadAll();
|
|
539
|
+
} else {
|
|
540
|
+
setMessages((prev) => ({ ...prev, rev4a: { type: 'error', text: json.error || 'Error' } }));
|
|
541
|
+
}
|
|
542
|
+
} catch (e: unknown) {
|
|
543
|
+
setMessages((prev) => ({ ...prev, rev4a: { type: 'error', text: e instanceof Error ? e.message : 'Network error' } }));
|
|
544
|
+
}
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/* ---- Toggle model ---- */
|
|
549
|
+
async function handleToggleModel(modelId: string, enabled: boolean) {
|
|
550
|
+
await withOverlay(`${enabled ? 'Enable' : 'Disable'} model…`, async () => {
|
|
551
|
+
setMessages((prev) => ({ ...prev, [`model-${modelId}`]: null }));
|
|
552
|
+
try {
|
|
553
|
+
const res = await fetch('/api/gateway/provider', {
|
|
554
|
+
method: 'PUT',
|
|
555
|
+
headers: { 'Content-Type': 'application/json' },
|
|
556
|
+
body: JSON.stringify({ modelId, enabled }),
|
|
557
|
+
});
|
|
558
|
+
const json = await res.json();
|
|
559
|
+
if (json.status === 'ok') {
|
|
560
|
+
// Update local state only — no full reload
|
|
561
|
+
setProviders((prev) =>
|
|
562
|
+
prev.map((p) => ({
|
|
563
|
+
...p,
|
|
564
|
+
models: p.models.map((m) =>
|
|
565
|
+
m.id === modelId ? { ...m, enabled } : m
|
|
566
|
+
),
|
|
567
|
+
})),
|
|
568
|
+
);
|
|
569
|
+
} else {
|
|
570
|
+
setMessages((prev) => ({ ...prev, [`model-${modelId}`]: { type: 'error', text: json.error || 'Error' } }));
|
|
571
|
+
}
|
|
572
|
+
} catch (e: unknown) {
|
|
573
|
+
setMessages((prev) => ({ ...prev, [`model-${modelId}`]: { type: 'error', text: e instanceof Error ? e.message : 'Network error' } }));
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/* ---- Agent model update ---- */
|
|
579
|
+
async function handleUpdateAgent(key: string, model?: string, fallbacksFromForm?: string[]) {
|
|
580
|
+
if (!model && !agentModelInput.trim()) return;
|
|
581
|
+
const modelValue = model || agentModelInput.trim();
|
|
582
|
+
const fallbacksValue = fallbacksFromForm || (agentFallbackInput.trim()
|
|
583
|
+
? agentFallbackInput.split(',').map((s) => s.trim()).filter(Boolean)
|
|
584
|
+
: []);
|
|
585
|
+
|
|
586
|
+
// Resolve container name from agentId or containerName
|
|
587
|
+
const agent = gatewayData?.agents?.list?.find(
|
|
588
|
+
(a) => a.containerName === key || a.agentId === key,
|
|
589
|
+
);
|
|
590
|
+
const containerName = agent?.containerName || key;
|
|
591
|
+
|
|
592
|
+
// Overlay: only during the PUT call
|
|
593
|
+
setOverlay({ active: true, message: 'Updating model…' });
|
|
594
|
+
try {
|
|
595
|
+
const res = await fetch('/api/gateway/agent', {
|
|
596
|
+
method: 'PUT',
|
|
597
|
+
headers: { 'Content-Type': 'application/json' },
|
|
598
|
+
body: JSON.stringify({ containerName, model: modelValue, fallbacks: fallbacksValue }),
|
|
599
|
+
});
|
|
600
|
+
const json = await res.json();
|
|
601
|
+
setOverlay({ active: false, message: '' });
|
|
602
|
+
|
|
603
|
+
if (json.status === 'ok') {
|
|
604
|
+
setMessages((prev) => ({ ...prev, [`agent-${key}`]: { type: 'success', text: 'Restarting gateway…' } }));
|
|
605
|
+
setEditingAgent(null);
|
|
606
|
+
setAgentModelInput('');
|
|
607
|
+
setAgentFallbackInput('');
|
|
608
|
+
// Poll in background: GET /api/gateway every second until it responds
|
|
609
|
+
for (let attempt = 0; attempt < 8; attempt++) {
|
|
610
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
611
|
+
try {
|
|
612
|
+
const gwRes = await fetch('/api/gateway');
|
|
613
|
+
if (gwRes.ok) {
|
|
614
|
+
const gwJson: GatewayData = await gwRes.json();
|
|
615
|
+
setGatewayData(gwJson);
|
|
616
|
+
break;
|
|
617
|
+
}
|
|
618
|
+
} catch { /* retry */ }
|
|
619
|
+
}
|
|
620
|
+
// Clear the message
|
|
621
|
+
setMessages((prev) => ({ ...prev, [`agent-${key}`]: null }));
|
|
622
|
+
} else {
|
|
623
|
+
setMessages((prev) => ({ ...prev, [`agent-${key}`]: { type: 'error', text: json.error || 'Error' } }));
|
|
624
|
+
}
|
|
625
|
+
} catch (e: unknown) {
|
|
626
|
+
setOverlay({ active: false, message: '' });
|
|
627
|
+
setMessages((prev) => ({ ...prev, [`agent-${key}`]: { type: 'error', text: e instanceof Error ? e.message : 'Network error' } }));
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/* ---- Helpers ---- */
|
|
632
|
+
const configuredCount = providers.filter((p) => p.configured && p.provider !== 'rev4a').length;
|
|
633
|
+
const rev4aKeyConfigured = providers.find((p) => p.provider === 'rev4a')?.configured ?? false;
|
|
634
|
+
const agentCount = gatewayData?.agents?.total ?? 0;
|
|
635
|
+
const allEnabledModels = providers
|
|
636
|
+
.filter((p) => p.configured)
|
|
637
|
+
.flatMap((p) =>
|
|
638
|
+
p.models.filter((m) => m.enabled).map((m) => ({ ...m, providerLabel: p.label })),
|
|
639
|
+
);
|
|
640
|
+
|
|
641
|
+
/* ---- Loading state ---- */
|
|
642
|
+
if (loading && providers.length === 0 && !error) {
|
|
643
|
+
return (
|
|
644
|
+
<Page maxWidth={1000}>
|
|
645
|
+
<PageHeader eyebrow="" title="Gateway" description="Loading…" />
|
|
646
|
+
<div style={{ display: 'grid', gap: 12 }}>
|
|
647
|
+
{[1, 2, 3].map((i) => (
|
|
648
|
+
<Surface key={i}>
|
|
649
|
+
<Skeleton width="60%" height={14} style={{ marginBottom: 10 }} />
|
|
650
|
+
<Skeleton width="80%" height={20} />
|
|
651
|
+
</Surface>
|
|
652
|
+
))}
|
|
653
|
+
</div>
|
|
654
|
+
</Page>
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
if (error && providers.length === 0) {
|
|
659
|
+
return (
|
|
660
|
+
<Page maxWidth={1000}>
|
|
661
|
+
<PageHeader eyebrow="" title="Gateway" description="Error" />
|
|
662
|
+
<Surface variant="panel" tone="danger">
|
|
663
|
+
<div style={{ fontSize: 13, lineHeight: 1.5 }}>{error}</div>
|
|
664
|
+
</Surface>
|
|
665
|
+
</Page>
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
return (
|
|
670
|
+
<Page maxWidth={1000}>
|
|
671
|
+
{/* Overlay loader */}
|
|
672
|
+
{overlay.active && <LoadingOverlay message={overlay.message} />}
|
|
673
|
+
|
|
674
|
+
<PageHeader
|
|
675
|
+
eyebrow=""
|
|
676
|
+
title="Gateway"
|
|
677
|
+
description="Provider, models and agents. API keys are stored in .env."
|
|
678
|
+
/>
|
|
679
|
+
|
|
680
|
+
{/* Sync all agents — propagate provider baseUrl + models to every container */}
|
|
681
|
+
<div style={{ marginBottom: 14, display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
682
|
+
<Button
|
|
683
|
+
variant="primary"
|
|
684
|
+
size="sm"
|
|
685
|
+
onClick={async () => {
|
|
686
|
+
setOverlay({ active: true, message: 'Syncing all agents…' });
|
|
687
|
+
try {
|
|
688
|
+
const res = await fetch('/api/gateway/provider', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ _syncOnly: true }) });
|
|
689
|
+
const data = await res.json();
|
|
690
|
+
setOverlay({ active: false, message: '' });
|
|
691
|
+
if (data.status === 'ok') {
|
|
692
|
+
setMessages((prev) => ({ ...prev, refresh: { type: 'success', text: `Synced ${data.sync?.length ?? 0} agent(s)` } }));
|
|
693
|
+
}
|
|
694
|
+
} catch (e: unknown) {
|
|
695
|
+
setOverlay({ active: false, message: '' });
|
|
696
|
+
setMessages((prev) => ({ ...prev, refresh: { type: 'error', text: e instanceof Error ? e.message : 'Sync failed' } }));
|
|
697
|
+
}
|
|
698
|
+
}}
|
|
699
|
+
>
|
|
700
|
+
↻ Sync All Agents
|
|
701
|
+
</Button>
|
|
702
|
+
{messages.refresh && (
|
|
703
|
+
<span style={{ fontSize: 11, color: messages.refresh.type === 'success' ? '#34d399' : '#ef4444' }}>
|
|
704
|
+
{messages.refresh.text}
|
|
705
|
+
</span>
|
|
706
|
+
)}
|
|
707
|
+
</div>
|
|
708
|
+
|
|
709
|
+
{/* Metrics */}
|
|
710
|
+
<section style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 12, marginBottom: 20 }}>
|
|
711
|
+
{skeletonRows ? (
|
|
712
|
+
<>
|
|
713
|
+
<Skeleton height={72} style={{ border: '1px solid rgba(255,255,255,0.06)' }} />
|
|
714
|
+
<Skeleton height={72} style={{ border: '1px solid rgba(255,255,255,0.06)' }} />
|
|
715
|
+
<Skeleton height={72} style={{ border: '1px solid rgba(255,255,255,0.06)' }} />
|
|
716
|
+
</>
|
|
717
|
+
) : (
|
|
718
|
+
<>
|
|
719
|
+
<Metric title="Configured providers" value={`${configuredCount} / ${providers.filter((p) => p.provider !== 'rev4a').length}`} subtitle="DeepSeek, OpenRouter, Codex, …" tone={configuredCount > 0 ? 'success' : 'warning'} />
|
|
720
|
+
<Metric title="Active models" value={allEnabledModels.length} subtitle="for all configured providers" tone={allEnabledModels.length > 0 ? 'success' : 'warning'} />
|
|
721
|
+
<Metric title="Agents" value={agentCount} subtitle="with AGENT_ID label" tone={agentCount > 0 ? 'success' : 'warning'} />
|
|
722
|
+
</>
|
|
723
|
+
)}
|
|
724
|
+
</section>
|
|
725
|
+
|
|
726
|
+
{/* =========================================================== */}
|
|
727
|
+
{/* REV4A API KEY — sezione speciale */}
|
|
728
|
+
{/* =========================================================== */}
|
|
729
|
+
<div style={{ marginBottom: 20 }}>
|
|
730
|
+
<Surface variant="panel">
|
|
731
|
+
<div style={{ padding: '16px 18px', display: 'grid', gap: 14 }}>
|
|
732
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
|
733
|
+
<div style={{
|
|
734
|
+
width: 8, height: 8, borderRadius: '50%',
|
|
735
|
+
background: rev4aKeyConfigured ? 'var(--green)' : 'var(--gray)',
|
|
736
|
+
flexShrink: 0,
|
|
737
|
+
}} />
|
|
738
|
+
<div style={{ fontWeight: 600, fontSize: 14 }}>REV4A API KEY</div>
|
|
739
|
+
{rev4aKeyConfigured ? <Pill tone="success">Configured</Pill> : <Pill tone="warning">Not configured</Pill>}
|
|
740
|
+
</div>
|
|
741
|
+
|
|
742
|
+
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
|
743
|
+
<PasswordInput
|
|
744
|
+
value={apiKeyInputs['rev4a'] ?? ''}
|
|
745
|
+
onChange={(v) => setApiKeyInputs((prev) => ({ ...prev, rev4a: v }))}
|
|
746
|
+
placeholder="REV4A_API_KEY"
|
|
747
|
+
style={{ flex: 1, minWidth: 200 }}
|
|
748
|
+
showGenerate
|
|
749
|
+
/>
|
|
750
|
+
<Button variant="primary" size="sm"
|
|
751
|
+
loading={overlay.active}
|
|
752
|
+
disabled={apiKeyInputs['rev4a']?.trim() === originalKeys['rev4a']?.trim()}
|
|
753
|
+
onClick={() => handleSaveRev4aKey()}>
|
|
754
|
+
Save
|
|
755
|
+
</Button>
|
|
756
|
+
|
|
757
|
+
{messages['rev4a'] && (
|
|
758
|
+
<div style={{
|
|
759
|
+
flex: '1 1 100%', padding: '6px 10px', borderRadius: 0,
|
|
760
|
+
fontSize: 12, fontWeight: 500,
|
|
761
|
+
background: messages['rev4a']!.type === 'success' ? 'rgba(var(--green-rgb,34,197,94),0.1)' : 'rgba(var(--red-rgb,239,68,68),0.1)',
|
|
762
|
+
color: messages['rev4a']!.type === 'success' ? 'var(--green)' : 'var(--red)',
|
|
763
|
+
}}>
|
|
764
|
+
{messages['rev4a']!.text}
|
|
765
|
+
</div>
|
|
766
|
+
)}
|
|
767
|
+
</div>
|
|
768
|
+
</div>
|
|
769
|
+
</Surface>
|
|
770
|
+
</div>
|
|
771
|
+
|
|
772
|
+
{/* Tabs */}
|
|
773
|
+
<Tabs
|
|
774
|
+
items={[
|
|
775
|
+
{
|
|
776
|
+
label: 'Provider',
|
|
777
|
+
active: tab === 'providers',
|
|
778
|
+
onClick: () => setTab('providers'),
|
|
779
|
+
count: `${configuredCount}/${providers.filter(p => p.provider !== 'rev4a').length}`,
|
|
780
|
+
},
|
|
781
|
+
{
|
|
782
|
+
label: 'Agents',
|
|
783
|
+
active: tab === 'agents',
|
|
784
|
+
onClick: () => setTab('agents'),
|
|
785
|
+
count: agentCount,
|
|
786
|
+
},
|
|
787
|
+
]}
|
|
788
|
+
/>
|
|
789
|
+
|
|
790
|
+
{/* =========================================================== */}
|
|
791
|
+
{/* TAB: PROVIDERS */}
|
|
792
|
+
{/* =========================================================== */}
|
|
793
|
+
{tab === 'providers' && (
|
|
794
|
+
<section style={{ display: 'grid', gap: 16 }}>
|
|
795
|
+
{providers.filter((p) => p.provider !== 'rev4a').map((p) => {
|
|
796
|
+
const enabledCount = p.models.filter((m) => m.enabled).length;
|
|
797
|
+
const msg = messages[p.provider];
|
|
798
|
+
const isExpanded = expandedProviders.has(p.provider);
|
|
799
|
+
|
|
800
|
+
const toggleExpanded = () => {
|
|
801
|
+
setExpandedProviders((prev) => {
|
|
802
|
+
const next = new Set(prev);
|
|
803
|
+
if (next.has(p.provider)) next.delete(p.provider);
|
|
804
|
+
else next.add(p.provider);
|
|
805
|
+
return next;
|
|
806
|
+
});
|
|
807
|
+
};
|
|
808
|
+
|
|
809
|
+
return (
|
|
810
|
+
<Surface key={p.provider} variant="panel">
|
|
811
|
+
{/* Accordion header — always visible, clickable */}
|
|
812
|
+
<div
|
|
813
|
+
onClick={toggleExpanded}
|
|
814
|
+
style={{
|
|
815
|
+
padding: '12px 18px',
|
|
816
|
+
display: 'flex',
|
|
817
|
+
alignItems: 'center',
|
|
818
|
+
gap: 12,
|
|
819
|
+
flexWrap: 'wrap',
|
|
820
|
+
cursor: 'pointer',
|
|
821
|
+
userSelect: 'none',
|
|
822
|
+
}}
|
|
823
|
+
>
|
|
824
|
+
{/* Expand/collapse arrow */}
|
|
825
|
+
<span style={{
|
|
826
|
+
fontSize: 10,
|
|
827
|
+
color: 'var(--text-dim)',
|
|
828
|
+
transition: 'transform 0.15s',
|
|
829
|
+
transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)',
|
|
830
|
+
minWidth: 12,
|
|
831
|
+
textAlign: 'center',
|
|
832
|
+
}}>
|
|
833
|
+
▶
|
|
834
|
+
</span>
|
|
835
|
+
<div style={{ width: 8, height: 8, borderRadius: '50%', background: p.configured ? 'var(--green)' : 'var(--gray)', flexShrink: 0 }} />
|
|
836
|
+
<div style={{ fontWeight: 600, fontSize: 14 }}>{p.label}</div>
|
|
837
|
+
{p.configured ? <Pill tone="success">Connected</Pill> : <Pill tone="warning">Not configured</Pill>}
|
|
838
|
+
{enabledCount > 0 ? <Pill tone="accent">{enabledCount} active models</Pill> : <Pill>{enabledCount} active models</Pill>}
|
|
839
|
+
<span style={{ marginLeft: 'auto', fontSize: 11, color: 'var(--text-dim)' }}>
|
|
840
|
+
{p.models.length} models
|
|
841
|
+
</span>
|
|
842
|
+
{/* Balance badge — auto-loaded for supported providers with configured key */}
|
|
843
|
+
{p.configured && ['deepseek','openrouter','glm'].includes(p.provider) && (
|
|
844
|
+
balances[p.provider] && !balances[p.provider]?.error ? (
|
|
845
|
+
<span
|
|
846
|
+
title={balances[p.provider]?.label || ''}
|
|
847
|
+
onClick={(e) => { e.stopPropagation(); fetchBalance(p.provider); }}
|
|
848
|
+
style={{
|
|
849
|
+
fontSize: 11, fontWeight: 600,
|
|
850
|
+
color: 'var(--green)',
|
|
851
|
+
background: 'rgba(var(--green-rgb,34,197,94),0.1)',
|
|
852
|
+
padding: '2px 8px', borderRadius: 0,
|
|
853
|
+
border: '1px solid rgba(var(--green-rgb,34,197,94),0.2)',
|
|
854
|
+
cursor: 'pointer',
|
|
855
|
+
}}
|
|
856
|
+
>
|
|
857
|
+
{balances[p.provider]!.balance}
|
|
858
|
+
</span>
|
|
859
|
+
) : (
|
|
860
|
+
<span
|
|
861
|
+
onClick={(e) => { e.stopPropagation(); fetchBalance(p.provider); }}
|
|
862
|
+
style={{
|
|
863
|
+
fontSize: 11,
|
|
864
|
+
color: 'var(--text-dim)',
|
|
865
|
+
opacity: 0.5,
|
|
866
|
+
cursor: 'pointer',
|
|
867
|
+
}}
|
|
868
|
+
>
|
|
869
|
+
{fetchingBalance[p.provider] ? '…' : '…'}
|
|
870
|
+
</span>
|
|
871
|
+
)
|
|
872
|
+
)}
|
|
873
|
+
</div>
|
|
874
|
+
|
|
875
|
+
{/* Accordion body — expandable */}
|
|
876
|
+
{isExpanded && (
|
|
877
|
+
<div style={{ padding: '0 18px 16px 18px', display: 'grid', gap: 14, borderTop: '1px solid var(--border)', paddingTop: 14 }}>
|
|
878
|
+
{/* API Key input con show/hide integrato */}
|
|
879
|
+
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
|
880
|
+
<PasswordInput
|
|
881
|
+
value={apiKeyInputs[p.provider] ?? ''}
|
|
882
|
+
onChange={(v) => setApiKeyInputs((prev) => ({ ...prev, [p.provider]: v }))}
|
|
883
|
+
placeholder={`API Key ${p.label}`}
|
|
884
|
+
style={{ flex: 1, minWidth: 200 }}
|
|
885
|
+
/>
|
|
886
|
+
<Button variant="primary" size="sm"
|
|
887
|
+
loading={overlay.active}
|
|
888
|
+
disabled={apiKeyInputs[p.provider]?.trim() === originalKeys[p.provider]?.trim()}
|
|
889
|
+
onClick={() => handleSave(p.provider)}>
|
|
890
|
+
Save
|
|
891
|
+
</Button>
|
|
892
|
+
{p.configured && (
|
|
893
|
+
<Button variant="danger" size="sm" disabled={overlay.active} onClick={() => handleRemove(p.provider)}>
|
|
894
|
+
Remove
|
|
895
|
+
</Button>
|
|
896
|
+
)}
|
|
897
|
+
</div>
|
|
898
|
+
|
|
899
|
+
{p.baseUrl && (
|
|
900
|
+
<div style={{ fontSize: 11, color: 'var(--text-dim)' }}>
|
|
901
|
+
Endpoint: <code style={{ background: 'rgba(255,255,255,0.06)', padding: '2px 6px', borderRadius: 0 }}>{p.baseUrl}</code>
|
|
902
|
+
{' • '}<a href={p.docsUrl} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--violet)' }}>Get key ↗</a>
|
|
903
|
+
</div>
|
|
904
|
+
)}
|
|
905
|
+
|
|
906
|
+
{/* Models with checkboxes */}
|
|
907
|
+
{p.models.length > 0 && (
|
|
908
|
+
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 12 }}>
|
|
909
|
+
<div style={{ fontSize: 11, color: 'var(--text-dim)', marginBottom: 8, textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
|
910
|
+
Models
|
|
911
|
+
<span style={{ marginLeft: 8, textTransform: 'none', letterSpacing: 'normal', color: 'var(--violet)', opacity: 0.7, fontSize: 10 }}>
|
|
912
|
+
in / out per 1M tokens
|
|
913
|
+
</span>
|
|
914
|
+
</div>
|
|
915
|
+
<div style={{ display: 'grid', gap: 4, maxHeight: 360, overflowY: 'auto' }}>
|
|
916
|
+
{p.models.map((m) => {
|
|
917
|
+
const modelMsg = messages[`model-${m.id}`];
|
|
918
|
+
return (
|
|
919
|
+
<label key={m.id} style={{
|
|
920
|
+
display: 'flex', alignItems: 'center', gap: 10, padding: '5px 8px', borderRadius: 0,
|
|
921
|
+
background: m.enabled ? 'rgba(var(--green-rgb,34,197,94),0.06)' : 'transparent',
|
|
922
|
+
cursor: overlay.active ? 'wait' : 'pointer', fontSize: 12,
|
|
923
|
+
}}>
|
|
924
|
+
<input type="checkbox" checked={m.enabled} disabled={overlay.active}
|
|
925
|
+
onChange={(e) => handleToggleModel(m.id, e.target.checked)}
|
|
926
|
+
style={{ accentColor: 'var(--green)', cursor: 'pointer' }} />
|
|
927
|
+
<div style={{ flex: 1 }}>
|
|
928
|
+
<div style={{ fontWeight: m.enabled ? 500 : 400 }}>{m.name}</div>
|
|
929
|
+
<div style={{ fontSize: 11, color: 'var(--text-dim)', fontFamily: 'monospace' }}>
|
|
930
|
+
{m.id}
|
|
931
|
+
<span style={{ marginLeft: 8, color: 'var(--violet)', fontWeight: 500 }}>
|
|
932
|
+
{fmtPrice((m as any).pricing)}
|
|
933
|
+
</span>
|
|
934
|
+
</div>
|
|
935
|
+
</div>
|
|
936
|
+
{modelMsg && (
|
|
937
|
+
<span style={{ fontSize: 11, color: modelMsg.type === 'success' ? 'var(--green)' : 'var(--red)' }}>
|
|
938
|
+
{modelMsg.text}
|
|
939
|
+
</span>
|
|
940
|
+
)}
|
|
941
|
+
</label>
|
|
942
|
+
);
|
|
943
|
+
})}
|
|
944
|
+
</div>
|
|
945
|
+
</div>
|
|
946
|
+
)}
|
|
947
|
+
|
|
948
|
+
{msg && (
|
|
949
|
+
<div className={msg.type === 'success' ? 'msg-success' : 'msg-error'}>
|
|
950
|
+
{msg.text}
|
|
951
|
+
</div>
|
|
952
|
+
)}
|
|
953
|
+
</div>
|
|
954
|
+
)}
|
|
955
|
+
</Surface>
|
|
956
|
+
);
|
|
957
|
+
})}
|
|
958
|
+
</section>
|
|
959
|
+
)}
|
|
960
|
+
|
|
961
|
+
{/* =========================================================== */}
|
|
962
|
+
{/* TAB: AGENTS */}
|
|
963
|
+
{/* =========================================================== */}
|
|
964
|
+
{tab === 'agents' && (
|
|
965
|
+
<section>
|
|
966
|
+
{allEnabledModels.length > 0 && (
|
|
967
|
+
<div style={{ marginBottom: 16 }}><Surface variant="panel">
|
|
968
|
+
<div style={{ padding: '12px 16px' }}>
|
|
969
|
+
<div style={{ fontSize: 11, color: 'var(--text-dim)', marginBottom: 8, textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
|
970
|
+
Models available for agents ({allEnabledModels.length})
|
|
971
|
+
</div>
|
|
972
|
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
|
973
|
+
{allEnabledModels.map((m) => {
|
|
974
|
+
const tone: Tone | undefined = m.providerLabel === 'Rev4a Aliases' ? 'accent' : undefined;
|
|
975
|
+
return <Pill key={m.id} tone={tone}>{m.id} <span style={{ opacity: 0.5 }}>({m.providerLabel})</span></Pill>;
|
|
976
|
+
})}
|
|
977
|
+
</div>
|
|
978
|
+
</div>
|
|
979
|
+
</Surface></div>
|
|
980
|
+
)}
|
|
981
|
+
|
|
982
|
+
{(!gatewayData?.agents?.list || gatewayData.agents.list.length === 0) ? (
|
|
983
|
+
<Surface variant="panel">
|
|
984
|
+
<div style={{ padding: '24px 16px', textAlign: 'center', fontSize: 13, color: 'var(--text-dim)' }}>
|
|
985
|
+
No agents found. Start a container with the <code style={{ background: 'rgba(255,255,255,0.06)', padding: '2px 6px', borderRadius: 0 }}>AGENT_ID</code> label.
|
|
986
|
+
</div>
|
|
987
|
+
</Surface>
|
|
988
|
+
) : (
|
|
989
|
+
<div style={{ display: 'grid', gap: 14 }}>
|
|
990
|
+
{gatewayData.agents.list.map((agent) => {
|
|
991
|
+
const key = agent.agentId || agent.containerName;
|
|
992
|
+
const msg = messages[`agent-${key}`];
|
|
993
|
+
const isEditing = editingAgent === key;
|
|
994
|
+
return (
|
|
995
|
+
<Surface key={key} variant="panel">
|
|
996
|
+
<div style={{ padding: '16px 18px', display: 'grid', gap: 12 }}>
|
|
997
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
|
998
|
+
<div className={'gateway-agent-dot' + (agent.state === 'running' ? ' gateway-agent-dot--ok' : ' gateway-agent-dot--err')} />
|
|
999
|
+
<div style={{ fontWeight: 600 }}>{agent.agentId}</div>
|
|
1000
|
+
<Pill tone={agent.state === 'running' ? 'success' : 'danger'}>{agent.state}</Pill>
|
|
1001
|
+
</div>
|
|
1002
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '4px 16px', fontSize: 12 }}>
|
|
1003
|
+
<span style={{ color: 'var(--text-dim)' }}>Primary model:</span>
|
|
1004
|
+
<span style={{ fontWeight: 500 }}>{agent.defaultModel || '—'}</span>
|
|
1005
|
+
<span style={{ color: 'var(--text-dim)' }}>Fallback:</span>
|
|
1006
|
+
<span style={{ fontWeight: 500 }}>{agent.fallbacks.length > 0 ? agent.fallbacks.join(', ') : '—'}</span>
|
|
1007
|
+
</div>
|
|
1008
|
+
{isEditing ? (
|
|
1009
|
+
<AgentEditForm
|
|
1010
|
+
agentId={agent.agentId}
|
|
1011
|
+
allModels={allEnabledModels}
|
|
1012
|
+
initialModel={agent.defaultModel}
|
|
1013
|
+
initialFallbacks={agent.fallbacks.join(', ')}
|
|
1014
|
+
saving={overlay.active}
|
|
1015
|
+
onSave={(model, fallbacks) => handleUpdateAgent(key, model, fallbacks)}
|
|
1016
|
+
onCancel={() => setEditingAgent(null)}
|
|
1017
|
+
/>
|
|
1018
|
+
) : (
|
|
1019
|
+
<Button variant="secondary" size="sm" onClick={() => { setEditingAgent(key); setAgentModelInput(agent.defaultModel || ''); setAgentFallbackInput(agent.fallbacks.join(', ')); }}>
|
|
1020
|
+
✏️ Change model
|
|
1021
|
+
</Button>
|
|
1022
|
+
)}
|
|
1023
|
+
{msg && (
|
|
1024
|
+
<div className={msg.type === 'success' ? 'msg-success' : 'msg-error'}>
|
|
1025
|
+
{msg.text}
|
|
1026
|
+
</div>
|
|
1027
|
+
)}
|
|
1028
|
+
</div>
|
|
1029
|
+
</Surface>
|
|
1030
|
+
);
|
|
1031
|
+
})}
|
|
1032
|
+
</div>
|
|
1033
|
+
)}
|
|
1034
|
+
</section>
|
|
1035
|
+
)}
|
|
1036
|
+
<ConfirmModal
|
|
1037
|
+
open={removeConfirm !== null}
|
|
1038
|
+
onClose={() => setRemoveConfirm(null)}
|
|
1039
|
+
onConfirm={executeRemove}
|
|
1040
|
+
title="Remove API Key"
|
|
1041
|
+
message={`Remove API key for ${removeConfirm || ''}?`}
|
|
1042
|
+
confirmLabel="Remove"
|
|
1043
|
+
cancelLabel="Cancel"
|
|
1044
|
+
tone="danger"
|
|
1045
|
+
/>
|
|
1046
|
+
</Page>
|
|
1047
|
+
);
|
|
1048
|
+
}
|