@chrysb/alphaclaw 0.1.22 → 0.1.24

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/bin/alphaclaw.js CHANGED
@@ -122,6 +122,20 @@ try {
122
122
  console.log(`[alphaclaw] .env setup skipped: ${e.message}`);
123
123
  }
124
124
 
125
+ // ---------------------------------------------------------------------------
126
+ // 5. Symlink <rootDir>/.openclaw/.env -> <rootDir>/.env
127
+ // ---------------------------------------------------------------------------
128
+
129
+ const openclawEnvLink = path.join(openclawDir, ".env");
130
+ try {
131
+ if (!fs.existsSync(openclawEnvLink)) {
132
+ fs.symlinkSync(envFilePath, openclawEnvLink);
133
+ console.log(`[alphaclaw] Symlinked ${openclawEnvLink} -> ${envFilePath}`);
134
+ }
135
+ } catch (e) {
136
+ console.log(`[alphaclaw] .env symlink skipped: ${e.message}`);
137
+ }
138
+
125
139
  // ---------------------------------------------------------------------------
126
140
  // 6. Load .env into process.env
127
141
  // ---------------------------------------------------------------------------
@@ -41,12 +41,25 @@ body::before {
41
41
  z-index: 0;
42
42
  }
43
43
 
44
+ /* Standardised card / section label. */
45
+ .card-label {
46
+ font-weight: 600;
47
+ letter-spacing: 0.04em;
48
+ color: var(--text-muted);
49
+ }
50
+
44
51
  /* Unified panel treatment across tabs/pages. */
45
52
  .bg-surface {
46
53
  background: var(--panel-bg-contrast) !important;
47
54
  border-color: var(--panel-border-contrast) !important;
48
55
  }
49
56
 
57
+ /* Solid background for modals so page content doesn't bleed through. */
58
+ .bg-modal {
59
+ background: var(--bg) !important;
60
+ border-color: var(--panel-border-contrast) !important;
61
+ }
62
+
50
63
  .border-border {
51
64
  border-color: var(--panel-border-contrast) !important;
52
65
  }
@@ -23,13 +23,14 @@ import { Channels, ALL_CHANNELS } from "./components/channels.js";
23
23
  import { Pairings } from "./components/pairings.js";
24
24
  import { DevicePairings } from "./components/device-pairings.js";
25
25
  import { Google } from "./components/google.js";
26
- import { Models } from "./components/models.js";
26
+ import { Features } from "./components/features.js";
27
+ import { Providers } from "./components/providers.js";
27
28
  import { Welcome } from "./components/welcome.js";
28
29
  import { Envars } from "./components/envars.js";
29
30
  import { ToastContainer, showToast } from "./components/toast.js";
30
31
  import { ChevronDownIcon } from "./components/icons.js";
31
32
  const html = htm.bind(h);
32
- const kUiTabs = ["general", "models", "envars"];
33
+ const kUiTabs = ["general", "providers", "envars"];
33
34
  const kDefaultUiTab = "general";
34
35
 
