@chrysb/alphaclaw 0.4.6-beta.4 → 0.4.6-beta.6
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/lib/public/js/app.js +158 -1073
- package/lib/public/js/components/envars.js +146 -29
- package/lib/public/js/components/features.js +1 -1
- package/lib/public/js/components/general/index.js +155 -0
- package/lib/public/js/components/icons.js +52 -0
- package/lib/public/js/components/info-tooltip.js +4 -7
- package/lib/public/js/components/models-tab/index.js +286 -0
- package/lib/public/js/components/models-tab/provider-auth-card.js +369 -0
- package/lib/public/js/components/models-tab/use-models.js +262 -0
- package/lib/public/js/components/models.js +1 -1
- package/lib/public/js/components/providers.js +1 -1
- package/lib/public/js/components/routes/browse-route.js +35 -0
- package/lib/public/js/components/routes/doctor-route.js +21 -0
- package/lib/public/js/components/routes/envars-route.js +11 -0
- package/lib/public/js/components/routes/general-route.js +45 -0
- package/lib/public/js/components/routes/index.js +11 -0
- package/lib/public/js/components/routes/models-route.js +11 -0
- package/lib/public/js/components/routes/providers-route.js +11 -0
- package/lib/public/js/components/routes/route-redirect.js +10 -0
- package/lib/public/js/components/routes/telegram-route.js +11 -0
- package/lib/public/js/components/routes/usage-route.js +15 -0
- package/lib/public/js/components/routes/watchdog-route.js +32 -0
- package/lib/public/js/components/routes/webhooks-route.js +43 -0
- package/lib/public/js/components/sidebar.js +2 -3
- package/lib/public/js/components/tooltip.js +106 -0
- package/lib/public/js/components/usage-tab/constants.js +1 -1
- package/lib/public/js/components/usage-tab/overview-section.js +124 -50
- package/lib/public/js/components/usage-tab/use-usage-tab.js +42 -11
- package/lib/public/js/components/welcome.js +1 -1
- package/lib/public/js/hooks/use-app-shell-controller.js +230 -0
- package/lib/public/js/hooks/use-app-shell-ui.js +112 -0
- package/lib/public/js/hooks/use-browse-navigation.js +193 -0
- package/lib/public/js/hooks/use-hash-location.js +32 -0
- package/lib/public/js/lib/api.js +35 -0
- package/lib/public/js/lib/app-navigation.js +39 -0
- package/lib/public/js/lib/browse-restart-policy.js +28 -0
- package/lib/public/js/lib/browse-route.js +57 -0
- package/lib/public/js/lib/format.js +12 -0
- package/lib/public/js/lib/model-config.js +1 -0
- package/lib/server/auth-profiles.js +291 -53
- package/lib/server/constants.js +24 -8
- package/lib/server/doctor/service.js +0 -3
- package/lib/server/gateway.js +50 -31
- package/lib/server/onboarding/index.js +2 -0
- package/lib/server/onboarding/validation.js +2 -2
- package/lib/server/routes/models.js +214 -2
- package/lib/server/routes/onboarding.js +2 -0
- package/lib/server/routes/system.js +42 -1
- package/lib/server/watchdog.js +14 -1
- package/lib/server.js +6 -0
- package/lib/setup/env.template +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { h } from "https://esm.sh/preact";
|
|
2
|
+
import { useState, useMemo } from "https://esm.sh/preact/hooks";
|
|
3
|
+
import htm from "https://esm.sh/htm";
|
|
4
|
+
import { PageHeader } from "../page-header.js";
|
|
5
|
+
import { LoadingSpinner } from "../loading-spinner.js";
|
|
6
|
+
import { ActionButton } from "../action-button.js";
|
|
7
|
+
import { Badge } from "../badge.js";
|
|
8
|
+
import { useModels } from "./use-models.js";
|
|
9
|
+
import { ProviderAuthCard } from "./provider-auth-card.js";
|
|
10
|
+
import { getModelProvider, getFeaturedModels } from "../../lib/model-config.js";
|
|
11
|
+
|
|
12
|
+
const html = htm.bind(h);
|
|
13
|
+
|
|
14
|
+
const deriveRequiredProviders = (configuredModels) => {
|
|
15
|
+
const providers = new Set();
|
|
16
|
+
for (const modelKey of Object.keys(configuredModels)) {
|
|
17
|
+
const provider = getModelProvider(modelKey);
|
|
18
|
+
if (provider) providers.add(provider);
|
|
19
|
+
}
|
|
20
|
+
return [...providers];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const kProviderDisplayOrder = [
|
|
24
|
+
"anthropic",
|
|
25
|
+
"openai",
|
|
26
|
+
"openai-codex",
|
|
27
|
+
"google",
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
export const Models = ({ onRestartRequired = () => {} }) => {
|
|
31
|
+
const {
|
|
32
|
+
catalog,
|
|
33
|
+
primary,
|
|
34
|
+
configuredModels,
|
|
35
|
+
authProfiles,
|
|
36
|
+
authOrder,
|
|
37
|
+
codexStatus,
|
|
38
|
+
loading,
|
|
39
|
+
saving,
|
|
40
|
+
ready,
|
|
41
|
+
error,
|
|
42
|
+
isDirty,
|
|
43
|
+
addModel,
|
|
44
|
+
removeModel,
|
|
45
|
+
setPrimaryModel,
|
|
46
|
+
editProfile,
|
|
47
|
+
editAuthOrder,
|
|
48
|
+
getProfileValue,
|
|
49
|
+
getEffectiveOrder,
|
|
50
|
+
cancelChanges,
|
|
51
|
+
saveAll,
|
|
52
|
+
refreshCodexStatus,
|
|
53
|
+
} = useModels();
|
|
54
|
+
|
|
55
|
+
const [showAllModels, setShowAllModels] = useState(false);
|
|
56
|
+
|
|
57
|
+
const configuredKeys = useMemo(
|
|
58
|
+
() => new Set(Object.keys(configuredModels)),
|
|
59
|
+
[configuredModels],
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const featuredModels = useMemo(() => getFeaturedModels(catalog), [catalog]);
|
|
63
|
+
|
|
64
|
+
const pickerModels = useMemo(() => {
|
|
65
|
+
const base = showAllModels
|
|
66
|
+
? catalog
|
|
67
|
+
: featuredModels.length > 0
|
|
68
|
+
? featuredModels
|
|
69
|
+
: catalog;
|
|
70
|
+
return base.filter((m) => !configuredKeys.has(m.key));
|
|
71
|
+
}, [catalog, featuredModels, showAllModels, configuredKeys]);
|
|
72
|
+
|
|
73
|
+
const canToggleFullCatalog =
|
|
74
|
+
featuredModels.length > 0 && catalog.length > featuredModels.length;
|
|
75
|
+
|
|
76
|
+
const requiredProviders = useMemo(
|
|
77
|
+
() => deriveRequiredProviders(configuredModels),
|
|
78
|
+
[configuredModels],
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const sortedProviders = useMemo(() => {
|
|
82
|
+
const ordered = [];
|
|
83
|
+
for (const p of kProviderDisplayOrder) {
|
|
84
|
+
if (requiredProviders.includes(p)) ordered.push(p);
|
|
85
|
+
}
|
|
86
|
+
for (const p of requiredProviders) {
|
|
87
|
+
if (!ordered.includes(p)) ordered.push(p);
|
|
88
|
+
}
|
|
89
|
+
return ordered;
|
|
90
|
+
}, [requiredProviders]);
|
|
91
|
+
|
|
92
|
+
const providerHasAuth = useMemo(() => {
|
|
93
|
+
const result = {};
|
|
94
|
+
for (const p of authProfiles) {
|
|
95
|
+
if (p.key || p.token || p.access) {
|
|
96
|
+
result[p.provider] = true;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (codexStatus?.connected) {
|
|
100
|
+
result["openai-codex"] = true;
|
|
101
|
+
}
|
|
102
|
+
return result;
|
|
103
|
+
}, [authProfiles, codexStatus]);
|
|
104
|
+
|
|
105
|
+
const configuredModelEntries = useMemo(
|
|
106
|
+
() =>
|
|
107
|
+
Object.keys(configuredModels).map((key) => {
|
|
108
|
+
const catalogEntry = catalog.find((m) => m.key === key);
|
|
109
|
+
const provider = getModelProvider(key);
|
|
110
|
+
const hasAuth = !!providerHasAuth[provider];
|
|
111
|
+
return {
|
|
112
|
+
key,
|
|
113
|
+
label: catalogEntry?.label || key,
|
|
114
|
+
isPrimary: key === primary,
|
|
115
|
+
hasAuth,
|
|
116
|
+
};
|
|
117
|
+
}),
|
|
118
|
+
[configuredModels, catalog, primary, providerHasAuth],
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
if (!ready) {
|
|
122
|
+
return html`
|
|
123
|
+
<div class="space-y-4">
|
|
124
|
+
<${PageHeader}
|
|
125
|
+
title="Models"
|
|
126
|
+
actions=${html`
|
|
127
|
+
<${ActionButton}
|
|
128
|
+
disabled=${true}
|
|
129
|
+
tone="primary"
|
|
130
|
+
size="sm"
|
|
131
|
+
idleLabel="Save changes"
|
|
132
|
+
className="transition-all"
|
|
133
|
+
/>
|
|
134
|
+
`}
|
|
135
|
+
/>
|
|
136
|
+
<div class="bg-surface border border-border rounded-xl p-4">
|
|
137
|
+
<div class="flex items-center gap-2 text-sm text-gray-400">
|
|
138
|
+
<${LoadingSpinner} className="h-4 w-4" />
|
|
139
|
+
Loading model settings...
|
|
140
|
+
</div>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return html`
|
|
147
|
+
<div class="space-y-4">
|
|
148
|
+
<${PageHeader}
|
|
149
|
+
title="Models"
|
|
150
|
+
actions=${html`
|
|
151
|
+
<${ActionButton}
|
|
152
|
+
onClick=${cancelChanges}
|
|
153
|
+
disabled=${!isDirty || saving}
|
|
154
|
+
tone="secondary"
|
|
155
|
+
size="sm"
|
|
156
|
+
idleLabel="Cancel"
|
|
157
|
+
className="transition-all"
|
|
158
|
+
/>
|
|
159
|
+
<${ActionButton}
|
|
160
|
+
onClick=${saveAll}
|
|
161
|
+
disabled=${!isDirty || saving}
|
|
162
|
+
loading=${saving}
|
|
163
|
+
tone="primary"
|
|
164
|
+
size="sm"
|
|
165
|
+
idleLabel="Save changes"
|
|
166
|
+
loadingLabel="Saving..."
|
|
167
|
+
className="transition-all"
|
|
168
|
+
/>
|
|
169
|
+
`}
|
|
170
|
+
/>
|
|
171
|
+
|
|
172
|
+
<!-- Configured Models -->
|
|
173
|
+
<div class="bg-surface border border-border rounded-xl p-4 space-y-3">
|
|
174
|
+
<h2 class="font-semibold text-sm">Available Models</h2>
|
|
175
|
+
|
|
176
|
+
${configuredModelEntries.length === 0
|
|
177
|
+
? html`<p class="text-xs text-gray-500">
|
|
178
|
+
No models configured. Add a model below.
|
|
179
|
+
</p>`
|
|
180
|
+
: html`
|
|
181
|
+
<div class="space-y-1">
|
|
182
|
+
${configuredModelEntries.map(
|
|
183
|
+
(entry) => html`
|
|
184
|
+
<div
|
|
185
|
+
class="flex items-center justify-between py-1.5 px-2 rounded-lg hover:bg-white/5"
|
|
186
|
+
>
|
|
187
|
+
<div class="flex items-center gap-2 min-w-0">
|
|
188
|
+
<span class="text-sm text-gray-200 truncate"
|
|
189
|
+
>${entry.label}</span
|
|
190
|
+
>
|
|
191
|
+
${entry.isPrimary
|
|
192
|
+
? html`<${Badge} tone="cyan">Primary</${Badge}>`
|
|
193
|
+
: entry.hasAuth
|
|
194
|
+
? html`
|
|
195
|
+
<button
|
|
196
|
+
onclick=${() => setPrimaryModel(entry.key)}
|
|
197
|
+
class="text-xs px-2 py-0.5 rounded-full text-gray-500 hover:text-gray-300 hover:bg-white/5"
|
|
198
|
+
>
|
|
199
|
+
Set primary
|
|
200
|
+
</button>
|
|
201
|
+
`
|
|
202
|
+
: html`<${Badge} tone="warning">Needs auth</${Badge}>`}
|
|
203
|
+
</div>
|
|
204
|
+
<button
|
|
205
|
+
onclick=${() => removeModel(entry.key)}
|
|
206
|
+
class="text-xs text-gray-600 hover:text-red-400 shrink-0 px-1"
|
|
207
|
+
>
|
|
208
|
+
Remove
|
|
209
|
+
</button>
|
|
210
|
+
</div>
|
|
211
|
+
`,
|
|
212
|
+
)}
|
|
213
|
+
</div>
|
|
214
|
+
`}
|
|
215
|
+
|
|
216
|
+
<div class="pt-2 border-t border-border space-y-2">
|
|
217
|
+
<label class="text-xs font-medium text-gray-400">Add model</label>
|
|
218
|
+
<select
|
|
219
|
+
onInput=${(e) => {
|
|
220
|
+
const val = e.target.value;
|
|
221
|
+
if (val) {
|
|
222
|
+
addModel(val);
|
|
223
|
+
if (!primary) setPrimaryModel(val);
|
|
224
|
+
}
|
|
225
|
+
e.target.value = "";
|
|
226
|
+
}}
|
|
227
|
+
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"
|
|
228
|
+
>
|
|
229
|
+
<option value="">Select a model to add...</option>
|
|
230
|
+
${pickerModels.map(
|
|
231
|
+
(m) =>
|
|
232
|
+
html`<option value=${m.key}>${m.label || m.key}</option>`,
|
|
233
|
+
)}
|
|
234
|
+
</select>
|
|
235
|
+
${canToggleFullCatalog
|
|
236
|
+
? html`
|
|
237
|
+
<button
|
|
238
|
+
type="button"
|
|
239
|
+
onclick=${() => setShowAllModels((prev) => !prev)}
|
|
240
|
+
class="text-xs text-gray-500 hover:text-gray-300"
|
|
241
|
+
>
|
|
242
|
+
${showAllModels
|
|
243
|
+
? "Show recommended models"
|
|
244
|
+
: "Show full model catalog"}
|
|
245
|
+
</button>
|
|
246
|
+
`
|
|
247
|
+
: null}
|
|
248
|
+
</div>
|
|
249
|
+
|
|
250
|
+
${loading
|
|
251
|
+
? html`<p class="text-xs text-gray-600">
|
|
252
|
+
Loading model catalog...
|
|
253
|
+
</p>`
|
|
254
|
+
: error
|
|
255
|
+
? html`<p class="text-xs text-gray-600">${error}</p>`
|
|
256
|
+
: null}
|
|
257
|
+
</div>
|
|
258
|
+
|
|
259
|
+
<!-- Provider Auth -->
|
|
260
|
+
${sortedProviders.length > 0
|
|
261
|
+
? html`
|
|
262
|
+
<div class="space-y-3">
|
|
263
|
+
<h2 class="font-semibold text-sm text-gray-300">
|
|
264
|
+
Provider Authentication
|
|
265
|
+
</h2>
|
|
266
|
+
${sortedProviders.map(
|
|
267
|
+
(provider) => html`
|
|
268
|
+
<${ProviderAuthCard}
|
|
269
|
+
provider=${provider}
|
|
270
|
+
authProfiles=${authProfiles}
|
|
271
|
+
authOrder=${authOrder}
|
|
272
|
+
codexStatus=${codexStatus}
|
|
273
|
+
onEditProfile=${editProfile}
|
|
274
|
+
onEditAuthOrder=${editAuthOrder}
|
|
275
|
+
getProfileValue=${getProfileValue}
|
|
276
|
+
getEffectiveOrder=${getEffectiveOrder}
|
|
277
|
+
onRefreshCodex=${refreshCodexStatus}
|
|
278
|
+
/>
|
|
279
|
+
`,
|
|
280
|
+
)}
|
|
281
|
+
</div>
|
|
282
|
+
`
|
|
283
|
+
: null}
|
|
284
|
+
</div>
|
|
285
|
+
`;
|
|
286
|
+
};
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import { h } from "https://esm.sh/preact";
|
|
2
|
+
import { useState, useRef, useEffect } from "https://esm.sh/preact/hooks";
|
|
3
|
+
import htm from "https://esm.sh/htm";
|
|
4
|
+
import { Badge } from "../badge.js";
|
|
5
|
+
import { SecretInput } from "../secret-input.js";
|
|
6
|
+
import { ActionButton } from "../action-button.js";
|
|
7
|
+
import { exchangeCodexOAuth, disconnectCodex } from "../../lib/api.js";
|
|
8
|
+
import { showToast } from "../toast.js";
|
|
9
|
+
|
|
10
|
+
const html = htm.bind(h);
|
|
11
|
+
|
|
12
|
+
const kProviderMeta = {
|
|
13
|
+
anthropic: {
|
|
14
|
+
label: "Anthropic",
|
|
15
|
+
modes: [
|
|
16
|
+
{
|
|
17
|
+
id: "api_key",
|
|
18
|
+
label: "API Key",
|
|
19
|
+
profileSuffix: "default",
|
|
20
|
+
placeholder: "sk-ant-api03-...",
|
|
21
|
+
url: "https://console.anthropic.com",
|
|
22
|
+
field: "key",
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
id: "token",
|
|
26
|
+
label: "Setup Token",
|
|
27
|
+
profileSuffix: "manual",
|
|
28
|
+
placeholder: "sk-ant-oat01-...",
|
|
29
|
+
hint: "From claude setup-token (uses your Claude subscription)",
|
|
30
|
+
field: "token",
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
openai: {
|
|
35
|
+
label: "OpenAI",
|
|
36
|
+
modes: [
|
|
37
|
+
{
|
|
38
|
+
id: "api_key",
|
|
39
|
+
label: "API Key",
|
|
40
|
+
profileSuffix: "default",
|
|
41
|
+
placeholder: "sk-...",
|
|
42
|
+
url: "https://platform.openai.com",
|
|
43
|
+
field: "key",
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
"openai-codex": {
|
|
48
|
+
label: "OpenAI Codex",
|
|
49
|
+
modes: [{ id: "oauth", label: "Codex OAuth", isCodexOauth: true }],
|
|
50
|
+
},
|
|
51
|
+
google: {
|
|
52
|
+
label: "Gemini",
|
|
53
|
+
modes: [
|
|
54
|
+
{
|
|
55
|
+
id: "api_key",
|
|
56
|
+
label: "API Key",
|
|
57
|
+
profileSuffix: "default",
|
|
58
|
+
placeholder: "AI...",
|
|
59
|
+
url: "https://aistudio.google.com",
|
|
60
|
+
field: "key",
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const kDefaultMode = {
|
|
67
|
+
id: "api_key",
|
|
68
|
+
label: "API Key",
|
|
69
|
+
profileSuffix: "default",
|
|
70
|
+
placeholder: "...",
|
|
71
|
+
field: "key",
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const getProviderMeta = (provider) =>
|
|
75
|
+
kProviderMeta[provider] || {
|
|
76
|
+
label: provider,
|
|
77
|
+
modes: [kDefaultMode],
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const resolveProfileId = (mode, provider) => {
|
|
81
|
+
const p = mode.provider || provider;
|
|
82
|
+
return `${p}:${mode.profileSuffix || "default"}`;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const CodexOAuthSection = ({ codexStatus, onRefreshCodex }) => {
|
|
86
|
+
const [authStarted, setAuthStarted] = useState(false);
|
|
87
|
+
const [authWaiting, setAuthWaiting] = useState(false);
|
|
88
|
+
const [manualInput, setManualInput] = useState("");
|
|
89
|
+
const [exchanging, setExchanging] = useState(false);
|
|
90
|
+
const popupPollRef = useRef(null);
|
|
91
|
+
|
|
92
|
+
useEffect(
|
|
93
|
+
() => () => {
|
|
94
|
+
if (popupPollRef.current) clearInterval(popupPollRef.current);
|
|
95
|
+
},
|
|
96
|
+
[],
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
const onMessage = async (e) => {
|
|
101
|
+
if (e.data?.codex === "success") {
|
|
102
|
+
showToast("Codex connected", "success");
|
|
103
|
+
setAuthStarted(false);
|
|
104
|
+
setAuthWaiting(false);
|
|
105
|
+
await onRefreshCodex();
|
|
106
|
+
} else if (e.data?.codex === "error") {
|
|
107
|
+
showToast(
|
|
108
|
+
`Codex auth failed: ${e.data.message || "unknown error"}`,
|
|
109
|
+
"error",
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
window.addEventListener("message", onMessage);
|
|
114
|
+
return () => window.removeEventListener("message", onMessage);
|
|
115
|
+
}, [onRefreshCodex]);
|
|
116
|
+
|
|
117
|
+
const startAuth = () => {
|
|
118
|
+
setAuthStarted(true);
|
|
119
|
+
setAuthWaiting(true);
|
|
120
|
+
const popup = window.open(
|
|
121
|
+
"/auth/codex/start",
|
|
122
|
+
"codex-auth",
|
|
123
|
+
"popup=yes,width=640,height=780",
|
|
124
|
+
);
|
|
125
|
+
if (!popup || popup.closed) {
|
|
126
|
+
setAuthWaiting(false);
|
|
127
|
+
window.location.href = "/auth/codex/start";
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (popupPollRef.current) clearInterval(popupPollRef.current);
|
|
131
|
+
popupPollRef.current = setInterval(() => {
|
|
132
|
+
if (popup.closed) {
|
|
133
|
+
clearInterval(popupPollRef.current);
|
|
134
|
+
popupPollRef.current = null;
|
|
135
|
+
setAuthWaiting(false);
|
|
136
|
+
}
|
|
137
|
+
}, 500);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const completeAuth = async () => {
|
|
141
|
+
if (!manualInput.trim() || exchanging) return;
|
|
142
|
+
setExchanging(true);
|
|
143
|
+
try {
|
|
144
|
+
const result = await exchangeCodexOAuth(manualInput.trim());
|
|
145
|
+
if (!result.ok)
|
|
146
|
+
throw new Error(result.error || "Codex OAuth exchange failed");
|
|
147
|
+
setManualInput("");
|
|
148
|
+
showToast("Codex connected", "success");
|
|
149
|
+
setAuthStarted(false);
|
|
150
|
+
setAuthWaiting(false);
|
|
151
|
+
await onRefreshCodex();
|
|
152
|
+
} catch (err) {
|
|
153
|
+
showToast(err.message || "Codex OAuth exchange failed", "error");
|
|
154
|
+
} finally {
|
|
155
|
+
setExchanging(false);
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const handleDisconnect = async () => {
|
|
160
|
+
const result = await disconnectCodex();
|
|
161
|
+
if (!result.ok) {
|
|
162
|
+
showToast(result.error || "Failed to disconnect Codex", "error");
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
showToast("Codex disconnected", "success");
|
|
166
|
+
setAuthStarted(false);
|
|
167
|
+
setAuthWaiting(false);
|
|
168
|
+
setManualInput("");
|
|
169
|
+
await onRefreshCodex();
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
return html`
|
|
173
|
+
<div class="space-y-2">
|
|
174
|
+
<div class="flex items-center justify-between">
|
|
175
|
+
<span class="text-xs text-gray-400">Codex OAuth</span>
|
|
176
|
+
${codexStatus.connected
|
|
177
|
+
? html`<${Badge} tone="success">Connected</${Badge}>`
|
|
178
|
+
: html`<${Badge} tone="warning">Not connected</${Badge}>`}
|
|
179
|
+
</div>
|
|
180
|
+
${codexStatus.connected
|
|
181
|
+
? html`
|
|
182
|
+
<div class="flex gap-2">
|
|
183
|
+
<button
|
|
184
|
+
onclick=${startAuth}
|
|
185
|
+
class="text-xs font-medium px-3 py-1.5 rounded-lg ac-btn-secondary"
|
|
186
|
+
>
|
|
187
|
+
Reconnect
|
|
188
|
+
</button>
|
|
189
|
+
<button
|
|
190
|
+
onclick=${handleDisconnect}
|
|
191
|
+
class="text-xs font-medium px-3 py-1.5 rounded-lg ac-btn-ghost"
|
|
192
|
+
>
|
|
193
|
+
Disconnect
|
|
194
|
+
</button>
|
|
195
|
+
</div>
|
|
196
|
+
`
|
|
197
|
+
: !authStarted
|
|
198
|
+
? html`
|
|
199
|
+
<button
|
|
200
|
+
onclick=${startAuth}
|
|
201
|
+
class="text-xs font-medium px-3 py-1.5 rounded-lg ac-btn-cyan"
|
|
202
|
+
>
|
|
203
|
+
Connect Codex OAuth
|
|
204
|
+
</button>
|
|
205
|
+
`
|
|
206
|
+
: html`
|
|
207
|
+
<div class="flex items-center justify-between gap-2">
|
|
208
|
+
<p class="text-xs text-gray-500">
|
|
209
|
+
${authWaiting
|
|
210
|
+
? "Complete login in the popup, then paste the redirect URL."
|
|
211
|
+
: "Paste the redirect URL from your browser to finish connecting."}
|
|
212
|
+
</p>
|
|
213
|
+
<button
|
|
214
|
+
onclick=${startAuth}
|
|
215
|
+
class="text-xs font-medium px-3 py-1.5 rounded-lg ac-btn-secondary shrink-0"
|
|
216
|
+
>
|
|
217
|
+
Restart
|
|
218
|
+
</button>
|
|
219
|
+
</div>
|
|
220
|
+
`}
|
|
221
|
+
${!codexStatus.connected && authStarted
|
|
222
|
+
? html`
|
|
223
|
+
<p class="text-xs text-gray-500">
|
|
224
|
+
After login, copy the full redirect URL (starts with
|
|
225
|
+
<code class="text-xs bg-black/30 px-1 rounded"
|
|
226
|
+
>http://localhost:1455/auth/callback</code
|
|
227
|
+
>) and paste it here.
|
|
228
|
+
</p>
|
|
229
|
+
<input
|
|
230
|
+
type="text"
|
|
231
|
+
value=${manualInput}
|
|
232
|
+
onInput=${(e) => setManualInput(e.target.value)}
|
|
233
|
+
placeholder="http://localhost:1455/auth/callback?code=...&state=..."
|
|
234
|
+
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"
|
|
235
|
+
/>
|
|
236
|
+
<${ActionButton}
|
|
237
|
+
onClick=${completeAuth}
|
|
238
|
+
disabled=${!manualInput.trim() || exchanging}
|
|
239
|
+
loading=${exchanging}
|
|
240
|
+
tone="primary"
|
|
241
|
+
size="sm"
|
|
242
|
+
idleLabel="Complete Codex OAuth"
|
|
243
|
+
loadingLabel="Completing..."
|
|
244
|
+
className="text-xs font-medium px-3 py-1.5"
|
|
245
|
+
/>
|
|
246
|
+
`
|
|
247
|
+
: null}
|
|
248
|
+
</div>
|
|
249
|
+
`;
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
export const ProviderAuthCard = ({
|
|
253
|
+
provider,
|
|
254
|
+
authProfiles,
|
|
255
|
+
authOrder,
|
|
256
|
+
codexStatus,
|
|
257
|
+
onEditProfile,
|
|
258
|
+
onEditAuthOrder,
|
|
259
|
+
getProfileValue,
|
|
260
|
+
getEffectiveOrder,
|
|
261
|
+
onRefreshCodex,
|
|
262
|
+
}) => {
|
|
263
|
+
const meta = getProviderMeta(provider);
|
|
264
|
+
const credentialModes = meta.modes.filter((m) => !m.isCodexOauth);
|
|
265
|
+
const hasMultipleModes = credentialModes.length > 1;
|
|
266
|
+
const showsInlineOauthStatus = meta.modes.some((m) => m.isCodexOauth);
|
|
267
|
+
|
|
268
|
+
const effectiveOrder = getEffectiveOrder(provider);
|
|
269
|
+
const activeProfileId = effectiveOrder?.[0] || null;
|
|
270
|
+
|
|
271
|
+
const isConnected =
|
|
272
|
+
credentialModes.some((mode) => {
|
|
273
|
+
const profileId = resolveProfileId(mode, provider);
|
|
274
|
+
const val = getProfileValue(profileId);
|
|
275
|
+
return !!(val?.key || val?.token || val?.access);
|
|
276
|
+
}) || (provider === "openai-codex" && !!codexStatus?.connected);
|
|
277
|
+
|
|
278
|
+
const handleSetActive = (mode) => {
|
|
279
|
+
const profileId = resolveProfileId(mode, provider);
|
|
280
|
+
const allIds = credentialModes.map((m) => resolveProfileId(m, provider));
|
|
281
|
+
const ordered = [profileId, ...allIds.filter((id) => id !== profileId)];
|
|
282
|
+
onEditAuthOrder(provider, ordered);
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
return html`
|
|
286
|
+
<div class="bg-surface border border-border rounded-xl p-4 space-y-3">
|
|
287
|
+
<div class="flex items-center justify-between">
|
|
288
|
+
<h3 class="font-semibold text-sm">${meta.label}</h3>
|
|
289
|
+
${showsInlineOauthStatus && credentialModes.length === 0
|
|
290
|
+
? null
|
|
291
|
+
: isConnected
|
|
292
|
+
? html`<${Badge} tone="success">Connected</${Badge}>`
|
|
293
|
+
: html`<${Badge} tone="warning">Not configured</${Badge}>`}
|
|
294
|
+
</div>
|
|
295
|
+
${credentialModes.map((mode) => {
|
|
296
|
+
const profileId = resolveProfileId(mode, provider);
|
|
297
|
+
const profileProvider = mode.provider || provider;
|
|
298
|
+
const currentValue = getProfileValue(profileId);
|
|
299
|
+
const fieldValue = currentValue?.[mode.field] || "";
|
|
300
|
+
const isActive =
|
|
301
|
+
!hasMultipleModes ||
|
|
302
|
+
activeProfileId === profileId ||
|
|
303
|
+
(!activeProfileId && mode === credentialModes[0]);
|
|
304
|
+
|
|
305
|
+
return html`
|
|
306
|
+
<div class="space-y-1.5">
|
|
307
|
+
<div class="flex items-center gap-2">
|
|
308
|
+
<label class="text-xs font-medium text-gray-400"
|
|
309
|
+
>${mode.label}</label
|
|
310
|
+
>
|
|
311
|
+
${hasMultipleModes && isActive
|
|
312
|
+
? html`<${Badge} tone="cyan">Primary</${Badge}>`
|
|
313
|
+
: null}
|
|
314
|
+
${hasMultipleModes && !isActive && fieldValue
|
|
315
|
+
? html`<button
|
|
316
|
+
onclick=${() => handleSetActive(mode)}
|
|
317
|
+
class="text-xs px-1.5 py-0.5 rounded-full text-gray-500 hover:text-gray-300 hover:bg-white/5"
|
|
318
|
+
>
|
|
319
|
+
Set primary
|
|
320
|
+
</button>`
|
|
321
|
+
: null}
|
|
322
|
+
${mode.url && !fieldValue
|
|
323
|
+
? html`<a
|
|
324
|
+
href=${mode.url}
|
|
325
|
+
target="_blank"
|
|
326
|
+
class="text-xs hover:underline"
|
|
327
|
+
style="color: var(--accent-link)"
|
|
328
|
+
>Get</a
|
|
329
|
+
>`
|
|
330
|
+
: null}
|
|
331
|
+
</div>
|
|
332
|
+
<${SecretInput}
|
|
333
|
+
value=${fieldValue}
|
|
334
|
+
onInput=${(e) => {
|
|
335
|
+
const newVal = e.target.value;
|
|
336
|
+
const cred = {
|
|
337
|
+
type: mode.id,
|
|
338
|
+
provider: profileProvider,
|
|
339
|
+
[mode.field]: newVal,
|
|
340
|
+
};
|
|
341
|
+
if (currentValue?.expires) cred.expires = currentValue.expires;
|
|
342
|
+
onEditProfile(profileId, cred);
|
|
343
|
+
if (hasMultipleModes && newVal && !isActive) {
|
|
344
|
+
handleSetActive(mode);
|
|
345
|
+
}
|
|
346
|
+
}}
|
|
347
|
+
placeholder=${mode.placeholder || ""}
|
|
348
|
+
isSecret=${true}
|
|
349
|
+
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"
|
|
350
|
+
/>
|
|
351
|
+
${mode.hint
|
|
352
|
+
? html`<p class="text-xs text-gray-600">${mode.hint}</p>`
|
|
353
|
+
: null}
|
|
354
|
+
</div>
|
|
355
|
+
`;
|
|
356
|
+
})}
|
|
357
|
+
${meta.modes.some((m) => m.isCodexOauth)
|
|
358
|
+
? html`
|
|
359
|
+
<div class="border border-border rounded-lg p-3">
|
|
360
|
+
<${CodexOAuthSection}
|
|
361
|
+
codexStatus=${codexStatus}
|
|
362
|
+
onRefreshCodex=${onRefreshCodex}
|
|
363
|
+
/>
|
|
364
|
+
</div>
|
|
365
|
+
`
|
|
366
|
+
: null}
|
|
367
|
+
</div>
|
|
368
|
+
`;
|
|
369
|
+
};
|