diff --git a/lib/public/css/explorer.css b/lib/public/css/explorer.css index 6bbec04d..8e1bdadf 100644 --- a/lib/public/css/explorer.css +++ b/lib/public/css/explorer.css @@ -1880,3 +1880,51 @@ } } + +/* ── Light theme overrides ─────────────────────── */ + +[data-theme="light"] .sidebar-tab { + color: var(--text-muted); +} + +[data-theme="light"] .sidebar-tab:hover { + color: var(--text-bright); +} + +[data-theme="light"] .sidebar-tab.active { + color: #0e7490; + background: rgba(8, 145, 178, 0.1); +} + +[data-theme="light"] .file-viewer-protected-banner { + background: rgba(234, 179, 8, 0.12); +} + +[data-theme="light"] .file-viewer-protected-banner-text { + color: #92400e; +} + +[data-theme="light"] .file-viewer-protected-banner-unlocked { + color: #a16207; +} + +[data-theme="light"] .file-viewer-protected-banner.is-locked { + background: rgba(220, 38, 38, 0.1); +} + +[data-theme="light"] .file-viewer-protected-banner.is-locked .file-viewer-protected-banner-text { + color: #b91c1c; +} + +[data-theme="light"] .file-viewer-protected-banner.is-locked .file-viewer-protected-banner-icon { + color: #dc2626; +} + +[data-theme="light"] .file-viewer-diff-banner { + background: rgba(59, 130, 246, 0.08); +} + +[data-theme="light"] .file-viewer-diff-banner .file-viewer-protected-banner-text, +[data-theme="light"] .file-viewer-diff-banner { + color: #1d4ed8; +} diff --git a/lib/public/css/shell.css b/lib/public/css/shell.css index de8315d5..76162a91 100644 --- a/lib/public/css/shell.css +++ b/lib/public/css/shell.css @@ -491,3 +491,152 @@ pointer-events: auto; } } + +/* ── Theme toggle dropdown ────────────────────── */ + +.theme-toggle-menu { + position: relative; + display: inline-flex; +} + +.theme-toggle-trigger { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 6px; + border: none; + background: transparent; + color: var(--text-dim); + cursor: pointer; + transition: color 0.15s, background 0.15s; +} + +.theme-toggle-trigger:hover { + color: var(--text-muted); + background: var(--bg-hover); +} + +.theme-toggle-dropdown { + position: absolute; + top: calc(100% + 4px); + right: 0; + min-width: 120px; + padding: 4px; + background: var(--bg-content); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35); + z-index: 50; + display: flex; + flex-direction: column; +} + +[data-theme="light"] .theme-toggle-dropdown { + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); +} + +.theme-toggle-option { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 6px 10px; + border: none; + border-radius: 5px; + background: transparent; + color: var(--text-muted); + font-size: 12px; + font-family: inherit; + cursor: pointer; + transition: color 0.15s, background 0.15s; +} + +.theme-toggle-option:hover { + background: var(--bg-hover); + color: var(--text); +} + +.theme-toggle-option.active { + color: var(--accent); +} + +/* ── Light theme overrides ─────────────────────── */ + +[data-theme="light"] .app-sidebar { + background: + linear-gradient(180deg, rgba(0, 0, 0, 0.02) 0%, rgba(0, 0, 0, 0.04) 100%), + var(--bg-sidebar); + border-right-color: rgba(0, 0, 0, 0.1); +} + +[data-theme="light"] .sidebar-brand { + color: var(--text); +} + +[data-theme="light"] .sidebar-label { + color: var(--text-muted); +} + +[data-theme="light"] .sidebar-nav a { + color: var(--text); +} + +[data-theme="light"] .sidebar-nav a:hover { + background: rgba(0, 0, 0, 0.06); + color: var(--text-bright); +} + +[data-theme="light"] .sidebar-nav a.active { + background: rgba(8, 145, 178, 0.1); + color: #0e7490; +} + +[data-theme="light"] .sidebar-nav a.active::before { + background: #0e7490; +} + +[data-theme="light"] .brand-dropdown { + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); +} + +[data-theme="light"] .global-restart-banner__content { + background: rgba(254, 243, 199, 0.97); + border: 1px solid rgba(202, 138, 4, 0.5); + box-shadow: 0 18px 46px rgba(0, 0, 0, 0.12); +} + +[data-theme="light"] .global-restart-banner__text { + color: #78350f; +} + +[data-theme="light"] .global-restart-banner__dismiss { + color: #78350f; +} + +[data-theme="light"] .global-restart-banner__dismiss:hover { + color: #451a03; +} + +[data-theme="light"] .sidebar-update-btn { + border-color: rgba(202, 138, 4, 0.3); + color: #a16207; + background: rgba(202, 138, 4, 0.06); +} + +[data-theme="light"] .sidebar-update-btn:hover { + background: rgba(202, 138, 4, 0.1); + border-color: rgba(202, 138, 4, 0.4); +} + +[data-theme="light"] .sidebar-resizer:hover::after, +[data-theme="light"] .sidebar-resizer.is-resizing::after { + background: rgba(8, 145, 178, 0.55); +} + +@media (max-width: 768px) { + [data-theme="light"] .app-sidebar { + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.15); + } +} diff --git a/lib/public/css/theme.css b/lib/public/css/theme.css index 7cc526bc..73d76014 100644 --- a/lib/public/css/theme.css +++ b/lib/public/css/theme.css @@ -52,6 +52,52 @@ --status-info-border: rgba(14, 116, 144, 0.8); } +/* ── Light theme ─────────────────────────────────── */ +[data-theme="light"] { + --bg: #f8f9fb; + --bg-sidebar: #f0f2f5; + --bg-content: #ffffff; + --bg-hover: rgba(0, 0, 0, 0.04); + --bg-active: rgba(8, 145, 178, 0.08); + --border: rgba(0, 0, 0, 0.08); + --border-strong: rgba(0, 0, 0, 0.15); + --text: #1f2937; + --text-muted: #6b7280; + --text-dim: #9ca3af; + --text-bright: #111827; + --card-label-bright: #1f2937; + --accent: #0891b2; + --accent-dim: rgba(8, 145, 178, 0.3); + --accent-link: #0e7490; + --orange: #c2410c; + --comment: #9ca3af; + --keyword: #dc2626; + --string: #2563eb; + --number: #0284c7; + --panel-bg-contrast: rgba(0, 0, 0, 0.02); + --panel-border-contrast: rgba(0, 0, 0, 0.1); + --field-bg-contrast: rgba(0, 0, 0, 0.04); + --field-border-contrast: rgba(0, 0, 0, 0.15); + --overlay: rgba(0, 0, 0, 0.5); + + --status-error: #dc2626; + --status-error-muted: #ef4444; + --status-error-bg: rgba(254, 226, 226, 0.95); + --status-error-border: rgba(252, 165, 165, 0.8); + --status-warning: #a16207; + --status-warning-muted: #854d0e; + --status-warning-bg: rgba(254, 249, 195, 0.95); + --status-warning-border: rgba(202, 138, 4, 0.5); + --status-success: #16a34a; + --status-success-muted: #22c55e; + --status-success-bg: rgba(220, 252, 231, 0.95); + --status-success-border: rgba(134, 239, 172, 0.8); + --status-info: #0891b2; + --status-info-muted: #06b6d4; + --status-info-bg: rgba(207, 250, 254, 0.95); + --status-info-border: rgba(103, 232, 249, 0.8); +} + html, body { height: 100%; } body { @@ -62,6 +108,20 @@ body { line-height: 1.6; } +.ac-logo-mark { + display: inline-block; + flex: 0 0 auto; + width: var(--ac-logo-width, 20px); + height: var(--ac-logo-height, 20px); + background: #00efff; + -webkit-mask: url("../img/logo.svg") center / contain no-repeat; + mask: url("../img/logo.svg") center / contain no-repeat; +} + +[data-theme="light"] .ac-logo-mark { + background: var(--accent); +} + /* Subtle grid texture overlay */ body::before { content: ''; @@ -738,3 +798,208 @@ textarea:focus { overflow-y: auto !important; } +/* ── Light theme overrides for hardcoded dark patterns ── */ + +[data-theme="light"] body::before { + background-image: + linear-gradient(rgba(0, 0, 0, 0.03) 1px, transparent 1px), + linear-gradient(90deg, rgba(0, 0, 0, 0.03) 1px, transparent 1px); +} + +[data-theme="light"] .ac-history-item { + background: rgba(0, 0, 0, 0.02); +} + +[data-theme="light"] .ac-history-summary { + color: var(--text); +} + +[data-theme="light"] .ac-history-item[open] > .ac-history-summary .ac-history-toggle { + color: var(--text); +} + +[data-theme="light"] .ac-surface-inset { + background: rgba(0, 0, 0, 0.02); +} + +[data-theme="light"] .snippet-collapse-fade { + background: linear-gradient(to bottom, transparent 0%, rgba(255, 255, 255, 0.85) 70%); +} + +[data-theme="light"] input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):focus, +[data-theme="light"] select:focus, +[data-theme="light"] textarea:focus { + border-color: rgba(0, 0, 0, 0.35); +} + +[data-theme="light"] ::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.12); +} + +[data-theme="light"] .scope-btn { background: rgba(0, 0, 0, 0.03); } +[data-theme="light"] .scope-btn-read.active, +[data-theme="light"] .scope-btn-write.active { + background: rgba(0, 0, 0, 0.03); + color: var(--text-bright); + border-color: rgba(0, 0, 0, 0.35); +} + +[data-theme="light"] .ac-btn-cyan { + border: 1px solid var(--accent-dim); + background: linear-gradient(180deg, rgba(8, 145, 178, 0.1) 0%, rgba(8, 145, 178, 0.05) 100%); + color: var(--accent); + box-shadow: inset 0 0 0 1px rgba(8, 145, 178, 0.08); +} + +[data-theme="light"] .ac-btn-cyan:hover:not(:disabled) { + border-color: rgba(8, 145, 178, 0.6); + background: linear-gradient(180deg, rgba(8, 145, 178, 0.16) 0%, rgba(8, 145, 178, 0.08) 100%); + color: #065666; + box-shadow: inset 0 0 0 1px rgba(8, 145, 178, 0.15), 0 0 12px rgba(8, 145, 178, 0.1); +} + +[data-theme="light"] .ac-btn-cyan-ghost { + border: 1px solid var(--accent-dim); + color: var(--accent); + background: rgba(8, 145, 178, 0.04); +} + +[data-theme="light"] .ac-btn-cyan-ghost:hover { + border-color: rgba(8, 145, 178, 0.5); + color: #065666; + background: rgba(8, 145, 178, 0.08); +} + +[data-theme="light"] .ac-btn-secondary { + color: var(--text); + background: rgba(0, 0, 0, 0.02); +} + +[data-theme="light"] .ac-btn-secondary:hover:not(:disabled) { + border-color: rgba(0, 0, 0, 0.25); + color: var(--text-bright); + background: rgba(0, 0, 0, 0.04); +} + +[data-theme="light"] .ac-btn-ghost:hover:not(:disabled) { + color: var(--text-bright); +} + +[data-theme="light"] .ac-btn-danger { + border: 1px solid rgba(220, 38, 38, 0.3); + background: rgba(220, 38, 38, 0.06); + color: #dc2626; +} + +[data-theme="light"] .ac-btn-danger:hover:not(:disabled) { + border-color: rgba(220, 38, 38, 0.5); + background: rgba(220, 38, 38, 0.1); + color: #b91c1c; +} + +[data-theme="light"] .ac-btn-green { + border: 1px solid rgba(22, 163, 74, 0.3); + background: linear-gradient(180deg, rgba(22, 163, 74, 0.1) 0%, rgba(22, 163, 74, 0.05) 100%); + color: #16a34a; + box-shadow: inset 0 0 0 1px rgba(22, 163, 74, 0.08); +} + +[data-theme="light"] .ac-btn-green:hover:not(:disabled) { + border-color: rgba(22, 163, 74, 0.5); + background: linear-gradient(180deg, rgba(22, 163, 74, 0.16) 0%, rgba(22, 163, 74, 0.08) 100%); + color: #15803d; + box-shadow: inset 0 0 0 1px rgba(22, 163, 74, 0.15), 0 0 12px rgba(22, 163, 74, 0.08); +} + +[data-theme="light"] .ac-toggle-track { + background: rgba(0, 0, 0, 0.1); +} + +[data-theme="light"] .ac-toggle-thumb { + background: #9ca3af; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1); +} + +[data-theme="light"] .ac-toggle-input:checked + .ac-toggle-track { + border-color: rgba(8, 145, 178, 0.6); + background: rgba(8, 145, 178, 0.12); + box-shadow: inset 0 0 0 1px rgba(8, 145, 178, 0.15); +} + +[data-theme="light"] .ac-toggle-input:checked + .ac-toggle-track .ac-toggle-thumb { + background: #0891b2; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.08); +} + +[data-theme="light"] .ac-toggle-label { + color: var(--text); +} + +[data-theme="light"] .ac-path-card { + background: rgba(0, 0, 0, 0.02); +} + +[data-theme="light"] .ac-path-card:hover { + border-color: rgba(8, 145, 178, 0.4); + background: rgba(8, 145, 178, 0.04); + box-shadow: inset 0 0 0 1px rgba(8, 145, 178, 0.08), 0 0 12px rgba(8, 145, 178, 0.06); +} + +[data-theme="light"] .ac-path-card:hover .ac-path-title { + color: #065666; +} + +[data-theme="light"] .ac-path-card:hover .ac-path-desc { + color: var(--text-muted); +} + +[data-theme="light"] .ac-segmented-control { + background: rgba(0, 0, 0, 0.02); + border-color: rgba(0, 0, 0, 0.12); +} + +[data-theme="light"] .ac-segmented-control-button:hover { + background: rgba(0, 0, 0, 0.04); +} + +[data-theme="light"] .ac-segmented-control-button.active { + background: rgba(8, 145, 178, 0.12); + color: #0e7490; +} + +[data-theme="light"] .ac-segmented-control-dark { + background: rgba(0, 0, 0, 0.04); +} + +/* Modal and link overrides for light mode */ +[data-theme="light"] .bg-modal { + background: #ffffff; +} + +[data-theme="light"] a[style*="color: rgba(99, 235, 255"] { + color: #0e7490 !important; +} + +[data-theme="light"] a[style*="color: rgba(99, 235, 255"]:hover { + color: var(--text-bright) !important; +} + +[data-theme="light"] .text-cyan-400 { + color: #0e7490 !important; +} + +[data-theme="light"] .text-cyan-300 { + color: #0e7490 !important; +} + +[data-theme="light"] .text-blue-400 { + color: #1d4ed8 !important; +} + +[data-theme="light"] .text-indigo-300 { + color: #4338ca !important; +} + +[data-theme="light"] .text-purple-400 { + color: #7e22ce !important; +} diff --git a/lib/public/js/app.js b/lib/public/js/app.js index 3b5ecfe9..7a703b7e 100644 --- a/lib/public/js/app.js +++ b/lib/public/js/app.js @@ -9,6 +9,7 @@ import { } from "wouter-preact"; import { logout } from "./lib/api.js"; import { Welcome } from "./components/welcome/index.js"; +import { ThemeToggle } from "./components/theme-toggle.js"; import { ToastContainer } from "./components/toast.js"; import { GlobalRestartBanner } from "./components/global-restart-banner.js"; import { LoadingSpinner } from "./components/loading-spinner.js"; @@ -160,6 +161,9 @@ const App = () => { class="min-h-screen flex flex-col items-center pt-12 pb-8 px-4" style="position: relative; z-index: 1" > +
+ <${ThemeToggle} /> +
<${Welcome} onComplete=${controllerActions.handleOnboardingComplete} acVersion=${controllerState.acVersion} diff --git a/lib/public/js/components/icons.js b/lib/public/js/components/icons.js index 05500ccd..abe691d1 100644 --- a/lib/public/js/components/icons.js +++ b/lib/public/js/components/icons.js @@ -508,6 +508,44 @@ export const EyeLineIcon = ({ className = "" }) => html` `; +export const SunIcon = ({ className = "" }) => html` + +`; + +export const MoonIcon = ({ className = "" }) => html` + +`; + export const FullscreenLineIcon = ({ className = "" }) => html` { const [authWaiting, setAuthWaiting] = useState(false); const [manualInput, setManualInput] = useState(""); const [exchanging, setExchanging] = useState(false); + const exchangeInFlightRef = useRef(false); const popupPollRef = useRef(null); useEffect( @@ -117,6 +122,30 @@ const CodexOAuthSection = ({ codexStatus, onRefreshCodex }) => { [], ); + const submitAuthInput = async (input) => { + const normalizedInput = String(input || "").trim(); + if (!normalizedInput || exchangeInFlightRef.current) return; + exchangeInFlightRef.current = true; + setManualInput(normalizedInput); + setExchanging(true); + try { + const result = await exchangeCodexOAuth(normalizedInput); + if (!result.ok) + throw new Error(result.error || "Codex OAuth exchange failed"); + setManualInput(""); + showToast("Codex connected", "success"); + setAuthStarted(false); + setAuthWaiting(false); + await onRefreshCodex(); + } catch (err) { + setAuthWaiting(false); + showToast(err.message || "Codex OAuth exchange failed", "error"); + } finally { + exchangeInFlightRef.current = false; + setExchanging(false); + } + }; + useEffect(() => { const onMessage = async (e) => { if (e.data?.codex === "success") { @@ -124,6 +153,8 @@ const CodexOAuthSection = ({ codexStatus, onRefreshCodex }) => { setAuthStarted(false); setAuthWaiting(false); await onRefreshCodex(); + } else if (isCodexAuthCallbackMessage(e.data)) { + await submitAuthInput(e.data.input); } else if (e.data?.codex === "error") { showToast( `Codex auth failed: ${e.data.message || "unknown error"}`, @@ -133,19 +164,14 @@ const CodexOAuthSection = ({ codexStatus, onRefreshCodex }) => { }; window.addEventListener("message", onMessage); return () => window.removeEventListener("message", onMessage); - }, [onRefreshCodex]); + }, [onRefreshCodex, submitAuthInput]); const startAuth = () => { setAuthStarted(true); setAuthWaiting(true); - const popup = window.open( - "/auth/codex/start", - "codex-auth", - "popup=yes,width=640,height=780", - ); + const popup = openCodexAuthWindow(); if (!popup || popup.closed) { setAuthWaiting(false); - window.location.href = "/auth/codex/start"; return; } if (popupPollRef.current) clearInterval(popupPollRef.current); @@ -159,22 +185,7 @@ const CodexOAuthSection = ({ codexStatus, onRefreshCodex }) => { }; const completeAuth = async () => { - if (!manualInput.trim() || exchanging) return; - setExchanging(true); - try { - const result = await exchangeCodexOAuth(manualInput.trim()); - if (!result.ok) - throw new Error(result.error || "Codex OAuth exchange failed"); - setManualInput(""); - showToast("Codex connected", "success"); - setAuthStarted(false); - setAuthWaiting(false); - await onRefreshCodex(); - } catch (err) { - showToast(err.message || "Codex OAuth exchange failed", "error"); - } finally { - setExchanging(false); - } + await submitAuthInput(manualInput); }; const handleDisconnect = async () => { @@ -198,7 +209,23 @@ const CodexOAuthSection = ({ codexStatus, onRefreshCodex }) => { ? html`<${Badge} tone="success">Connected` : html`<${Badge} tone="warning">Not connected`} - ${codexStatus.connected + ${authStarted + ? html` +
+

+ ${authWaiting + ? "Complete login in the popup. AlphaClaw should finish automatically, but you can paste the redirect URL below if it doesn't." + : "Paste the redirect URL from your browser to finish connecting."} +

+ +
+ ` + : codexStatus.connected ? html`
` - : !authStarted + : html` + + `} + ${authStarted ? html` - - ` - : html` -
-

- ${authWaiting - ? "Complete login in the popup, then paste the redirect URL." - : "Paste the redirect URL from your browser to finish connecting."} -

- -
- `} - ${!codexStatus.connected && authStarted - ? html`

After login, copy the full redirect URL (starts with String(value?.key || value?.token || value?.access || "").trim(); +const kNoModelsFoundError = "No models found"; +const kModelSettingsLoadError = "Failed to load model settings"; export const useModels = (agentId) => { const isScoped = !!agentId; const normalizedAgentId = String(agentId || "").trim(); const useCache = !isScoped; const [catalog, setCatalog] = useState(() => (useCache && kModelsTabCache?.catalog) || []); + const [catalogStatus, setCatalogStatus] = useState( + () => + (useCache && kModelsTabCache?.catalogStatus) || { + source: "", + fetchedAt: null, + stale: false, + refreshing: false, + }, + ); const [primary, setPrimary] = useState(() => (useCache && kModelsTabCache?.primary) || ""); const [configuredModels, setConfiguredModels] = useState( () => (useCache && kModelsTabCache?.configuredModels) || {}, @@ -48,7 +67,7 @@ export const useModels = (agentId) => { const modelsConfigCacheKey = normalizedAgentId ? `/api/models/config?agentId=${encodeURIComponent(normalizedAgentId)}` : "/api/models/config"; - const catalogFetchState = useCachedFetch("/api/models", fetchModels, { + const catalogFetchState = useCachedFetch(kModelCatalogCacheKey, fetchModels, { maxAgeMs: 30000, }); const configFetchState = useCachedFetch( @@ -59,6 +78,41 @@ export const useModels = (agentId) => { const codexFetchState = useCachedFetch("/api/codex/status", fetchCodexStatus, { maxAgeMs: 15000, }); + const catalogPoll = usePolling(fetchModels, kModelCatalogPollIntervalMs, { + enabled: ready && isModelCatalogRefreshing(catalogStatus), + pauseWhenHidden: true, + cacheKey: kModelCatalogCacheKey, + }); + + const syncCatalogError = useCallback((catalogModels) => { + setError((current) => { + if (catalogModels.length > 0) { + return current === kNoModelsFoundError ? "" : current; + } + return current || kNoModelsFoundError; + }); + }, []); + + const applyCatalogResult = useCallback( + (catalogResult) => { + const catalogModels = getModelCatalogModels(catalogResult); + const nextCatalogStatus = { + source: String(catalogResult?.source || ""), + fetchedAt: Number(catalogResult?.fetchedAt || 0) || null, + stale: Boolean(catalogResult?.stale), + refreshing: Boolean(catalogResult?.refreshing), + }; + setCatalog(catalogModels); + setCatalogStatus(nextCatalogStatus); + updateCache({ + catalog: catalogModels, + catalogStatus: nextCatalogStatus, + }); + syncCatalogError(catalogModels); + return catalogModels; + }, + [syncCatalogError, updateCache], + ); const refresh = useCallback(async () => { if (!ready) setLoading(true); @@ -69,10 +123,7 @@ export const useModels = (agentId) => { configFetchState.refresh({ force: true }), codexFetchState.refresh({ force: true }), ]); - const catalogModels = Array.isArray(catalogResult.models) - ? catalogResult.models - : []; - setCatalog(catalogModels); + const catalogModels = applyCatalogResult(catalogResult); const p = configResult.primary || ""; const cm = configResult.configuredModels || {}; const ap = configResult.authProfiles || []; @@ -94,20 +145,31 @@ export const useModels = (agentId) => { authOrder: ao, codexStatus: codex || { connected: false }, }); - if (!catalogModels.length) setError("No models found"); } catch (err) { - setError("Failed to load model settings"); - showToast(`Failed to load model settings: ${err.message}`, "error"); + setError(kModelSettingsLoadError); + showToast(`${kModelSettingsLoadError}: ${err.message}`, "error"); } finally { setReady(true); setLoading(false); } - }, [catalogFetchState, codexFetchState, configFetchState, ready, updateCache, agentId, isScoped]); + }, [ + applyCatalogResult, + catalogFetchState, + codexFetchState, + configFetchState, + ready, + updateCache, + ]); useEffect(() => { refresh(); }, [agentId]); + useEffect(() => { + if (!catalogPoll.data) return; + applyCatalogResult(catalogPoll.data); + }, [applyCatalogResult, catalogPoll.data]); + const stableStringify = (obj) => JSON.stringify(Object.keys(obj).sort().reduce((acc, k) => { acc[k] = obj[k]; return acc; }, {})); @@ -261,6 +323,7 @@ export const useModels = (agentId) => { if (result.syncWarning) { showToast(`Saved, but git-sync failed: ${result.syncWarning}`, "warning"); } + invalidateCache(kModelCatalogCacheKey); await refresh(); } catch (err) { showToast(err.message || "Failed to save changes", "error"); @@ -274,6 +337,8 @@ export const useModels = (agentId) => { profileEdits, orderEdits, authProfiles, + isScoped, + agentId, refresh, ]); diff --git a/lib/public/js/components/models.js b/lib/public/js/components/models.js index 8d97213b..e0001c18 100644 --- a/lib/public/js/components/models.js +++ b/lib/public/js/components/models.js @@ -24,6 +24,10 @@ import { kProviderLabels, kProviderOrder, } from "../lib/model-config.js"; +import { + isCodexAuthCallbackMessage, + openCodexAuthWindow, +} from "../lib/codex-oauth-window.js"; const html = htm.bind(h); @@ -51,6 +55,7 @@ export const Models = () => { const [savedModel, setSavedModel] = useState(() => kModelsTabCache?.savedModel || ""); const [modelDirty, setModelDirty] = useState(false); const [savedAiValues, setSavedAiValues] = useState(() => kModelsTabCache?.savedAiValues || {}); + const codexExchangeInFlightRef = useRef(false); const codexPopupPollRef = useRef(null); const refresh = async () => { @@ -122,18 +127,43 @@ export const Models = () => { } }, []); + const submitCodexAuthInput = async (input) => { + const normalizedInput = String(input || "").trim(); + if (!normalizedInput || codexExchangeInFlightRef.current) return; + codexExchangeInFlightRef.current = true; + setCodexManualInput(normalizedInput); + setCodexExchanging(true); + try { + const result = await exchangeCodexOAuth(normalizedInput); + if (!result.ok) throw new Error(result.error || "Codex OAuth exchange failed"); + setCodexManualInput(""); + showToast("Codex connected", "success"); + setCodexAuthStarted(false); + setCodexAuthWaiting(false); + await refreshCodexConnection(); + } catch (err) { + setCodexAuthWaiting(false); + showToast(err.message || "Codex OAuth exchange failed", "error"); + } finally { + codexExchangeInFlightRef.current = false; + setCodexExchanging(false); + } + }; + useEffect(() => { const onMessage = async (e) => { if (e.data?.codex === "success") { showToast("Codex connected", "success"); await refreshCodexConnection(); + } else if (isCodexAuthCallbackMessage(e.data)) { + await submitCodexAuthInput(e.data.input); } else if (e.data?.codex === "error") { showToast(`Codex auth failed: ${e.data.message || "unknown error"}`, "error"); } }; window.addEventListener("message", onMessage); return () => window.removeEventListener("message", onMessage); - }, []); + }, [submitCodexAuthInput]); const setEnvValue = (key, value) => { setEnvVars((prev) => { @@ -194,10 +224,9 @@ export const Models = () => { if (codexStatus.connected) return; setCodexAuthStarted(true); setCodexAuthWaiting(true); - const popup = window.open("/auth/codex/start", "codex-auth", "popup=yes,width=640,height=780"); + const popup = openCodexAuthWindow(); if (!popup || popup.closed) { setCodexAuthWaiting(false); - window.location.href = "/auth/codex/start"; return; } if (codexPopupPollRef.current) { @@ -213,21 +242,7 @@ export const Models = () => { }; const completeCodexAuth = async () => { - if (!codexManualInput.trim() || codexExchanging) return; - setCodexExchanging(true); - try { - const result = await exchangeCodexOAuth(codexManualInput.trim()); - if (!result.ok) throw new Error(result.error || "Codex OAuth exchange failed"); - setCodexManualInput(""); - showToast("Codex connected", "success"); - setCodexAuthStarted(false); - setCodexAuthWaiting(false); - await refreshCodexConnection(); - } catch (err) { - showToast(err.message || "Codex OAuth exchange failed", "error"); - } finally { - setCodexExchanging(false); - } + await submitCodexAuthInput(codexManualInput); }; const handleCodexDisconnect = async () => { @@ -301,7 +316,23 @@ export const Models = () => { ? html`<${Badge} tone="success">Connected` : html`<${Badge} tone="warning">Not connected`} - ${codexStatus.connected + ${codexAuthStarted + ? html` +

+

+ ${codexAuthWaiting + ? "Complete login in the popup. AlphaClaw should finish automatically, but you can paste the redirect URL below if it doesn't." + : "Paste the redirect URL from your browser to finish connecting."} +

+ +
+ ` + : codexStatus.connected ? html`
` - : !codexAuthStarted - ? html` + : html` - ` - : html` -
-

- ${codexAuthWaiting - ? "Complete login in the popup, then paste the redirect URL." - : "Paste the redirect URL from your browser to finish connecting."} -

- -
`} - ${!codexStatus.connected && codexAuthStarted + ${codexAuthStarted ? html`

After login, copy the full redirect URL (starts with diff --git a/lib/public/js/components/onboarding/use-welcome-codex.js b/lib/public/js/components/onboarding/use-welcome-codex.js index c7b7b7dd..17fba712 100644 --- a/lib/public/js/components/onboarding/use-welcome-codex.js +++ b/lib/public/js/components/onboarding/use-welcome-codex.js @@ -4,6 +4,10 @@ import { exchangeCodexOAuth, fetchCodexStatus, } from "../../lib/api.js"; +import { + isCodexAuthCallbackMessage, + openCodexAuthWindow, +} from "../../lib/codex-oauth-window.js"; export const useWelcomeCodex = ({ setFormError } = {}) => { const [codexStatus, setCodexStatus] = useState({ connected: false }); @@ -12,6 +16,7 @@ export const useWelcomeCodex = ({ setFormError } = {}) => { const [codexExchanging, setCodexExchanging] = useState(false); const [codexAuthStarted, setCodexAuthStarted] = useState(false); const [codexAuthWaiting, setCodexAuthWaiting] = useState(false); + const codexExchangeInFlightRef = useRef(false); const codexPopupPollRef = useRef(null); const refreshCodexStatus = async () => { @@ -33,10 +38,36 @@ export const useWelcomeCodex = ({ setFormError } = {}) => { refreshCodexStatus(); }, []); + const submitCodexAuthInput = async (input) => { + const normalizedInput = String(input || "").trim(); + if (!normalizedInput || codexExchangeInFlightRef.current) return; + codexExchangeInFlightRef.current = true; + setCodexManualInput(normalizedInput); + setCodexExchanging(true); + setFormError(null); + try { + const result = await exchangeCodexOAuth(normalizedInput); + if (!result.ok) + throw new Error(result.error || "Codex OAuth exchange failed"); + setCodexManualInput(""); + setCodexAuthStarted(false); + setCodexAuthWaiting(false); + await refreshCodexStatus(); + } catch (err) { + setCodexAuthWaiting(false); + setFormError(err.message || "Codex OAuth exchange failed"); + } finally { + codexExchangeInFlightRef.current = false; + setCodexExchanging(false); + } + }; + useEffect(() => { const onMessage = async (e) => { if (e.data?.codex === "success") { await refreshCodexStatus(); + } else if (isCodexAuthCallbackMessage(e.data)) { + await submitCodexAuthInput(e.data.input); } if (e.data?.codex === "error") { setFormError(`Codex auth failed: ${e.data.message || "unknown error"}`); @@ -44,7 +75,7 @@ export const useWelcomeCodex = ({ setFormError } = {}) => { }; window.addEventListener("message", onMessage); return () => window.removeEventListener("message", onMessage); - }, [setFormError]); + }, [setFormError, submitCodexAuthInput]); useEffect( () => () => { @@ -60,15 +91,9 @@ export const useWelcomeCodex = ({ setFormError } = {}) => { if (codexStatus.connected) return; setCodexAuthStarted(true); setCodexAuthWaiting(true); - const authUrl = "/auth/codex/start"; - const popup = window.open( - authUrl, - "codex-auth", - "popup=yes,width=640,height=780", - ); + const popup = openCodexAuthWindow(); if (!popup || popup.closed) { setCodexAuthWaiting(false); - window.location.href = authUrl; return; } if (codexPopupPollRef.current) { @@ -84,22 +109,7 @@ export const useWelcomeCodex = ({ setFormError } = {}) => { }; const completeCodexAuth = async () => { - if (!codexManualInput.trim() || codexExchanging) return; - setCodexExchanging(true); - setFormError(null); - try { - const result = await exchangeCodexOAuth(codexManualInput.trim()); - if (!result.ok) - throw new Error(result.error || "Codex OAuth exchange failed"); - setCodexManualInput(""); - setCodexAuthStarted(false); - setCodexAuthWaiting(false); - await refreshCodexStatus(); - } catch (err) { - setFormError(err.message || "Codex OAuth exchange failed"); - } finally { - setCodexExchanging(false); - } + await submitCodexAuthInput(codexManualInput); }; const handleCodexDisconnect = async () => { diff --git a/lib/public/js/components/onboarding/welcome-config.js b/lib/public/js/components/onboarding/welcome-config.js index 59ce6b10..5b2236cb 100644 --- a/lib/public/js/components/onboarding/welcome-config.js +++ b/lib/public/js/components/onboarding/welcome-config.js @@ -11,6 +11,8 @@ export const kGithubFlowImport = "import"; export const kGithubTargetRepoModeCreate = "create"; export const kGithubTargetRepoModeExistingEmpty = "existing-empty"; +const hasValue = (value) => !!String(value || "").trim(); + export const normalizeGithubRepoInput = (repoInput) => String(repoInput || "") .trim() @@ -25,6 +27,74 @@ export const isValidGithubRepoInput = (repoInput) => { return parts.length === 2 && !parts.some((part) => /\s/.test(part)); }; +const getGithubGroupError = (vals) => { + const githubFlow = vals._GITHUB_FLOW || kGithubFlowFresh; + if (!hasValue(vals.GITHUB_TOKEN)) { + return "Enter a GitHub personal access token to continue."; + } + if (!hasValue(vals.GITHUB_WORKSPACE_REPO)) { + return 'Enter the target repo as "owner/repo".'; + } + if (!isValidGithubRepoInput(vals.GITHUB_WORKSPACE_REPO)) { + return 'Target repo must be in "owner/repo" format.'; + } + if (githubFlow === kGithubFlowImport) { + if (!hasValue(vals._GITHUB_SOURCE_REPO)) { + return 'Enter the source repo as "owner/repo".'; + } + if (!isValidGithubRepoInput(vals._GITHUB_SOURCE_REPO)) { + return 'Source repo must be in "owner/repo" format.'; + } + } + return ""; +}; + +const getAiGroupError = (vals, ctx = {}) => { + if (!hasValue(vals.MODEL_KEY) || !String(vals.MODEL_KEY).includes("/")) { + return "Choose a model to continue."; + } + if (ctx.selectedProvider === "openai-codex" && ctx.codexLoading) { + return "Checking Codex OAuth status. Try Next again in a moment."; + } + if (!ctx.hasAi) { + return ctx.selectedProvider === "openai-codex" + ? "Connect Codex OAuth to continue." + : "Add credentials for the selected model provider to continue."; + } + return ""; +}; + +const getChannelsGroupError = (vals) => { + const hasTelegram = hasValue(vals.TELEGRAM_BOT_TOKEN); + const hasDiscord = hasValue(vals.DISCORD_BOT_TOKEN); + const hasSlackBot = hasValue(vals.SLACK_BOT_TOKEN); + const hasSlackApp = hasValue(vals.SLACK_APP_TOKEN); + + if (hasSlackBot && !hasSlackApp) { + return "Add the Slack app token to continue with Slack."; + } + if (!hasSlackBot && hasSlackApp) { + return "Add the Slack bot token to continue with Slack."; + } + if (!hasTelegram && !hasDiscord && !(hasSlackBot && hasSlackApp)) { + return "Add at least one channel to continue."; + } + return ""; +}; + +export const getWelcomeGroupError = (groupId, vals, ctx = {}) => { + switch (groupId) { + case "github": + return getGithubGroupError(vals); + case "ai": + return getAiGroupError(vals, ctx); + case "channels": + return getChannelsGroupError(vals); + default: + return ""; + } +}; + export const kWelcomeGroups = [ { id: "github", @@ -64,21 +134,14 @@ export const kWelcomeGroups = [ placeholder: "ghp_... or github_pat_...", }, ], - validate: (vals) => { - const githubFlow = vals._GITHUB_FLOW || kGithubFlowFresh; - const hasTarget = isValidGithubRepoInput(vals.GITHUB_WORKSPACE_REPO); - const hasSource = - githubFlow !== kGithubFlowImport || - isValidGithubRepoInput(vals._GITHUB_SOURCE_REPO); - return !!(vals.GITHUB_TOKEN && hasTarget && hasSource); - }, + validate: (vals, ctx = {}) => !getWelcomeGroupError("github", vals, ctx), }, { id: "ai", title: "Primary Agent Model", description: "Choose your main model and authenticate its provider", fields: kAllAiAuthFields, - validate: (vals, ctx = {}) => !!(vals.MODEL_KEY && ctx.hasAi), + validate: (vals, ctx = {}) => !getWelcomeGroupError("ai", vals, ctx), }, { id: "channels", @@ -152,7 +215,7 @@ export const kWelcomeGroups = [ placeholder: "xapp-...", }, ], - validate: (vals) => !!(vals.TELEGRAM_BOT_TOKEN || vals.DISCORD_BOT_TOKEN || (vals.SLACK_BOT_TOKEN && vals.SLACK_APP_TOKEN)), + validate: (vals, ctx = {}) => !getWelcomeGroupError("channels", vals, ctx), }, { id: "tools", @@ -175,3 +238,6 @@ export const kWelcomeGroups = [ validate: () => true, }, ]; + +export const findFirstInvalidWelcomeGroup = (vals, ctx = {}) => + kWelcomeGroups.find((group) => getWelcomeGroupError(group.id, vals, ctx)) || null; diff --git a/lib/public/js/components/onboarding/welcome-form-step.js b/lib/public/js/components/onboarding/welcome-form-step.js index 44e37b59..94e49e55 100644 --- a/lib/public/js/components/onboarding/welcome-form-step.js +++ b/lib/public/js/components/onboarding/welcome-form-step.js @@ -50,12 +50,10 @@ export const WelcomeFormStep = ({ error, step, totalGroups, - currentGroupValid, goBack, goNext, loading, githubStepLoading, - allValid, handleSubmit, }) => { const [showOptionalOpenai, setShowOptionalOpenai] = useState(false); @@ -294,13 +292,12 @@ export const WelcomeFormStep = ({ /> `} - ${!codexStatus.connected && - codexAuthStarted && + ${codexAuthStarted && html`

${codexAuthWaiting - ? "Complete login in the popup, then paste the full redirect URL from the address bar (starts with " + ? "Complete login in the popup. AlphaClaw should finish automatically, but if it doesn't, paste the full redirect URL from the address bar (starts with " : "Paste the full redirect URL from the address bar (starts with "} http://localhost:1455/auth/callback

`} <${ActionButton} onClick=${goNext} - disabled=${!currentGroupValid} loading=${activeGroup.id === "github" && githubStepLoading} tone="primary" size="md" @@ -466,7 +462,6 @@ export const WelcomeFormStep = ({ : html`
`} <${ActionButton} onClick=${handleSubmit} - disabled=${!allValid} loading=${loading} tone="primary" size="md" diff --git a/lib/public/js/components/onboarding/welcome-header.js b/lib/public/js/components/onboarding/welcome-header.js index b8794b06..6df96461 100644 --- a/lib/public/js/components/onboarding/welcome-header.js +++ b/lib/public/js/components/onboarding/welcome-header.js @@ -20,13 +20,11 @@ export const WelcomeHeader = ({ return html`
- alphaclaw +

Setup

Let's get your agent running @@ -34,7 +32,7 @@ export const WelcomeHeader = ({

${isPreStep ? "Choose your destiny" @@ -51,16 +49,16 @@ export const WelcomeHeader = ({ const isPairingComplete = idx < step || (isPairingStep && group.id === "pairing"); const bg = isPreStep - ? "rgba(82, 94, 122, 0.45)" + ? "var(--border-strong)" : isActive - ? "rgba(99, 235, 255, 0.9)" + ? "var(--accent)" : group.id === "pairing" ? isPairingComplete - ? "rgba(99, 235, 255, 0.55)" - : "rgba(82, 94, 122, 0.45)" + ? "var(--accent-dim)" + : "var(--border-strong)" : isComplete - ? "rgba(99, 235, 255, 0.55)" - : "rgba(82, 94, 122, 0.45)"; + ? "var(--accent-dim)" + : "var(--border-strong)"; return html`
{ if (error) { return html`
-

Setup failed

+

Setup failed

Fix the values and try again.

{
- <${LoadingSpinner} className="h-8 w-8 text-white" /> -

+ <${LoadingSpinner} className="h-8 w-8 text-body" /> +

Initializing OpenClaw...

This could take 10-15 seconds

diff --git a/lib/public/js/components/providers.js b/lib/public/js/components/providers.js index ce15ef8b..d5cc8300 100644 --- a/lib/public/js/components/providers.js +++ b/lib/public/js/components/providers.js @@ -27,6 +27,10 @@ import { kProviderFeatures, kCoreProviders, } from "../lib/model-config.js"; +import { + isCodexAuthCallbackMessage, + openCodexAuthWindow, +} from "../lib/codex-oauth-window.js"; const html = htm.bind(h); @@ -89,6 +93,7 @@ export const Providers = ({ onRestartRequired = () => {} }) => { () => kProvidersTabCache?.savedAiValues || {}, ); const [showMoreProviders, setShowMoreProviders] = useState(false); + const codexExchangeInFlightRef = useRef(false); const codexPopupPollRef = useRef(null); const refresh = async () => { @@ -171,11 +176,37 @@ export const Providers = ({ onRestartRequired = () => {} }) => { [], ); + const submitCodexAuthInput = async (input) => { + const normalizedInput = String(input || "").trim(); + if (!normalizedInput || codexExchangeInFlightRef.current) return; + codexExchangeInFlightRef.current = true; + setCodexManualInput(normalizedInput); + setCodexExchanging(true); + try { + const result = await exchangeCodexOAuth(normalizedInput); + if (!result.ok) + throw new Error(result.error || "Codex OAuth exchange failed"); + setCodexManualInput(""); + showToast("Codex connected", "success"); + setCodexAuthStarted(false); + setCodexAuthWaiting(false); + await refreshCodexConnection(); + } catch (err) { + setCodexAuthWaiting(false); + showToast(err.message || "Codex OAuth exchange failed", "error"); + } finally { + codexExchangeInFlightRef.current = false; + setCodexExchanging(false); + } + }; + useEffect(() => { const onMessage = async (e) => { if (e.data?.codex === "success") { showToast("Codex connected", "success"); await refreshCodexConnection(); + } else if (isCodexAuthCallbackMessage(e.data)) { + await submitCodexAuthInput(e.data.input); } else if (e.data?.codex === "error") { showToast( `Codex auth failed: ${e.data.message || "unknown error"}`, @@ -185,7 +216,7 @@ export const Providers = ({ onRestartRequired = () => {} }) => { }; window.addEventListener("message", onMessage); return () => window.removeEventListener("message", onMessage); - }, []); + }, [submitCodexAuthInput]); const setEnvValue = (key, value) => { setEnvVars((prev) => { @@ -296,14 +327,9 @@ export const Providers = ({ onRestartRequired = () => {} }) => { if (codexStatus.connected) return; setCodexAuthStarted(true); setCodexAuthWaiting(true); - const popup = window.open( - "/auth/codex/start", - "codex-auth", - "popup=yes,width=640,height=780", - ); + const popup = openCodexAuthWindow(); if (!popup || popup.closed) { setCodexAuthWaiting(false); - window.location.href = "/auth/codex/start"; return; } if (codexPopupPollRef.current) { @@ -319,22 +345,7 @@ export const Providers = ({ onRestartRequired = () => {} }) => { }; const completeCodexAuth = async () => { - if (!codexManualInput.trim() || codexExchanging) return; - setCodexExchanging(true); - try { - const result = await exchangeCodexOAuth(codexManualInput.trim()); - if (!result.ok) - throw new Error(result.error || "Codex OAuth exchange failed"); - setCodexManualInput(""); - showToast("Codex connected", "success"); - setCodexAuthStarted(false); - setCodexAuthWaiting(false); - await refreshCodexConnection(); - } catch (err) { - showToast(err.message || "Codex OAuth exchange failed", "error"); - } finally { - setCodexExchanging(false); - } + await submitCodexAuthInput(codexManualInput); }; const handleCodexDisconnect = async () => { @@ -385,7 +396,23 @@ export const Providers = ({ onRestartRequired = () => {} }) => { ? html`<${Badge} tone="success">Connected` : html`<${Badge} tone="warning">Not connected`}
- ${codexStatus.connected + ${codexAuthStarted + ? html` +
+

+ ${codexAuthWaiting + ? "Complete login in the popup. AlphaClaw should finish automatically, but you can paste the redirect URL below if it doesn't." + : "Paste the redirect URL from your browser to finish connecting."} +

+ +
+ ` + : codexStatus.connected ? html`
` - : !codexAuthStarted - ? html` + : html` - ` - : html` -
-

- ${codexAuthWaiting - ? "Complete login in the popup, then paste the redirect URL." - : "Paste the redirect URL from your browser to finish connecting."} -

- -
`} - ${!codexStatus.connected && codexAuthStarted + ${codexAuthStarted ? html`

After login, copy the full redirect URL (starts with diff --git a/lib/public/js/components/sidebar.js b/lib/public/js/components/sidebar.js index d95bf824..fc4b6ebd 100644 --- a/lib/public/js/components/sidebar.js +++ b/lib/public/js/components/sidebar.js @@ -33,6 +33,7 @@ import { getSessionDisplayLabel, getSessionRowKey, } from "../lib/session-keys.js"; +import { ThemeToggle } from "./theme-toggle.js"; const html = htm.bind(h); const kBrowseBottomPanelUiSettingKey = "browseBottomPanelHeightPx"; @@ -246,8 +247,14 @@ export const AppSidebar = ({ return html`

+ `; +}; diff --git a/lib/public/js/components/welcome/index.js b/lib/public/js/components/welcome/index.js index 8121f65d..026f4348 100644 --- a/lib/public/js/components/welcome/index.js +++ b/lib/public/js/components/welcome/index.js @@ -102,12 +102,10 @@ export const Welcome = ({ onComplete, acVersion }) => { error=${state.formError} step=${state.step} totalGroups=${kWelcomeGroups.length} - currentGroupValid=${state.currentGroupValid} goBack=${actions.goBack} goNext=${actions.goNext} loading=${state.loading} githubStepLoading=${state.githubStepLoading} - allValid=${state.allValid} handleSubmit=${actions.handleSubmit} /> `} diff --git a/lib/public/js/components/welcome/use-welcome.js b/lib/public/js/components/welcome/use-welcome.js index bc6c724d..d071325b 100644 --- a/lib/public/js/components/welcome/use-welcome.js +++ b/lib/public/js/components/welcome/use-welcome.js @@ -6,6 +6,7 @@ import { applyImport, fetchModels, } from "../../lib/api.js"; +import { useCachedFetch } from "../../hooks/use-cached-fetch.js"; import { getModelProvider, getAuthProviderFromModelProvider, @@ -13,8 +14,15 @@ import { getVisibleAiFieldKeys, kProviderAuthFields, } from "../../lib/model-config.js"; +import { + getInitialOnboardingModelKey, + getModelCatalogModels, + kModelCatalogCacheKey, +} from "../../lib/model-catalog.js"; import { kWelcomeGroups, + getWelcomeGroupError, + findFirstInvalidWelcomeGroup, isValidGithubRepoInput, kGithubFlowFresh, kGithubFlowImport, @@ -76,11 +84,18 @@ const normalizePlaceholderReview = (review) => { export const useWelcome = ({ onComplete }) => { const kSetupStepIndex = kWelcomeGroups.length; const kPairingStepIndex = kSetupStepIndex + 1; - const { vals, setVals, setValue, step, setStep, setupError, setSetupError } = - useWelcomeStorage({ - kSetupStepIndex, - kPairingStepIndex, - }); + const { + vals, + setVals, + setValue: setStoredValue, + step, + setStep, + setupError, + setSetupError, + } = useWelcomeStorage({ + kSetupStepIndex, + kPairingStepIndex, + }); const [models, setModels] = useState([]); const [modelsLoading, setModelsLoading] = useState(true); const [modelsError, setModelsError] = useState(null); @@ -110,6 +125,14 @@ export const useWelcome = ({ onComplete }) => { const [importScanResult, setImportScanResult] = useState(null); const [importScanning, setImportScanning] = useState(false); const [importError, setImportError] = useState(null); + const modelsFetchState = useCachedFetch(kModelCatalogCacheKey, fetchModels, { + maxAgeMs: 30000, + }); + + const setValue = (key, value) => { + if (formError) setFormError(null); + setStoredValue(key, value); + }; const setImportStep = (nextStep) => { setImportStepState(nextStep); @@ -129,21 +152,54 @@ export const useWelcome = ({ onComplete }) => { }; useEffect(() => { - fetchModels() - .then((result) => { - const list = Array.isArray(result.models) ? result.models : []; - const featured = getFeaturedModels(list); - setModels(list); - if (!vals.MODEL_KEY && list.length > 0) { - const defaultModel = featured[0] || list[0]; - setVals((prev) => ({ ...prev, MODEL_KEY: defaultModel.key })); - } - }) - .catch(() => setModelsError("Failed to load models")) - .finally(() => setModelsLoading(false)); - }, []); + const list = getModelCatalogModels(modelsFetchState.data); + if (!modelsFetchState.data) return; + setModels(list); + setModelsError(list.length > 0 ? null : "No models found"); + const defaultModelKey = getInitialOnboardingModelKey({ + catalog: list, + currentModelKey: vals.MODEL_KEY, + }); + if (!vals.MODEL_KEY && defaultModelKey) { + setVals((prev) => ({ ...prev, MODEL_KEY: defaultModelKey })); + } + }, [modelsFetchState.data, setVals, vals.MODEL_KEY]); + + useEffect(() => { + const hasModels = getModelCatalogModels(modelsFetchState.data).length > 0; + setModelsLoading(modelsFetchState.loading && !hasModels); + }, [modelsFetchState.data, modelsFetchState.loading]); + + useEffect(() => { + if (!modelsFetchState.error) return; + setModelsError("Failed to load models"); + setModelsLoading(false); + }, [modelsFetchState.error]); + + const getValidationContext = (currentVals = {}) => { + const currentSelectedProvider = getModelProvider( + String(currentVals.MODEL_KEY || "").trim(), + ); + const currentSelectedAuthProvider = + getAuthProviderFromModelProvider(currentSelectedProvider); + const currentProviderAuthFields = + kProviderAuthFields[currentSelectedAuthProvider] || []; + const currentHasAi = + currentSelectedProvider === "openai-codex" + ? !!codexStatus.connected + : currentProviderAuthFields.some((field) => + !!String(currentVals[field.key] || "").trim(), + ); - const selectedProvider = getModelProvider(vals.MODEL_KEY); + return { + hasAi: currentHasAi, + selectedProvider: currentSelectedProvider, + codexLoading, + }; + }; + + const validationContext = getValidationContext(vals); + const { selectedProvider, hasAi } = validationContext; const placeholderReview = normalizePlaceholderReview( vals[kImportPlaceholderReviewKey], ); @@ -164,23 +220,10 @@ export const useWelcome = ({ onComplete }) => { const canToggleFullCatalog = featuredModels.length > 0 && models.length > featuredModels.length; const visibleAiFieldKeys = getVisibleAiFieldKeys(selectedProvider); - const selectedAuthProvider = getAuthProviderFromModelProvider(selectedProvider); - const selectedProviderAuthFields = kProviderAuthFields[selectedAuthProvider] || []; - const hasAi = - selectedProvider === "openai-codex" - ? !!codexStatus.connected - : selectedProviderAuthFields.some( - (field) => !!String(vals[field.key] || "").trim(), - ); - - const allValid = kWelcomeGroups.every((group) => group.validate(vals, { hasAi })); const isPreStep = step === -1; const isSetupStep = step === kSetupStepIndex; const isPairingStep = step === kPairingStepIndex; const activeGroup = step >= 0 && step < kSetupStepIndex ? kWelcomeGroups[step] : null; - const currentGroupValid = activeGroup - ? activeGroup.validate(vals, { hasAi }) - : false; const selectedPairingChannel = String( vals[kPairingChannelKey] || getPreferredPairingChannel(vals), ); @@ -202,7 +245,21 @@ export const useWelcome = ({ onComplete }) => { const handleSubmit = async () => { const { normalizedVals, didChange } = normalizeOnboardingVals(vals); if (didChange) setVals(normalizedVals); - if (!kWelcomeGroups.every((group) => group.validate(normalizedVals, { hasAi }))) { + const submitValidationContext = getValidationContext(normalizedVals); + const invalidGroup = findFirstInvalidWelcomeGroup( + normalizedVals, + submitValidationContext, + ); + if (invalidGroup) { + setFormError( + getWelcomeGroupError( + invalidGroup.id, + normalizedVals, + submitValidationContext, + ), + ); + setSetupError(null); + setStep(kWelcomeGroups.findIndex((group) => group.id === invalidGroup.id)); return; } if (loading) return; @@ -309,7 +366,17 @@ export const useWelcome = ({ onComplete }) => { const goNext = async () => { const { normalizedVals, didChange } = normalizeOnboardingVals(vals); if (didChange) setVals(normalizedVals); - if (!activeGroup || !activeGroup.validate(normalizedVals, { hasAi })) return; + if (!activeGroup) return; + const stepValidationContext = getValidationContext(normalizedVals); + const stepValidationError = getWelcomeGroupError( + activeGroup.id, + normalizedVals, + stepValidationContext, + ); + if (stepValidationError) { + setFormError(stepValidationError); + return; + } setFormError(null); if (activeGroup.id === "github") { const githubFlow = normalizedVals._GITHUB_FLOW || kGithubFlowFresh; @@ -545,12 +612,10 @@ export const useWelcome = ({ onComplete }) => { canToggleFullCatalog, visibleAiFieldKeys, hasAi, - allValid, isPreStep, isSetupStep, isPairingStep, activeGroup, - currentGroupValid, selectedPairingChannel, placeholderReview, isImportStep, diff --git a/lib/public/js/lib/codex-oauth-window.js b/lib/public/js/lib/codex-oauth-window.js new file mode 100644 index 00000000..508b58a5 --- /dev/null +++ b/lib/public/js/lib/codex-oauth-window.js @@ -0,0 +1,22 @@ +const kCodexAuthStartPath = "/auth/codex/start"; +const kCodexAuthWindowName = "codex-auth"; +const kCodexAuthPopupFeatures = "popup=yes,width=640,height=780"; +const kCodexAuthCallbackMessageType = "callback-input"; + +export const openCodexAuthWindow = () => { + const popup = window.open( + kCodexAuthStartPath, + kCodexAuthWindowName, + kCodexAuthPopupFeatures, + ); + if (!popup || popup.closed) { + window.location.href = kCodexAuthStartPath; + return null; + } + return popup; +}; + +export const isCodexAuthCallbackMessage = (value) => + value?.codex === kCodexAuthCallbackMessageType && + typeof value.input === "string" && + value.input.trim().length > 0; diff --git a/lib/public/js/lib/model-catalog.js b/lib/public/js/lib/model-catalog.js new file mode 100644 index 00000000..45e9bc83 --- /dev/null +++ b/lib/public/js/lib/model-catalog.js @@ -0,0 +1,20 @@ +import { getFeaturedModels } from "./model-config.js"; + +export const kModelCatalogCacheKey = "/api/models"; +export const kModelCatalogPollIntervalMs = 3000; + +export const getModelCatalogModels = (payload) => + Array.isArray(payload?.models) ? payload.models : []; + +export const isModelCatalogRefreshing = (payload) => + Boolean(payload?.refreshing); + +export const getInitialOnboardingModelKey = ({ + catalog = [], + currentModelKey = "", +} = {}) => { + const normalizedCurrent = String(currentModelKey || "").trim(); + if (normalizedCurrent) return normalizedCurrent; + const featuredModels = getFeaturedModels(catalog); + return String(featuredModels[0]?.key || catalog[0]?.key || ""); +}; diff --git a/lib/public/js/lib/storage-keys.js b/lib/public/js/lib/storage-keys.js index 2f0114cd..b88431fa 100644 --- a/lib/public/js/lib/storage-keys.js +++ b/lib/public/js/lib/storage-keys.js @@ -6,6 +6,7 @@ // --- UI settings (single JSON blob containing sub-keys) --- export const kUiSettingsStorageKey = "alphaclaw.ui.settings"; +export const kThemeStorageKey = "alphaclaw.ui.theme"; // --- Browse / file viewer --- export const kFileViewerModeStorageKey = "alphaclaw.browse.viewerMode"; @@ -30,4 +31,3 @@ export const kAgentLastSessionKey = "alphaclaw.agent.lastSessionKey"; // --- Chat --- export const kChatSessionDraftsStorageKey = "alphaclaw.chat.sessionDrafts"; - diff --git a/lib/public/login.html b/lib/public/login.html index ca71a8cc..42e93488 100644 --- a/lib/public/login.html +++ b/lib/public/login.html @@ -11,6 +11,14 @@ +
@@ -53,10 +61,6 @@

diff --git a/lib/server/db/webhooks/index.js b/lib/server/db/webhooks/index.js index b79d5d07..c7c99f2d 100644 --- a/lib/server/db/webhooks/index.js +++ b/lib/server/db/webhooks/index.js @@ -10,6 +10,7 @@ let pruneTimer = null; const kDefaultRequestLimit = 50; const kMaxRequestLimit = 200; const kPruneIntervalMs = 12 * 60 * 60 * 1000; +const kHealthSummaryWindow = 25; const ensureDb = () => { if (!db) throw new Error("Webhooks DB not initialized"); @@ -202,22 +203,61 @@ const getHookSummaries = () => { const database = ensureDb(); const rows = database .prepare(` + WITH ranked_requests AS ( + SELECT + hook_name, + created_at, + gateway_status, + ROW_NUMBER() OVER ( + PARTITION BY hook_name + ORDER BY created_at DESC, id DESC + ) AS row_num + FROM webhook_requests + ), + overall_counts AS ( + SELECT + hook_name, + MAX(created_at) AS last_received, + COUNT(*) AS total_count, + SUM(CASE WHEN gateway_status >= 200 AND gateway_status < 300 THEN 1 ELSE 0 END) AS success_count, + SUM(CASE WHEN gateway_status IS NULL OR gateway_status < 200 OR gateway_status >= 300 THEN 1 ELSE 0 END) AS error_count + FROM webhook_requests + GROUP BY hook_name + ), + recent_counts AS ( + SELECT + hook_name, + COUNT(*) AS recent_total_count, + SUM(CASE WHEN gateway_status >= 200 AND gateway_status < 300 THEN 1 ELSE 0 END) AS recent_success_count, + SUM(CASE WHEN gateway_status IS NULL OR gateway_status < 200 OR gateway_status >= 300 THEN 1 ELSE 0 END) AS recent_error_count + FROM ranked_requests + WHERE row_num <= $health_window + GROUP BY hook_name + ) SELECT - hook_name, - MAX(created_at) AS last_received, - COUNT(*) AS total_count, - SUM(CASE WHEN gateway_status >= 200 AND gateway_status < 300 THEN 1 ELSE 0 END) AS success_count, - SUM(CASE WHEN gateway_status IS NULL OR gateway_status < 200 OR gateway_status >= 300 THEN 1 ELSE 0 END) AS error_count - FROM webhook_requests - GROUP BY hook_name + overall_counts.hook_name, + overall_counts.last_received, + overall_counts.total_count, + overall_counts.success_count, + overall_counts.error_count, + COALESCE(recent_counts.recent_total_count, 0) AS recent_total_count, + COALESCE(recent_counts.recent_success_count, 0) AS recent_success_count, + COALESCE(recent_counts.recent_error_count, 0) AS recent_error_count + FROM overall_counts + LEFT JOIN recent_counts + ON recent_counts.hook_name = overall_counts.hook_name `) - .all(); + .all({ $health_window: kHealthSummaryWindow }); return rows.map((row) => ({ hookName: row.hook_name, lastReceived: row.last_received || null, totalCount: Number(row.total_count || 0), successCount: Number(row.success_count || 0), errorCount: Number(row.error_count || 0), + recentTotalCount: Number(row.recent_total_count || 0), + recentSuccessCount: Number(row.recent_success_count || 0), + recentErrorCount: Number(row.recent_error_count || 0), + healthWindowSize: kHealthSummaryWindow, })); }; diff --git a/lib/server/model-catalog-cache.js b/lib/server/model-catalog-cache.js new file mode 100644 index 00000000..6f4f45d9 --- /dev/null +++ b/lib/server/model-catalog-cache.js @@ -0,0 +1,251 @@ +const fs = require("fs"); +const path = require("path"); +const { ALPHACLAW_DIR, kFallbackOnboardingModels } = require("./constants"); + +const kModelCatalogCacheVersion = 1; +const kModelCatalogRefreshBackoffMs = 30 * 1000; +const kDefaultCachePath = path.join(ALPHACLAW_DIR, "cache", "model-catalog.json"); + +const createResponse = ({ + source = "fallback", + fetchedAt = null, + stale = false, + refreshing = false, + models = [], +} = {}) => ({ + ok: true, + source, + fetchedAt, + stale, + refreshing, + models, +}); + +const normalizeCachedModels = ({ + models, + normalizeOnboardingModels = (items) => items, +} = {}) => + normalizeOnboardingModels( + (Array.isArray(models) ? models : []).map((model) => ({ + key: model?.key, + name: model?.label || model?.name || model?.key, + })), + ); + +const normalizeCacheEntry = ({ + raw, + normalizeOnboardingModels = (items) => items, +} = {}) => { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; + const fetchedAt = Number(raw.fetchedAt || 0); + const models = normalizeCachedModels({ + models: raw.models, + normalizeOnboardingModels, + }); + if (!Number.isFinite(fetchedAt) || fetchedAt <= 0 || models.length === 0) { + return null; + } + return { + version: kModelCatalogCacheVersion, + fetchedAt, + models, + }; +}; + +const createModelCatalogCache = ({ + fsModule = fs, + pathModule = path, + shellCmd, + gatewayEnv = () => ({}), + parseJsonFromNoisyOutput = () => ({}), + normalizeOnboardingModels = (items) => items, + fallbackModels = kFallbackOnboardingModels, + cachePath = kDefaultCachePath, + refreshBackoffMs = kModelCatalogRefreshBackoffMs, + now = () => Date.now(), + setTimeoutFn = setTimeout, + clearTimeoutFn = clearTimeout, + logger = console, +} = {}) => { + let cacheLoaded = false; + let memoryCache = null; + let cacheIsStale = false; + let refreshPromise = null; + let retryTimer = null; + let backoffUntilMs = 0; + + const clearRetryTimer = () => { + if (!retryTimer) return; + clearTimeoutFn(retryTimer); + retryTimer = null; + }; + + const isRefreshPending = () => !!refreshPromise || !!retryTimer; + + const setCacheEntry = (entry, { fresh = false } = {}) => { + memoryCache = entry; + cacheLoaded = true; + cacheIsStale = !fresh; + backoffUntilMs = 0; + clearRetryTimer(); + return memoryCache; + }; + + const readDiskCache = () => { + if (cacheLoaded) return memoryCache; + cacheLoaded = true; + try { + const raw = JSON.parse(fsModule.readFileSync(cachePath, "utf8")); + const entry = normalizeCacheEntry({ + raw, + normalizeOnboardingModels, + }); + if (!entry) return null; + memoryCache = entry; + cacheIsStale = true; + return memoryCache; + } catch { + memoryCache = null; + cacheIsStale = false; + return null; + } + }; + + const writeDiskCache = (entry) => { + fsModule.mkdirSync(pathModule.dirname(cachePath), { recursive: true }); + fsModule.writeFileSync( + cachePath, + `${JSON.stringify(entry, null, 2)}\n`, + "utf8", + ); + }; + + const loadFreshCatalog = async () => { + const output = await shellCmd("openclaw models list --all --json", { + env: gatewayEnv(), + timeout: 20000, + }); + const parsed = parseJsonFromNoisyOutput(output); + const models = normalizeOnboardingModels(parsed?.models || []); + if (models.length === 0) { + throw new Error("No models found"); + } + const entry = { + version: kModelCatalogCacheVersion, + fetchedAt: now(), + models, + }; + writeDiskCache(entry); + setCacheEntry(entry, { fresh: true }); + return entry; + }; + + const scheduleRetry = () => { + if (!memoryCache || retryTimer) return; + const delayMs = Math.max(backoffUntilMs - now(), 0); + retryTimer = setTimeoutFn(() => { + retryTimer = null; + if (!memoryCache || !cacheIsStale || refreshPromise) return; + void startBackgroundRefresh(); + }, delayMs); + if (typeof retryTimer?.unref === "function") retryTimer.unref(); + }; + + const handleRefreshFailure = (err) => { + if (memoryCache) { + cacheIsStale = true; + backoffUntilMs = now() + refreshBackoffMs; + scheduleRetry(); + logger.error?.( + `[models] Failed to refresh cached models: ${err.message || String(err)}`, + ); + return; + } + logger.error?.( + `[models] Failed to load dynamic models: ${err.message || String(err)}`, + ); + }; + + const startBackgroundRefresh = () => { + readDiskCache(); + if (!memoryCache) return null; + if (refreshPromise) return refreshPromise; + if (retryTimer) return null; + if (backoffUntilMs > now()) { + scheduleRetry(); + return null; + } + refreshPromise = Promise.resolve() + .then(() => loadFreshCatalog()) + .catch((err) => { + handleRefreshFailure(err); + return null; + }) + .finally(() => { + refreshPromise = null; + }); + return refreshPromise; + }; + + return { + async getCatalogResponse() { + readDiskCache(); + if (memoryCache && !cacheIsStale) { + return createResponse({ + source: "openclaw", + fetchedAt: memoryCache.fetchedAt, + stale: false, + refreshing: false, + models: memoryCache.models, + }); + } + if (memoryCache) { + startBackgroundRefresh(); + return createResponse({ + source: "cache", + fetchedAt: memoryCache.fetchedAt, + stale: true, + refreshing: isRefreshPending(), + models: memoryCache.models, + }); + } + try { + const freshEntry = await loadFreshCatalog(); + return createResponse({ + source: "openclaw", + fetchedAt: freshEntry.fetchedAt, + stale: false, + refreshing: false, + models: freshEntry.models, + }); + } catch (err) { + handleRefreshFailure(err); + return createResponse({ + source: "fallback", + fetchedAt: null, + stale: false, + refreshing: false, + models: fallbackModels, + }); + } + }, + + markStale() { + readDiskCache(); + if (!memoryCache) return; + cacheIsStale = true; + backoffUntilMs = 0; + clearRetryTimer(); + }, + }; +}; + +module.exports = { + createModelCatalogCache, + createResponse, + normalizeCachedModels, + normalizeCacheEntry, + kModelCatalogCacheVersion, + kModelCatalogRefreshBackoffMs, + kDefaultCachePath, +}; diff --git a/lib/server/routes/models.js b/lib/server/routes/models.js index 96b9d611..1cb69abf 100644 --- a/lib/server/routes/models.js +++ b/lib/server/routes/models.js @@ -1,4 +1,5 @@ const { kFallbackOnboardingModels } = require("../constants"); +const { createModelCatalogCache } = require("../model-catalog-cache"); const runModelsGitSync = async (shellCmd) => { if (typeof shellCmd !== "function") return null; @@ -22,6 +23,13 @@ const registerModelRoutes = ({ readEnvFile, writeEnvFile, reloadEnv, + modelCatalogCache = createModelCatalogCache({ + shellCmd, + gatewayEnv, + parseJsonFromNoisyOutput, + normalizeOnboardingModels, + fallbackModels: kFallbackOnboardingModels, + }), }) => { const upsertEnvVar = (items, key, value) => { const next = Array.isArray(items) ? [...items] : []; @@ -154,29 +162,8 @@ const registerModelRoutes = ({ // ── Existing CLI-backed catalog/status routes ── app.get("/api/models", async (req, res) => { - try { - const output = await shellCmd("openclaw models list --all --json", { - env: gatewayEnv(), - timeout: 20000, - }); - const parsed = parseJsonFromNoisyOutput(output); - const models = normalizeOnboardingModels(parsed?.models || []); - if (models.length > 0) { - return res.json({ ok: true, source: "openclaw", models }); - } - return res.json({ - ok: true, - source: "fallback", - models: kFallbackOnboardingModels, - }); - } catch (err) { - console.error("[models] Failed to load dynamic models:", err.message); - return res.json({ - ok: true, - source: "fallback", - models: kFallbackOnboardingModels, - }); - } + const response = await modelCatalogCache.getCatalogResponse(); + return res.json(response); }); app.get("/api/models/status", async (req, res) => { @@ -210,6 +197,7 @@ const registerModelRoutes = ({ env: gatewayEnv(), timeout: 30000, }); + modelCatalogCache.markStale(); res.json({ ok: true }); } catch (err) { res @@ -286,6 +274,7 @@ const registerModelRoutes = ({ authProfiles.syncConfigAuthReferencesForAgent(agentId); const syncWarning = await runModelsGitSync(shellCmd); + modelCatalogCache.markStale(); res.json({ ok: true, ...(syncWarning ? { syncWarning } : {}), @@ -338,6 +327,7 @@ const registerModelRoutes = ({ const agentId = req.query.agentId || undefined; authProfiles.upsertProfile(profileId, credential, agentId); syncEnvVarsForProfiles([{ id: profileId, ...credential }]); + modelCatalogCache.markStale(); res.json({ ok: true }); } catch (err) { res @@ -359,6 +349,7 @@ const registerModelRoutes = ({ try { const agentId = req.query.agentId || undefined; const removed = authProfiles.removeProfile(profileId, agentId); + modelCatalogCache.markStale(); res.json({ ok: true, removed }); } catch (err) { res diff --git a/lib/server/routes/webhooks.js b/lib/server/routes/webhooks.js index 4cf4a539..207b9361 100644 --- a/lib/server/routes/webhooks.js +++ b/lib/server/routes/webhooks.js @@ -29,13 +29,24 @@ const mergeWebhookAndSummary = ({ webhook, summary }) => { const totalCount = Number(summary?.totalCount || 0); const errorCount = Number(summary?.errorCount || 0); const successCount = Number(summary?.successCount || 0); + const recentTotalCount = Number(summary?.recentTotalCount || 0); + const recentErrorCount = Number(summary?.recentErrorCount || 0); + const recentSuccessCount = Number(summary?.recentSuccessCount || 0); + const healthWindowSize = Number(summary?.healthWindowSize || 0); return { ...webhook, lastReceived: summary?.lastReceived || null, totalCount, successCount, errorCount, - health: buildHealth({ totalCount, errorCount }), + recentTotalCount, + recentSuccessCount, + recentErrorCount, + healthWindowSize, + health: buildHealth({ + totalCount: recentTotalCount || totalCount, + errorCount: recentTotalCount > 0 ? recentErrorCount : errorCount, + }), }; }; diff --git a/tests/frontend/codex-oauth-window.test.js b/tests/frontend/codex-oauth-window.test.js new file mode 100644 index 00000000..6c355809 --- /dev/null +++ b/tests/frontend/codex-oauth-window.test.js @@ -0,0 +1,54 @@ +const loadCodexOauthWindow = async () => + import("../../lib/public/js/lib/codex-oauth-window.js"); + +describe("frontend/codex-oauth-window", () => { + beforeEach(() => { + vi.resetModules(); + global.window = { + open: vi.fn(), + location: { href: "http://localhost/" }, + }; + }); + + it("uses popup features when opening Codex auth", async () => { + global.window.open.mockReturnValue({ closed: false }); + const mod = await loadCodexOauthWindow(); + + const opened = mod.openCodexAuthWindow(); + + expect(global.window.open).toHaveBeenCalledWith( + "/auth/codex/start", + "codex-auth", + "popup=yes,width=640,height=780", + ); + expect(opened).toBeTruthy(); + }); + + it("falls back to navigating the current page when opening fails", async () => { + global.window.open.mockReturnValue(null); + const mod = await loadCodexOauthWindow(); + + const opened = mod.openCodexAuthWindow(); + + expect(opened).toBeNull(); + expect(global.window.location.href).toBe("/auth/codex/start"); + }); + + it("detects automatic localhost callback messages", async () => { + const mod = await loadCodexOauthWindow(); + + expect( + mod.isCodexAuthCallbackMessage({ + codex: "callback-input", + input: "http://localhost:1455/auth/callback?code=abc&state=def", + }), + ).toBe(true); + expect(mod.isCodexAuthCallbackMessage({ codex: "success" })).toBe(false); + expect( + mod.isCodexAuthCallbackMessage({ + codex: "callback-input", + input: " ", + }), + ).toBe(false); + }); +}); diff --git a/tests/frontend/model-catalog.test.js b/tests/frontend/model-catalog.test.js new file mode 100644 index 00000000..26cf3f9b --- /dev/null +++ b/tests/frontend/model-catalog.test.js @@ -0,0 +1,51 @@ +describe("frontend/model-catalog", () => { + it("returns catalog models when the payload is valid", async () => { + const { getModelCatalogModels } = await import( + "../../lib/public/js/lib/model-catalog.js" + ); + + expect( + getModelCatalogModels({ + models: [{ key: "openai/gpt-5.4", label: "GPT-5.4" }], + }), + ).toEqual([{ key: "openai/gpt-5.4", label: "GPT-5.4" }]); + expect(getModelCatalogModels(null)).toEqual([]); + }); + + it("preserves an existing onboarding selection", async () => { + const { getInitialOnboardingModelKey } = await import( + "../../lib/public/js/lib/model-catalog.js" + ); + + expect( + getInitialOnboardingModelKey({ + catalog: [{ key: "openai-codex/gpt-5.4", label: "GPT-5.4" }], + currentModelKey: "anthropic/claude-opus-4-6", + }), + ).toBe("anthropic/claude-opus-4-6"); + }); + + it("picks the first featured onboarding model when nothing is selected", async () => { + const { getInitialOnboardingModelKey } = await import( + "../../lib/public/js/lib/model-catalog.js" + ); + + expect( + getInitialOnboardingModelKey({ + catalog: [ + { key: "openai-codex/gpt-5.4", label: "GPT-5.4" }, + { key: "anthropic/claude-opus-4-6", label: "Opus 4.6" }, + ], + }), + ).toBe("anthropic/claude-opus-4-6"); + }); + + it("reports whether the catalog is still refreshing", async () => { + const { isModelCatalogRefreshing } = await import( + "../../lib/public/js/lib/model-catalog.js" + ); + + expect(isModelCatalogRefreshing({ refreshing: true })).toBe(true); + expect(isModelCatalogRefreshing({ refreshing: false })).toBe(false); + }); +}); diff --git a/tests/frontend/welcome-config.test.js b/tests/frontend/welcome-config.test.js new file mode 100644 index 00000000..8ce7b002 --- /dev/null +++ b/tests/frontend/welcome-config.test.js @@ -0,0 +1,74 @@ +const loadWelcomeConfig = async () => + import("../../lib/public/js/components/onboarding/welcome-config.js"); + +describe("frontend/welcome-config", () => { + it("reports a target repo format error for invalid GitHub input", async () => { + const welcomeConfig = await loadWelcomeConfig(); + + expect( + welcomeConfig.getWelcomeGroupError("github", { + GITHUB_TOKEN: "ghp_123", + GITHUB_WORKSPACE_REPO: "owner-only", + }), + ).toBe('Target repo must be in "owner/repo" format.'); + }); + + it("requires a source repo when import mode is selected", async () => { + const welcomeConfig = await loadWelcomeConfig(); + + expect( + welcomeConfig.getWelcomeGroupError("github", { + _GITHUB_FLOW: welcomeConfig.kGithubFlowImport, + GITHUB_TOKEN: "ghp_123", + GITHUB_WORKSPACE_REPO: "owner/target-repo", + _GITHUB_SOURCE_REPO: "", + }), + ).toBe('Enter the source repo as "owner/repo".'); + }); + + it("returns a Codex-specific auth message for the AI step", async () => { + const welcomeConfig = await loadWelcomeConfig(); + + expect( + welcomeConfig.getWelcomeGroupError( + "ai", + { MODEL_KEY: "openai-codex/gpt-5.4" }, + { + selectedProvider: "openai-codex", + hasAi: false, + codexLoading: false, + }, + ), + ).toBe("Connect Codex OAuth to continue."); + }); + + it("requires both Slack tokens before the channels step can pass", async () => { + const welcomeConfig = await loadWelcomeConfig(); + + expect( + welcomeConfig.getWelcomeGroupError("channels", { + SLACK_BOT_TOKEN: "xoxb-123", + SLACK_APP_TOKEN: "", + }), + ).toBe("Add the Slack app token to continue with Slack."); + }); + + it("finds the first invalid step in welcome order", async () => { + const welcomeConfig = await loadWelcomeConfig(); + + const invalidGroup = welcomeConfig.findFirstInvalidWelcomeGroup( + { + GITHUB_TOKEN: "ghp_123", + GITHUB_WORKSPACE_REPO: "owner/target-repo", + MODEL_KEY: "openai-codex/gpt-5.4", + }, + { + selectedProvider: "openai-codex", + hasAi: false, + codexLoading: false, + }, + ); + + expect(invalidGroup?.id).toBe("ai"); + }); +}); diff --git a/tests/server/model-catalog-cache.test.js b/tests/server/model-catalog-cache.test.js new file mode 100644 index 00000000..d6003467 --- /dev/null +++ b/tests/server/model-catalog-cache.test.js @@ -0,0 +1,188 @@ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const { + createModelCatalogCache, + kModelCatalogRefreshBackoffMs, +} = require("../../lib/server/model-catalog-cache"); +const { kFallbackOnboardingModels } = require("../../lib/server/constants"); + +const flushPromises = async () => { + await Promise.resolve(); + await Promise.resolve(); +}; + +const normalizeModels = (models = []) => + (Array.isArray(models) ? models : []) + .filter((model) => model?.key) + .map((model) => ({ + key: model.key, + provider: String(model.key).split("/")[0] || "", + label: model.name || model.label || model.key, + })); + +const writeCacheFile = ({ cachePath, fetchedAt = 1000, models = [] }) => { + fs.mkdirSync(path.dirname(cachePath), { recursive: true }); + fs.writeFileSync( + cachePath, + `${JSON.stringify({ version: 1, fetchedAt, models }, null, 2)}\n`, + "utf8", + ); +}; + +describe("server/model-catalog-cache", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns cached models immediately and shares a single in-flight refresh", async () => { + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "alphaclaw-model-catalog-cache-"), + ); + const cachePath = path.join(tempRoot, "cache", "model-catalog.json"); + writeCacheFile({ + cachePath, + fetchedAt: 111, + models: normalizeModels([{ key: "openai/gpt-cached", label: "Cached" }]), + }); + + let resolveShell; + const shellCmd = vi.fn( + () => + new Promise((resolve) => { + resolveShell = resolve; + }), + ); + const parseJsonFromNoisyOutput = vi.fn(() => ({ + models: [{ key: "openai/gpt-fresh", name: "Fresh" }], + })); + const cache = createModelCatalogCache({ + cachePath, + shellCmd, + gatewayEnv: () => ({ OPENCLAW_GATEWAY_TOKEN: "token" }), + parseJsonFromNoisyOutput, + normalizeOnboardingModels: normalizeModels, + }); + + const first = await cache.getCatalogResponse(); + const second = await cache.getCatalogResponse(); + + expect(first).toEqual({ + ok: true, + source: "cache", + fetchedAt: 111, + stale: true, + refreshing: true, + models: normalizeModels([{ key: "openai/gpt-cached", label: "Cached" }]), + }); + expect(second.source).toBe("cache"); + expect(second.refreshing).toBe(true); + expect(shellCmd).toHaveBeenCalledTimes(1); + + resolveShell("{}"); + await flushPromises(); + + const fresh = await cache.getCatalogResponse(); + expect(fresh).toEqual({ + ok: true, + source: "openclaw", + fetchedAt: expect.any(Number), + stale: false, + refreshing: false, + models: normalizeModels([{ key: "openai/gpt-fresh", name: "Fresh" }]), + }); + const written = JSON.parse(fs.readFileSync(cachePath, "utf8")); + expect(written.models).toEqual( + normalizeModels([{ key: "openai/gpt-fresh", name: "Fresh" }]), + ); + }); + + it("keeps serving cache after refresh failures and retries after backoff", async () => { + vi.useFakeTimers(); + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "alphaclaw-model-catalog-backoff-"), + ); + const cachePath = path.join(tempRoot, "cache", "model-catalog.json"); + writeCacheFile({ + cachePath, + fetchedAt: 222, + models: normalizeModels([{ key: "openai/gpt-cached", label: "Cached" }]), + }); + + const shellCmd = vi + .fn() + .mockRejectedValueOnce(new Error("boom")) + .mockResolvedValueOnce("{}"); + const parseJsonFromNoisyOutput = vi.fn(() => ({ + models: [{ key: "openai/gpt-retried", name: "Retried" }], + })); + const cache = createModelCatalogCache({ + cachePath, + shellCmd, + parseJsonFromNoisyOutput, + normalizeOnboardingModels: normalizeModels, + setTimeoutFn: setTimeout, + clearTimeoutFn: clearTimeout, + }); + + const cached = await cache.getCatalogResponse(); + expect(cached.source).toBe("cache"); + expect(cached.refreshing).toBe(true); + expect(shellCmd).toHaveBeenCalledTimes(1); + + await flushPromises(); + + const afterFailure = await cache.getCatalogResponse(); + expect(afterFailure).toEqual({ + ok: true, + source: "cache", + fetchedAt: 222, + stale: true, + refreshing: true, + models: normalizeModels([{ key: "openai/gpt-cached", label: "Cached" }]), + }); + + await vi.advanceTimersByTimeAsync(kModelCatalogRefreshBackoffMs - 1); + expect(shellCmd).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(1); + await flushPromises(); + expect(shellCmd).toHaveBeenCalledTimes(2); + + const fresh = await cache.getCatalogResponse(); + expect(fresh).toEqual({ + ok: true, + source: "openclaw", + fetchedAt: expect.any(Number), + stale: false, + refreshing: false, + models: normalizeModels([{ key: "openai/gpt-retried", name: "Retried" }]), + }); + }); + + it("falls back when no cache exists and the CLI load fails", async () => { + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "alphaclaw-model-catalog-fallback-"), + ); + const cachePath = path.join(tempRoot, "cache", "model-catalog.json"); + const shellCmd = vi.fn().mockRejectedValue(new Error("boom")); + const cache = createModelCatalogCache({ + cachePath, + shellCmd, + parseJsonFromNoisyOutput: vi.fn(() => ({})), + normalizeOnboardingModels: normalizeModels, + }); + + const response = await cache.getCatalogResponse(); + + expect(response).toEqual({ + ok: true, + source: "fallback", + fetchedAt: null, + stale: false, + refreshing: false, + models: kFallbackOnboardingModels, + }); + }); +}); diff --git a/tests/server/routes-models.test.js b/tests/server/routes-models.test.js index fc61bc56..ccf46d60 100644 --- a/tests/server/routes-models.test.js +++ b/tests/server/routes-models.test.js @@ -1,6 +1,10 @@ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); const express = require("express"); const request = require("supertest"); +const { createModelCatalogCache } = require("../../lib/server/model-catalog-cache"); const { registerModelRoutes } = require("../../lib/server/routes/models"); const { kFallbackOnboardingModels } = require("../../lib/server/constants"); @@ -39,9 +43,20 @@ const createModelDeps = () => { const createApp = (deps) => { const app = express(); app.use(express.json()); + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "alphaclaw-routes-models-"), + ); + const modelCatalogCache = createModelCatalogCache({ + cachePath: path.join(tempRoot, "cache", "model-catalog.json"), + shellCmd: deps.shellCmd, + gatewayEnv: deps.gatewayEnv, + parseJsonFromNoisyOutput: deps.parseJsonFromNoisyOutput, + normalizeOnboardingModels: deps.normalizeOnboardingModels, + }); registerModelRoutes({ app, ...deps, + modelCatalogCache, }); return app; }; @@ -61,11 +76,16 @@ describe("server/routes/models", () => { const res = await request(app).get("/api/models"); expect(res.status).toBe(200); - expect(res.body).toEqual({ - ok: true, - source: "openclaw", - models: [{ key: "openai/gpt-5.1-codex", provider: "openai", label: "GPT" }], - }); + expect(res.body).toEqual( + expect.objectContaining({ + ok: true, + source: "openclaw", + stale: false, + refreshing: false, + fetchedAt: expect.any(Number), + models: [{ key: "openai/gpt-5.1-codex", provider: "openai", label: "GPT" }], + }), + ); expect(deps.shellCmd).toHaveBeenCalledWith("openclaw models list --all --json", { env: { OPENCLAW_GATEWAY_TOKEN: "token" }, timeout: 20000, @@ -82,9 +102,14 @@ describe("server/routes/models", () => { const res = await request(app).get("/api/models"); expect(res.status).toBe(200); - expect(res.body.ok).toBe(true); - expect(res.body.source).toBe("fallback"); - expect(res.body.models).toEqual(kFallbackOnboardingModels); + expect(res.body).toEqual({ + ok: true, + source: "fallback", + fetchedAt: null, + stale: false, + refreshing: false, + models: kFallbackOnboardingModels, + }); }); it("returns fallback models when command throws", async () => { @@ -95,8 +120,14 @@ describe("server/routes/models", () => { const res = await request(app).get("/api/models"); expect(res.status).toBe(200); - expect(res.body.ok).toBe(true); - expect(res.body.source).toBe("fallback"); + expect(res.body).toEqual({ + ok: true, + source: "fallback", + fetchedAt: null, + stale: false, + refreshing: false, + models: kFallbackOnboardingModels, + }); }); it("returns model status payload on GET /api/models/status", async () => { diff --git a/tests/server/routes-webhooks.test.js b/tests/server/routes-webhooks.test.js index dfb819ce..ce0c1b2c 100644 --- a/tests/server/routes-webhooks.test.js +++ b/tests/server/routes-webhooks.test.js @@ -212,4 +212,56 @@ describe("server/routes/webhooks", () => { expect(response.body?.webhook?.to).toBe("new-session"); expect(response.body?.webhook?.agentId).toBe("alpha"); }); + + it("uses the recent request window for webhook health", async () => { + const openclawDir = "/tmp/openclaw"; + const configPath = path.join(openclawDir, "openclaw.json"); + const fs = createMemoryFs({ + [configPath]: JSON.stringify({ + agents: { + list: [{ id: "main", default: true }], + }, + }), + }); + createWebhook({ + fs, + constants: { OPENCLAW_DIR: openclawDir }, + name: "recent-health", + }); + const app = createApp({ + fs, + constants: { OPENCLAW_DIR: openclawDir }, + webhooksDb: { + getHookSummaries: () => [ + { + hookName: "recent-health", + lastReceived: "2026-04-02T12:00:00.000Z", + totalCount: 30, + successCount: 25, + errorCount: 5, + recentTotalCount: 25, + recentSuccessCount: 25, + recentErrorCount: 0, + healthWindowSize: 25, + }, + ], + getRequests: () => [], + getRequestById: () => null, + deleteRequestsByHook: () => 0, + createOauthCallback: () => null, + getOauthCallbackByHook: () => null, + rotateOauthCallback: () => null, + deleteOauthCallback: () => 0, + }, + }); + + const response = await request(app).get("/api/webhooks"); + + expect(response.status).toBe(200); + expect(response.body?.webhooks).toHaveLength(1); + expect(response.body?.webhooks?.[0]?.errorCount).toBe(5); + expect(response.body?.webhooks?.[0]?.recentErrorCount).toBe(0); + expect(response.body?.webhooks?.[0]?.healthWindowSize).toBe(25); + expect(response.body?.webhooks?.[0]?.health).toBe("green"); + }); }); diff --git a/tests/server/webhooks-db.test.js b/tests/server/webhooks-db.test.js index eefd1a03..46c3926a 100644 --- a/tests/server/webhooks-db.test.js +++ b/tests/server/webhooks-db.test.js @@ -51,4 +51,43 @@ describe("server/webhooks-db", () => { fs.rmSync(rootDir, { recursive: true, force: true }); }); + + it("tracks recent health counts separately from all-time totals", () => { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "webhooks-db-health-")); + const { + initWebhooksDb, + insertRequest, + getHookSummaries, + } = loadWebhooksDb(); + + initWebhooksDb({ rootDir }); + + for (let index = 0; index < 30; index += 1) { + insertRequest({ + hookName: "recent-health", + method: "POST", + headers: {}, + payload: `{"index":${index}}`, + payloadTruncated: false, + payloadSize: 12, + sourceIp: "127.0.0.1", + gatewayStatus: index < 5 ? 500 : 200, + gatewayBody: "", + }); + } + + const summary = getHookSummaries().find( + (item) => item.hookName === "recent-health", + ); + + expect(summary).toBeTruthy(); + expect(summary.totalCount).toBe(30); + expect(summary.errorCount).toBe(5); + expect(summary.recentTotalCount).toBe(25); + expect(summary.recentSuccessCount).toBe(25); + expect(summary.recentErrorCount).toBe(0); + expect(summary.healthWindowSize).toBe(25); + + fs.rmSync(rootDir, { recursive: true, force: true }); + }); });