35
36
  const GeneralTab = ({ onSwitchTab, isActive }) => {
@@ -163,6 +164,7 @@ const GeneralTab = ({ onSwitchTab, isActive }) => {
163
164
  onApprove=${handleApprove}
164
165
  onReject=${handleReject}
165
166
  />
167
+ <${Features} onSwitchTab=${onSwitchTab} />
166
168
  <${Google} key=${googleKey} gatewayStatus=${gatewayStatus} />
167
169
 
168
170
  ${repo &&
@@ -401,7 +403,7 @@ function App() {
401
403
 
402
404
  const kNavItems = [
403
405
  { id: "general", label: "General" },
404
- { id: "models", label: "Models" },
406
+ { id: "providers", label: "Providers" },
405
407
  { id: "envars", label: "Envars" },
406
408
  ];
407
409
 
@@ -476,8 +478,8 @@ function App() {
476
478
  isActive=${tab === "general"}
477
479
  />
478
480
  </div>
479
- <div style=${{ display: tab === "models" ? "" : "none" }}>
480
- <${Models} />
481
+ <div style=${{ display: tab === "providers" ? "" : "none" }}>
482
+ <${Providers} />
481
483
  </div>
482
484
  <div style=${{ display: tab === "envars" ? "" : "none" }}>
483
485
  <${Envars} />
@@ -6,6 +6,7 @@ const html = htm.bind(h);
6
6
  const kToneClasses = {
7
7
  success: "bg-green-500/10 text-green-500",
8
8
  warning: "bg-yellow-500/10 text-yellow-500",
9
+ danger: "bg-red-500/10 text-red-400",
9
10
  neutral: "bg-gray-500/10 text-gray-400",
10
11
  };
11
12
 
@@ -12,7 +12,7 @@ const kChannelMeta = {
12
12
  export function Channels({ channels, onSwitchTab }) {
13
13
  return html`
14
14
  <div class="bg-surface border border-border rounded-xl p-4">
15
- <h2 class="font-semibold mb-3">Channels</h2>
15
+ <h2 class="card-label mb-3">Channels</h2>
16
16
  <div class="space-y-2">
17
17
  ${channels ? ALL_CHANNELS.map(ch => {
18
18
  const info = channels[ch];
@@ -100,7 +100,7 @@ export const CredentialsModal = ({ visible, onClose, onSaved }) => {
100
100
  }}
101
101
  >
102
102
  <div
103
- class="bg-surface border border-border rounded-xl p-6 max-w-lg w-full space-y-4"
103
+ class="bg-modal border border-border rounded-xl p-6 max-w-lg w-full space-y-4"
104
104
  >
105
105
  <h2 class="text-lg font-semibold">Connect Google Workspace</h2>
106
106
  <div class="space-y-3">
@@ -50,7 +50,7 @@ const EnvRow = ({ envVar, onChange, onDelete, disabled }) => {
50
50
  ? "bg-green-500"
51
51
  : "bg-gray-600"}"
52
52
  />
53
- <code class="text-xs truncate" style="color: var(--text-muted)">${envVar.key}</code>
53
+ <code class="text-sm truncate">${envVar.key}</code>
54
54
  </div>
55
55
  <div class="flex-1 min-w-0">
56
56
  <div class="flex items-center gap-1">
@@ -286,7 +286,7 @@ export const Envars = () => {
286
286
  .map(
287
287
  (g) => html`
288
288
  <div class="bg-surface border border-border rounded-xl overflow-hidden">
289
- <h3 class="text-xs font-semibold px-4 pt-3 pb-2" style="color: var(--text-dim); letter-spacing: 0.05em">
289
+ <h3 class="card-label text-xs px-4 pt-3 pb-2">
290
290
  ${kGroupLabels[g] || g}
291
291
  </h3>
292
292
  <div class="divide-y divide-border">
@@ -306,7 +306,7 @@ export const Envars = () => {
306
306
 
307
307
  <div class="bg-surface border border-border rounded-xl overflow-hidden">
308
308
  <div class="flex items-center justify-between px-4 pt-3 pb-2">
309
- <h3 class="text-xs font-semibold" style="color: var(--text-dim); letter-spacing: 0.05em">Add Variable</h3>
309
+ <h3 class="card-label text-xs">Add Variable</h3>
310
310
  <span class="text-xs" style="color: var(--text-dim)">Paste KEY=VALUE or multiple lines</span>
311
311
  </div>
312
312
  <div class="flex items-start gap-4 px-4 py-3 border-t border-border">
@@ -0,0 +1,91 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import { useState, useEffect } from "https://esm.sh/preact/hooks";
3
+ import htm from "https://esm.sh/htm";
4
+ import { fetchEnvVars } from "../lib/api.js";
5
+ import { Badge } from "./badge.js";
6
+ import {
7
+ kFeatureDefs,
8
+ kProviderAuthFields,
9
+ kProviderLabels,
10
+ } from "../lib/model-config.js";
11
+
12
+ const html = htm.bind(h);
13
+
14
+ const getKeyVal = (vars, key) => vars.find((v) => v.key === key)?.value || "";
15
+
16
+ const resolveFeatureStatus = (feature, envVars) => {
17
+ for (const provider of feature.providers) {
18
+ const fields = kProviderAuthFields[provider] || [];
19
+ const hasKey = fields.some((f) => !!getKeyVal(envVars, f.key));
20
+ if (hasKey) return { active: true, provider };
21
+ }
22
+ return { active: false, provider: null };
23
+ };
24
+
25
+ export const Features = ({ onSwitchTab }) => {
26
+ const [envVars, setEnvVars] = useState([]);
27
+ const [loaded, setLoaded] = useState(false);
28
+
29
+ useEffect(() => {
30
+ fetchEnvVars()
31
+ .then((data) => {
32
+ setEnvVars(data.vars || []);
33
+ setLoaded(true);
34
+ })
35
+ .catch(() => setLoaded(true));
36
+ }, []);
37
+
38
+ if (!loaded) return null;
39
+
40
+ return html`
41
+ <div class="bg-surface border border-border rounded-xl p-4">
42
+ <h2 class="card-label mb-3">Features</h2>
43
+ <div class="space-y-2">
44
+ ${kFeatureDefs.map((feature) => {
45
+ const status = resolveFeatureStatus(feature, envVars);
46
+ return html`
47
+ <div class="flex justify-between items-center py-1.5">
48
+ <span class="text-sm text-gray-300">${feature.label}</span>
49
+ ${status.active
50
+ ? html`
51
+ <span class="flex items-center gap-2">
52
+ <span class="text-xs text-gray-400">
53
+ ${kProviderLabels[status.provider] || status.provider}
54
+ </span>
55
+ <${Badge} tone="success">Enabled</${Badge}>
56
+ </span>
57
+ `
58
+ : feature.hasDefault
59
+ ? html`
60
+ <span class="flex items-center gap-2">
61
+ <a
62
+ href="#"
63
+ onclick=${(e) => {
64
+ e.preventDefault();
65
+ onSwitchTab?.("providers");
66
+ }}
67
+ class="text-xs text-gray-500 hover:text-gray-300"
68
+ >Add provider</a>
69
+ <${Badge} tone="success">Default</${Badge}>
70
+ </span>
71
+ `
72
+ : html`
73
+ <span class="flex items-center gap-2">
74
+ <a
75
+ href="#"
76
+ onclick=${(e) => {
77
+ e.preventDefault();
78
+ onSwitchTab?.("providers");
79
+ }}
80
+ class="text-xs text-gray-500 hover:text-gray-300"
81
+ >Add provider</a>
82
+ <${Badge} tone="danger">Disabled</${Badge}>
83
+ </span>
84
+ `}
85
+ </div>
86
+ `;
87
+ })}
88
+ </div>
89
+ </div>
90
+ `;
91
+ };
@@ -123,7 +123,7 @@ export function Google({ gatewayStatus }) {
123
123
 
124
124
  if (!google) {
125
125
  return html` <div class="bg-surface border border-border rounded-xl p-4">
126
- <h2 class="font-semibold mb-3">Google Workspace</h2>
126
+ <h2 class="card-label mb-3">Google Workspace</h2>
127
127
  <div class="text-gray-500 text-sm text-center py-2">Loading...</div>
128
128
  </div>`;
129
129
  }
@@ -138,7 +138,7 @@ export function Google({ gatewayStatus }) {
138
138
 
139
139
  return html`
140
140
  <div class="bg-surface border border-border rounded-xl p-4">
141
- <h2 class="font-semibold mb-3">Google Workspace</h2>
141
+ <h2 class="card-label mb-3">Google Workspace</h2>
142
142
  ${hasCredentials
143
143
  ? html`
144
144
  <div class="space-y-3">
@@ -60,7 +60,7 @@ export function Pairings({ pending, channels, visible, onApprove, onReject }) {
60
60
 
61
61
  return html`
62
62
  <div class="bg-surface border border-border rounded-xl p-4">
63
- <h2 class="font-semibold mb-3">Pending Pairings</h2>
63
+ <h2 class="card-label mb-3">Pending Pairings</h2>
64
64
  ${pending.length > 0
65
65
  ? html`<div>
66
66
  ${pending.map(p => html`<${PairingRow} key=${p.id} p=${p} onApprove=${onApprove} onReject=${onReject} />`)}
@@ -0,0 +1,542 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import { useEffect, useRef, useState } from "https://esm.sh/preact/hooks";
3
+ import htm from "https://esm.sh/htm";
4
+ import {
5
+ fetchEnvVars,
6
+ saveEnvVars,
7
+ fetchModels,
8
+ fetchModelStatus,
9
+ setPrimaryModel,
10
+ fetchCodexStatus,
11
+ disconnectCodex,
12
+ exchangeCodexOAuth,
13
+ restartGateway,
14
+ } from "../lib/api.js";
15
+ import { showToast } from "./toast.js";
16
+ import { Badge } from "./badge.js";
17
+ import { SecretInput } from "./secret-input.js";
18
+ import {
19
+ getModelProvider,
20
+ getAuthProviderFromModelProvider,
21
+ getFeaturedModels,
22
+ kProviderAuthFields,
23
+ kProviderLabels,
24
+ kProviderOrder,
25
+ kProviderFeatures,
26
+ } from "../lib/model-config.js";
27
+
28
+ const html = htm.bind(h);
29
+
30
+ const getKeyVal = (vars, key) => vars.find((v) => v.key === key)?.value || "";
31
+ const kAiCredentialKeys = Object.values(kProviderAuthFields)
32
+ .flat()
33
+ .map((field) => field.key)
34
+ .filter((key, idx, arr) => arr.indexOf(key) === idx);
35
+ let kProvidersTabCache = null;
36
+
37
+ const FeatureTags = ({ provider }) => {
38
+ const features = kProviderFeatures[provider] || [];
39
+ if (!features.length) return null;
40
+ return html`
41
+ <div class="flex flex-wrap gap-1.5">
42
+ ${features.map(
43
+ (f) => html`
44
+ <span
45
+ class="text-xs px-1.5 py-0.5 rounded-md bg-white/5 text-gray-400"
46
+ >${f}</span
47
+ >
48
+ `,
49
+ )}
50
+ </div>
51
+ `;
52
+ };
53
+
54
+ export const Providers = () => {
55
+ const [envVars, setEnvVars] = useState(() => kProvidersTabCache?.envVars || []);
56
+ const [models, setModels] = useState(() => kProvidersTabCache?.models || []);
57
+ const [selectedModel, setSelectedModel] = useState(() => kProvidersTabCache?.selectedModel || "");
58
+ const [showAllModels, setShowAllModels] = useState(() => kProvidersTabCache?.showAllModels || false);
59
+ const [savingChanges, setSavingChanges] = useState(false);
60
+ const [codexStatus, setCodexStatus] = useState(() => kProvidersTabCache?.codexStatus || { connected: false });
61
+ const [codexManualInput, setCodexManualInput] = useState("");
62
+ const [codexExchanging, setCodexExchanging] = useState(false);
63
+ const [codexAuthStarted, setCodexAuthStarted] = useState(false);
64
+ const [codexAuthWaiting, setCodexAuthWaiting] = useState(false);
65
+ const [modelsLoading, setModelsLoading] = useState(() => !kProvidersTabCache);
66
+ const [modelsError, setModelsError] = useState(() => kProvidersTabCache?.modelsError || "");
67
+ const [ready, setReady] = useState(() => !!kProvidersTabCache);
68
+ const [savedModel, setSavedModel] = useState(() => kProvidersTabCache?.savedModel || "");
69
+ const [modelDirty, setModelDirty] = useState(false);
70
+ const [savedAiValues, setSavedAiValues] = useState(() => kProvidersTabCache?.savedAiValues || {});
71
+ const [restartRequired, setRestartRequired] = useState(false);
72
+ const [restartingGateway, setRestartingGateway] = useState(false);
73
+ const codexPopupPollRef = useRef(null);
74
+
75
+ const refresh = async () => {
76
+ if (!ready) setModelsLoading(true);
77
+ setModelsError("");
78
+ try {
79
+ const [env, modelCatalog, modelStatus, codex] = await Promise.all([
80
+ fetchEnvVars(),
81
+ fetchModels(),
82
+ fetchModelStatus(),
83
+ fetchCodexStatus(),
84
+ ]);
85
+ setEnvVars(env.vars || []);
86
+ const catalogModels = Array.isArray(modelCatalog.models) ? modelCatalog.models : [];
87
+ setModels(catalogModels);
88
+ const currentModel = modelStatus.modelKey || "";
89
+ setSelectedModel(currentModel);
90
+ setCodexStatus(codex || { connected: false });
91
+ setSavedModel(currentModel);
92
+ setModelDirty(false);
93
+ const nextSavedAiValues = Object.fromEntries(
94
+ kAiCredentialKeys.map((key) => [key, getKeyVal(env.vars || [], key)]),
95
+ );
96
+ setSavedAiValues(nextSavedAiValues);
97
+ const nextModelsError = catalogModels.length ? "" : "No models found";
98
+ setModelsError(nextModelsError);
99
+ kProvidersTabCache = {
100
+ envVars: env.vars || [],
101
+ models: catalogModels,
102
+ selectedModel: currentModel,
103
+ savedModel: currentModel,
104
+ savedAiValues: nextSavedAiValues,
105
+ codexStatus: codex || { connected: false },
106
+ showAllModels,
107
+ modelsError: nextModelsError,
108
+ };
109
+ } catch (err) {
110
+ setModelsError("Failed to load provider settings");
111
+ showToast(`Failed to load provider settings: ${err.message}`, "red");
112
+ } finally {
113
+ setReady(true);
114
+ setModelsLoading(false);
115
+ }
116
+ };
117
+
118
+ const refreshCodexConnection = async () => {
119
+ try {
120
+ const codex = await fetchCodexStatus();
121
+ setCodexStatus(codex || { connected: false });
122
+ if (codex?.connected) {
123
+ setCodexAuthStarted(false);
124
+ setCodexAuthWaiting(false);
125
+ }
126
+ kProvidersTabCache = { ...(kProvidersTabCache || {}), codexStatus: codex || { connected: false } };
127
+ } catch {
128
+ setCodexStatus({ connected: false });
129
+ kProvidersTabCache = { ...(kProvidersTabCache || {}), codexStatus: { connected: false } };
130
+ }
131
+ };
132
+
133
+ useEffect(() => {
134
+ refresh();
135
+ }, []);
136
+
137
+ useEffect(() => () => {
138
+ if (codexPopupPollRef.current) {
139
+ clearInterval(codexPopupPollRef.current);
140
+ codexPopupPollRef.current = null;
141
+ }
142
+ }, []);
143
+
144
+ useEffect(() => {
145
+ const onMessage = async (e) => {
146
+ if (e.data?.codex === "success") {
147
+ showToast("Codex connected", "green");
148
+ await refreshCodexConnection();
149
+ } else if (e.data?.codex === "error") {
150
+ showToast(`Codex auth failed: ${e.data.message || "unknown error"}`, "red");
151
+ }
152
+ };
153
+ window.addEventListener("message", onMessage);
154
+ return () => window.removeEventListener("message", onMessage);
155
+ }, []);
156
+
157
+ const setEnvValue = (key, value) => {
158
+ setEnvVars((prev) => {
159
+ const next = prev.map((v) => (v.key === key ? { ...v, value } : v));
160
+ kProvidersTabCache = { ...(kProvidersTabCache || {}), envVars: next };
161
+ return next;
162
+ });
163
+ };
164
+
165
+ const selectedModelProvider = getModelProvider(selectedModel);
166
+ const selectedAuthProvider = getAuthProviderFromModelProvider(selectedModelProvider);
167
+ const primaryProvider = kProviderOrder.includes(selectedAuthProvider)
168
+ ? selectedAuthProvider
169
+ : kProviderOrder[0];
170
+ const otherProviders = kProviderOrder.filter((p) => p !== primaryProvider);
171
+ const featuredModels = getFeaturedModels(models);
172
+ const baseModelOptions = showAllModels
173
+ ? models
174
+ : featuredModels.length > 0
175
+ ? featuredModels
176
+ : models;
177
+ const selectedModelOption = models.find((model) => model.key === selectedModel);
178
+ const modelOptions =
179
+ selectedModelOption &&
180
+ !baseModelOptions.some((model) => model.key === selectedModelOption.key)
181
+ ? [...baseModelOptions, selectedModelOption]
182
+ : baseModelOptions;
183
+ const canToggleFullCatalog = featuredModels.length > 0 && models.length > featuredModels.length;
184
+
185
+ const aiCredentialsDirty = kAiCredentialKeys.some(
186
+ (key) => getKeyVal(envVars, key) !== (savedAiValues[key] || ""),
187
+ );
188
+ const hasSelectedProviderAuth =
189
+ selectedModelProvider === "anthropic"
190
+ ? !!(getKeyVal(envVars, "ANTHROPIC_API_KEY") || getKeyVal(envVars, "ANTHROPIC_TOKEN"))
191
+ : selectedModelProvider === "openai"
192
+ ? !!getKeyVal(envVars, "OPENAI_API_KEY")
193
+ : selectedModelProvider === "openai-codex"
194
+ ? !!(codexStatus.connected || getKeyVal(envVars, "OPENAI_API_KEY"))
195
+ : selectedModelProvider === "google"
196
+ ? !!getKeyVal(envVars, "GEMINI_API_KEY")
197
+ : false;
198
+ const canSaveChanges = !savingChanges && (aiCredentialsDirty || (modelDirty && hasSelectedProviderAuth));
199
+
200
+ const saveChanges = async () => {
201
+ if (savingChanges) return;
202
+ if (!modelDirty && !aiCredentialsDirty) return;
203
+ if (modelDirty && !hasSelectedProviderAuth) {
204
+ showToast("Add credentials for the selected model provider before saving model changes", "red");
205
+ return;
206
+ }
207
+ setSavingChanges(true);
208
+ try {
209
+ const targetModel = selectedModel;
210
+
211
+ if (aiCredentialsDirty) {
212
+ const payload = envVars
213
+ .filter((v) => v.editable)
214
+ .map((v) => ({ key: v.key, value: v.value }));
215
+ const envResult = await saveEnvVars(payload);
216
+ if (!envResult.ok) throw new Error(envResult.error || "Failed to save env vars");
217
+ if (envResult.restartRequired) setRestartRequired(true);
218
+ }
219
+
220
+ if (modelDirty && targetModel) {
221
+ const modelResult = await setPrimaryModel(targetModel);
222
+ if (!modelResult.ok) throw new Error(modelResult.error || "Failed to set primary model");
223
+ const status = await fetchModelStatus();
224
+ if (status?.ok === false) {
225
+ throw new Error(status.error || "Failed to verify primary model");
226
+ }
227
+ const activeModel = status?.modelKey || "";
228
+ if (activeModel && activeModel !== targetModel) {
229
+ throw new Error(`Primary model did not apply. Expected ${targetModel} but active is ${activeModel}`);
230
+ }
231
+ setSavedModel(targetModel);
232
+ setModelDirty(false);
233
+ kProvidersTabCache = { ...(kProvidersTabCache || {}), selectedModel: targetModel, savedModel: targetModel };
234
+ }
235
+
236
+ showToast("Changes saved", "green");
237
+ await refresh();
238
+ } catch (err) {
239
+ showToast(err.message || "Failed to save changes", "red");
240
+ } finally {
241
+ setSavingChanges(false);
242
+ }
243
+ };
244
+
245
+ const startCodexAuth = () => {
246
+ if (codexStatus.connected) return;
247
+ setCodexAuthStarted(true);
248
+ setCodexAuthWaiting(true);
249
+ const popup = window.open("/auth/codex/start", "codex-auth", "popup=yes,width=640,height=780");
250
+ if (!popup || popup.closed) {
251
+ setCodexAuthWaiting(false);
252
+ window.location.href = "/auth/codex/start";
253
+ return;
254
+ }
255
+ if (codexPopupPollRef.current) {
256
+ clearInterval(codexPopupPollRef.current);
257
+ }
258
+ codexPopupPollRef.current = setInterval(() => {
259
+ if (popup.closed) {
260
+ clearInterval(codexPopupPollRef.current);
261
+ codexPopupPollRef.current = null;
262
+ setCodexAuthWaiting(false);
263
+ }
264
+ }, 500);
265
+ };
266
+
267
+ const completeCodexAuth = async () => {
268
+ if (!codexManualInput.trim() || codexExchanging) return;
269
+ setCodexExchanging(true);
270
+ try {
271
+ const result = await exchangeCodexOAuth(codexManualInput.trim());
272
+ if (!result.ok) throw new Error(result.error || "Codex OAuth exchange failed");
273
+ setCodexManualInput("");
274
+ showToast("Codex connected", "green");
275
+ setCodexAuthStarted(false);
276
+ setCodexAuthWaiting(false);
277
+ await refreshCodexConnection();
278
+ } catch (err) {
279
+ showToast(err.message || "Codex OAuth exchange failed", "red");
280
+ } finally {
281
+ setCodexExchanging(false);
282
+ }
283
+ };
284
+
285
+ const handleCodexDisconnect = async () => {
286
+ const result = await disconnectCodex();
287
+ if (!result.ok) {
288
+ showToast(result.error || "Failed to disconnect Codex", "red");
289
+ return;
290
+ }
291
+ showToast("Codex disconnected", "green");
292
+ setCodexAuthStarted(false);
293
+ setCodexAuthWaiting(false);
294
+ setCodexManualInput("");
295
+ await refreshCodexConnection();
296
+ };
297
+
298
+ const renderCredentialField = (field) => html`
299
+ <div class="space-y-1">
300
+ <div class="flex items-center gap-3">
301
+ <label class="text-xs font-medium text-gray-400">${field.label}</label>
302
+ ${field.url && !getKeyVal(envVars, field.key)
303
+ ? html`<a
304
+ href=${field.url}
305
+ target="_blank"
306
+ class="text-xs hover:underline"
307
+ style="color: var(--accent-link)"
308
+ >Get</a>`
309
+ : null}
310
+ </div>
311
+ <${SecretInput}
312
+ value=${getKeyVal(envVars, field.key)}
313
+ onInput=${(e) => setEnvValue(field.key, e.target.value)}
314
+ placeholder=${field.placeholder || ""}
315
+ isSecret=${!field.isText}
316
+ inputClass="flex-1 w-full bg-black/30 border border-border rounded-lg px-3 py-2 text-sm text-gray-200 outline-none focus:border-gray-500 font-mono"
317
+ />
318
+ ${field.hint
319
+ ? html`<p class="text-xs text-gray-600">${field.hint}</p>`
320
+ : null}
321
+ </div>
322
+ `;
323
+
324
+ const renderCodexOAuth = () => html`
325
+ <div class="border border-border rounded-lg p-3 space-y-2">
326
+ <div class="flex items-center justify-between">
327
+ <span class="text-xs text-gray-400">Codex OAuth</span>
328
+ ${codexStatus.connected
329
+ ? html`<${Badge} tone="success">Connected</${Badge}>`
330
+ : html`<${Badge} tone="warning">Not connected</${Badge}>`}
331
+ </div>
332
+ ${codexStatus.connected
333
+ ? html`
334
+ <div class="flex gap-2">
335
+ <button
336
+ onclick=${startCodexAuth}
337
+ class="text-xs font-medium px-3 py-1.5 rounded-lg border border-border text-gray-300 hover:border-gray-500"
338
+ >
339
+ Reconnect Codex
340
+ </button>
341
+ <button
342
+ onclick=${handleCodexDisconnect}
343
+ class="text-xs font-medium px-3 py-1.5 rounded-lg border border-border text-gray-300 hover:border-gray-500"
344
+ >
345
+ Disconnect
346
+ </button>
347
+ </div>
348
+ `
349
+ : !codexAuthStarted
350
+ ? html`
351
+ <button
352
+ onclick=${startCodexAuth}
353
+ class="text-xs font-medium px-3 py-1.5 rounded-lg ac-btn-cyan"
354
+ >
355
+ Connect Codex OAuth
356
+ </button>
357
+ `
358
+ : html`
359
+ <div class="flex items-center justify-between gap-2">
360
+ <p class="text-xs text-gray-500">
361
+ ${codexAuthWaiting
362
+ ? "Complete login in the popup, then paste the redirect URL."
363
+ : "Paste the redirect URL from your browser to finish connecting."}
364
+ </p>
365
+ <button
366
+ onclick=${startCodexAuth}
367
+ class="text-xs font-medium px-3 py-1.5 rounded-lg border border-border text-gray-300 hover:border-gray-500 shrink-0"
368
+ >
369
+ Restart
370
+ </button>
371
+ </div>
372
+ `}
373
+ ${!codexStatus.connected && codexAuthStarted
374
+ ? html`
375
+ <p class="text-xs text-gray-500">
376
+ After login, copy the full redirect URL (starts with
377
+ <code class="text-xs bg-black/30 px-1 rounded">http://localhost:1455/auth/callback</code>)
378
+ and paste it here.
379
+ </p>
380
+ <input
381
+ type="text"
382
+ value=${codexManualInput}
383
+ onInput=${(e) => setCodexManualInput(e.target.value)}
384
+ placeholder="http://localhost:1455/auth/callback?code=...&state=..."
385
+ 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"
386
+ />
387
+ <button
388
+ onclick=${completeCodexAuth}
389
+ disabled=${!codexManualInput.trim() || codexExchanging}
390
+ class="text-xs font-medium px-3 py-1.5 rounded-lg ac-btn-cyan"
391
+ >
392
+ ${codexExchanging ? "Completing..." : "Complete Codex OAuth"}
393
+ </button>
394
+ `
395
+ : null}
396
+ </div>
397
+ `;
398
+
399
+ const providerHasKey = (provider) => {
400
+ const fields = kProviderAuthFields[provider] || [];
401
+ return fields.some((f) => !!getKeyVal(envVars, f.key));
402
+ };
403
+
404
+ const renderProviderCard = (provider) => {
405
+ const fields = kProviderAuthFields[provider] || [];
406
+ const hasCodex = provider === "openai";
407
+ const hasKey = providerHasKey(provider);
408
+ return html`
409
+ <div class="bg-surface border border-border rounded-xl p-4 space-y-3">
410
+ <div class="flex items-center gap-2">
411
+ <h3 class="font-semibold text-sm">
412
+ ${kProviderLabels[provider] || provider}
413
+ </h3>
414
+ ${hasKey
415
+ ? html`<span class="inline-block w-1.5 h-1.5 rounded-full bg-green-500" />`
416
+ : null}
417
+ </div>
418
+ ${fields.map((field) => renderCredentialField(field))}
419
+ ${hasCodex ? renderCodexOAuth() : null}
420
+ <${FeatureTags} provider=${provider} />
421
+ </div>
422
+ `;
423
+ };
424
+
425
+ if (!ready) {
426
+ return html`
427
+ <div class="bg-surface border border-border rounded-xl p-4">
428
+ <div class="flex items-center gap-2 text-sm text-gray-400">
429
+ <svg class="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
430
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
431
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
432
+ </svg>
433
+ Loading provider settings...
434
+ </div>
435
+ </div>
436
+ `;
437
+ }
438
+
439
+ const renderPrimaryProviderContent = () => {
440
+ const fields = kProviderAuthFields[primaryProvider] || [];
441
+ const hasCodex = primaryProvider === "openai";
442
+ return html`
443
+ ${fields.map((field) => renderCredentialField(field))}
444
+ ${hasCodex ? renderCodexOAuth() : null}
445
+ `;
446
+ };
447
+
448
+ return html`
449
+ <div class="space-y-4">
450
+ <div class="bg-surface border border-border rounded-xl p-4 space-y-3">
451
+ <h2 class="font-semibold text-sm">Primary Agent Model</h2>
452
+ <select
453
+ value=${selectedModel}
454
+ onInput=${(e) => {
455
+ const next = e.target.value;
456
+ setSelectedModel(next);
457
+ setModelDirty(next !== savedModel);
458
+ kProvidersTabCache = { ...(kProvidersTabCache || {}), selectedModel: next };
459
+ }}
460
+ 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"
461
+ >
462
+ <option value="">Select a model</option>
463
+ ${modelOptions.map(
464
+ (model) => html`<option value=${model.key}>${model.label || model.key}</option>`,
465
+ )}
466
+ </select>
467
+ <p class="text-xs text-gray-600">
468
+ ${modelsLoading ? "Loading model catalog..." : modelsError ? modelsError : ""}
469
+ </p>
470
+ ${canToggleFullCatalog
471
+ ? html`
472
+ <div>
473
+ <button
474
+ type="button"
475
+ onclick=${() =>
476
+ setShowAllModels((prev) => {
477
+ const next = !prev;
478
+ kProvidersTabCache = { ...(kProvidersTabCache || {}), showAllModels: next };
479
+ return next;
480
+ })}
481
+ class="text-xs text-gray-500 hover:text-gray-300"
482
+ >
483
+ ${showAllModels ? "Show recommended models" : "Show full model catalog"}
484
+ </button>
485
+ </div>
486
+ `
487
+ : null}
488
+ <div class="pt-2 border-t border-border space-y-3">
489
+ ${renderPrimaryProviderContent()}
490
+ </div>
491
+ </div>
492
+
493
+ ${otherProviders.map((provider) => renderProviderCard(provider))}
494
+
495
+ ${restartRequired
496
+ ? html`<div
497
+ class="bg-yellow-500/10 border border-yellow-500/30 rounded-xl p-4 flex items-center justify-between gap-3"
498
+ >
499
+ <p class="text-sm text-yellow-200">
500
+ Gateway restart required to apply changes.
501
+ </p>
502
+ <button
503
+ onclick=${async () => {
504
+ if (restartingGateway) return;
505
+ setRestartingGateway(true);
506
+ try {
507
+ await restartGateway();
508
+ setRestartRequired(false);
509
+ showToast("Gateway restarted", "success");
510
+ } catch (err) {
511
+ showToast("Restart failed: " + err.message, "error");
512
+ } finally {
513
+ setRestartingGateway(false);
514
+ }
515
+ }}
516
+ disabled=${restartingGateway}
517
+ 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
518
+ ? "opacity-60 cursor-not-allowed"
519
+ : ""}"
520
+ >
521
+ ${restartingGateway ? "Restarting..." : "Restart Gateway"}
522
+ </button>
523
+ </div>`
524
+ : null}
525
+
526
+ <button
527
+ onclick=${saveChanges}
528
+ disabled=${!canSaveChanges}
529
+ class="w-full text-sm font-medium px-4 py-2.5 rounded-xl transition-all ac-btn-cyan"
530
+ >
531
+ ${savingChanges ? "Saving..." : "Save changes"}
532
+ </button>
533
+ ${modelDirty && !hasSelectedProviderAuth
534
+ ? html`
535
+ <p class="text-xs text-yellow-500">
536
+ Set credentials for the selected provider before saving this model change.
537
+ </p>
538
+ `
539
+ : null}
540
+ </div>
541
+ `;
542
+ };
@@ -46,20 +46,15 @@ export const kProviderAuthFields = {
46
46
  {
47
47
  key: "ANTHROPIC_API_KEY",
48
48
  label: "Anthropic API Key",
49
- hint: html`From${" "}
50
- <a
51
- href="https://console.anthropic.com"
52
- target="_blank"
53
- class="hover:underline"
54
- style="color: var(--accent-link)"
55
- >console.anthropic.com</a
56
- >${" "}— recommended`,
49
+ url: "https://console.anthropic.com",
50
+ linkText: "Get key",
57
51
  placeholder: "sk-ant-...",
58
52
  },
59
53
  {
60
54
  key: "ANTHROPIC_TOKEN",
61
55
  label: "Anthropic Setup Token",
62
56
  hint: "From claude setup-token (uses your Claude subscription)",
57
+ linkText: "Get token",
63
58
  placeholder: "Token...",
64
59
  },
65
60
  ],
@@ -67,14 +62,8 @@ export const kProviderAuthFields = {
67
62
  {
68
63
  key: "OPENAI_API_KEY",
69
64
  label: "OpenAI API Key",
70
- hint: html`From${" "}
71
- <a
72
- href="https://platform.openai.com"
73
- target="_blank"
74
- class="hover:underline"
75
- style="color: var(--accent-link)"
76
- >platform.openai.com</a
77
- >`,
65
+ url: "https://platform.openai.com",
66
+ linkText: "Get key",
78
67
  placeholder: "sk-...",
79
68
  },
80
69
  ],
@@ -82,26 +71,94 @@ export const kProviderAuthFields = {
82
71
  {
83
72
  key: "GEMINI_API_KEY",
84
73
  label: "Gemini API Key",
85
- hint: html`From${" "}
86
- <a
87
- href="https://aistudio.google.com"
88
- target="_blank"
89
- class="hover:underline"
90
- style="color: var(--accent-link)"
91
- >aistudio.google.com</a
92
- >`,
74
+ url: "https://aistudio.google.com",
75
+ linkText: "Get key",
93
76
  placeholder: "AI...",
94
77
  },
95
78
  ],
79
+ mistral: [
80
+ {
81
+ key: "MISTRAL_API_KEY",
82
+ label: "Mistral API Key",
83
+ url: "https://console.mistral.ai",
84
+ linkText: "Get key",
85
+ placeholder: "sk-...",
86
+ },
87
+ ],
88
+ voyage: [
89
+ {
90
+ key: "VOYAGE_API_KEY",
91
+ label: "Voyage API Key",
92
+ url: "https://dash.voyageai.com",
93
+ linkText: "Get key",
94
+ placeholder: "pa-...",
95
+ },
96
+ ],
97
+ groq: [
98
+ {
99
+ key: "GROQ_API_KEY",
100
+ label: "Groq API Key",
101
+ url: "https://console.groq.com",
102
+ linkText: "Get key",
103
+ placeholder: "gsk_...",
104
+ },
105
+ ],
106
+ deepgram: [
107
+ {
108
+ key: "DEEPGRAM_API_KEY",
109
+ label: "Deepgram API Key",
110
+ url: "https://console.deepgram.com",
111
+ linkText: "Get key",
112
+ placeholder: "dg-...",
113
+ },
114
+ ],
96
115
  };
97
116
 
98
117
  export const kProviderLabels = {
99
118
  anthropic: "Anthropic",
100
119
  openai: "OpenAI",
101
120
  google: "Gemini",
121
+ mistral: "Mistral",
122
+ voyage: "Voyage",
123
+ groq: "Groq",
124
+ deepgram: "Deepgram",
125
+ };
126
+
127
+ export const kProviderOrder = [
128
+ "anthropic",
129
+ "openai",
130
+ "google",
131
+ "mistral",
132
+ "voyage",
133
+ "groq",
134
+ "deepgram",
135
+ ];
136
+
137
+ export const kProviderFeatures = {
138
+ anthropic: ["Agent Model"],
139
+ openai: ["Agent Model", "Embeddings", "Audio"],
140
+ google: ["Agent Model", "Embeddings", "Audio"],
141
+ mistral: ["Agent Model", "Embeddings", "Audio"],
142
+ voyage: ["Embeddings"],
143
+ groq: ["Agent Model", "Audio"],
144
+ deepgram: ["Audio"],
102
145
  };
103
146
 
104
- export const kProviderOrder = ["anthropic", "openai", "google"];
147
+ export const kFeatureDefs = [
148
+ {
149
+ id: "embeddings",
150
+ label: "Memory Embeddings",
151
+ tag: "Embeddings",
152
+ providers: ["openai", "google", "voyage", "mistral"],
153
+ },
154
+ {
155
+ id: "audio",
156
+ label: "Audio Transcription",
157
+ tag: "Audio",
158
+ hasDefault: true,
159
+ providers: ["openai", "groq", "deepgram", "google", "mistral"],
160
+ },
161
+ ];
105
162
 
106
163
  export const getVisibleAiFieldKeys = (provider) => {
107
164
  const authProvider = getAuthProviderFromModelProvider(provider);
@@ -171,6 +171,30 @@ const kKnownVars = [
171
171
  group: "channels",
172
172
  hint: "From Discord Developer Portal",
173
173
  },
174
+ {
175
+ key: "MISTRAL_API_KEY",
176
+ label: "Mistral API Key",
177
+ group: "models",
178
+ hint: "From console.mistral.ai",
179
+ },
180
+ {
181
+ key: "VOYAGE_API_KEY",
182
+ label: "Voyage API Key",
183
+ group: "models",
184
+ hint: "From dash.voyageai.com",
185
+ },
186
+ {
187
+ key: "GROQ_API_KEY",
188
+ label: "Groq API Key",
189
+ group: "models",
190
+ hint: "From console.groq.com",
191
+ },
192
+ {
193
+ key: "DEEPGRAM_API_KEY",
194
+ label: "Deepgram API Key",
195
+ group: "models",
196
+ hint: "From console.deepgram.com",
197
+ },
174
198
  {
175
199
  key: "BRAVE_API_KEY",
176
200
  label: "Brave Search API Key",
@@ -1,5 +1,6 @@
1
1
  const { exec, execSync } = require("child_process");
2
2
  const fs = require("fs");
3
+ const os = require("os");
3
4
  const path = require("path");
4
5
  const {
5
6
  kVersionCacheTtlMs,
@@ -85,74 +86,114 @@ const createOpenclawVersionService = ({
85
86
  }
86
87
  };
87
88
 
88
- const installLatestOpenclaw = () =>
89
- new Promise((resolve, reject) => {
90
- const installDir = (() => {
91
- // Resolve the consumer app root (for example /app in Docker), not this package directory.
92
- let dir = kNpmPackageRoot;
93
- while (dir !== path.dirname(dir)) {
94
- const parent = path.dirname(dir);
89
+ const findInstallDir = () => {
90
+ // Resolve the consumer app root (for example /app in Docker), not this package directory.
91
+ let dir = kNpmPackageRoot;
92
+ while (dir !== path.dirname(dir)) {
93
+ const parent = path.dirname(dir);
94
+ if (
95
+ path.basename(parent) === "node_modules" ||
96
+ parent.includes(`${path.sep}node_modules${path.sep}`)
97
+ ) {
98
+ dir = parent;
99
+ continue;
100
+ }
101
+ const pkgPath = path.join(parent, "package.json");
102
+ if (fs.existsSync(pkgPath)) {
103
+ try {
104
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
95
105
  if (
96
- path.basename(parent) === "node_modules" ||
97
- parent.includes(`${path.sep}node_modules${path.sep}`)
106
+ pkg.dependencies?.["@chrysb/alphaclaw"] ||
107
+ pkg.devDependencies?.["@chrysb/alphaclaw"] ||
108
+ pkg.optionalDependencies?.["@chrysb/alphaclaw"]
98
109
  ) {
99
- dir = parent;
100
- continue;
110
+ return parent;
101
111
  }
102
- const pkgPath = path.join(parent, "package.json");
103
- if (fs.existsSync(pkgPath)) {
104
- try {
105
- const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
106
- if (
107
- pkg.dependencies?.["@chrysb/alphaclaw"] ||
108
- pkg.devDependencies?.["@chrysb/alphaclaw"] ||
109
- pkg.optionalDependencies?.["@chrysb/alphaclaw"]
110
- ) {
111
- return parent;
112
- }
113
- } catch {}
114
- }
115
- dir = parent;
116
- }
117
- return kNpmPackageRoot;
118
- })();
112
+ } catch {}
113
+ }
114
+ dir = parent;
115
+ }
116
+ return kNpmPackageRoot;
117
+ };
118
+
119
+ // Install to a temp directory, then copy into the real node_modules.
120
+ // Running `npm install` directly in the app dir causes EBUSY on Docker
121
+ // because npm tries to rename directories that the running process holds open.
122
+ // Copying individual files (cp -af) avoids the rename syscall entirely.
123
+ const installLatestOpenclaw = () =>
124
+ new Promise((resolve, reject) => {
125
+ const installDir = findInstallDir();
126
+ const tmpDir = fs.mkdtempSync(
127
+ path.join(os.tmpdir(), "openclaw-update-"),
128
+ );
129
+ const cleanup = () => {
130
+ try {
131
+ fs.rmSync(tmpDir, { recursive: true, force: true });
132
+ } catch {}
133
+ };
134
+
135
+ fs.writeFileSync(
136
+ path.join(tmpDir, "package.json"),
137
+ JSON.stringify({
138
+ private: true,
139
+ dependencies: { openclaw: "latest" },
140
+ }),
141
+ );
142
+
143
+ const npmEnv = {
144
+ ...process.env,
145
+ npm_config_update_notifier: "false",
146
+ npm_config_fund: "false",
147
+ npm_config_audit: "false",
148
+ };
149
+
119
150
  console.log(
120
- `[alphaclaw] Running: npm install --omit=dev --no-save --package-lock=false --prefer-online openclaw@latest (cwd: ${installDir})`,
151
+ `[alphaclaw] Running: npm install openclaw@latest in temp dir (target: ${installDir})`,
121
152
  );
122
153
  exec(
123
- "npm install --omit=dev --no-save --package-lock=false --prefer-online openclaw@latest",
124
- {
125
- cwd: installDir,
126
- env: {
127
- ...process.env,
128
- npm_config_update_notifier: "false",
129
- npm_config_fund: "false",
130
- npm_config_audit: "false",
131
- },
132
- timeout: 180000,
133
- },
134
- (err, stdout, stderr) => {
135
- if (err) {
136
- const message = String(stderr || err.message || "").trim();
154
+ "npm install --omit=dev --prefer-online --package-lock=false",
155
+ { cwd: tmpDir, env: npmEnv, timeout: 180000 },
156
+ (installErr, stdout, stderr) => {
157
+ if (installErr) {
158
+ const message = String(stderr || installErr.message || "").trim();
137
159
  console.log(
138
160
  `[alphaclaw] openclaw install error: ${message.slice(0, 200)}`,
139
161
  );
162
+ cleanup();
140
163
  return reject(
141
164
  new Error(message || "Failed to install openclaw@latest"),
142
165
  );
143
166
  }
144
- if (stdout && stdout.trim()) {
167
+ if (stdout?.trim()) {
145
168
  console.log(
146
169
  `[alphaclaw] openclaw install stdout: ${stdout.trim().slice(0, 300)}`,
147
170
  );
148
171
  }
149
- if (stderr && stderr.trim()) {
150
- console.log(
151
- `[alphaclaw] openclaw install stderr: ${stderr.trim().slice(0, 300)}`,
152
- );
153
- }
154
- console.log("[alphaclaw] openclaw install completed");
155
- resolve({ stdout: stdout.trim(), stderr: stderr.trim() });
172
+
173
+ const src = path.join(tmpDir, "node_modules");
174
+ const dest = path.join(installDir, "node_modules");
175
+ exec(
176
+ `cp -af "${src}/." "${dest}/"`,
177
+ { timeout: 60000 },
178
+ (cpErr) => {
179
+ cleanup();
180
+ if (cpErr) {
181
+ console.log(
182
+ `[alphaclaw] openclaw copy error: ${(cpErr.message || "").slice(0, 200)}`,
183
+ );
184
+ return reject(
185
+ new Error(
186
+ `Failed to copy updated openclaw files: ${cpErr.message}`,
187
+ ),
188
+ );
189
+ }
190
+ console.log("[alphaclaw] openclaw install completed");
191
+ resolve({
192
+ stdout: stdout?.trim() || "",
193
+ stderr: stderr?.trim() || "",
194
+ });
195
+ },
196
+ );
156
197
  },
157
198
  );
158
199
  });
@@ -8,8 +8,8 @@ AlphaClaw UI: `{{SETUP_UI_URL}}`
8
8
 
9
9
  | Tab | URL | What it manages |
10
10
  | ------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
11
- | General | `{{SETUP_UI_URL}}#general` | Gateway status & restart, channel health (Telegram/Discord), pending pairings, Google Workspace connection, repo auto-sync schedule, OpenClaw dashboard |
12
- | Models | `{{SETUP_UI_URL}}#models` | AI provider credentials (Anthropic, OpenAI, Google), Codex OAuth |
11
+ | General | `{{SETUP_UI_URL}}#general` | Gateway status & restart, channel health (Telegram/Discord), pending pairings, feature health (Embeddings/Audio), Google Workspace connection, repo auto-sync schedule, OpenClaw dashboard |
12
+ | Providers | `{{SETUP_UI_URL}}#providers` | AI provider credentials (Anthropic, OpenAI, Gemini, Mistral, Voyage, Groq, Deepgram), feature capabilities, Codex OAuth |
13
13
  | Envars | `{{SETUP_UI_URL}}#envars` | View/edit/add environment variables (saved to `/data/.env`), gateway restart to apply changes |
14
14
 
15
15
  ### Environment variables
@@ -27,7 +27,7 @@ When a user asks about pairing their Telegram or Discord account:
27
27
 
28
28
  ### Connecting OpenAI Codex OAuth
29
29
 
30
- > Connect or reconnect Codex OAuth from the Setup UI → **Models** tab ({{BASE_URL}}#models). Click **Connect Codex OAuth** and follow the popup flow.
30
+ > Connect or reconnect Codex OAuth from the Setup UI → **Providers** tab ({{BASE_URL}}#providers). Click **Connect Codex OAuth** and follow the popup flow.
31
31
 
32
32
  ### Connecting Google Workspace
33
33
 
@@ -65,6 +65,6 @@ Config lives at `/data/.openclaw/gogcli/`.
65
65
 
66
66
  This is a reference so you know what's available — not an invitation to call these endpoints.
67
67
 
68
- - **General tab** (`{{BASE_URL}}#general`): Gateway status/restart, OpenClaw version + update, channel health, pending pairings, Google Workspace
69
- - **Models tab** (`{{BASE_URL}}#models`): Primary model selection, provider credentials, Codex OAuth
68
+ - **General tab** (`{{BASE_URL}}#general`): Gateway status/restart, OpenClaw version + update, channel health, pending pairings, feature health (Embeddings/Audio), Google Workspace
69
+ - **Providers tab** (`{{BASE_URL}}#providers`): Primary model selection, AI provider credentials, feature capabilities, Codex OAuth
70
70
  - **Envars tab** (`{{BASE_URL}}#envars`): View/edit/add environment variables, save to `/data/.env`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrysb/alphaclaw",
3
- "version": "0.1.22",
3
+ "version": "0.1.24",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },