@chrysb/alphaclaw 0.4.6-beta.3 → 0.4.6-beta.5
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/doctor/index.js +1 -2
- package/lib/public/js/components/general/index.js +155 -0
- package/lib/public/js/components/general/use-general-tab.js +233 -0
- 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/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/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/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/server/auth-profiles.js +223 -52
- package/lib/server/doctor/prompt.js +4 -1
- package/lib/server/gateway.js +29 -9
- package/lib/server/routes/models.js +170 -2
- package/lib/server/watchdog.js +14 -1
- package/lib/server.js +1 -0
- package/package.json +1 -1
|
@@ -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">Active</${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 active
|
|
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
|
+
};
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { useState, useEffect, useRef, useCallback } from "https://esm.sh/preact/hooks";
|
|
2
|
+
import {
|
|
3
|
+
fetchModels,
|
|
4
|
+
fetchModelsConfig,
|
|
5
|
+
saveModelsConfig,
|
|
6
|
+
fetchCodexStatus,
|
|
7
|
+
disconnectCodex,
|
|
8
|
+
} from "../../lib/api.js";
|
|
9
|
+
import { showToast } from "../toast.js";
|
|
10
|
+
|
|
11
|
+
let kModelsTabCache = null;
|
|
12
|
+
|
|
13
|
+
export const useModels = () => {
|
|
14
|
+
const [catalog, setCatalog] = useState(() => kModelsTabCache?.catalog || []);
|
|
15
|
+
const [primary, setPrimary] = useState(() => kModelsTabCache?.primary || "");
|
|
16
|
+
const [configuredModels, setConfiguredModels] = useState(
|
|
17
|
+
() => kModelsTabCache?.configuredModels || {},
|
|
18
|
+
);
|
|
19
|
+
const [authProfiles, setAuthProfiles] = useState(
|
|
20
|
+
() => kModelsTabCache?.authProfiles || [],
|
|
21
|
+
);
|
|
22
|
+
const [authOrder, setAuthOrder] = useState(
|
|
23
|
+
() => kModelsTabCache?.authOrder || {},
|
|
24
|
+
);
|
|
25
|
+
const [codexStatus, setCodexStatus] = useState(
|
|
26
|
+
() => kModelsTabCache?.codexStatus || { connected: false },
|
|
27
|
+
);
|
|
28
|
+
const [loading, setLoading] = useState(() => !kModelsTabCache);
|
|
29
|
+
const [saving, setSaving] = useState(false);
|
|
30
|
+
const [ready, setReady] = useState(() => !!kModelsTabCache);
|
|
31
|
+
const [error, setError] = useState("");
|
|
32
|
+
|
|
33
|
+
const [profileEdits, setProfileEdits] = useState({});
|
|
34
|
+
const [orderEdits, setOrderEdits] = useState({});
|
|
35
|
+
|
|
36
|
+
const savedPrimaryRef = useRef(kModelsTabCache?.primary || "");
|
|
37
|
+
const savedConfiguredRef = useRef(kModelsTabCache?.configuredModels || {});
|
|
38
|
+
|
|
39
|
+
const updateCache = useCallback((patch) => {
|
|
40
|
+
kModelsTabCache = { ...(kModelsTabCache || {}), ...patch };
|
|
41
|
+
}, []);
|
|
42
|
+
|
|
43
|
+
const refresh = useCallback(async () => {
|
|
44
|
+
if (!ready) setLoading(true);
|
|
45
|
+
setError("");
|
|
46
|
+
try {
|
|
47
|
+
const [catalogResult, configResult, codex] = await Promise.all([
|
|
48
|
+
fetchModels(),
|
|
49
|
+
fetchModelsConfig(),
|
|
50
|
+
fetchCodexStatus(),
|
|
51
|
+
]);
|
|
52
|
+
const catalogModels = Array.isArray(catalogResult.models)
|
|
53
|
+
? catalogResult.models
|
|
54
|
+
: [];
|
|
55
|
+
setCatalog(catalogModels);
|
|
56
|
+
const p = configResult.primary || "";
|
|
57
|
+
const cm = configResult.configuredModels || {};
|
|
58
|
+
const ap = configResult.authProfiles || [];
|
|
59
|
+
const ao = configResult.authOrder || {};
|
|
60
|
+
setPrimary(p);
|
|
61
|
+
setConfiguredModels(cm);
|
|
62
|
+
setAuthProfiles(ap);
|
|
63
|
+
setAuthOrder(ao);
|
|
64
|
+
setCodexStatus(codex || { connected: false });
|
|
65
|
+
setProfileEdits({});
|
|
66
|
+
setOrderEdits({});
|
|
67
|
+
savedPrimaryRef.current = p;
|
|
68
|
+
savedConfiguredRef.current = cm;
|
|
69
|
+
updateCache({
|
|
70
|
+
catalog: catalogModels,
|
|
71
|
+
primary: p,
|
|
72
|
+
configuredModels: cm,
|
|
73
|
+
authProfiles: ap,
|
|
74
|
+
authOrder: ao,
|
|
75
|
+
codexStatus: codex || { connected: false },
|
|
76
|
+
});
|
|
77
|
+
if (!catalogModels.length) setError("No models found");
|
|
78
|
+
} catch (err) {
|
|
79
|
+
setError("Failed to load model settings");
|
|
80
|
+
showToast(`Failed to load model settings: ${err.message}`, "error");
|
|
81
|
+
} finally {
|
|
82
|
+
setReady(true);
|
|
83
|
+
setLoading(false);
|
|
84
|
+
}
|
|
85
|
+
}, [ready, updateCache]);
|
|
86
|
+
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
refresh();
|
|
89
|
+
}, []);
|
|
90
|
+
|
|
91
|
+
const modelConfigDirty =
|
|
92
|
+
primary !== savedPrimaryRef.current ||
|
|
93
|
+
JSON.stringify(configuredModels) !==
|
|
94
|
+
JSON.stringify(savedConfiguredRef.current);
|
|
95
|
+
|
|
96
|
+
const authDirty = (() => {
|
|
97
|
+
const hasProfileChanges = Object.entries(profileEdits).some(
|
|
98
|
+
([id, cred]) => {
|
|
99
|
+
const existing = authProfiles.find((p) => p.id === id);
|
|
100
|
+
const newVal = cred?.key || cred?.token || cred?.access || "";
|
|
101
|
+
const oldVal =
|
|
102
|
+
existing?.key || existing?.token || existing?.access || "";
|
|
103
|
+
return newVal !== oldVal && newVal !== "";
|
|
104
|
+
},
|
|
105
|
+
);
|
|
106
|
+
const hasOrderChanges = Object.entries(orderEdits).some(
|
|
107
|
+
([provider, order]) => {
|
|
108
|
+
const existing = authOrder[provider];
|
|
109
|
+
return JSON.stringify(order) !== JSON.stringify(existing);
|
|
110
|
+
},
|
|
111
|
+
);
|
|
112
|
+
return hasProfileChanges || hasOrderChanges;
|
|
113
|
+
})();
|
|
114
|
+
|
|
115
|
+
const isDirty = modelConfigDirty || authDirty;
|
|
116
|
+
|
|
117
|
+
const addModel = useCallback(
|
|
118
|
+
(modelKey) => {
|
|
119
|
+
if (!modelKey) return;
|
|
120
|
+
setConfiguredModels((prev) => {
|
|
121
|
+
const next = { ...prev, [modelKey]: {} };
|
|
122
|
+
updateCache({ configuredModels: next });
|
|
123
|
+
return next;
|
|
124
|
+
});
|
|
125
|
+
},
|
|
126
|
+
[updateCache],
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
const removeModel = useCallback(
|
|
130
|
+
(modelKey) => {
|
|
131
|
+
setConfiguredModels((prev) => {
|
|
132
|
+
const next = { ...prev };
|
|
133
|
+
delete next[modelKey];
|
|
134
|
+
updateCache({ configuredModels: next });
|
|
135
|
+
return next;
|
|
136
|
+
});
|
|
137
|
+
if (primary === modelKey) {
|
|
138
|
+
const remaining = Object.keys(configuredModels).filter(
|
|
139
|
+
(k) => k !== modelKey,
|
|
140
|
+
);
|
|
141
|
+
const newPrimary = remaining[0] || "";
|
|
142
|
+
setPrimary(newPrimary);
|
|
143
|
+
updateCache({ primary: newPrimary });
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
[primary, configuredModels, updateCache],
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const setPrimaryModel = useCallback(
|
|
150
|
+
(modelKey) => {
|
|
151
|
+
setPrimary(modelKey);
|
|
152
|
+
updateCache({ primary: modelKey });
|
|
153
|
+
},
|
|
154
|
+
[updateCache],
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
const editProfile = useCallback((profileId, credential) => {
|
|
158
|
+
setProfileEdits((prev) => ({ ...prev, [profileId]: credential }));
|
|
159
|
+
}, []);
|
|
160
|
+
|
|
161
|
+
const editAuthOrder = useCallback((provider, orderedIds) => {
|
|
162
|
+
setOrderEdits((prev) => ({ ...prev, [provider]: orderedIds }));
|
|
163
|
+
}, []);
|
|
164
|
+
|
|
165
|
+
const getProfileValue = useCallback(
|
|
166
|
+
(profileId) => {
|
|
167
|
+
if (profileEdits[profileId] !== undefined) return profileEdits[profileId];
|
|
168
|
+
const existing = authProfiles.find((p) => p.id === profileId);
|
|
169
|
+
return existing || null;
|
|
170
|
+
},
|
|
171
|
+
[profileEdits, authProfiles],
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const getEffectiveOrder = useCallback(
|
|
175
|
+
(provider) => {
|
|
176
|
+
if (orderEdits[provider] !== undefined) return orderEdits[provider];
|
|
177
|
+
return authOrder[provider] || null;
|
|
178
|
+
},
|
|
179
|
+
[orderEdits, authOrder],
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
const cancelChanges = useCallback(() => {
|
|
183
|
+
const savedPrimary = savedPrimaryRef.current || "";
|
|
184
|
+
const savedConfigured = savedConfiguredRef.current || {};
|
|
185
|
+
setPrimary(savedPrimary);
|
|
186
|
+
setConfiguredModels(savedConfigured);
|
|
187
|
+
setProfileEdits({});
|
|
188
|
+
setOrderEdits({});
|
|
189
|
+
updateCache({
|
|
190
|
+
primary: savedPrimary,
|
|
191
|
+
configuredModels: savedConfigured,
|
|
192
|
+
});
|
|
193
|
+
}, [updateCache]);
|
|
194
|
+
|
|
195
|
+
const saveAll = useCallback(async () => {
|
|
196
|
+
if (saving) return;
|
|
197
|
+
setSaving(true);
|
|
198
|
+
try {
|
|
199
|
+
const changedProfiles = Object.entries(profileEdits)
|
|
200
|
+
.filter(([, cred]) => {
|
|
201
|
+
const val = cred?.key || cred?.token || cred?.access || "";
|
|
202
|
+
return val !== "";
|
|
203
|
+
})
|
|
204
|
+
.map(([id, cred]) => ({ id, ...cred }));
|
|
205
|
+
|
|
206
|
+
const result = await saveModelsConfig({
|
|
207
|
+
primary,
|
|
208
|
+
configuredModels,
|
|
209
|
+
profiles: changedProfiles.length > 0 ? changedProfiles : undefined,
|
|
210
|
+
authOrder:
|
|
211
|
+
Object.keys(orderEdits).length > 0 ? orderEdits : undefined,
|
|
212
|
+
});
|
|
213
|
+
if (!result.ok)
|
|
214
|
+
throw new Error(result.error || "Failed to save config");
|
|
215
|
+
showToast("Changes saved", "success");
|
|
216
|
+
if (result.syncWarning) {
|
|
217
|
+
showToast(`Saved, but git-sync failed: ${result.syncWarning}`, "warning");
|
|
218
|
+
}
|
|
219
|
+
await refresh();
|
|
220
|
+
} catch (err) {
|
|
221
|
+
showToast(err.message || "Failed to save changes", "error");
|
|
222
|
+
} finally {
|
|
223
|
+
setSaving(false);
|
|
224
|
+
}
|
|
225
|
+
}, [saving, primary, configuredModels, profileEdits, orderEdits, refresh]);
|
|
226
|
+
|
|
227
|
+
const refreshCodexStatus = useCallback(async () => {
|
|
228
|
+
try {
|
|
229
|
+
const codex = await fetchCodexStatus();
|
|
230
|
+
setCodexStatus(codex || { connected: false });
|
|
231
|
+
updateCache({ codexStatus: codex || { connected: false } });
|
|
232
|
+
} catch {
|
|
233
|
+
setCodexStatus({ connected: false });
|
|
234
|
+
updateCache({ codexStatus: { connected: false } });
|
|
235
|
+
}
|
|
236
|
+
}, [updateCache]);
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
catalog,
|
|
240
|
+
primary,
|
|
241
|
+
configuredModels,
|
|
242
|
+
authProfiles,
|
|
243
|
+
authOrder,
|
|
244
|
+
codexStatus,
|
|
245
|
+
loading,
|
|
246
|
+
saving,
|
|
247
|
+
ready,
|
|
248
|
+
error,
|
|
249
|
+
isDirty,
|
|
250
|
+
refresh,
|
|
251
|
+
addModel,
|
|
252
|
+
removeModel,
|
|
253
|
+
setPrimaryModel,
|
|
254
|
+
editProfile,
|
|
255
|
+
editAuthOrder,
|
|
256
|
+
getProfileValue,
|
|
257
|
+
getEffectiveOrder,
|
|
258
|
+
cancelChanges,
|
|
259
|
+
saveAll,
|
|
260
|
+
refreshCodexStatus,
|
|
261
|
+
};
|
|
262
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { h } from "https://esm.sh/preact";
|
|
2
|
+
import htm from "https://esm.sh/htm";
|
|
3
|
+
import { FileViewer } from "../file-viewer/index.js";
|
|
4
|
+
|
|
5
|
+
const html = htm.bind(h);
|
|
6
|
+
|
|
7
|
+
export const BrowseRoute = ({
|
|
8
|
+
activeBrowsePath = "",
|
|
9
|
+
browseView = "edit",
|
|
10
|
+
lineTarget = 0,
|
|
11
|
+
lineEndTarget = 0,
|
|
12
|
+
selectedBrowsePath = "",
|
|
13
|
+
onNavigateToBrowseFile = () => {},
|
|
14
|
+
onEditSelectedBrowseFile = () => {},
|
|
15
|
+
onClearSelection = () => {},
|
|
16
|
+
}) => html`
|
|
17
|
+
<div class="w-full">
|
|
18
|
+
<${FileViewer}
|
|
19
|
+
filePath=${activeBrowsePath}
|
|
20
|
+
isPreviewOnly=${false}
|
|
21
|
+
browseView=${browseView}
|
|
22
|
+
lineTarget=${lineTarget}
|
|
23
|
+
lineEndTarget=${lineEndTarget}
|
|
24
|
+
onRequestEdit=${(targetPath) => {
|
|
25
|
+
const normalizedTargetPath = String(targetPath || "");
|
|
26
|
+
if (normalizedTargetPath && normalizedTargetPath !== selectedBrowsePath) {
|
|
27
|
+
onNavigateToBrowseFile(normalizedTargetPath, { view: "edit" });
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
onEditSelectedBrowseFile();
|
|
31
|
+
}}
|
|
32
|
+
onRequestClearSelection=${onClearSelection}
|
|
33
|
+
/>
|
|
34
|
+
</div>
|
|
35
|
+
`;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { h } from "https://esm.sh/preact";
|
|
2
|
+
import htm from "https://esm.sh/htm";
|
|
3
|
+
import { DoctorTab } from "../doctor/index.js";
|
|
4
|
+
|
|
5
|
+
const html = htm.bind(h);
|
|
6
|
+
|
|
7
|
+
export const DoctorRoute = ({ onNavigateToBrowseFile = () => {} }) => html`
|
|
8
|
+
<div class="pt-4">
|
|
9
|
+
<${DoctorTab}
|
|
10
|
+
isActive=${true}
|
|
11
|
+
onOpenFile=${(relativePath, options = {}) => {
|
|
12
|
+
const browsePath = `workspace/${String(relativePath || "").trim().replace(/^workspace\//, "")}`;
|
|
13
|
+
onNavigateToBrowseFile(browsePath, {
|
|
14
|
+
view: "edit",
|
|
15
|
+
...(options.line ? { line: options.line } : {}),
|
|
16
|
+
...(options.lineEnd ? { lineEnd: options.lineEnd } : {}),
|
|
17
|
+
});
|
|
18
|
+
}}
|
|
19
|
+
/>
|
|
20
|
+
</div>
|
|
21
|
+
`;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { h } from "https://esm.sh/preact";
|
|
2
|
+
import htm from "https://esm.sh/htm";
|
|
3
|
+
import { Envars } from "../envars.js";
|
|
4
|
+
|
|
5
|
+
const html = htm.bind(h);
|
|
6
|
+
|
|
7
|
+
export const EnvarsRoute = ({ onRestartRequired = () => {} }) => html`
|
|
8
|
+
<div class="pt-4">
|
|
9
|
+
<${Envars} onRestartRequired=${onRestartRequired} />
|
|
10
|
+
</div>
|
|
11
|
+
`;
|