diff --git a/.gitignore b/.gitignore index 8ee0b5d9..771e647e 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,6 @@ devlog/ # Test-generated artifacts tests/.tmp-*/ .claude/ + +# Local Playwright / screenshot artifacts +output/ diff --git a/gui/package.json b/gui/package.json index f3df2de4..5870ec87 100644 --- a/gui/package.json +++ b/gui/package.json @@ -7,7 +7,7 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "lint:i18n": "eslint src/pages src/components src/App.tsx src/ui.tsx", + "lint:i18n": "eslint src/pages src/components src/App.tsx src/ui.tsx src/provider-workspace-data.ts", "preview": "vite preview" }, "dependencies": { diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 5562fc93..78238202 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import Dashboard from "./pages/Dashboard"; import Providers from "./pages/Providers"; import Models from "./pages/Models"; @@ -6,24 +6,26 @@ import Subagents from "./pages/Subagents"; import Logs from "./pages/Logs"; import Debug from "./pages/Debug"; import Usage from "./pages/Usage"; -import CodexAuth from "./pages/CodexAuth"; import ApiKeys from "./pages/ApiKeys"; import ClaudeCode from "./pages/ClaudeCode"; -import { IconGrid, IconServer, IconBoxes, IconBot, IconList, IconTerminal, IconActivity, IconKey, IconGithub, IconMenu, IconSun, IconMoon, IconMonitor, IconGlobe, IconPower, IconSparkle, IconX } from "./icons"; +import { IconGrid, IconServer, IconBoxes, IconBot, IconList, IconTerminal, IconActivity, IconGithub, IconMenu, IconSun, IconMoon, IconMonitor, IconGlobe, IconPower, IconSparkle, IconX } from "./icons"; import { useI18n, useT, LOCALES, type Locale, type TKey } from "./i18n"; import { Select } from "./ui"; import { installApiAuthFetch } from "./api"; installApiAuthFetch(); -type Page = "dashboard" | "providers" | "models" | "subagents" | "logs" | "debug" | "usage" | "codex-auth" | "api" | "claude"; +type Page = "dashboard" | "providers" | "models" | "subagents" | "logs" | "debug" | "usage" | "api" | "claude"; type Theme = "light" | "dark" | "system"; -const VALID_PAGES = new Set(["dashboard", "providers", "models", "subagents", "logs", "debug", "usage", "codex-auth", "api", "claude"]); +const VALID_PAGES = new Set(["dashboard", "providers", "models", "subagents", "logs", "debug", "usage", "api", "claude"]); -function readPageFromHash(): Page { +function readHashNavigation(): { page: Page; focusChatGptAuth: boolean } { const raw = location.hash.replace(/^#\/?/, ""); - return VALID_PAGES.has(raw as Page) ? (raw as Page) : "dashboard"; + // Legacy Codex Auth sidebar route → Providers → ChatGPT Authentication. + if (raw === "codex-auth") return { page: "providers", focusChatGptAuth: true }; + if (VALID_PAGES.has(raw as Page)) return { page: raw as Page, focusChatGptAuth: false }; + return { page: "dashboard", focusChatGptAuth: false }; } const API_BASE = import.meta.env.VITE_API_BASE || ""; @@ -37,7 +39,6 @@ const NAV: { id: Page; tkey: TKey; Icon: typeof IconGrid }[] = [ { id: "logs", tkey: "nav.logs", Icon: IconList }, { id: "debug", tkey: "nav.debug", Icon: IconTerminal }, { id: "usage", tkey: "nav.usage", Icon: IconActivity }, - { id: "codex-auth", tkey: "nav.codexAuth", Icon: IconKey }, { id: "api", tkey: "nav.api", Icon: IconGlobe }, { id: "claude", tkey: "nav.claude", Icon: IconSparkle }, ]; @@ -57,7 +58,9 @@ function readStoredTheme(): Theme { } export default function App() { - const [page, setPageState] = useState(readPageFromHash); + const initialNav = readHashNavigation(); + const [page, setPageState] = useState(initialNav.page); + const [focusChatGptAuth, setFocusChatGptAuth] = useState(initialNav.focusChatGptAuth); const [theme, setTheme] = useState(readStoredTheme); const [runtimeVersion, setRuntimeVersion] = useState(null); const { locale, setLocale } = useI18n(); @@ -71,14 +74,29 @@ export default function App() { useEffect(() => { // External navigation (hash edit, back/forward) also dismisses the mobile drawer. - const onHash = () => { setPageState(readPageFromHash()); setNavOpen(false); }; + const onHash = () => { + const next = readHashNavigation(); + setPageState(next.page); + if (next.focusChatGptAuth) setFocusChatGptAuth(true); + setNavOpen(false); + if (next.focusChatGptAuth && window.location.hash !== "#providers") { + history.replaceState(null, "", "#providers"); + } + }; window.addEventListener("hashchange", onHash); return () => window.removeEventListener("hashchange", onHash); }, []); + // Rewrite legacy #codex-auth on first paint so the Providers nav item stays active. + useEffect(() => { + if (focusChatGptAuth && window.location.hash === "#codex-auth") { + history.replaceState(null, "", "#providers"); + } + }, [focusChatGptAuth]); + useEffect(() => { const nextHash = `#${page}`; - if (window.location.hash !== nextHash) { + if (window.location.hash !== nextHash && window.location.hash !== "#codex-auth") { window.location.hash = page; } }, [page]); @@ -106,6 +124,8 @@ export default function App() { return () => { cancelled = true; clearInterval(interval); }; }, []); + const clearChatGptAuthFocus = useCallback(() => setFocusChatGptAuth(false), []); + const cycleTheme = () => setTheme(t => (t === "light" ? "dark" : t === "dark" ? "system" : "light")); const ThemeIcon = THEME_ICON[theme]; const displayedVersion = runtimeVersion ?? __APP_VERSION__; @@ -247,15 +267,20 @@ export default function App() {
-
+
{page === "dashboard" && } - {page === "providers" && } + {page === "providers" && ( + + )} {page === "models" && } {page === "subagents" && } {page === "logs" && } {page === "debug" && } {page === "usage" && } - {page === "codex-auth" && } {page === "api" && } {page === "claude" && }
diff --git a/gui/src/codex-quota-utils.ts b/gui/src/codex-quota-utils.ts index 2371fb7d..2c8b323c 100644 --- a/gui/src/codex-quota-utils.ts +++ b/gui/src/codex-quota-utils.ts @@ -1,7 +1,9 @@ export interface AccountQuota { weeklyPercent?: number; + fiveHourPercent?: number; monthlyPercent?: number; weeklyResetAt?: number; + fiveHourResetAt?: number; monthlyResetAt?: number; customWindows?: { label: string; percent: number; resetAt?: number }[]; resetCredits?: number; diff --git a/gui/src/components/AddCodexAccountModal.tsx b/gui/src/components/AddCodexAccountModal.tsx index c05b8b4a..d765dd7a 100644 --- a/gui/src/components/AddCodexAccountModal.tsx +++ b/gui/src/components/AddCodexAccountModal.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from "react"; -import { IconGlobe, IconLink } from "../icons"; +import { IconLink } from "../icons"; import { useT } from "../i18n"; export default function AddCodexAccountModal({ @@ -25,6 +25,21 @@ export default function AddCodexAccountModal({ const [error, setError] = useState(""); const [authUrl, setAuthUrl] = useState(""); const [copied, setCopied] = useState(false); + const [existingIds, setExistingIds] = useState([]); + const [starting, setStarting] = useState(false); + + useEffect(() => { + let cancelled = false; + fetch(`${apiBase}/api/codex-auth/accounts`) + .then(r => r.json()) + .then((data: { accounts?: Array<{ id: string }> }) => { + if (!cancelled) { + setExistingIds((data.accounts ?? []).map(a => a.id.toLowerCase())); + } + }) + .catch(() => {}); + return () => { cancelled = true; }; + }, [apiBase]); const stopPolling = () => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } @@ -77,102 +92,136 @@ export default function AddCodexAccountModal({ return () => window.removeEventListener("keydown", onKey); }); + const slug = id.trim(); + const slugKey = slug.toLowerCase(); + const slugDuplicate = slugKey !== "" && existingIds.includes(slugKey); + const canStart = !starting && !slugDuplicate; + + const startOAuth = async () => { + if (!canStart) return; + setError(""); + if (slugDuplicate) { + setError(t("codexAuth.slugDuplicate", { id: slug })); + return; + } + setStarting(true); + try { + const requestedId = slug; + const resp = await fetch(`${apiBase}/api/codex-auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(requestedId ? { id: requestedId } : {}), + }); + const data = await resp.json() as { url?: string; flowId?: string; error?: string; status?: string }; + if (!aliveRef.current) return; + if (resp.status === 409) { + setError(t("codexAuth.oauthAlreadyInProgress")); + return; + } + if (data.url) { + flowRef.current = data.flowId ?? null; + setAuthUrl(data.url); + setStep("oauth-waiting"); + stopPolling(); + const fid = data.flowId ?? ""; + const statusUrl = fid + ? `${apiBase}/api/codex-auth/login-status?flowId=${encodeURIComponent(fid)}${requestedId ? `&accountId=${encodeURIComponent(requestedId)}` : ""}` + : `${apiBase}/api/codex-auth/login-status`; + pollRef.current = setInterval(async () => { + try { + const st = await fetch(statusUrl).then(r => r.json()) as { status: string; error?: string }; + if (st.status === "done") { + stopPolling(); + flowRef.current = null; + onAdded(); + onClose(); + } else if (st.status === "error" || st.status === "expired") { + stopPolling(); + flowRef.current = null; + if (aliveRef.current) { setStep("pick"); setError(st.error ?? "Login failed"); } + } + } catch { /* ignore network errors during polling */ } + }, 2000); + timeoutRef.current = setTimeout(() => { + if (pollRef.current) { + void cancelLogin(); + if (aliveRef.current) { setStep("pick"); setError(t("modal.loginTimeout")); } + } + }, 300_000); + } + if (data.error && !data.url) setError(data.error); + } catch (e) { + setError(String(e)); + } finally { + if (aliveRef.current) setStarting(false); + } + }; + return ( -
-
e.stopPropagation()} style={{ maxWidth: 440 }}> +
+
e.stopPropagation()} style={{ maxWidth: 440 }}> {step === "pick" && ( <>

{t("codexAuth.addTitle")}

{t("codexAuth.addPickDesc")}

- + setId(e.target.value)} - style={{ marginBottom: 12 }} + onChange={e => { setId(e.target.value); setError(""); }} + aria-describedby="codex-account-slug-hint" + aria-invalid={slugDuplicate} /> - - - {error &&
{error}
} + {error && !slugDuplicate && ( +
{error}
+ )} - +
+ +
)} {step === "oauth-waiting" && ( <> -

{t("codexAuth.oauthLogin")}

+

{t("codexAuth.signInWithChatGpt")}

{t("codexAuth.oauthWaiting")}

- {error &&
{error}
}
- +
+ +
)}
diff --git a/gui/src/components/AddProviderModal.tsx b/gui/src/components/AddProviderModal.tsx index 0603faab..aa6b6d03 100644 --- a/gui/src/components/AddProviderModal.tsx +++ b/gui/src/components/AddProviderModal.tsx @@ -1,7 +1,11 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import { IconX, IconLock, IconKey, IconExternal } from "../icons"; -import { useT } from "../i18n"; +import { IconX, IconLock, IconKey, IconExternal, IconGlobe, IconChevron, IconInfo, IconServer } from "../icons"; +import { useT, type TFn } from "../i18n"; import { buildProviderPayload, type ProviderPayload } from "../provider-payload"; +import { formatProviderDisplayName, providerBrandColor, providerIconSrc } from "../provider-icons"; + +/** How many providers fit on the first sheet (room for Free/Paid tabs + footer). */ +const HOME_SLOT_COUNT = 8; export type ProviderConfig = ProviderPayload; @@ -18,8 +22,10 @@ interface Preset { /** Where to create/copy the API key (for auth === "key" catalog providers). */ dashboardUrl?: string; note?: string; - /** API key is optional — provider works without one (free public tier). */ + /** API key is optional — provider works without one (keyless free). */ keyOptional?: boolean; + /** Free pricing — may still require an API key (e.g. NVIDIA NIM). */ + freeTier?: boolean; } interface FormState { @@ -31,21 +37,70 @@ interface FormState { defaultModel: string; } +export type AccountLoginStatus = { loggedIn: boolean; email?: string; error?: string }; +export type AccountLoginRow = { + id: string; + label: string; + kind: "oauth" | "key"; + statusLabel?: string; +}; + export default function AddProviderModal({ - apiBase, existingNames, onClose, onAdded, + apiBase, existingNames, onClose, onAdded, initialCustom = false, + initialTier = "free", + accountRows = [], + accountStatus = {}, + accountBusy = null, + accountLoginHint = null, + onAccountLogin, + onAccountCancelLogin, + onAccountLogout, + accountManualCode = "", + onAccountManualCodeChange, + onAccountManualCodeSubmit, + accountManualCodeBusy = false, + accountManualCodeMsg = "", + onOpen, }: { apiBase: string; existingNames: string[]; onClose: () => void; onAdded: (name: string) => void; + /** Skip catalog picker and open the custom-provider form immediately. */ + initialCustom?: boolean; + /** Opening catalog tab (Free / Paid / Logins). */ + initialTier?: "free" | "paid" | "accounts"; + /** Third-tab account login rows (oauth + key-configured), styled like the catalog. */ + accountRows?: AccountLoginRow[]; + accountStatus?: Record; + accountBusy?: string | null; + accountLoginHint?: { provider: string; url?: string; instructions?: string } | null; + onAccountLogin?: (provider: string) => void; + /** Stop an in-progress OAuth browser wait for this account row. */ + onAccountCancelLogin?: (provider: string) => void; + onAccountLogout?: (provider: string) => void; + accountManualCode?: string; + onAccountManualCodeChange?: (value: string) => void; + onAccountManualCodeSubmit?: (provider: string) => void; + accountManualCodeBusy?: boolean; + accountManualCodeMsg?: string; + /** Called once when the modal mounts (e.g. refresh oauth status for Accounts tab). */ + onOpen?: () => void; }) { const t = useT(); const fallbackPresets = useMemo(() => [ { id: "custom", label: t("modal.customProvider"), adapter: "openai-chat", baseUrl: "", auth: "key" }, ], [t]); const [query, setQuery] = useState(""); - const [preset, setPreset] = useState(null); - const [form, setForm] = useState(null); + const [tier, setTier] = useState<"free" | "paid" | "accounts">(initialTier); + const [catalogView, setCatalogView] = useState<"home" | "browse">("home"); + const [usageRank, setUsageRank] = useState>({}); + const [preset, setPreset] = useState(initialCustom ? fallbackPresets[0]! : null); + const [form, setForm] = useState( + initialCustom + ? { name: "", adapter: "openai-chat", baseUrl: "", authMode: "key", apiKey: "", defaultModel: "" } + : null, + ); const [saving, setSaving] = useState(false); const [error, setError] = useState(""); const [oauthSupported, setOauthSupported] = useState([]); @@ -57,26 +112,75 @@ export default function AddProviderModal({ const [manualCodeMsg, setManualCodeMsg] = useState(""); const [manualCodeOk, setManualCodeOk] = useState(true); const [presets, setPresets] = useState(fallbackPresets); + const [presetsLoading, setPresetsLoading] = useState(true); + const [presetsApiBase, setPresetsApiBase] = useState(apiBase); const searchRef = useRef(null); + const dialogRef = useRef(null); const aliveRef = useRef(true); const loadedPresetsRef = useRef(false); - useEffect(() => { searchRef.current?.focus(); }, []); - useEffect(() => () => { aliveRef.current = false; }, []); // stop the OAuth poll if the modal unmounts + // Adjust loading flag while rendering when the API base changes (avoids setState-in-effect). + if (presetsApiBase !== apiBase) { + setPresetsApiBase(apiBase); + setPresetsLoading(true); + } + + useEffect(() => { onOpen?.(); }, [onOpen]); + useEffect(() => { if (!initialCustom && catalogView === "browse") searchRef.current?.focus(); }, [initialCustom, catalogView]); useEffect(() => { - const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; + aliveRef.current = true; + return () => { aliveRef.current = false; }; + }, []); // stop the OAuth poll if the modal unmounts + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { onClose(); return; } + if (e.key !== "Tab" || !dialogRef.current) return; + const focusable = [...dialogRef.current.querySelectorAll( + 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])', + )].filter(el => !el.hasAttribute("disabled") && el.offsetParent !== null); + if (focusable.length === 0) return; + const first = focusable[0]!; + const last = focusable[focusable.length - 1]!; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + }; window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); + const previouslyFocused = document.activeElement as HTMLElement | null; + const initial = dialogRef.current?.querySelector("button, [href], input, select, textarea"); + initial?.focus(); + return () => { + window.removeEventListener("keydown", onKey); + previouslyFocused?.focus?.(); + }; }, [onClose]); useEffect(() => { fetch(`${apiBase}/api/oauth/providers`).then(r => r.json()).then(d => setOauthSupported(d.providers ?? [])).catch(() => {}); }, [apiBase]); useEffect(() => { + let cancelled = false; fetch(`${apiBase}/api/provider-presets`).then(r => r.json()).then((d: { providers?: Preset[] }) => { + if (cancelled) return; if (Array.isArray(d.providers) && d.providers.length > 0) { loadedPresetsRef.current = true; setPresets(d.providers); } + }).catch(() => {}).finally(() => { + if (!cancelled) setPresetsLoading(false); + }); + return () => { cancelled = true; }; + }, [apiBase]); + useEffect(() => { + fetch(`${apiBase}/api/usage?range=30d`).then(r => r.json()).then((d: { + providers?: Array<{ provider: string; requests: number }>; + }) => { + const rank: Record = {}; + for (const row of d.providers ?? []) rank[row.provider] = row.requests; + setUsageRank(rank); }).catch(() => {}); }, [apiBase]); // Keep the custom fallback label in sync when language changes and API presets never loaded. @@ -84,12 +188,38 @@ export default function AddProviderModal({ if (!loadedPresetsRef.current) setPresets(fallbackPresets); }, [fallbackPresets]); - const filtered = useMemo(() => { + const catalog = useMemo(() => presets.filter(p => p.id !== "custom"), [presets]); + + const sortedByUsage = useMemo(() => { + return [...catalog].sort((a, b) => { + const ra = usageRank[a.id] ?? 0; + const rb = usageRank[b.id] ?? 0; + if (rb !== ra) return rb - ra; + return a.label.localeCompare(b.label); + }); + }, [catalog, usageRank]); + + const freePresets = useMemo( + () => sortedByUsage.filter(isFreePreset), + [sortedByUsage], + ); + const paidPresets = useMemo( + () => sortedByUsage.filter(p => !isFreePreset(p)), + [sortedByUsage], + ); + + const tierList = tier === "paid" ? paidPresets : freePresets; + + const homeList = useMemo( + () => tierList.slice(0, HOME_SLOT_COUNT), + [tierList], + ); + + const browseList = useMemo(() => { const q = query.trim().toLowerCase(); - if (!q) return presets; - // Match by provider name/id — not adapter, since most share "openai-chat" and would all match. - return presets.filter(p => p.label.toLowerCase().includes(q) || p.id.toLowerCase().includes(q)); - }, [query, presets]); + if (!q) return tierList; + return tierList.filter(p => p.label.toLowerCase().includes(q) || p.id.toLowerCase().includes(q)); + }, [tierList, query]); const choosePreset = (p: Preset) => { setPreset(p); @@ -118,6 +248,13 @@ export default function AddProviderModal({ setManualCode(""); setManualCodeMsg(""); setManualCodeOk(true); + setCatalogView("home"); + setQuery(""); + }; + + const openCustom = () => { + const custom = fallbackPresets[0]!; + choosePreset(custom); }; const submit = async () => { @@ -125,6 +262,10 @@ export default function AddProviderModal({ const name = form.name.trim(); if (!name) { setError(t("modal.nameRequired")); return; } if (!form.baseUrl.trim()) { setError(t("modal.baseUrlRequired")); return; } + const willOverwrite = existingNames.includes(name); + if (willOverwrite && !window.confirm(t("modal.overwriteConfirm", { name }))) { + return; + } const provider = buildProviderPayload(form); setSaving(true); @@ -137,7 +278,13 @@ export default function AddProviderModal({ }); if (!res.ok) { const d = await res.json().catch(() => ({})); - setError(d.error || t("modal.failedStatus", { status: res.status })); + const raw = String(d.error || ""); + const lower = raw.toLowerCase(); + if (lower.includes("api key") || lower.includes("unauthorized") || lower.includes("invalid key") || res.status === 401) { + setError(t("modal.invalidApiKey")); + } else { + setError(raw || t("modal.failedStatus", { status: res.status })); + } return; } onAdded(name); @@ -184,7 +331,12 @@ export default function AddProviderModal({ if (s?.loggedIn) { onAdded(providerId); return; } if (s?.error) { setOauthMsgTone("warn"); - setOauthMsg(t("modal.loginError", { error: s.error })); + const err = String(s.error).toLowerCase(); + setOauthMsg( + err.includes("cancel") || err.includes("expired") + ? t("modal.oauthCancelled") + : t("modal.loginError", { error: s.error }), + ); return; } } @@ -237,58 +389,273 @@ export default function AddProviderModal({ return (
-
e.stopPropagation()}> +
e.stopPropagation()}>

{preset ? t("modal.addNamed", { label: preset.label }) : t("modal.add")}

{!preset ? ( - <> - setQuery(e.target.value)} - placeholder={t("modal.search")} - /> -
- {filtered.map(p => ( - + + +
+ +
+ {presetsLoading && tier !== "accounts" ? ( +
+ {t("pws.catalogLoading")} +
+ ) : tier === "accounts" ? ( +
+ {accountRows.length === 0 && ( +
{t("modal.accountsEmpty")}
+ )} + {accountRows.map(row => { + const st = accountStatus[row.id] ?? { loggedIn: false }; + const isBusy = accountBusy === row.id; + const hint = accountLoginHint?.provider === row.id ? accountLoginHint : null; + const icon = providerIconSrc(row.id); + const brand = providerBrandColor(row.id); + const statusText = row.kind === "key" + ? (row.statusLabel ?? t("prov.hasApiKey")) + : st.loggedIn + ? (st.email?.trim() || t("prov.loggedIn")) + : t("prov.notLoggedIn"); + const statusOk = row.kind === "key" || !!st.loggedIn; + const showOauthActions = row.kind === "oauth"; + const statusDisplay = statusText.trim() || t("prov.notLoggedIn"); + return ( +
+
+
+ {hint && (hint.url || hint.instructions || isBusy) && ( +
+
+ {hint.url && ( + + {t("prov.didntOpen")} + + )} + {hint.instructions && {hint.instructions}} +
+
+ onAccountManualCodeChange?.(e.target.value)} + onKeyDown={e => { + if (e.key === "Enter") { + e.preventDefault(); + onAccountManualCodeSubmit?.(row.id); + } + }} + placeholder={t("prov.pasteRedirect")} + aria-label={t("prov.pasteRedirect")} + disabled={accountManualCodeBusy} + /> + +
+
{accountManualCodeMsg || t("prov.pasteRedirectHint")}
+
+ )} +
+ ); + })} +
+ ) : catalogView === "home" ? ( + <> + - ))} - {filtered.length === 0 &&
{t("modal.noMatch")}
} +
+ {homeList.map(p => ( + choosePreset(p)} /> + ))} + {homeList.length === 0 && ( +
{t("modal.noMatch")}
+ )} +
+ + ) : ( + <> + + setQuery(e.target.value)} + placeholder={t("modal.search")} + style={{ marginBottom: 4 }} + /> +
+ {browseList.map(p => ( + choosePreset(p)} /> + ))} + {browseList.length === 0 && ( +
+ {query.trim() ? t("pws.noSearchResults") : t("modal.noMatch")} +
+ )} +
+ + )} +
+ + {/* Custom endpoint is for Free/Paid catalogs — not account login. */} + {tier !== "accounts" && ( +
+
+
+
- + )} +
) : form && ( preset.auth === "oauth" && form.authMode === "oauth" ? ( // OAuth login pane
-
{preset.note ?? t("modal.oauthDefaultNote")}
+
+
+ {t("modal.oauthConnectTitle", { label: formatProviderDisplayName(preset.label || preset.id) })} +
+
+ {localizePresetNote(preset, t)} +
+
{oauthSupported.includes(preset.oauthProvider ?? "") ? ( ) : (
- {t("modal.oauthComingSoon", { label: preset.label })} + {t("modal.oauthComingSoon", { label: formatProviderDisplayName(preset.label || preset.id) })}
)} {oauthMsg && ( @@ -336,7 +703,9 @@ export default function AddProviderModal({ )}
)} -
+
+ +
-
-
) : ( @@ -373,18 +740,50 @@ export default function AddProviderModal({ {preset.note &&
{preset.note}
} )} - - setForm({ ...form, name: e.target.value })} placeholder={t("modal.namePlaceholder")} /> - - {dup &&
{t("modal.duplicateWarn", { name: form.name.trim() })}
} - - - - - setForm({ ...form, baseUrl: e.target.value })} placeholder={t("modal.baseUrlPlaceholder")} /> - + {dup && ( +
+ {t("modal.duplicateWarn", { name: formatProviderDisplayName(form.name.trim()) })} +
{t("modal.overwriteWarnDetail")}
+
+ )} + {isCustom ? ( + <> + + setForm({ ...form, name: e.target.value })} placeholder={t("modal.namePlaceholder")} /> + + + + + + setForm({ ...form, baseUrl: e.target.value })} placeholder={t("modal.baseUrlPlaceholder")} /> + + + ) : ( +
+ {t("modal.advancedSettings")} + + setForm({ ...form, name: e.target.value })} placeholder={t("modal.namePlaceholder")} /> + + + + + + + +
+ )} {form.authMode === "forward" ? (
{t("modal.forwardHintPrefix")}{" "} @@ -415,11 +814,22 @@ export default function AddProviderModal({ setForm({ ...form, defaultModel: e.target.value })} placeholder={t("modal.defaultModelPlaceholder")} /> {error &&
{error}
} -
- - {preset.auth === "oauth" && } -
+
+
+ {preset.auth === "oauth" && ( + + )} +
) @@ -437,3 +847,62 @@ function Field({ label, children }: { label: string; children: React.ReactNode } ); } + +function isFreePreset(p: Preset): boolean { + if (p.freeTier) return true; + if (p.keyOptional) return true; + if (p.auth === "local") return true; + try { + const host = new URL(p.baseUrl).hostname.replace(/^\[|\]$/g, "").toLowerCase(); + return host === "localhost" || host === "127.0.0.1" || host === "::1"; + } catch { + return false; + } +} + +function authBadge(p: Preset, t: TFn): string { + if (p.auth === "local") return t("modal.badge.local"); + if (p.auth === "oauth") return t("modal.badge.oauth"); + if (p.auth === "forward") return t("modal.badge.codexLogin"); + return t("modal.badge.api"); +} + +/** Prefer localized notes for known presets; fall back to registry English / generic body. */ +function localizePresetNote(preset: Preset, t: TFn): string { + const id = preset.id.toLowerCase(); + if (id === "anthropic" || (preset.auth === "oauth" && id.includes("anthropic"))) { + return t("modal.anthropicOauthNote"); + } + if (preset.auth === "forward" || id === "openai" || id === "chatgpt") { + return t("modal.forwardNote"); + } + if (preset.note?.trim()) return preset.note.trim(); + return t("modal.oauthConnectBody", { label: formatProviderDisplayName(preset.label || preset.id) }); +} + +function ProviderConnectRow({ + preset, t, onConnect, +}: { + preset: Preset; + t: TFn; + onConnect: () => void; +}) { + const icon = providerIconSrc(preset.id, { adapter: preset.adapter, baseUrl: preset.baseUrl }); + const brand = providerBrandColor(preset.id); + return ( +
+
+ ); +} diff --git a/gui/src/pages/CodexAuth.tsx b/gui/src/components/CodexAccountPool.tsx similarity index 59% rename from gui/src/pages/CodexAuth.tsx rename to gui/src/components/CodexAccountPool.tsx index 85c3dab4..be474d87 100644 --- a/gui/src/pages/CodexAuth.tsx +++ b/gui/src/components/CodexAccountPool.tsx @@ -1,27 +1,41 @@ import { useCallback, useEffect, useState } from "react"; import { useT, type TFn } from "../i18n"; -import { IconLock, IconPlus, IconX, IconAlert, IconRefresh, IconTicket } from "../icons"; -import { Notice, EmptyState } from "../ui"; -import AddCodexAccountModal from "../components/AddCodexAccountModal"; +import { IconPlus, IconX, IconAlert, IconRefresh, IconTicket } from "../icons"; +import { Notice } from "../ui"; +import AddCodexAccountModal from "./AddCodexAccountModal"; import type { AccountQuota } from "../codex-quota-utils"; -import QuotaBars from "../components/QuotaBars"; +import QuotaBars from "./QuotaBars"; -interface AccountEntry { - id: string; email: string; plan?: string; isMain: boolean; - hasCredential: boolean; quota: AccountQuota | null; +export interface CodexAccountEntry { + id: string; + email: string; + plan?: string; + isMain: boolean; + hasCredential: boolean; + quota: AccountQuota | null; needsReauth?: boolean; } -export default function CodexAuth({ apiBase }: { apiBase: string }) { +type CodexAccountPoolProps = { + apiBase: string; + /** When true, omit page chrome — parent supplies the section title (Providers Overview). */ + embedded?: boolean; +}; + +/** + * Global ChatGPT / Codex account pool (main + extras). Shared by the former + * Codex Auth page and ChatGPT forward-provider Overview Authentication. + */ +export default function CodexAccountPool({ apiBase, embedded = false }: CodexAccountPoolProps) { const t = useT(); - const [accounts, setAccounts] = useState([]); + const [accounts, setAccounts] = useState([]); const [activeId, setActiveId] = useState(null); const [autoThreshold, setAutoThreshold] = useState(80); - const [confirm, setConfirm] = useState(null); + const [confirm, setConfirm] = useState(null); const [showAdd, setShowAdd] = useState(false); const [toast, setToast] = useState(""); const [refreshingQuota, setRefreshingQuota] = useState(false); - const [resetPopup, setResetPopup] = useState(null); + const [resetPopup, setResetPopup] = useState(null); const [resetConfirm, setResetConfirm] = useState(false); const [redeeming, setRedeeming] = useState(false); const [creditDetails, setCreditDetails] = useState<{ granted_at: string; expires_at: string }[] | null>(null); @@ -41,6 +55,7 @@ export default function CodexAuth({ apiBase }: { apiBase: string }) { return false; } }, [apiBase]); + useEffect(() => { const timeout = window.setTimeout(() => { void load(); @@ -69,7 +84,7 @@ export default function CodexAuth({ apiBase }: { apiBase: string }) { const remove = async (id: string) => { if (!window.confirm(t("codexAuth.removeConfirm", { id }))) return; await fetch(`${apiBase}/api/codex-auth/accounts?id=${id}`, { method: "DELETE" }); - load(); + void load(); }; const toggleAuto = async () => { @@ -92,7 +107,7 @@ export default function CodexAuth({ apiBase }: { apiBase: string }) { } }; - const openResetPopup = async (account: AccountEntry) => { + const openResetPopup = async (account: CodexAccountEntry) => { setResetPopup(account); setResetConfirm(false); setCreditDetails(null); @@ -147,83 +162,17 @@ export default function CodexAuth({ apiBase }: { apiBase: string }) { // Main is the active/next account when no pool account is selected (legacy null) or when // it is explicitly set to the rotation id "__main__". const isMainActive = !activeId || activeId === "__main__"; + const mainConfirmEntry: CodexAccountEntry = { + id: "__main__", + email: main?.email ?? t("codexAuth.appLogin"), + plan: main?.plan, + isMain: true, + hasCredential: true, + quota: main?.quota ?? null, + }; - return ( -
-
-

{t("nav.codexAuth")}

- -
- - {toast && {toast}} - -
!isMainActive ? setConfirm({ id: "__main__", email: main?.email ?? "Codex App", plan: main?.plan, isMain: true, hasCredential: true, quota: main?.quota ?? null }) : undefined} - style={{ cursor: isMainActive ? "default" : "pointer", marginBottom: 12 }}> -
- - {t("codexAuth.mainAccount")} - - {main && openResetPopup({ ...main, id: "__main__" } as AccountEntry)} />} - - {isMainActive ? t("codexAuth.nextSession") : t("codexAuth.current")} - - - {t("codexAuth.appLogin")} -
-
{main?.email ?? "Codex App login"}{main?.plan ? ` · ${main.plan}` : ""}
- {main?.quota && } -
- -
- {t("codexAuth.accountPool")} -
- -
- - {pool.length === 0 && } - - {pool.map(a => ( -
!a.needsReauth && setConfirm(a)} style={{ cursor: a.needsReauth ? "default" : "pointer", marginBottom: 8 }}> -
- - {a.email} - - {a.plan && {a.plan}} - openResetPopup(a)} /> - {a.needsReauth && {t("codexAuth.needsReauth")}} - {isNext(a.id) && !a.needsReauth && {t("codexAuth.nextSession")}} - - -
- {a.needsReauth - ?
{t("codexAuth.tokenExpired")}
- : } -
- ))} - -
-
- {t("codexAuth.autoSwitch")} -
{t("codexAuth.autoSwitchDesc")}
-
- -
- + const modals = ( + <> {confirm && (
setConfirm(null)}>
e.stopPropagation()}> @@ -239,8 +188,8 @@ export default function CodexAuth({ apiBase }: { apiBase: string }) {
{t("codexAuth.cacheWarning")}
)}
- - +
@@ -267,7 +216,7 @@ export default function CodexAuth({ apiBase }: { apiBase: string }) { ))}
)} - @@ -295,8 +244,8 @@ export default function CodexAuth({ apiBase }: { apiBase: string }) {

{t("codexAuth.irreversible")}

- - +
@@ -310,9 +259,228 @@ export default function CodexAuth({ apiBase }: { apiBase: string }) { setShowAdd(false)} - onAdded={() => { load(); setToast(t("codexAuth.accountAdded")); setTimeout(() => setToast(""), 5000); }} + onAdded={() => { void load(); setToast(t("codexAuth.accountAdded")); setTimeout(() => setToast(""), 5000); }} /> )} + + ); + + // Overview embed: match OAuth providers (Anthropic) — simple rows, no quota bars (Usage tab). + if (embedded) { + const mainEmail = main?.email?.trim() || ""; + const mainLabel = mainEmail + ? (main?.plan ? `${mainEmail} · ${main.plan}` : mainEmail) + : t("codexAuth.appLogin"); + return ( +
+ {toast && {toast}} + +
+
+ + {main && ( + openResetPopup({ ...main, id: "__main__" } as CodexAccountEntry)} + /> + )} +
+ + {pool.map(a => ( +
+ + openResetPopup(a)} /> + +
+ ))} +
+ + + +
+
+ {t("codexAuth.autoSwitch")} +
{t("codexAuth.autoSwitchDesc")}
+
+ +
+ + {modals} +
+ ); + } + + return ( +
+
+

{t("nav.codexAuth")}

+ +
+ + {toast && {toast}} + +
!isMainActive ? setConfirm(mainConfirmEntry) : undefined} + style={{ cursor: isMainActive ? "default" : "pointer", marginBottom: 12 }}> +
+ + {t("codexAuth.mainAccount")} + + {main && openResetPopup({ ...main, id: "__main__" } as CodexAccountEntry)} />} + + {isMainActive ? t("codexAuth.nextSession") : t("codexAuth.current")} + + + {t("codexAuth.appLogin")} +
+
{main?.email ?? t("codexAuth.appLogin")}{main?.plan ? ` · ${main.plan}` : ""}
+ {main?.quota && } +
+ +
+ {t("codexAuth.accountPool")} +
+ +
+ + {pool.length === 0 && ( +
+ {t("codexAuth.noPoolInline")} +
+ )} + + {pool.map(a => ( +
!a.needsReauth && setConfirm(a)} style={{ cursor: a.needsReauth ? "default" : "pointer", marginBottom: 8 }}> +
+ + {a.email} + + {a.plan && {a.plan}} + openResetPopup(a)} /> + {a.needsReauth && {t("codexAuth.needsReauth")}} + {isNext(a.id) && !a.needsReauth && {t("codexAuth.nextSession")}} + + +
+ {a.needsReauth + ?
{t("codexAuth.tokenExpired")}
+ : } +
+ ))} + +
+
+ {t("codexAuth.autoSwitch")} +
{t("codexAuth.autoSwitchDesc")}
+
+ +
+ + {modals} +
+ ); +} + +/** Compact Settings status for forward/ChatGPT providers (no full pool UI). */ +export function CodexForwardAuthStatus({ apiBase }: { apiBase: string }) { + const t = useT(); + const [label, setLabel] = useState(null); + + useEffect(() => { + let cancelled = false; + const run = async () => { + try { + const [accts, active] = await Promise.all([ + fetch(`${apiBase}/api/codex-auth/accounts`).then(r => r.json()), + fetch(`${apiBase}/api/codex-auth/active`).then(r => r.json()), + ]); + if (cancelled) return; + const accounts = (accts.accounts ?? []) as CodexAccountEntry[]; + const activeId = (active.activeCodexAccountId ?? null) as string | null; + const isMain = !activeId || activeId === "__main__"; + const next = isMain + ? accounts.find(a => a.isMain) + : accounts.find(a => a.id === activeId); + setLabel(next?.email ?? (isMain ? t("codexAuth.appLogin") : activeId)); + } catch { + if (!cancelled) setLabel(null); + } + }; + void run(); + return () => { cancelled = true; }; + }, [apiBase, t]); + + return ( +
+ + {t("pws.auth.chatgptPassthrough")} + {t("pws.authForwardDesc")} + {t("pws.authForwardManageHint")} + + {label ? t("pws.authForwardNextSession", { email: label }) : t("pws.authForwardCredentials")} + +
); } @@ -348,15 +516,17 @@ function CreditItem({ index, grantedAt, expiresAt, isNext, t }: { ); } -function TicketBadge({ account, onClick, t }: { account: AccountEntry; onClick: () => void; t: TFn }) { +function TicketBadge({ account, onClick, t }: { account: CodexAccountEntry; onClick: () => void; t: TFn }) { const credits = account.quota?.resetCredits; if (credits === undefined) return null; const hasCredits = typeof credits === "number" && credits > 0; + const tip = t("codexAuth.resetCreditsTitleTooltip", { count: String(credits) }); return ( + ); +} + +// --------------------------------------------------------------------------- +// Connection card +// --------------------------------------------------------------------------- + +function localizeProviderNote(item: WorkspaceItem, t: TFn): string { + const note = (item.note ?? "").trim(); + const id = item.name.toLowerCase(); + if (id === "anthropic" || note.startsWith("Sign in with your Claude")) { + return t("modal.anthropicOauthNote"); + } + if ( + item.authMode === "forward" + || id === "openai" + || id === "chatgpt" + || note.startsWith("Uses your Codex login") + ) { + return t("modal.forwardNote"); + } + return note; +} + +function ConnectionCard({ item, onEdit, lastCheckedAt }: { + item: WorkspaceItem; + onEdit: () => void; + lastCheckedAt?: number; +}) { + const t = useT(); + const timeLabels = useRelativeTimeLabels(); + const baseUrl = item.baseUrl?.trim() ? item.baseUrl : "—"; + const status = binProviderStatus(item); + // Match design mock: connection cell uses "Connected" while the list uses Ready/Needs setup. + const statusText = status === "ready" + ? t("pws.status.connected") + : status === "needs-setup" + ? t("pws.status.needsSetup") + : t("prov.disabledBadge"); + const configurationText = status === "ready" + ? t("pws.ops.ok") + : status === "needs-setup" ? t("pws.ops.creds") : t("pws.ops.disabled"); + const statusCls = status === "ready" + ? "pwi-connection-status pwi-connection-status--ok" + : "pwi-connection-status pwi-connection-status--warn"; + return ( +
+

{t("pws.connection")}

+
+
+
{t("dash.status")}
+
{statusText}
+
+
+
{t("modal.baseUrl")}
+
{baseUrl}
+
+
+
{t("pws.cell.lastChecked")}
+
{formatRelativeTime(lastCheckedAt, timeLabels)}
+
+
+
{t("pws.cell.auth")}
+
{authModeLabel(item, t)}
+
+
+
{t("pws.cell.defaultModel")}
+
+ {item.defaultModel + ? <>{item.defaultModel}{" "}{t("prov.defaultBadge")} + : } +
+
+
+
+ + {status === "ready" + ? + +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Stats sidebar +// --------------------------------------------------------------------------- + +function StatsSidebar({ item, usageTotals, quotaReport, onViewUsage }: { + item: WorkspaceItem; + usageTotals?: ProviderUsageTotals; + quotaReport?: ProviderQuotaReportView; + onViewUsage: () => void; +}) { + const t = useT(); + const { locale } = useI18n(); + const timeLabels = useRelativeTimeLabels(); + const requests = usageTotals?.requests; + const tokens = usageTotals?.totalTokens; + const hasRequests = typeof requests === "number"; + const hasTokens = typeof tokens === "number"; + const hasQuota = !!quotaReport; + const hasStatRows = hasRequests || hasTokens || hasQuota; + return ( + + ); +} + +// --------------------------------------------------------------------------- +// Tab panels +// --------------------------------------------------------------------------- + +function TabOverview({ + item, apiBase, usageTotals, quotaReport, onSelectTab, lastCheckedAt, + oauth, accounts, keys, busy, loginHint, authHandlers, +}: { + item: WorkspaceItem; + apiBase: string; + usageTotals?: ProviderUsageTotals; + quotaReport?: ProviderQuotaReportView; + onSelectTab: (tab: Tab) => void; + lastCheckedAt?: number; + oauth?: { loggedIn: boolean; email?: string; error?: string }; + accounts?: OAuthAccountRow[]; + keys?: ApiKeyRow[]; + busy?: boolean; + loginHint?: LoginHint | null; + authHandlers?: ProviderAuthHandlers; +}) { + return ( +
+ {/* Note is shown in the Notes card only — avoid duplicating above Connection. */} +
+
+ onSelectTab("settings")} lastCheckedAt={lastCheckedAt ?? quotaReport?.updatedAt} /> + +
+ onSelectTab("usage")} /> +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Accounts / API keys (login-logout + multi-key) — new workspace style +// --------------------------------------------------------------------------- + +function AuthAccountsCard({ + item, apiBase, oauth, accounts = [], keys = [], busy = false, loginHint, authHandlers, +}: { + item: WorkspaceItem; + apiBase: string; + oauth?: { loggedIn: boolean; email?: string; error?: string }; + accounts?: OAuthAccountRow[]; + keys?: ApiKeyRow[]; + busy?: boolean; + loginHint?: LoginHint | null; + authHandlers?: ProviderAuthHandlers; +}) { + const t = useT(); + const [addingKey, setAddingKey] = useState(false); + const [newKey, setNewKey] = useState(""); + const [keyBusy, setKeyBusy] = useState(false); + + const mode = (item.authMode ?? "").toLowerCase(); + const isOauth = mode === "oauth"; + const isForward = mode === "forward"; + const isLocal = mode === "local" || isLocalProvider(item); + const isKeyOptional = item.keyOptional === true; + const hasKeyMaterial = item.hasApiKey === true || keys.length > 0; + // Key-auth: explicit key mode, or unspecified mode that is not oauth/forward/local. + // Keyless free (keyOptional, no keys) must not show "No API key" / "Add API key". + const isKeyAuth = + (mode === "key" || (!isOauth && !isForward && !isLocal) || item.hasApiKey === true) && + !(isKeyOptional && !hasKeyMaterial); + + // ChatGPT forward: global Codex account pool lives here (not a separate sidebar page). + if (isForward) { + return ( +
+

{t("pws.availableAccounts")}

+
+ +
+
+ ); + } + + // Always show when handlers exist: oauth login, key pool, or at least a configured key/local note. + if (!authHandlers) return null; + if (!isOauth && !isKeyAuth && !isLocal) return null; + + const hintForThis = loginHint?.provider === item.name ? loginHint : null; + + const submitKey = async () => { + const key = newKey.trim(); + if (!key) return; + setKeyBusy(true); + const ok = await authHandlers.onAddApiKey(item.name, key); + setKeyBusy(false); + if (ok) { + setNewKey(""); + setAddingKey(false); + } + }; + + return ( +
+

+ {isOauth ? t("pws.availableAccounts") : t("pws.apiKeys")} +

+
+ {isOauth && ( + <> +
+
+ + {busy && ( +
+
+ )} + + {accounts.length > 0 && ( +
+ {accounts.map(account => ( +
+ + +
+ ))} +
+ )} + + + )} + + {(isKeyAuth || (!isOauth && item.hasApiKey)) && ( + <> +
+ 0 ? "pwi-auth-dot--ok" : "pwi-auth-dot--off"}`} aria-hidden="true" /> + + {item.hasApiKey || keys.length > 0 + ? (keys.find(k => k.active)?.masked ?? t("prov.hasApiKey")) + : t("pws.noApiKey")} + +
+ {keys.length > 0 && ( +
+ {keys.map(entry => ( +
+ + +
+ ))} +
+ )} + {addingKey ? ( +
+ setNewKey(e.target.value)} + onKeyDown={e => { + if (e.key === "Enter") void submitKey(); + if (e.key === "Escape") { setAddingKey(false); setNewKey(""); } + }} + /> + + +
+ ) : ( + + )} + + )} +
+
+ ); +} + +function TabModels({ + item, + modelCount, + availableModels, + selectedModels, + modelsLoading = false, + modelsLoadFailed = false, + onRetryModels, +}: { + item: WorkspaceItem; + modelCount: number; + availableModels: string[]; + selectedModels: string[]; + modelsLoading?: boolean; + modelsLoadFailed?: boolean; + onRetryModels?: () => void; +}) { + const t = useT(); + const [query, setQuery] = useState(""); + const [copiedId, setCopiedId] = useState(null); + const listRef = useRef(null); + const selectedSet = useMemo(() => new Set(selectedModels), [selectedModels]); + const models = useMemo(() => { + const base = availableModels.length > 0 + ? availableModels + : item.defaultModel + ? [item.defaultModel] + : []; + const q = query.trim().toLowerCase(); + if (!q) return base; + return base.filter(id => id.toLowerCase().includes(q)); + }, [availableModels, item.defaultModel, query]); + + const virtualize = models.length > 40; + // eslint-disable-next-line react-hooks/incompatible-library -- known useVirtualizer limitation + const virtualizer = useVirtualizer({ + count: virtualize ? models.length : 0, + getScrollElement: () => listRef.current, + estimateSize: () => 36, + overscan: 12, + }); + + const copyModelId = async (modelId: string) => { + try { + await navigator.clipboard.writeText(modelId); + setCopiedId(modelId); + window.setTimeout(() => setCopiedId(prev => (prev === modelId ? null : prev)), 1200); + } catch { + /* ignore clipboard failures */ + } + }; + + const renderRow = (modelId: string, style?: CSSProperties) => { + const isDefault = modelId === item.defaultModel; + const isSelected = selectedSet.has(modelId); + return ( +
+ {modelId} + + {isDefault ? {t("prov.defaultBadge")} : null} + {isSelected ? {t("pws.selected")} : null} + + +
+ ); + }; + + return ( +
+
+ {t("pws.tab.models")} + {modelCount > 0 ? ( + {t("pws.modelsAvailable", { count: modelCount })} + ) : null} +
+
+ {modelsLoading && availableModels.length === 0 && !item.defaultModel ? ( +

{t("pws.modelsLoading")}

+ ) : modelsLoadFailed && availableModels.length === 0 && !item.defaultModel ? ( +
+ {t("pws.modelsLoadFailed")} + {onRetryModels && ( + + )} +
+ ) : availableModels.length > 0 || item.defaultModel ? ( + <> +
+ +
+ {models.length === 0 ? ( +
+ {t("pws.noModelsMatch")} +
+ ) : virtualize ? ( +
+
+ {virtualizer.getVirtualItems().map(vItem => { + const modelId = models[vItem.index]!; + return renderRow(modelId, { + position: "absolute", + top: vItem.start, + left: 0, + width: "100%", + }); + })} +
+
+ ) : ( +
+ {models.map(modelId => renderRow(modelId))} +
+ )} + + ) : ( +
+
{t("pws.noStaticModels")}
+

{t("pws.noStaticModelsDesc")}

+
+ {t("pws.defaultModelLabel")} + {t("pws.defaultModelNone")} +
+
+ )} +
+
+ ); +} + +function TabUsage({ item, usageTotals, quotaReport }: { + item: WorkspaceItem; + usageTotals?: ProviderUsageTotals; + quotaReport?: ProviderQuotaReportView; +}) { + const t = useT(); + const { locale } = useI18n(); + const timeLabels = useRelativeTimeLabels(); + const when = formatRelativeTime(quotaReport?.updatedAt, timeLabels); + const hasUsage = usageTotals?.requests !== undefined; + const quota = accountQuotaFromReport(quotaReport); + const hasQuotaMeta = !!quotaReport; + return ( +
+
+ {t("pws.tab.usage")} +
+
+
+

{t("pws.usageLast30d")}

+ {hasUsage ? ( +
+
+ {formatRequestCount(usageTotals.requests, locale)} + {t("pws.metricRequests")} +
+
+ {formatTokenCount(usageTotals.totalTokens, locale)} + {t("pws.metricTokens")} +
+
+ ) : ( +

{t("pws.usageUnavailable")}

+ )} +
+
+

{t("pws.rateLimits")}

+ {quota ? ( + <> + + {hasQuotaMeta && ( +
+ {quotaReport.source?.trim() ? ( +
+
{t("pws.quotaSource")}
+
{formatQuotaSourceLabel(quotaReport.source, t)}
+
+ ) : null} +
+
{t("pws.lastUpdated")}
+
{when}
+
+
+ )} + + ) : ( +

{t("pws.quotaUnavailable")}

+ )} + {!hasUsage && !hasQuotaMeta && ( +

{t("pws.noUsageFor", { name: formatProviderDisplayName(item.name) })}

+ )} +
+
+
+ ); +} + +function TabSettings({ + item, + apiBase, + oauth, + availableModels, + onUpdateProvider, + onDirtyChange, +}: { + item: WorkspaceItem; + apiBase: string; + oauth?: { loggedIn: boolean; email?: string; error?: string }; + availableModels: string[]; + onUpdateProvider?: (name: string, patch: ProviderUpdatePatch) => Promise<{ ok: boolean; error?: string }>; + onDirtyChange?: (dirty: boolean) => void; +}) { + const initialAuthMode = String(item.authMode ?? (item.keyOptional ? "local" : "key")); + const [adapter, setAdapter] = useState(item.adapter); + const [baseUrl, setBaseUrl] = useState(item.baseUrl); + const [defaultModel, setDefaultModel] = useState(item.defaultModel ?? ""); + const [authMode, setAuthMode] = useState(initialAuthMode); + const [apiKey, setApiKey] = useState(""); + const [note, setNote] = useState(item.note ?? ""); + const t = useT(); + const [saving, setSaving] = useState(false); + const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null); + + // Reset the settings form when the selected provider (or its server fields) change. + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional form reset on provider switch + setAdapter(item.adapter); + setBaseUrl(item.baseUrl); + setDefaultModel(item.defaultModel ?? ""); + setAuthMode(String(item.authMode ?? (item.keyOptional ? "local" : "key"))); + setApiKey(""); + setNote(item.note ?? ""); + setMsg(null); + }, [item.name, item.adapter, item.baseUrl, item.defaultModel, item.authMode, item.keyOptional, item.note]); + + const dirty = adapter.trim() !== item.adapter + || baseUrl.trim() !== item.baseUrl + || defaultModel.trim() !== (item.defaultModel ?? "") + || authMode !== String(item.authMode ?? (item.keyOptional ? "local" : "key")) + || note.trim() !== (item.note ?? "") + || apiKey.trim() !== ""; + + useEffect(() => { + onDirtyChange?.(dirty); + return () => onDirtyChange?.(false); + }, [dirty, onDirtyChange]); + + const modelOptions = useMemo(() => { + const set = new Set(availableModels); + if (defaultModel.trim()) set.add(defaultModel.trim()); + if (item.defaultModel) set.add(item.defaultModel); + return [...set].sort((a, b) => a.localeCompare(b)); + }, [availableModels, defaultModel, item.defaultModel]); + + const adapterOptions = useMemo(() => { + const list = [...SETTINGS_ADAPTERS] as string[]; + if (adapter && !list.includes(adapter)) list.unshift(adapter); + return list; + }, [adapter]); + + const save = async () => { + if (!onUpdateProvider) { + setMsg({ ok: false, text: t("pws.updatesUnavailable") }); + return; + } + if (!adapter.trim() || !baseUrl.trim()) { + setMsg({ ok: false, text: t("pws.adapterBaseRequired") }); + return; + } + setSaving(true); + setMsg(null); + const patch: ProviderUpdatePatch = { + adapter: adapter.trim(), + baseUrl: baseUrl.trim(), + defaultModel: defaultModel.trim(), + authMode, + note: note.trim(), + }; + if (apiKey.trim()) patch.apiKey = apiKey.trim(); + const res = await onUpdateProvider(item.name, patch); + setSaving(false); + setMsg(res.ok + ? { ok: true, text: t("pws.settingsSaved") } + : { ok: false, text: res.error || t("prov.saveFailed") }); + if (res.ok) setApiKey(""); + }; + + const hasKey = item.hasApiKey === true; + const isPreset = isCatalogProviderId(item.name); + + const discard = () => { + setAdapter(item.adapter); + setBaseUrl(item.baseUrl); + setDefaultModel(item.defaultModel ?? ""); + setAuthMode(String(item.authMode ?? (item.keyOptional ? "local" : "key"))); + setApiKey(""); + setNote(item.note ?? ""); + setMsg(null); + }; + + const advancedFields = ( + <> + + + + + + ); + + return ( + <> +
+
+ {t("pws.connectionSettings")} + {dirty && {t("pws.settingsDirty")}} +
+
+ {isPreset ? ( +
+ {t("pws.advancedSettings")} + {advancedFields} +
+ ) : advancedFields} + + +