@chrysb/alphaclaw 0.1.19 → 0.1.21

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.
@@ -0,0 +1,173 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import { useState } from "https://esm.sh/preact/hooks";
3
+ import htm from "https://esm.sh/htm";
4
+ import { Badge } from "../badge.js";
5
+
6
+ const html = htm.bind(h);
7
+
8
+ const kChannelMeta = {
9
+ telegram: {
10
+ label: "Telegram",
11
+ iconSrc: "/assets/icons/telegram.svg",
12
+ },
13
+ discord: {
14
+ label: "Discord",
15
+ iconSrc: "/assets/icons/discord.svg",
16
+ },
17
+ };
18
+
19
+ const PairingRow = ({ pairing, onApprove, onReject }) => {
20
+ const [busyAction, setBusyAction] = useState("");
21
+
22
+ const handleApprove = async () => {
23
+ setBusyAction("approve");
24
+ try {
25
+ await onApprove(pairing.id, pairing.channel);
26
+ } finally {
27
+ setBusyAction("");
28
+ }
29
+ };
30
+
31
+ const handleReject = async () => {
32
+ setBusyAction("reject");
33
+ try {
34
+ await onReject(pairing.id, pairing.channel);
35
+ } finally {
36
+ setBusyAction("");
37
+ }
38
+ };
39
+
40
+ return html`
41
+ <div class="bg-black/30 rounded-lg p-3 mb-2">
42
+ <div class="flex items-center justify-between gap-2 mb-2">
43
+ <div class="font-medium text-sm">
44
+ ${pairing.code || pairing.id || "Pending request"}
45
+ </div>
46
+ <span class="text-[11px] px-2 py-0.5 rounded-full border border-border text-gray-400">
47
+ Request
48
+ </span>
49
+ </div>
50
+ <p class="text-xs text-gray-500 mb-3">
51
+ Approve to connect this account and finish setup.
52
+ </p>
53
+ <div class="flex gap-2">
54
+ <button
55
+ onclick=${handleApprove}
56
+ disabled=${!!busyAction}
57
+ class="ac-btn-green text-xs font-medium px-3 py-1.5 rounded-lg ${busyAction ? "opacity-60 cursor-not-allowed" : ""}"
58
+ >
59
+ ${busyAction === "approve" ? "Approving..." : "Approve"}
60
+ </button>
61
+ <button
62
+ onclick=${handleReject}
63
+ disabled=${!!busyAction}
64
+ class="bg-gray-800 text-gray-300 text-xs px-3 py-1.5 rounded-lg hover:bg-gray-700 ${busyAction ? "opacity-60 cursor-not-allowed" : ""}"
65
+ >
66
+ ${busyAction === "reject" ? "Rejecting..." : "Reject"}
67
+ </button>
68
+ </div>
69
+ </div>
70
+ `;
71
+ };
72
+
73
+ export const WelcomePairingStep = ({
74
+ channel,
75
+ pairings,
76
+ channels,
77
+ loading,
78
+ error,
79
+ onApprove,
80
+ onReject,
81
+ canFinish,
82
+ onContinue,
83
+ }) => {
84
+ const channelMeta = kChannelMeta[channel] || {
85
+ label: channel ? channel.charAt(0).toUpperCase() + channel.slice(1) : "Channel",
86
+ iconSrc: "",
87
+ };
88
+ const channelInfo = channels?.[channel];
89
+
90
+ if (!channel) {
91
+ return html`
92
+ <div class="bg-red-900/30 border border-red-800 rounded-xl p-3 text-red-300 text-sm">
93
+ Missing channel configuration. Go back and add a Telegram or Discord bot token.
94
+ </div>
95
+ `;
96
+ }
97
+
98
+ if (canFinish) {
99
+ return html`
100
+ <div class="min-h-[300px] pb-6 px-6 flex flex-col">
101
+ <div class="flex-1 flex items-center justify-center text-center">
102
+ <div class="space-y-3 max-w-xl mx-auto">
103
+ <p class="text-sm font-medium text-green-300 mb-12">🎉 Setup complete</p>
104
+ <p class="text-xs text-gray-300">
105
+ Your ${channelMeta.label} channel is connected. You can switch to ${channelMeta.label} and start using your agent now.
106
+ </p>
107
+ <p class="text-xs text-gray-500 font-normal opacity-85">
108
+ Continue to the dashboard to explore extras like Google Workspace and additional integrations.
109
+ </p>
110
+ </div>
111
+ </div>
112
+ <button
113
+ onclick=${onContinue}
114
+ class="w-full max-w-xl mx-auto text-sm font-medium px-4 py-2.5 rounded-xl transition-all ac-btn-cyan mt-3"
115
+ >
116
+ Continue to dashboard
117
+ </button>
118
+ </div>
119
+ `;
120
+ }
121
+
122
+ return html`
123
+ <div class="min-h-[300px] pb-6 flex flex-col gap-3">
124
+ <div class="flex items-center justify-end gap-2">
125
+ <${Badge} tone="warning"
126
+ >${loading
127
+ ? "Checking..."
128
+ : pairings.length > 0
129
+ ? "Pairing request detected"
130
+ : "Awaiting pairing"}</${Badge}
131
+ >
132
+ </div>
133
+
134
+ ${pairings.length > 0
135
+ ? html`<div class="flex-1 flex items-center">
136
+ <div class="w-full">
137
+ ${pairings.map(
138
+ (pairing) =>
139
+ html`<${PairingRow}
140
+ key=${pairing.id}
141
+ pairing=${pairing}
142
+ onApprove=${onApprove}
143
+ onReject=${onReject}
144
+ />`,
145
+ )}
146
+ </div>
147
+ </div>`
148
+ : html`<div class="flex-1 flex items-center justify-center text-center py-4">
149
+ <div class="space-y-4">
150
+ ${channelMeta.iconSrc
151
+ ? html`<img
152
+ src=${channelMeta.iconSrc}
153
+ alt=${channelMeta.label}
154
+ class="w-8 h-8 mx-auto rounded-md"
155
+ />`
156
+ : null}
157
+ <p class="text-gray-300 text-sm">
158
+ Send a message to your ${channelMeta.label} bot
159
+ </p>
160
+ <p class="text-gray-600 text-xs">
161
+ The pairing request will appear here in 5-10 seconds
162
+ </p>
163
+ </div>
164
+ </div>`}
165
+
166
+ ${error
167
+ ? html`<div class="bg-red-900/30 border border-red-800 rounded-xl p-3 text-red-300 text-sm">
168
+ ${error}
169
+ </div>`
170
+ : null}
171
+ </div>
172
+ `;
173
+ };
@@ -1,45 +1,117 @@
1
1
  import { h } from "https://esm.sh/preact";
2
+ import { useEffect, useState } from "https://esm.sh/preact/hooks";
2
3
  import htm from "https://esm.sh/htm";
3
4
 
4
5
  const html = htm.bind(h);
6
+ const kSetupTips = [
7
+ {
8
+ label: "🛡️ Safety tip",
9
+ text: "Be careful what you give access to. Read access is always safer than write access.",
10
+ },
11
+ {
12
+ label: "🧠 Best practice",
13
+ text: "Trust but verify. Your agent may not always know what it's doing, so check the results.",
14
+ },
15
+ {
16
+ label: "💡 Idea",
17
+ text: "Ask your agent to create a morning briefing for you.",
18
+ },
19
+ {
20
+ label: "🧠 Best practice",
21
+ text: "Ask your agent to review its own code and make sure it's doing what you want it to do.",
22
+ },
23
+ {
24
+ label: "💡 Idea",
25
+ text: "Tell your agent to review the latest news and provide a summary.",
26
+ },
27
+ {
28
+ label: "🛡️ Safety tip",
29
+ text: "Be incredibly careful installing skills from the internet - they may contain malicious code.",
30
+ },
31
+ ];
5
32
 
6
- export const WelcomeSetupStep = ({ error, loading, onRetry }) => html`
7
- <div class="py-10 flex flex-col items-center text-center gap-4">
8
- <svg
9
- class="animate-spin h-8 w-8 text-white"
10
- viewBox="0 0 24 24"
11
- fill="none"
12
- >
13
- <circle
14
- class="opacity-25"
15
- cx="12"
16
- cy="12"
17
- r="10"
18
- stroke="currentColor"
19
- stroke-width="4"
20
- />
21
- <path
22
- class="opacity-75"
23
- fill="currentColor"
24
- d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
25
- />
26
- </svg>
27
- <h3 class="text-lg font-semibold text-white">Initializing OpenClaw...</h3>
28
- <p class="text-sm text-gray-500">This could take 10-15 seconds</p>
29
- </div>
33
+ export const WelcomeSetupStep = ({ error, loading, onRetry, onBack }) => {
34
+ const [tipIndex, setTipIndex] = useState(0);
30
35
 
31
- ${error
32
- ? html`<div class="bg-red-900/30 border border-red-800 rounded-xl p-3 text-red-300 text-sm">
36
+ useEffect(() => {
37
+ if (error || !loading) return;
38
+ const timer = setInterval(() => {
39
+ setTipIndex((idx) => (idx + 1) % kSetupTips.length);
40
+ }, 5200);
41
+ return () => clearInterval(timer);
42
+ }, [error, loading]);
43
+
44
+ if (error) {
45
+ return html`
46
+ <div class="py-4 flex flex-col items-center text-center gap-3">
47
+ <h3 class="text-lg font-semibold text-white">Setup failed</h3>
48
+ <p class="text-sm text-gray-500">Fix the values and try again.</p>
49
+ </div>
50
+ <div
51
+ class="bg-red-900/30 border border-red-800 rounded-xl p-3 text-red-300 text-sm"
52
+ >
33
53
  ${error}
34
54
  </div>
35
- <button
36
- onclick=${onRetry}
37
- disabled=${loading}
38
- class="w-full text-sm font-medium px-4 py-3 rounded-xl transition-all ${loading
39
- ? "bg-gray-800 text-gray-500 cursor-not-allowed"
40
- : "bg-white text-black hover:opacity-85"}"
55
+ <div class="grid grid-cols-2 gap-2">
56
+ <button
57
+ onclick=${onBack}
58
+ disabled=${loading}
59
+ class="w-full text-sm font-medium px-4 py-3 rounded-xl transition-all border border-border text-gray-300 hover:border-gray-500 ${loading
60
+ ? "opacity-60 cursor-not-allowed"
61
+ : ""}"
62
+ >
63
+ Back
64
+ </button>
65
+ <button
66
+ onclick=${onRetry}
67
+ disabled=${loading}
68
+ class="w-full text-sm font-medium px-4 py-3 rounded-xl transition-all ${loading
69
+ ? "bg-gray-800 text-gray-500 cursor-not-allowed"
70
+ : "bg-white text-black hover:opacity-85"}"
71
+ >
72
+ ${loading ? "Retrying..." : "Retry"}
73
+ </button>
74
+ </div>
75
+ `;
76
+ }
77
+
78
+ const currentTip = kSetupTips[tipIndex];
79
+
80
+ return html`
81
+ <div class="min-h-[320px] py-4 flex flex-col">
82
+ <div
83
+ class="flex-1 flex flex-col items-center justify-center text-center gap-4"
84
+ >
85
+ <svg
86
+ class="animate-spin h-8 w-8 text-white"
87
+ viewBox="0 0 24 24"
88
+ fill="none"
89
+ >
90
+ <circle
91
+ class="opacity-25"
92
+ cx="12"
93
+ cy="12"
94
+ r="10"
95
+ stroke="currentColor"
96
+ stroke-width="4"
97
+ />
98
+ <path
99
+ class="opacity-75"
100
+ fill="currentColor"
101
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
102
+ />
103
+ </svg>
104
+ <h3 class="text-lg font-semibold text-white">
105
+ Initializing OpenClaw...
106
+ </h3>
107
+ <p class="text-sm text-gray-500">This could take 10-15 seconds</p>
108
+ </div>
109
+ <div
110
+ class="mt-3 bg-black/20 border border-border rounded-lg px-3 py-2 text-xs text-gray-500"
41
111
  >
42
- ${loading ? "Retrying..." : "Retry"}
43
- </button>`
44
- : null}
45
- `;
112
+ <span class="text-gray-400">${currentTip.label}: </span>
113
+ ${currentTip.text}
114
+ </div>
115
+ </div>
116
+ `;
117
+ };
@@ -13,6 +13,7 @@ const html = htm.bind(h);
13
13
  export const SecretInput = ({
14
14
  value = "",
15
15
  onInput,
16
+ onBlur,
16
17
  placeholder = "",
17
18
  inputClass = "",
18
19
  disabled = false,
@@ -28,6 +29,7 @@ export const SecretInput = ({
28
29
  value=${value}
29
30
  placeholder=${placeholder}
30
31
  onInput=${onInput}
32
+ onBlur=${onBlur}
31
33
  disabled=${disabled}
32
34
  class=${inputClass}
33
35
  />
@@ -7,19 +7,36 @@ import {
7
7
  fetchCodexStatus,
8
8
  disconnectCodex,
9
9
  exchangeCodexOAuth,
10
+ fetchStatus,
11
+ fetchPairings,
12
+ approvePairing,
13
+ rejectPairing,
10
14
  } from "../lib/api.js";
15
+ import { usePolling } from "../hooks/usePolling.js";
11
16
  import {
12
17
  getModelProvider,
13
18
  getFeaturedModels,
14
19
  getVisibleAiFieldKeys,
15
20
  } from "../lib/model-config.js";
16
- import { kWelcomeGroups } from "./onboarding/welcome-config.js";
21
+ import {
22
+ kWelcomeGroups,
23
+ isValidGithubRepoInput,
24
+ } from "./onboarding/welcome-config.js";
17
25
  import { WelcomeHeader } from "./onboarding/welcome-header.js";
18
26
  import { WelcomeSetupStep } from "./onboarding/welcome-setup-step.js";
19
27
  import { WelcomeFormStep } from "./onboarding/welcome-form-step.js";
28
+ import { WelcomePairingStep } from "./onboarding/welcome-pairing-step.js";
29
+ import {
30
+ getPreferredPairingChannel,
31
+ isChannelPaired,
32
+ } from "./onboarding/pairing-utils.js";
20
33
  const html = htm.bind(h);
21
34
  const kOnboardingStorageKey = "openclaw_setup";
22
35
  const kOnboardingStepKey = "_step";
36
+ const kPairingChannelKey = "_pairingChannel";
37
+ const kMaxOnboardingVars = 64;
38
+ const kMaxEnvKeyLength = 128;
39
+ const kMaxEnvValueLength = 4096;
23
40
 
24
41
  export const Welcome = ({ onComplete }) => {
25
42
  const [initialSetupState] = useState(() => {
@@ -133,20 +150,47 @@ export const Welcome = ({ onComplete }) => {
133
150
  : false;
134
151
 
135
152
  const allValid = kWelcomeGroups.every((g) => g.validate(vals, { hasAi }));
136
- const kFinalSetupStep = kWelcomeGroups.length;
153
+ const kSetupStepIndex = kWelcomeGroups.length;
154
+ const kPairingStepIndex = kSetupStepIndex + 1;
137
155
  const [step, setStep] = useState(() => {
138
156
  const parsedStep = Number.parseInt(
139
157
  String(initialSetupState?.[kOnboardingStepKey] || ""),
140
158
  10,
141
159
  );
142
160
  if (!Number.isFinite(parsedStep)) return 0;
143
- return Math.max(0, Math.min(kFinalSetupStep, parsedStep));
161
+ return Math.max(0, Math.min(kPairingStepIndex, parsedStep));
144
162
  });
145
- const isSetupStep = step === kFinalSetupStep;
146
- const activeGroup = !isSetupStep ? kWelcomeGroups[step] : null;
163
+ const [pairingError, setPairingError] = useState(null);
164
+ const [pairingComplete, setPairingComplete] = useState(false);
165
+ const isSetupStep = step === kSetupStepIndex;
166
+ const isPairingStep = step === kPairingStepIndex;
167
+ const activeGroup = step < kSetupStepIndex ? kWelcomeGroups[step] : null;
147
168
  const currentGroupValid = activeGroup
148
169
  ? activeGroup.validate(vals, { hasAi })
149
170
  : false;
171
+ const selectedPairingChannel = String(
172
+ vals[kPairingChannelKey] || getPreferredPairingChannel(vals),
173
+ );
174
+ const pairingStatusPoll = usePolling(fetchStatus, 3000, {
175
+ enabled: isPairingStep,
176
+ });
177
+ const pairingRequestsPoll = usePolling(
178
+ async () => {
179
+ const payload = await fetchPairings();
180
+ const allPending = payload.pending || [];
181
+ return allPending.filter((p) => p.channel === selectedPairingChannel);
182
+ },
183
+ 1000,
184
+ { enabled: isPairingStep && !!selectedPairingChannel },
185
+ );
186
+ const pairingChannels = pairingStatusPoll.data?.channels || {};
187
+ const canFinishPairing = isChannelPaired(pairingChannels, selectedPairingChannel);
188
+
189
+ useEffect(() => {
190
+ if (isPairingStep && canFinishPairing) {
191
+ setPairingComplete(true);
192
+ }
193
+ }, [isPairingStep, canFinishPairing]);
150
194
 
151
195
  useEffect(() => {
152
196
  localStorage.setItem(
@@ -218,21 +262,59 @@ export const Welcome = ({ onComplete }) => {
218
262
 
219
263
  const handleSubmit = async () => {
220
264
  if (!allValid || loading) return;
221
- setStep(kFinalSetupStep);
265
+ const vars = Object.entries(vals)
266
+ .filter(
267
+ ([key]) => key !== "MODEL_KEY" && !String(key || "").startsWith("_"),
268
+ )
269
+ .filter(([, v]) => v)
270
+ .map(([key, value]) => ({ key, value }));
271
+ const preflightError = (() => {
272
+ if (!vals.MODEL_KEY || !String(vals.MODEL_KEY).includes("/")) {
273
+ return "A model selection is required";
274
+ }
275
+ if (vars.length > kMaxOnboardingVars) {
276
+ return `Too many environment variables (max ${kMaxOnboardingVars})`;
277
+ }
278
+ for (const entry of vars) {
279
+ const key = String(entry?.key || "");
280
+ const value = String(entry?.value || "");
281
+ if (!key) return "Each variable must include a key";
282
+ if (key.length > kMaxEnvKeyLength) {
283
+ return `Variable key is too long: ${key.slice(0, 32)}...`;
284
+ }
285
+ if (value.length > kMaxEnvValueLength) {
286
+ return `Value too long for ${key} (max ${kMaxEnvValueLength} chars)`;
287
+ }
288
+ }
289
+ if (!vals.GITHUB_TOKEN || !isValidGithubRepoInput(vals.GITHUB_WORKSPACE_REPO)) {
290
+ return 'GITHUB_WORKSPACE_REPO must be in "owner/repo" format.';
291
+ }
292
+ return "";
293
+ })();
294
+ if (preflightError) {
295
+ setError(preflightError);
296
+ setStep(Math.max(0, kWelcomeGroups.findIndex((g) => g.id === "github")));
297
+ return;
298
+ }
299
+ setStep(kSetupStepIndex);
222
300
  setLoading(true);
223
301
  setError(null);
302
+ setPairingError(null);
224
303
 
225
304
  try {
226
- const vars = Object.entries(vals)
227
- .filter(
228
- ([key]) => key !== "MODEL_KEY" && !String(key || "").startsWith("_"),
229
- )
230
- .filter(([, v]) => v)
231
- .map(([key, value]) => ({ key, value }));
232
305
  const result = await runOnboard(vars, vals.MODEL_KEY);
233
306
  if (!result.ok) throw new Error(result.error || "Onboarding failed");
234
- localStorage.removeItem(kOnboardingStorageKey);
235
- onComplete();
307
+ const pairingChannel = getPreferredPairingChannel(vals);
308
+ if (!pairingChannel) {
309
+ throw new Error("No Telegram or Discord bot token configured for pairing.");
310
+ }
311
+ setVals((prev) => ({
312
+ ...prev,
313
+ [kPairingChannelKey]: pairingChannel,
314
+ }));
315
+ setLoading(false);
316
+ setStep(kPairingStepIndex);
317
+ setPairingComplete(false);
236
318
  } catch (err) {
237
319
  console.error("Onboard error:", err);
238
320
  setError(err.message);
@@ -240,10 +322,43 @@ export const Welcome = ({ onComplete }) => {
240
322
  }
241
323
  };
242
324
 
325
+ const handlePairingApprove = async (id, channel) => {
326
+ try {
327
+ setPairingError(null);
328
+ const result = await approvePairing(id, channel);
329
+ if (!result.ok) throw new Error(result.error || "Could not approve pairing");
330
+ setPairingComplete(true);
331
+ pairingRequestsPoll.refresh();
332
+ pairingStatusPoll.refresh();
333
+ } catch (err) {
334
+ setPairingError(err.message || "Could not approve pairing");
335
+ }
336
+ };
337
+
338
+ const handlePairingReject = async (id, channel) => {
339
+ try {
340
+ setPairingError(null);
341
+ const result = await rejectPairing(id, channel);
342
+ if (!result.ok) throw new Error(result.error || "Could not reject pairing");
343
+ pairingRequestsPoll.refresh();
344
+ } catch (err) {
345
+ setPairingError(err.message || "Could not reject pairing");
346
+ }
347
+ };
348
+
349
+ const finishOnboarding = () => {
350
+ localStorage.removeItem(kOnboardingStorageKey);
351
+ onComplete();
352
+ };
353
+
243
354
  const goBack = () => {
244
355
  if (isSetupStep) return;
245
356
  setStep((prev) => Math.max(0, prev - 1));
246
357
  };
358
+ const goBackFromSetupError = () => {
359
+ setLoading(false);
360
+ setStep(kWelcomeGroups.length - 1);
361
+ };
247
362
 
248
363
  const goNext = () => {
249
364
  if (!activeGroup || !currentGroupValid) return;
@@ -252,8 +367,14 @@ export const Welcome = ({ onComplete }) => {
252
367
 
253
368
  const activeStepLabel = isSetupStep
254
369
  ? "Initializing"
255
- : activeGroup?.title || "Setup";
256
- const stepNumber = isSetupStep ? kWelcomeGroups.length + 1 : step + 1;
370
+ : isPairingStep
371
+ ? "Pairing"
372
+ : activeGroup?.title || "Setup";
373
+ const stepNumber = isSetupStep
374
+ ? kWelcomeGroups.length + 1
375
+ : isPairingStep
376
+ ? kWelcomeGroups.length + 2
377
+ : step + 1;
257
378
 
258
379
  return html`
259
380
  <div class="max-w-lg w-full space-y-5">
@@ -261,10 +382,9 @@ export const Welcome = ({ onComplete }) => {
261
382
  groups=${kWelcomeGroups}
262
383
  step=${step}
263
384
  isSetupStep=${isSetupStep}
385
+ isPairingStep=${isPairingStep}
264
386
  stepNumber=${stepNumber}
265
387
  activeStepLabel=${activeStepLabel}
266
- vals=${vals}
267
- hasAi=${hasAi}
268
388
  />
269
389
 
270
390
  <div class="bg-surface border border-border rounded-xl p-4 space-y-3">
@@ -273,7 +393,20 @@ export const Welcome = ({ onComplete }) => {
273
393
  error=${error}
274
394
  loading=${loading}
275
395
  onRetry=${handleSubmit}
396
+ onBack=${goBackFromSetupError}
276
397
  />`
398
+ : isPairingStep
399
+ ? html`<${WelcomePairingStep}
400
+ channel=${selectedPairingChannel}
401
+ pairings=${pairingRequestsPoll.data || []}
402
+ channels=${pairingChannels}
403
+ loading=${!pairingStatusPoll.data}
404
+ error=${pairingError}
405
+ onApprove=${handlePairingApprove}
406
+ onReject=${handlePairingReject}
407
+ canFinish=${pairingComplete || canFinishPairing}
408
+ onContinue=${finishOnboarding}
409
+ />`
277
410
  : html`
278
411
  <${WelcomeFormStep}
279
412
  activeGroup=${activeGroup}
@@ -1,6 +1,9 @@
1
1
  const authFetch = async (url, opts = {}) => {
2
2
  const res = await fetch(url, opts);
3
3
  if (res.status === 401) {
4
+ try {
5
+ window.localStorage?.clear?.();
6
+ } catch {}
4
7
  window.location.href = '/setup';
5
8
  throw new Error('Unauthorized');
6
9
  }
@@ -140,6 +143,16 @@ export async function rejectDevice(id) {
140
143
  return res.json();
141
144
  }
142
145
 
146
+ export const fetchAuthStatus = async () => {
147
+ const res = await authFetch('/api/auth/status');
148
+ return res.json();
149
+ };
150
+
151
+ export const logout = async () => {
152
+ const res = await authFetch('/api/auth/logout', { method: 'POST' });
153
+ return res.json();
154
+ };
155
+
143
156
  export async function fetchOnboardStatus() {
144
157
  const res = await authFetch('/api/onboard/status');
145
158
  return res.json();
@@ -203,5 +216,15 @@ export async function saveEnvVars(vars) {
203
216
  headers: { 'Content-Type': 'application/json' },
204
217
  body: JSON.stringify({ vars }),
205
218
  });
206
- return res.json();
219
+ const text = await res.text();
220
+ let data;
221
+ try {
222
+ data = text ? JSON.parse(text) : {};
223
+ } catch {
224
+ throw new Error(text || 'Could not parse env save response');
225
+ }
226
+ if (!res.ok) {
227
+ throw new Error(data.error || text || `HTTP ${res.status}`);
228
+ }
229
+ return data;
207
230
  }
@@ -1,3 +1,8 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import htm from "https://esm.sh/htm";
3
+
4
+ const html = htm.bind(h);
5
+
1
6
  export const getModelProvider = (modelKey) => String(modelKey || "").split("/")[0] || "";
2
7
 
3
8
  export const getAuthProviderFromModelProvider = (provider) =>
@@ -41,7 +46,14 @@ export const kProviderAuthFields = {
41
46
  {
42
47
  key: "ANTHROPIC_API_KEY",
43
48
  label: "Anthropic API Key",
44
- hint: "From console.anthropic.com — recommended",
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`,
45
57
  placeholder: "sk-ant-...",
46
58
  },
47
59
  {
@@ -55,7 +67,14 @@ export const kProviderAuthFields = {
55
67
  {
56
68
  key: "OPENAI_API_KEY",
57
69
  label: "OpenAI API Key",
58
- hint: "From platform.openai.com",
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
+ >`,
59
78
  placeholder: "sk-...",
60
79
  },
61
80
  ],
@@ -63,7 +82,14 @@ export const kProviderAuthFields = {
63
82
  {
64
83
  key: "GEMINI_API_KEY",
65
84
  label: "Gemini API Key",
66
- hint: "From aistudio.google.com",
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
+ >`,
67
93
  placeholder: "AI...",
68
94
  },
69
95
  ],