@chrysb/alphaclaw 0.2.3 → 0.3.1

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 (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +179 -0
  3. package/bin/alphaclaw.js +79 -0
  4. package/lib/public/css/shell.css +57 -2
  5. package/lib/public/css/theme.css +231 -0
  6. package/lib/public/js/app.js +330 -89
  7. package/lib/public/js/components/action-button.js +92 -0
  8. package/lib/public/js/components/channels.js +16 -7
  9. package/lib/public/js/components/confirm-dialog.js +25 -19
  10. package/lib/public/js/components/credentials-modal.js +32 -23
  11. package/lib/public/js/components/device-pairings.js +15 -2
  12. package/lib/public/js/components/envars.js +22 -65
  13. package/lib/public/js/components/features.js +1 -1
  14. package/lib/public/js/components/gateway.js +139 -32
  15. package/lib/public/js/components/global-restart-banner.js +31 -0
  16. package/lib/public/js/components/google.js +9 -9
  17. package/lib/public/js/components/icons.js +19 -0
  18. package/lib/public/js/components/info-tooltip.js +18 -0
  19. package/lib/public/js/components/loading-spinner.js +32 -0
  20. package/lib/public/js/components/modal-shell.js +42 -0
  21. package/lib/public/js/components/models.js +34 -29
  22. package/lib/public/js/components/onboarding/welcome-form-step.js +45 -32
  23. package/lib/public/js/components/onboarding/welcome-pairing-step.js +2 -2
  24. package/lib/public/js/components/onboarding/welcome-setup-step.js +7 -24
  25. package/lib/public/js/components/page-header.js +13 -0
  26. package/lib/public/js/components/pairings.js +15 -2
  27. package/lib/public/js/components/providers.js +216 -142
  28. package/lib/public/js/components/scope-picker.js +1 -1
  29. package/lib/public/js/components/secret-input.js +1 -0
  30. package/lib/public/js/components/telegram-workspace.js +37 -49
  31. package/lib/public/js/components/toast.js +34 -5
  32. package/lib/public/js/components/toggle-switch.js +25 -0
  33. package/lib/public/js/components/update-action-button.js +13 -53
  34. package/lib/public/js/components/watchdog-tab.js +312 -0
  35. package/lib/public/js/components/webhooks.js +1010 -0
  36. package/lib/public/js/components/welcome.js +2 -1
  37. package/lib/public/js/lib/api.js +102 -1
  38. package/lib/public/js/lib/model-config.js +0 -5
  39. package/lib/server/alphaclaw-version.js +5 -3
  40. package/lib/server/constants.js +35 -0
  41. package/lib/server/discord-api.js +48 -0
  42. package/lib/server/gateway.js +64 -4
  43. package/lib/server/log-writer.js +102 -0
  44. package/lib/server/onboarding/github.js +21 -1
  45. package/lib/server/openclaw-version.js +2 -6
  46. package/lib/server/restart-required-state.js +86 -0
  47. package/lib/server/routes/auth.js +9 -4
  48. package/lib/server/routes/proxy.js +12 -14
  49. package/lib/server/routes/system.js +61 -15
  50. package/lib/server/routes/telegram.js +17 -48
  51. package/lib/server/routes/watchdog.js +68 -0
  52. package/lib/server/routes/webhooks.js +214 -0
  53. package/lib/server/telegram-api.js +11 -0
  54. package/lib/server/watchdog-db.js +148 -0
  55. package/lib/server/watchdog-notify.js +93 -0
  56. package/lib/server/watchdog.js +585 -0
  57. package/lib/server/webhook-middleware.js +195 -0
  58. package/lib/server/webhooks-db.js +265 -0
  59. package/lib/server/webhooks.js +238 -0
  60. package/lib/server.js +119 -4
  61. package/lib/setup/core-prompts/AGENTS.md +84 -0
  62. package/lib/setup/core-prompts/TOOLS.md +13 -0
  63. package/lib/setup/core-prompts/UI-DRY-OPPORTUNITIES.md +50 -0
  64. package/lib/setup/gitignore +2 -0
  65. package/package.json +11 -1
@@ -10,11 +10,13 @@ import {
10
10
  fetchCodexStatus,
11
11
  disconnectCodex,
12
12
  exchangeCodexOAuth,
13
- restartGateway,
14
13
  } from "../lib/api.js";
15
14
  import { showToast } from "./toast.js";
16
15
  import { Badge } from "./badge.js";
17
16
  import { SecretInput } from "./secret-input.js";
17
+ import { PageHeader } from "./page-header.js";
18
+ import { LoadingSpinner } from "./loading-spinner.js";
19
+ import { ActionButton } from "./action-button.js";
18
20
  import {
19
21
  getModelProvider,
20
22
  getAuthProviderFromModelProvider,
@@ -35,12 +37,15 @@ const kAiCredentialKeys = Object.values(kProviderAuthFields)
35
37
  .filter((key, idx, arr) => arr.indexOf(key) === idx);
36
38
  let kProvidersTabCache = null;
37
39
 
38
- const FeatureTags = ({ provider }) => {
39
- const features = kProviderFeatures[provider] || [];
40
- if (!features.length) return null;
40
+ const FeatureTags = ({ provider, features = null }) => {
41
+ const resolvedFeatures = Array.isArray(features)
42
+ ? features
43
+ : kProviderFeatures[provider] || [];
44
+ const uniqueFeatures = Array.from(new Set(resolvedFeatures));
45
+ if (!uniqueFeatures.length) return null;
41
46
  return html`
42
47
  <div class="flex flex-wrap gap-1.5">
43
- ${features.map(
48
+ ${uniqueFeatures.map(
44
49
  (f) => html`
45
50
  <span
46
51
  class="text-xs px-1.5 py-0.5 rounded-md bg-white/5 text-gray-400"
@@ -52,25 +57,37 @@ const FeatureTags = ({ provider }) => {
52
57
  `;
53
58
  };
54
59
 
55
- export const Providers = () => {
56
- const [envVars, setEnvVars] = useState(() => kProvidersTabCache?.envVars || []);
60
+ export const Providers = ({ onRestartRequired = () => {} }) => {
61
+ const [envVars, setEnvVars] = useState(
62
+ () => kProvidersTabCache?.envVars || [],
63
+ );
57
64
  const [models, setModels] = useState(() => kProvidersTabCache?.models || []);
58
- const [selectedModel, setSelectedModel] = useState(() => kProvidersTabCache?.selectedModel || "");
59
- const [showAllModels, setShowAllModels] = useState(() => kProvidersTabCache?.showAllModels || false);
65
+ const [selectedModel, setSelectedModel] = useState(
66
+ () => kProvidersTabCache?.selectedModel || "",
67
+ );
68
+ const [showAllModels, setShowAllModels] = useState(
69
+ () => kProvidersTabCache?.showAllModels || false,
70
+ );
60
71
  const [savingChanges, setSavingChanges] = useState(false);
61
- const [codexStatus, setCodexStatus] = useState(() => kProvidersTabCache?.codexStatus || { connected: false });
72
+ const [codexStatus, setCodexStatus] = useState(
73
+ () => kProvidersTabCache?.codexStatus || { connected: false },
74
+ );
62
75
  const [codexManualInput, setCodexManualInput] = useState("");
63
76
  const [codexExchanging, setCodexExchanging] = useState(false);
64
77
  const [codexAuthStarted, setCodexAuthStarted] = useState(false);
65
78
  const [codexAuthWaiting, setCodexAuthWaiting] = useState(false);
66
79
  const [modelsLoading, setModelsLoading] = useState(() => !kProvidersTabCache);
67
- const [modelsError, setModelsError] = useState(() => kProvidersTabCache?.modelsError || "");
80
+ const [modelsError, setModelsError] = useState(
81
+ () => kProvidersTabCache?.modelsError || "",
82
+ );
68
83
  const [ready, setReady] = useState(() => !!kProvidersTabCache);
69
- const [savedModel, setSavedModel] = useState(() => kProvidersTabCache?.savedModel || "");
84
+ const [savedModel, setSavedModel] = useState(
85
+ () => kProvidersTabCache?.savedModel || "",
86
+ );
70
87
  const [modelDirty, setModelDirty] = useState(false);
71
- const [savedAiValues, setSavedAiValues] = useState(() => kProvidersTabCache?.savedAiValues || {});
72
- const [restartRequired, setRestartRequired] = useState(false);
73
- const [restartingGateway, setRestartingGateway] = useState(false);
88
+ const [savedAiValues, setSavedAiValues] = useState(
89
+ () => kProvidersTabCache?.savedAiValues || {},
90
+ );
74
91
  const [showMoreProviders, setShowMoreProviders] = useState(false);
75
92
  const codexPopupPollRef = useRef(null);
76
93
 
@@ -85,7 +102,9 @@ export const Providers = () => {
85
102
  fetchCodexStatus(),
86
103
  ]);
87
104
  setEnvVars(env.vars || []);
88
- const catalogModels = Array.isArray(modelCatalog.models) ? modelCatalog.models : [];
105
+ const catalogModels = Array.isArray(modelCatalog.models)
106
+ ? modelCatalog.models
107
+ : [];
89
108
  setModels(catalogModels);
90
109
  const currentModel = modelStatus.modelKey || "";
91
110
  setSelectedModel(currentModel);
@@ -110,7 +129,7 @@ export const Providers = () => {
110
129
  };
111
130
  } catch (err) {
112
131
  setModelsError("Failed to load provider settings");
113
- showToast(`Failed to load provider settings: ${err.message}`, "red");
132
+ showToast(`Failed to load provider settings: ${err.message}`, "error");
114
133
  } finally {
115
134
  setReady(true);
116
135
  setModelsLoading(false);
@@ -125,10 +144,16 @@ export const Providers = () => {
125
144
  setCodexAuthStarted(false);
126
145
  setCodexAuthWaiting(false);
127
146
  }
128
- kProvidersTabCache = { ...(kProvidersTabCache || {}), codexStatus: codex || { connected: false } };
147
+ kProvidersTabCache = {
148
+ ...(kProvidersTabCache || {}),
149
+ codexStatus: codex || { connected: false },
150
+ };
129
151
  } catch {
130
152
  setCodexStatus({ connected: false });
131
- kProvidersTabCache = { ...(kProvidersTabCache || {}), codexStatus: { connected: false } };
153
+ kProvidersTabCache = {
154
+ ...(kProvidersTabCache || {}),
155
+ codexStatus: { connected: false },
156
+ };
132
157
  }
133
158
  };
134
159
 
@@ -136,20 +161,26 @@ export const Providers = () => {
136
161
  refresh();
137
162
  }, []);
138
163
 
139
- useEffect(() => () => {
140
- if (codexPopupPollRef.current) {
141
- clearInterval(codexPopupPollRef.current);
142
- codexPopupPollRef.current = null;
143
- }
144
- }, []);
164
+ useEffect(
165
+ () => () => {
166
+ if (codexPopupPollRef.current) {
167
+ clearInterval(codexPopupPollRef.current);
168
+ codexPopupPollRef.current = null;
169
+ }
170
+ },
171
+ [],
172
+ );
145
173
 
146
174
  useEffect(() => {
147
175
  const onMessage = async (e) => {
148
176
  if (e.data?.codex === "success") {
149
- showToast("Codex connected", "green");
177
+ showToast("Codex connected", "success");
150
178
  await refreshCodexConnection();
151
179
  } else if (e.data?.codex === "error") {
152
- showToast(`Codex auth failed: ${e.data.message || "unknown error"}`, "red");
180
+ showToast(
181
+ `Codex auth failed: ${e.data.message || "unknown error"}`,
182
+ "error",
183
+ );
153
184
  }
154
185
  };
155
186
  window.addEventListener("message", onMessage);
@@ -165,7 +196,9 @@ export const Providers = () => {
165
196
  };
166
197
 
167
198
  const selectedModelProvider = getModelProvider(selectedModel);
168
- const selectedAuthProvider = getAuthProviderFromModelProvider(selectedModelProvider);
199
+ const selectedAuthProvider = getAuthProviderFromModelProvider(
200
+ selectedModelProvider,
201
+ );
169
202
  const primaryProvider = kProviderOrder.includes(selectedAuthProvider)
170
203
  ? selectedAuthProvider
171
204
  : kProviderOrder[0];
@@ -174,36 +207,47 @@ export const Providers = () => {
174
207
  const baseModelOptions = showAllModels
175
208
  ? models
176
209
  : featuredModels.length > 0
177
- ? featuredModels
178
- : models;
179
- const selectedModelOption = models.find((model) => model.key === selectedModel);
210
+ ? featuredModels
211
+ : models;
212
+ const selectedModelOption = models.find(
213
+ (model) => model.key === selectedModel,
214
+ );
180
215
  const modelOptions =
181
216
  selectedModelOption &&
182
217
  !baseModelOptions.some((model) => model.key === selectedModelOption.key)
183
218
  ? [...baseModelOptions, selectedModelOption]
184
219
  : baseModelOptions;
185
- const canToggleFullCatalog = featuredModels.length > 0 && models.length > featuredModels.length;
220
+ const canToggleFullCatalog =
221
+ featuredModels.length > 0 && models.length > featuredModels.length;
186
222
 
187
223
  const aiCredentialsDirty = kAiCredentialKeys.some(
188
224
  (key) => getKeyVal(envVars, key) !== (savedAiValues[key] || ""),
189
225
  );
190
226
  const hasSelectedProviderAuth =
191
227
  selectedModelProvider === "anthropic"
192
- ? !!(getKeyVal(envVars, "ANTHROPIC_API_KEY") || getKeyVal(envVars, "ANTHROPIC_TOKEN"))
228
+ ? !!(
229
+ getKeyVal(envVars, "ANTHROPIC_API_KEY") ||
230
+ getKeyVal(envVars, "ANTHROPIC_TOKEN")
231
+ )
193
232
  : selectedModelProvider === "openai"
194
- ? !!getKeyVal(envVars, "OPENAI_API_KEY")
195
- : selectedModelProvider === "openai-codex"
196
- ? !!(codexStatus.connected || getKeyVal(envVars, "OPENAI_API_KEY"))
197
- : selectedModelProvider === "google"
198
- ? !!getKeyVal(envVars, "GEMINI_API_KEY")
199
- : false;
200
- const canSaveChanges = !savingChanges && (aiCredentialsDirty || (modelDirty && hasSelectedProviderAuth));
233
+ ? !!getKeyVal(envVars, "OPENAI_API_KEY")
234
+ : selectedModelProvider === "openai-codex"
235
+ ? !!(codexStatus.connected || getKeyVal(envVars, "OPENAI_API_KEY"))
236
+ : selectedModelProvider === "google"
237
+ ? !!getKeyVal(envVars, "GEMINI_API_KEY")
238
+ : false;
239
+ const canSaveChanges =
240
+ !savingChanges &&
241
+ (aiCredentialsDirty || (modelDirty && hasSelectedProviderAuth));
201
242
 
202
243
  const saveChanges = async () => {
203
244
  if (savingChanges) return;
204
245
  if (!modelDirty && !aiCredentialsDirty) return;
205
246
  if (modelDirty && !hasSelectedProviderAuth) {
206
- showToast("Add credentials for the selected model provider before saving model changes", "red");
247
+ showToast(
248
+ "Add credentials for the selected model provider before saving model changes",
249
+ "error",
250
+ );
207
251
  return;
208
252
  }
209
253
  setSavingChanges(true);
@@ -215,30 +259,38 @@ export const Providers = () => {
215
259
  .filter((v) => v.editable)
216
260
  .map((v) => ({ key: v.key, value: v.value }));
217
261
  const envResult = await saveEnvVars(payload);
218
- if (!envResult.ok) throw new Error(envResult.error || "Failed to save env vars");
219
- if (envResult.restartRequired) setRestartRequired(true);
262
+ if (!envResult.ok)
263
+ throw new Error(envResult.error || "Failed to save env vars");
264
+ if (envResult.restartRequired) onRestartRequired(true);
220
265
  }
221
266
 
222
267
  if (modelDirty && targetModel) {
223
268
  const modelResult = await setPrimaryModel(targetModel);
224
- if (!modelResult.ok) throw new Error(modelResult.error || "Failed to set primary model");
269
+ if (!modelResult.ok)
270
+ throw new Error(modelResult.error || "Failed to set primary model");
225
271
  const status = await fetchModelStatus();
226
272
  if (status?.ok === false) {
227
273
  throw new Error(status.error || "Failed to verify primary model");
228
274
  }
229
275
  const activeModel = status?.modelKey || "";
230
276
  if (activeModel && activeModel !== targetModel) {
231
- throw new Error(`Primary model did not apply. Expected ${targetModel} but active is ${activeModel}`);
277
+ throw new Error(
278
+ `Primary model did not apply. Expected ${targetModel} but active is ${activeModel}`,
279
+ );
232
280
  }
233
281
  setSavedModel(targetModel);
234
282
  setModelDirty(false);
235
- kProvidersTabCache = { ...(kProvidersTabCache || {}), selectedModel: targetModel, savedModel: targetModel };
283
+ kProvidersTabCache = {
284
+ ...(kProvidersTabCache || {}),
285
+ selectedModel: targetModel,
286
+ savedModel: targetModel,
287
+ };
236
288
  }
237
289
 
238
- showToast("Changes saved", "green");
239
290
  await refresh();
291
+ showToast("Changes saved", "success");
240
292
  } catch (err) {
241
- showToast(err.message || "Failed to save changes", "red");
293
+ showToast(err.message || "Failed to save changes", "error");
242
294
  } finally {
243
295
  setSavingChanges(false);
244
296
  }
@@ -248,7 +300,11 @@ export const Providers = () => {
248
300
  if (codexStatus.connected) return;
249
301
  setCodexAuthStarted(true);
250
302
  setCodexAuthWaiting(true);
251
- const popup = window.open("/auth/codex/start", "codex-auth", "popup=yes,width=640,height=780");
303
+ const popup = window.open(
304
+ "/auth/codex/start",
305
+ "codex-auth",
306
+ "popup=yes,width=640,height=780",
307
+ );
252
308
  if (!popup || popup.closed) {
253
309
  setCodexAuthWaiting(false);
254
310
  window.location.href = "/auth/codex/start";
@@ -271,14 +327,15 @@ export const Providers = () => {
271
327
  setCodexExchanging(true);
272
328
  try {
273
329
  const result = await exchangeCodexOAuth(codexManualInput.trim());
274
- if (!result.ok) throw new Error(result.error || "Codex OAuth exchange failed");
330
+ if (!result.ok)
331
+ throw new Error(result.error || "Codex OAuth exchange failed");
275
332
  setCodexManualInput("");
276
- showToast("Codex connected", "green");
333
+ showToast("Codex connected", "success");
277
334
  setCodexAuthStarted(false);
278
335
  setCodexAuthWaiting(false);
279
336
  await refreshCodexConnection();
280
337
  } catch (err) {
281
- showToast(err.message || "Codex OAuth exchange failed", "red");
338
+ showToast(err.message || "Codex OAuth exchange failed", "error");
282
339
  } finally {
283
340
  setCodexExchanging(false);
284
341
  }
@@ -287,10 +344,10 @@ export const Providers = () => {
287
344
  const handleCodexDisconnect = async () => {
288
345
  const result = await disconnectCodex();
289
346
  if (!result.ok) {
290
- showToast(result.error || "Failed to disconnect Codex", "red");
347
+ showToast(result.error || "Failed to disconnect Codex", "error");
291
348
  return;
292
349
  }
293
- showToast("Codex disconnected", "green");
350
+ showToast("Codex disconnected", "success");
294
351
  setCodexAuthStarted(false);
295
352
  setCodexAuthWaiting(false);
296
353
  setCodexManualInput("");
@@ -307,7 +364,8 @@ export const Providers = () => {
307
364
  target="_blank"
308
365
  class="text-xs hover:underline"
309
366
  style="color: var(--accent-link)"
310
- >Get</a>`
367
+ >Get</a
368
+ >`
311
369
  : null}
312
370
  </div>
313
371
  <${SecretInput}
@@ -336,48 +394,49 @@ export const Providers = () => {
336
394
  <div class="flex gap-2">
337
395
  <button
338
396
  onclick=${startCodexAuth}
339
- class="text-xs font-medium px-3 py-1.5 rounded-lg border border-border text-gray-300 hover:border-gray-500"
397
+ class="text-xs font-medium px-3 py-1.5 rounded-lg ac-btn-secondary"
340
398
  >
341
399
  Reconnect Codex
342
400
  </button>
343
401
  <button
344
402
  onclick=${handleCodexDisconnect}
345
- class="text-xs font-medium px-3 py-1.5 rounded-lg border border-border text-gray-300 hover:border-gray-500"
403
+ class="text-xs font-medium px-3 py-1.5 rounded-lg ac-btn-ghost"
346
404
  >
347
405
  Disconnect
348
406
  </button>
349
407
  </div>
350
408
  `
351
409
  : !codexAuthStarted
352
- ? html`
353
- <button
354
- onclick=${startCodexAuth}
355
- class="text-xs font-medium px-3 py-1.5 rounded-lg ac-btn-cyan"
356
- >
357
- Connect Codex OAuth
358
- </button>
359
- `
360
- : html`
361
- <div class="flex items-center justify-between gap-2">
362
- <p class="text-xs text-gray-500">
363
- ${codexAuthWaiting
364
- ? "Complete login in the popup, then paste the redirect URL."
365
- : "Paste the redirect URL from your browser to finish connecting."}
366
- </p>
410
+ ? html`
367
411
  <button
368
412
  onclick=${startCodexAuth}
369
- class="text-xs font-medium px-3 py-1.5 rounded-lg border border-border text-gray-300 hover:border-gray-500 shrink-0"
413
+ class="text-xs font-medium px-3 py-1.5 rounded-lg ac-btn-cyan"
370
414
  >
371
- Restart
415
+ Connect Codex OAuth
372
416
  </button>
373
- </div>
374
- `}
417
+ `
418
+ : html`
419
+ <div class="flex items-center justify-between gap-2">
420
+ <p class="text-xs text-gray-500">
421
+ ${codexAuthWaiting
422
+ ? "Complete login in the popup, then paste the redirect URL."
423
+ : "Paste the redirect URL from your browser to finish connecting."}
424
+ </p>
425
+ <button
426
+ onclick=${startCodexAuth}
427
+ class="text-xs font-medium px-3 py-1.5 rounded-lg ac-btn-secondary shrink-0"
428
+ >
429
+ Restart
430
+ </button>
431
+ </div>
432
+ `}
375
433
  ${!codexStatus.connected && codexAuthStarted
376
434
  ? html`
377
435
  <p class="text-xs text-gray-500">
378
436
  After login, copy the full redirect URL (starts with
379
- <code class="text-xs bg-black/30 px-1 rounded">http://localhost:1455/auth/callback</code>)
380
- and paste it here.
437
+ <code class="text-xs bg-black/30 px-1 rounded"
438
+ >http://localhost:1455/auth/callback</code
439
+ >) and paste it here.
381
440
  </p>
382
441
  <input
383
442
  type="text"
@@ -386,13 +445,16 @@ export const Providers = () => {
386
445
  placeholder="http://localhost:1455/auth/callback?code=...&state=..."
387
446
  class="w-full bg-black/30 border border-border rounded-lg px-3 py-2 text-xs text-gray-200 outline-none focus:border-gray-500"
388
447
  />
389
- <button
390
- onclick=${completeCodexAuth}
448
+ <${ActionButton}
449
+ onClick=${completeCodexAuth}
391
450
  disabled=${!codexManualInput.trim() || codexExchanging}
392
- class="text-xs font-medium px-3 py-1.5 rounded-lg ac-btn-cyan"
393
- >
394
- ${codexExchanging ? "Completing..." : "Complete Codex OAuth"}
395
- </button>
451
+ loading=${codexExchanging}
452
+ tone="primary"
453
+ size="sm"
454
+ idleLabel="Complete Codex OAuth"
455
+ loadingLabel="Completing..."
456
+ className="text-xs font-medium px-3 py-1.5"
457
+ />
396
458
  `
397
459
  : null}
398
460
  </div>
@@ -407,6 +469,7 @@ export const Providers = () => {
407
469
  const fields = kProviderAuthFields[provider] || [];
408
470
  const hasCodex = provider === "openai";
409
471
  const hasKey = providerHasKey(provider);
472
+ const openAiFeatures = kProviderFeatures.openai || [];
410
473
  return html`
411
474
  <div class="bg-surface border border-border rounded-xl p-4 space-y-3">
412
475
  <div class="flex items-center gap-2">
@@ -414,25 +477,43 @@ export const Providers = () => {
414
477
  ${kProviderLabels[provider] || provider}
415
478
  </h3>
416
479
  ${hasKey
417
- ? html`<span class="inline-block w-1.5 h-1.5 rounded-full bg-green-500" />`
480
+ ? html`<span
481
+ class="inline-block w-1.5 h-1.5 rounded-full bg-green-500"
482
+ />`
418
483
  : null}
419
484
  </div>
420
485
  ${fields.map((field) => renderCredentialField(field))}
486
+ ${provider === "openai"
487
+ ? html`<${FeatureTags} features=${openAiFeatures} />`
488
+ : null}
421
489
  ${hasCodex ? renderCodexOAuth() : null}
422
- <${FeatureTags} provider=${provider} />
490
+ ${provider !== "openai"
491
+ ? html`<${FeatureTags} provider=${provider} />`
492
+ : null}
423
493
  </div>
424
494
  `;
425
495
  };
426
496
 
427
497
  if (!ready) {
428
498
  return html`
429
- <div class="bg-surface border border-border rounded-xl p-4">
430
- <div class="flex items-center gap-2 text-sm text-gray-400">
431
- <svg class="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
432
- <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
433
- <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
434
- </svg>
435
- Loading provider settings...
499
+ <div class="space-y-4">
500
+ <${PageHeader}
501
+ title="Providers"
502
+ actions=${html`
503
+ <${ActionButton}
504
+ disabled=${true}
505
+ tone="primary"
506
+ size="sm"
507
+ idleLabel="Save changes"
508
+ className="transition-all"
509
+ />
510
+ `}
511
+ />
512
+ <div class="bg-surface border border-border rounded-xl p-4">
513
+ <div class="flex items-center gap-2 text-sm text-gray-400">
514
+ <${LoadingSpinner} className="h-4 w-4" />
515
+ Loading provider settings...
516
+ </div>
436
517
  </div>
437
518
  </div>
438
519
  `;
@@ -449,6 +530,22 @@ export const Providers = () => {
449
530
 
450
531
  return html`
451
532
  <div class="space-y-4">
533
+ <${PageHeader}
534
+ title="Providers"
535
+ actions=${html`
536
+ <${ActionButton}
537
+ onClick=${saveChanges}
538
+ disabled=${!canSaveChanges}
539
+ loading=${savingChanges}
540
+ tone="primary"
541
+ size="sm"
542
+ idleLabel="Save changes"
543
+ loadingLabel="Saving..."
544
+ className="transition-all"
545
+ />
546
+ `}
547
+ />
548
+
452
549
  <div class="bg-surface border border-border rounded-xl p-4 space-y-3">
453
550
  <h2 class="font-semibold text-sm">Primary Agent Model</h2>
454
551
  <select
@@ -457,17 +554,27 @@ export const Providers = () => {
457
554
  const next = e.target.value;
458
555
  setSelectedModel(next);
459
556
  setModelDirty(next !== savedModel);
460
- kProvidersTabCache = { ...(kProvidersTabCache || {}), selectedModel: next };
557
+ kProvidersTabCache = {
558
+ ...(kProvidersTabCache || {}),
559
+ selectedModel: next,
560
+ };
461
561
  }}
462
562
  class="w-full bg-black/30 border border-border rounded-lg pl-3 pr-8 py-2 text-sm text-gray-200 outline-none focus:border-gray-500"
463
563
  >
464
564
  <option value="">Select a model</option>
465
565
  ${modelOptions.map(
466
- (model) => html`<option value=${model.key}>${model.label || model.key}</option>`,
566
+ (model) =>
567
+ html`<option value=${model.key}>
568
+ ${model.label || model.key}
569
+ </option>`,
467
570
  )}
468
571
  </select>
469
572
  <p class="text-xs text-gray-600">
470
- ${modelsLoading ? "Loading model catalog..." : modelsError ? modelsError : ""}
573
+ ${modelsLoading
574
+ ? "Loading model catalog..."
575
+ : modelsError
576
+ ? modelsError
577
+ : ""}
471
578
  </p>
472
579
  ${canToggleFullCatalog
473
580
  ? html`
@@ -477,12 +584,17 @@ export const Providers = () => {
477
584
  onclick=${() =>
478
585
  setShowAllModels((prev) => {
479
586
  const next = !prev;
480
- kProvidersTabCache = { ...(kProvidersTabCache || {}), showAllModels: next };
587
+ kProvidersTabCache = {
588
+ ...(kProvidersTabCache || {}),
589
+ showAllModels: next,
590
+ };
481
591
  return next;
482
592
  })}
483
593
  class="text-xs text-gray-500 hover:text-gray-300"
484
594
  >
485
- ${showAllModels ? "Show recommended models" : "Show full model catalog"}
595
+ ${showAllModels
596
+ ? "Show recommended models"
597
+ : "Show full model catalog"}
486
598
  </button>
487
599
  </div>
488
600
  `
@@ -495,67 +607,29 @@ export const Providers = () => {
495
607
  ${otherProviders
496
608
  .filter((p) => kCoreProviders.has(p))
497
609
  .map((provider) => renderProviderCard(provider))}
498
-
499
610
  ${showMoreProviders
500
611
  ? otherProviders
501
612
  .filter((p) => !kCoreProviders.has(p))
502
613
  .map((provider) => renderProviderCard(provider))
503
614
  : null}
504
-
505
615
  ${otherProviders.some((p) => !kCoreProviders.has(p))
506
616
  ? html`
507
617
  <button
508
618
  type="button"
509
619
  onclick=${() => setShowMoreProviders((prev) => !prev)}
510
- class="w-full text-xs text-gray-500 hover:text-gray-300 py-1"
620
+ class="w-full text-xs px-3 py-1.5 rounded-lg ac-btn-ghost"
511
621
  >
512
- ${showMoreProviders ? "Hide additional providers" : "More providers"}
622
+ ${showMoreProviders
623
+ ? "Hide additional providers"
624
+ : "More providers"}
513
625
  </button>
514
626
  `
515
627
  : null}
516
-
517
- ${restartRequired
518
- ? html`<div
519
- class="bg-yellow-500/10 border border-yellow-500/30 rounded-xl p-4 flex items-center justify-between gap-3"
520
- >
521
- <p class="text-sm text-yellow-200">
522
- Gateway restart required to apply changes.
523
- </p>
524
- <button
525
- onclick=${async () => {
526
- if (restartingGateway) return;
527
- setRestartingGateway(true);
528
- try {
529
- await restartGateway();
530
- setRestartRequired(false);
531
- showToast("Gateway restarted", "success");
532
- } catch (err) {
533
- showToast("Restart failed: " + err.message, "error");
534
- } finally {
535
- setRestartingGateway(false);
536
- }
537
- }}
538
- disabled=${restartingGateway}
539
- class="text-xs px-2.5 py-1 rounded-lg border border-yellow-500/40 text-yellow-200 hover:border-yellow-400 hover:text-yellow-100 transition-colors shrink-0 ${restartingGateway
540
- ? "opacity-60 cursor-not-allowed"
541
- : ""}"
542
- >
543
- ${restartingGateway ? "Restarting..." : "Restart Gateway"}
544
- </button>
545
- </div>`
546
- : null}
547
-
548
- <button
549
- onclick=${saveChanges}
550
- disabled=${!canSaveChanges}
551
- class="w-full text-sm font-medium px-4 py-2.5 rounded-xl transition-all ac-btn-cyan"
552
- >
553
- ${savingChanges ? "Saving..." : "Save changes"}
554
- </button>
555
628
  ${modelDirty && !hasSelectedProviderAuth
556
629
  ? html`
557
630
  <p class="text-xs text-yellow-500">
558
- Set credentials for the selected provider before saving this model change.
631
+ Set credentials for the selected provider before saving this model
632
+ change.
559
633
  </p>
560
634
  `
561
635
  : null}
@@ -43,7 +43,7 @@ export function ScopePicker({ scopes, onToggle, apiStatus, loading }) {
43
43
  const api = status[s.key];
44
44
  let apiIndicator = null;
45
45
  if (loading && !api && (readOn || writeOn)) {
46
- apiIndicator = html`<span class="text-gray-500 text-xs flex items-center gap-1"><span class="inline-block w-3 h-3 border-2 border-gray-500 border-t-transparent rounded-full animate-spin"></span></span>`;
46
+ apiIndicator = html`<span class="text-gray-500 text-xs flex items-center gap-1"><span class="inline-block w-3 h-3 border-2 border-gray-500 border-t-transparent rounded-full ac-spinner"></span></span>`;
47
47
  } else if (api) {
48
48
  if (api.status === 'ok') {
49
49
  apiIndicator = html`<a href=${api.enableUrl || getApiEnableUrl(s.key)} target="_blank" class="text-green-500 hover:text-green-300 text-xs px-1.5 py-0.5 rounded bg-green-500/10">API ✓</a>`;
@@ -32,6 +32,7 @@ export const SecretInput = ({
32
32
  onBlur=${onBlur}
33
33
  disabled=${disabled}
34
34
  class=${inputClass}
35
+ autocomplete="off"
35
36
  />
36
37
  ${showToggle
37
38
  ? html`<button