@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 +14 -0
- package/lib/public/css/theme.css +13 -0
- package/lib/public/js/app.js +7 -5
- package/lib/public/js/components/badge.js +1 -0
- package/lib/public/js/components/channels.js +1 -1
- package/lib/public/js/components/credentials-modal.js +1 -1
- package/lib/public/js/components/envars.js +3 -3
- package/lib/public/js/components/features.js +91 -0
- package/lib/public/js/components/google.js +2 -2
- package/lib/public/js/components/pairings.js +1 -1
- package/lib/public/js/components/providers.js +542 -0
- package/lib/public/js/lib/model-config.js +82 -25
- package/lib/server/constants.js +24 -0
- package/lib/server/openclaw-version.js +92 -51
- package/lib/setup/core-prompts/TOOLS.md +2 -2
- package/lib/setup/skills/control-ui/SKILL.md +3 -3
- package/package.json +1 -1
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
|
// ---------------------------------------------------------------------------
|
package/lib/public/css/theme.css
CHANGED
|
@@ -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
|
}
|
package/lib/public/js/app.js
CHANGED
|
@@ -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 {
|
|
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", "
|
|
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: "
|
|
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 === "
|
|
480
|
-
<${
|
|
481
|
+
<div style=${{ display: tab === "providers" ? "" : "none" }}>
|
|
482
|
+
<${Providers} />
|
|
481
483
|
</div>
|
|
482
484
|
<div style=${{ display: tab === "envars" ? "" : "none" }}>
|
|
483
485
|
<${Envars} />
|
|
@@ -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="
|
|
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-
|
|
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-
|
|
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
|
|
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="
|
|
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="
|
|
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="
|
|
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="
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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
|
|
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);
|
package/lib/server/constants.js
CHANGED
|
@@ -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
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
97
|
-
|
|
106
|
+
pkg.dependencies?.["@chrysb/alphaclaw"] ||
|
|
107
|
+
pkg.devDependencies?.["@chrysb/alphaclaw"] ||
|
|
108
|
+
pkg.optionalDependencies?.["@chrysb/alphaclaw"]
|
|
98
109
|
) {
|
|
99
|
-
|
|
100
|
-
continue;
|
|
110
|
+
return parent;
|
|
101
111
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
151
|
+
`[alphaclaw] Running: npm install openclaw@latest in temp dir (target: ${installDir})`,
|
|
121
152
|
);
|
|
122
153
|
exec(
|
|
123
|
-
"npm install --omit=dev --
|
|
124
|
-
{
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
|
167
|
+
if (stdout?.trim()) {
|
|
145
168
|
console.log(
|
|
146
169
|
`[alphaclaw] openclaw install stdout: ${stdout.trim().slice(0, 300)}`,
|
|
147
170
|
);
|
|
148
171
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
|
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 → **
|
|
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
|
-
- **
|
|
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`
|