@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.
Files changed (290) hide show
  1. package/README.md +150 -0
  2. package/agent-templates/README.md +37 -0
  3. package/agent-templates/argus/AGENTS.md +52 -0
  4. package/agent-templates/argus/IDENTITY.md +7 -0
  5. package/agent-templates/argus/MEMORY.md +9 -0
  6. package/agent-templates/argus/SOUL.md +24 -0
  7. package/agent-templates/argus/TOOLS.md +8 -0
  8. package/agent-templates/atlas/AGENTS.md +47 -0
  9. package/agent-templates/atlas/HEARTBEAT.md +10 -0
  10. package/agent-templates/atlas/IDENTITY.md +6 -0
  11. package/agent-templates/atlas/MEMORY.md +9 -0
  12. package/agent-templates/atlas/SOUL.md +21 -0
  13. package/agent-templates/atlas/TOOLS.md +4 -0
  14. package/agent-templates/base-image/.trigger +0 -0
  15. package/agent-templates/base-image/Dockerfile +90 -0
  16. package/agent-templates/base-image/entrypoint.sh +37 -0
  17. package/agent-templates/custom/AGENTS.md +29 -0
  18. package/agent-templates/custom/BOOTSTRAP.md +56 -0
  19. package/agent-templates/custom/MEMORY.md +8 -0
  20. package/agent-templates/prometheus/AGENTS.md +37 -0
  21. package/agent-templates/prometheus/IDENTITY.md +6 -0
  22. package/agent-templates/prometheus/MEMORY.md +9 -0
  23. package/agent-templates/prometheus/SOUL.md +21 -0
  24. package/agent-templates/prometheus/TOOLS.md +4 -0
  25. package/app/agents/ChannelManager.tsx +465 -0
  26. package/app/agents/ImageDownloadBanner.tsx +159 -0
  27. package/app/agents/PageClient.tsx +1285 -0
  28. package/app/agents/create/PageClient.tsx +437 -0
  29. package/app/agents/create/page.tsx +126 -0
  30. package/app/agents/loading.tsx +14 -0
  31. package/app/agents/page.tsx +5 -0
  32. package/app/api/agents/[id]/backup/route.ts +169 -0
  33. package/app/api/agents/[id]/channels/pairing/route.ts +69 -0
  34. package/app/api/agents/[id]/channels/route.ts +14 -0
  35. package/app/api/agents/[id]/channels/telegram/route.ts +50 -0
  36. package/app/api/agents/[id]/lifecycle/route.ts +94 -0
  37. package/app/api/agents/[id]/recreate/route.ts +199 -0
  38. package/app/api/agents/[id]/restart/route.ts +33 -0
  39. package/app/api/agents/[id]/restore/route.ts +81 -0
  40. package/app/api/agents/[id]/route.ts +280 -0
  41. package/app/api/agents/channels-summary/route.ts +39 -0
  42. package/app/api/agents/config-json/route.ts +58 -0
  43. package/app/api/agents/create/route.ts +488 -0
  44. package/app/api/agents/download-image/route.ts +32 -0
  45. package/app/api/agents/image-status/route.ts +126 -0
  46. package/app/api/agents/route.ts +213 -0
  47. package/app/api/agents/token/route.ts +94 -0
  48. package/app/api/agents-active/route.ts +190 -0
  49. package/app/api/agents-config/route.ts +629 -0
  50. package/app/api/alerts/smoke/route.ts +26 -0
  51. package/app/api/aliases/add/route.ts +28 -0
  52. package/app/api/aliases/remove/route.ts +27 -0
  53. package/app/api/assistant/route.ts +204 -0
  54. package/app/api/auth/check/route.ts +38 -0
  55. package/app/api/auth/login/route.ts +85 -0
  56. package/app/api/auth/logout/route.ts +7 -0
  57. package/app/api/auth/status/route.ts +33 -0
  58. package/app/api/config/env/route.ts +132 -0
  59. package/app/api/config/restart/route.ts +26 -0
  60. package/app/api/containers/route.ts +75 -0
  61. package/app/api/cost-override/route.ts +46 -0
  62. package/app/api/costs/route.ts +84 -0
  63. package/app/api/credentials/[id]/reveal/route.ts +21 -0
  64. package/app/api/credentials/[id]/route.ts +52 -0
  65. package/app/api/credentials/[id]/sync/route.ts +58 -0
  66. package/app/api/credentials/detect/route.ts +246 -0
  67. package/app/api/credentials/route.ts +33 -0
  68. package/app/api/crons/[id]/route.ts +69 -0
  69. package/app/api/crons/route.ts +12 -0
  70. package/app/api/crons/runs/route.ts +31 -0
  71. package/app/api/debug/route.ts +11 -0
  72. package/app/api/envcheck/route.ts +18 -0
  73. package/app/api/events/route.ts +24 -0
  74. package/app/api/gateway/agent/route.ts +154 -0
  75. package/app/api/gateway/provider/balance/route.ts +38 -0
  76. package/app/api/gateway/provider/keys.ts +38 -0
  77. package/app/api/gateway/provider/oauth/route.ts +304 -0
  78. package/app/api/gateway/provider/route.ts +209 -0
  79. package/app/api/gateway/route.ts +223 -0
  80. package/app/api/gateway/sync.ts +189 -0
  81. package/app/api/lineage/route.ts +54 -0
  82. package/app/api/memory-context/route.ts +15 -0
  83. package/app/api/metrics/route.ts +35 -0
  84. package/app/api/models/route.ts +77 -0
  85. package/app/api/plugins/route.js +37 -0
  86. package/app/api/provider/upstream.ts +142 -0
  87. package/app/api/provider/v1/chat/completions/route.ts +146 -0
  88. package/app/api/provider/v1/models/route.ts +167 -0
  89. package/app/api/provider-usage/route.ts +629 -0
  90. package/app/api/session/route.ts +39 -0
  91. package/app/api/sessions/[id]/route.ts +38 -0
  92. package/app/api/sessions/route.ts +23 -0
  93. package/app/api/setup/agent-image/route.ts +102 -0
  94. package/app/api/setup/password/route.ts +111 -0
  95. package/app/api/setup/restart/route.ts +76 -0
  96. package/app/api/skills/delete/route.js +105 -0
  97. package/app/api/skills/promote/route.js +129 -0
  98. package/app/api/skills/route.js +203 -0
  99. package/app/api/skills/save/route.js +142 -0
  100. package/app/api/stats/route.ts +34 -0
  101. package/app/api/stats-since/route.ts +20 -0
  102. package/app/api/stream/route.ts +70 -0
  103. package/app/api/system-health/route.ts +233 -0
  104. package/app/api/tool-calls/route.ts +24 -0
  105. package/app/api/tools-config/route.ts +163 -0
  106. package/app/api/update-check/route.ts +127 -0
  107. package/app/api/version/route.ts +25 -0
  108. package/app/api/wizard/complete/route.ts +32 -0
  109. package/app/api/wizard/reset/route.ts +28 -0
  110. package/app/api/wizard/status/route.ts +28 -0
  111. package/app/api/workspace/route.ts +392 -0
  112. package/app/api/workspace/stream/route.ts +136 -0
  113. package/app/components/AppShell.tsx +33 -0
  114. package/app/components/AuthGuard.tsx +91 -0
  115. package/app/components/CostBreakdown.tsx +37 -0
  116. package/app/components/DashboardHeader.tsx +73 -0
  117. package/app/components/DashboardLayout.tsx +76 -0
  118. package/app/components/DashboardToolbar.tsx +61 -0
  119. package/app/components/FloatingActions.tsx +33 -0
  120. package/app/components/Icons.tsx +78 -0
  121. package/app/components/LineageGraphPage.tsx +142 -0
  122. package/app/components/LiveFeed.tsx +48 -0
  123. package/app/components/MobileBottomNav.tsx +84 -0
  124. package/app/components/ModelPickerModal.tsx +94 -0
  125. package/app/components/PasswordInput.tsx +86 -0
  126. package/app/components/PulseChat.tsx +362 -0
  127. package/app/components/Rev4aLoader.tsx +48 -0
  128. package/app/components/SessionDrawer.tsx +263 -0
  129. package/app/components/SessionTopology.tsx +289 -0
  130. package/app/components/Sidebar.tsx +294 -0
  131. package/app/components/Skeleton.tsx +133 -0
  132. package/app/components/SystemCockpit.tsx +169 -0
  133. package/app/components/VersionBanner.tsx +105 -0
  134. package/app/components/WizardButton.tsx +30 -0
  135. package/app/components/ui/Badge.tsx +30 -0
  136. package/app/components/ui/Button.tsx +59 -0
  137. package/app/components/ui/ConfirmModal.tsx +46 -0
  138. package/app/components/ui/FilterBar.tsx +33 -0
  139. package/app/components/ui/Input.tsx +25 -0
  140. package/app/components/ui/ItemList.tsx +83 -0
  141. package/app/components/ui/LoadingSpinner.tsx +30 -0
  142. package/app/components/ui/Metric.tsx +22 -0
  143. package/app/components/ui/Modal.tsx +42 -0
  144. package/app/components/ui/Page.tsx +18 -0
  145. package/app/components/ui/Pill.tsx +6 -0
  146. package/app/components/ui/PropertyList.tsx +24 -0
  147. package/app/components/ui/Select.tsx +26 -0
  148. package/app/components/ui/StatusCard.tsx +32 -0
  149. package/app/components/ui/Surface.tsx +14 -0
  150. package/app/components/ui/Tabs.tsx +55 -0
  151. package/app/components/ui/TemplateOption.tsx +59 -0
  152. package/app/components/ui/Toast.tsx +44 -0
  153. package/app/components/ui/index.ts +19 -0
  154. package/app/components/ui/tokens.ts +18 -0
  155. package/app/config/PageClient.tsx +293 -0
  156. package/app/config/loading.tsx +10 -0
  157. package/app/config/page.tsx +7 -0
  158. package/app/containers/ContainersClient.tsx +129 -0
  159. package/app/containers/loading.tsx +21 -0
  160. package/app/containers/page.tsx +5 -0
  161. package/app/containers/terminal/[id]/TerminalClient.tsx +313 -0
  162. package/app/containers/terminal/[id]/page.tsx +6 -0
  163. package/app/credentials/PageClient.tsx +598 -0
  164. package/app/credentials/loading.tsx +10 -0
  165. package/app/credentials/page.tsx +7 -0
  166. package/app/crons/PageClient.tsx +538 -0
  167. package/app/crons/loading.tsx +10 -0
  168. package/app/crons/page.tsx +5 -0
  169. package/app/design-system.ts +57 -0
  170. package/app/gateway/PageClient.tsx +1048 -0
  171. package/app/gateway/loading.tsx +24 -0
  172. package/app/gateway/page.tsx +5 -0
  173. package/app/globals.css +1474 -0
  174. package/app/layout.tsx +45 -0
  175. package/app/lib/models-context.tsx +41 -0
  176. package/app/lineage/loading.tsx +10 -0
  177. package/app/lineage/page.tsx +13 -0
  178. package/app/loading.tsx +16 -0
  179. package/app/login/page.tsx +160 -0
  180. package/app/memory/MemoryContextPageClient.tsx +196 -0
  181. package/app/memory/loading.tsx +10 -0
  182. package/app/memory/page.tsx +8 -0
  183. package/app/page.tsx +31 -0
  184. package/app/plugins/PageClient.tsx +296 -0
  185. package/app/plugins/loading.tsx +10 -0
  186. package/app/plugins/page.tsx +5 -0
  187. package/app/setup/PageClient.tsx +227 -0
  188. package/app/setup/loading.tsx +21 -0
  189. package/app/setup/page.tsx +7 -0
  190. package/app/skills/PageClient.tsx +499 -0
  191. package/app/skills/loading.tsx +10 -0
  192. package/app/skills/page.tsx +5 -0
  193. package/app/tools/ToolsPageClient.tsx +308 -0
  194. package/app/tools/loading.tsx +10 -0
  195. package/app/tools/page.tsx +62 -0
  196. package/app/tools/tools-catalog.ts +141 -0
  197. package/app/wizard/PageClient.tsx +485 -0
  198. package/app/wizard/icons.tsx +89 -0
  199. package/app/wizard/loading.tsx +24 -0
  200. package/app/wizard/page.tsx +7 -0
  201. package/app/wizard/storage.ts +33 -0
  202. package/app/wizard/useWizard.ts +101 -0
  203. package/app/workspace/WorkspaceClient.tsx +633 -0
  204. package/app/workspace/loading.tsx +36 -0
  205. package/app/workspace/page.tsx +8 -0
  206. package/bin/docker-entrypoint.sh +21 -0
  207. package/bin/postinstall.js +20 -0
  208. package/bin/rev4a.js +696 -0
  209. package/daemon.js +783 -0
  210. package/docs/ARCHITECTURE.md +557 -0
  211. package/docs/CONTAINER-TERMINAL.md +280 -0
  212. package/docs/DESIGN-SYSTEM.md +99 -0
  213. package/docs/FRONTEND-ARCHITECTURE.md +119 -0
  214. package/docs/REV4A.md +504 -0
  215. package/docs/dev/API-REFERENCE.md +1835 -0
  216. package/docs/dev/DATABASE.md +224 -0
  217. package/docs/dev/GATEWAY.md +300 -0
  218. package/docs/dev/PROVIDERS.md +229 -0
  219. package/docs/dev/SESSION-MAINTENANCE-PLAN.md +607 -0
  220. package/docs/dev/WORKSPACE.md +199 -0
  221. package/docs/rag/DATA-FRESHNESS.md +109 -0
  222. package/docs/rag/GLOSSARY.md +125 -0
  223. package/docs/rag/REV4A-OVERVIEW.md +135 -0
  224. package/docs/rag/WHAT-I-CAN-ANSWER.md +124 -0
  225. package/lib/agent-setup.ts +130 -0
  226. package/lib/alerts.ts +115 -0
  227. package/lib/apiFetch.ts +7 -0
  228. package/lib/auth.ts +28 -0
  229. package/lib/billing.ts +100 -0
  230. package/lib/buildAgentImage.ts +346 -0
  231. package/lib/channelManager.ts +386 -0
  232. package/lib/container.ts +60 -0
  233. package/lib/credentials/db.ts +86 -0
  234. package/lib/credentials/delivery.ts +448 -0
  235. package/lib/credentials/detect.ts +149 -0
  236. package/lib/credentials/hash.ts +9 -0
  237. package/lib/credentials/providers.ts +110 -0
  238. package/lib/credentials/vault.ts +195 -0
  239. package/lib/db-bootstrap.d.ts +3 -0
  240. package/lib/db-bootstrap.mjs +144 -0
  241. package/lib/db.ts +25 -0
  242. package/lib/docker-utils.ts +60 -0
  243. package/lib/hooks/useDashboard.ts +264 -0
  244. package/lib/hooks/useRev4aTimezone.ts +28 -0
  245. package/lib/hooks/useScreenshot.ts +30 -0
  246. package/lib/memory-context.ts +385 -0
  247. package/lib/model-catalogue.ts +131 -0
  248. package/lib/model-pricing.ts +63 -0
  249. package/lib/openclaw-cron.ts +350 -0
  250. package/lib/patterns/ApiAdapter.ts +73 -0
  251. package/lib/patterns/ApiClient.ts +65 -0
  252. package/lib/patterns/EventBus.ts +113 -0
  253. package/lib/patterns/FilterStrategy.ts +79 -0
  254. package/lib/patterns/SessionFactory.ts +117 -0
  255. package/lib/patterns/sessionPresentation.ts +117 -0
  256. package/lib/provider-balance.ts +163 -0
  257. package/lib/requireAuth.tsx +38 -0
  258. package/lib/rev4a-auth.d.ts +3 -0
  259. package/lib/rev4a-auth.js +36 -0
  260. package/lib/rev4a-paths.ts +145 -0
  261. package/lib/timezone.ts +46 -0
  262. package/lib/types/index.ts +96 -0
  263. package/lib/utils/format.ts +82 -0
  264. package/lineage.js +56 -0
  265. package/model-pricing.json +482 -0
  266. package/models.config.json +844 -0
  267. package/next-env.d.ts +6 -0
  268. package/next.config.mjs +7 -0
  269. package/package.json +82 -0
  270. package/public/apple-touch-icon.png +0 -0
  271. package/public/favicon-64.png +0 -0
  272. package/public/favicon.svg +58 -0
  273. package/public/icon-192-maskable.png +0 -0
  274. package/public/icon-192.png +0 -0
  275. package/public/icon-512-maskable.png +0 -0
  276. package/public/icon-512.png +0 -0
  277. package/public/manifest.json +38 -0
  278. package/public/rev4a-logo.png +0 -0
  279. package/public/rev4a-logo.svg +58 -0
  280. package/rev4a-rules/AGENTS.md +37 -0
  281. package/scripts/add-auth.cjs +185 -0
  282. package/scripts/backup.sh +66 -0
  283. package/scripts/check-providers.mjs +143 -0
  284. package/scripts/docker/traefik/docker-compose.yml +32 -0
  285. package/scripts/docker/traefik/dynamic/.gitkeep +18 -0
  286. package/scripts/docker/traefik/traefik.yml +37 -0
  287. package/scripts/remove-auth-profile.js +56 -0
  288. package/scripts/restore.sh +82 -0
  289. package/terminal-ws-server.js +139 -0
  290. 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
+ }