From ba80cc81eb6ae6ed8abbc90c3b174d75f6aa5f3e Mon Sep 17 00:00:00 2001 From: Viet Nguyen Duc Date: Sat, 1 Aug 2026 05:08:32 +0700 Subject: [PATCH] fix(ui): repair provider setup flow in Get Started onboarding The onboarding wizard and the Add/Edit Provider dialog render the same AddProviderForm, but the wizard is wired up differently, and several defects lived in those differences. Model probe never fired during onboarding. The model-probe useEffect in App.tsx guarded on `providerAddOpen` only, which is set true solely when opening the manual Add/Edit Provider dialog. It is never set during the onboarding wizard, so after entering an endpoint + API key on the Get Started flow and reaching the "Pick models" step, probeProviderCandidates() never fired and the model list stayed empty with no loading state. Regressed in 9f704fc ("Guide onboarding through granular provider setup steps"), which narrowed the guard from `providerAddOpen || (activeView === "onboarding" && onboardingStep === "provider")` down to `providerAddOpen`. Restore the broader guard so the probe runs both when the dialog is open and when onboarding is on the provider step. A rejected API key produced an empty model list with no feedback. Typing an API key switches the probe from mode "protocols" to mode "models", but the error-reporting branch was guarded on `probeMode !== "models"`, so it never ran in exactly that mode. The backend still runs full protocol probing in models mode, so a 401 came back as `supported: false` with a real message that was then discarded. Report the failure, while staying quiet when models were discovered, since a provider can expose a working catalog while a protocol probe endpoint 404s. "Added models" faked a loading state for data it already had. The panel renders local draft state, yet showed a skeleton and hid its whole toolbar whenever the catalog probe re-ran, leaving the header badge reading a real count above shimmer rows and removing the "Custom model" button, the only way to add models on a provider with no catalog. Keep both panels' controls mounted, disabling the catalog search while loading, which also removes a layout shift and mid-typing focus loss. An empty catalog gave no way forward, so point at "Custom model" (en+zh). Onboarding's "Check Connection" spent real credits with no confirmation. The dialog wraps onCheck in a confirm step that warns about account balance, lets the user pick models, and shows per-model results; onboarding passed onCheckProvider straight through, so one click fired a billable request against every configured model and discarded the report. Extract that step as ProviderConnectivityCheckDialog and use it from both surfaces. ProviderConnectionStatusRow hardcoded bg-emerald-50 / border-emerald-200 / bg-amber-50 with no dark variant, so the "Verify connection" status chips rendered as bright light blobs on the dark card. Use alpha fills, matching the pattern used elsewhere in the file. The provider error banner was not announced; add role="alert". Signed-off-by: Viet Nguyen Duc --- packages/ui/src/pages/home/App.tsx | 9 +- .../src/pages/home/components/onboarding.tsx | 19 +- .../src/pages/home/components/providers.tsx | 326 +++++++++--------- packages/ui/src/pages/home/shared/i18n.tsx | 2 + .../ui/test/integration/providers.test.ts | 63 +++- 5 files changed, 255 insertions(+), 164 deletions(-) diff --git a/packages/ui/src/pages/home/App.tsx b/packages/ui/src/pages/home/App.tsx index 69479c34..2ed32c7a 100644 --- a/packages/ui/src/pages/home/App.tsx +++ b/packages/ui/src/pages/home/App.tsx @@ -1263,7 +1263,8 @@ function App() { } useEffect(() => { - if (!window.ccr || !providerAddOpen) { + const providerFormVisible = providerAddOpen || (activeView === "onboarding" && onboardingStep === "provider"); + if (!window.ccr || !providerFormVisible) { return; } if (providerDraft.protocolDetectionMode === "manual") { @@ -1369,7 +1370,11 @@ function App() { return applyProviderProbeResult(current, result.probe); }); - if (probeMode !== "models" && !providerProbeHasSupportedProtocol(result.probe)) { + // In "models" mode the probe still reports protocol support, so a rejected API key + // surfaces here as unsupported protocols and an empty catalog. Report it instead of + // leaving the model picker silently empty, but stay quiet when models were discovered + // (a provider can expose a working catalog while a protocol probe endpoint 404s). + if (!providerProbeHasSupportedProtocol(result.probe) && (probeMode !== "models" || result.probe.models.length === 0)) { const message = result.probe.protocols.find((item) => item.message)?.message || "Request failed."; setProviderProbeError(translateAppErrorMessage(copy, message)); } diff --git a/packages/ui/src/pages/home/components/onboarding.tsx b/packages/ui/src/pages/home/components/onboarding.tsx index 807ae0c2..8290007e 100644 --- a/packages/ui/src/pages/home/components/onboarding.tsx +++ b/packages/ui/src/pages/home/components/onboarding.tsx @@ -6,7 +6,7 @@ import { useState, UserRound, X } from "../shared/index"; -import { AddProviderForm, providerSetupStepIds, type ProviderSetupStepId } from "./providers"; +import { AddProviderForm, ProviderConnectivityCheckDialog, providerSetupStepIds, type ProviderSetupStepId } from "./providers"; import { AddProfileForm } from "./profiles"; type OnboardingMascotTone = "cyan" | "orange" | "violet"; @@ -69,7 +69,7 @@ export function OnboardingView({ config: AppConfig; endpoint: string; gatewayStatus: GatewayStatus; - onCheckProvider: () => Promise; + onCheckProvider: (models?: string[]) => Promise; onChangeProfile: (patch: Partial) => void; onChangeProvider: (patch: Partial, resetProbe?: boolean) => void; onComplete: () => void | Promise; @@ -88,6 +88,7 @@ export function OnboardingView({ }) { const t = useAppText(); const shouldReduceMotion = useReducedMotion(); + const [providerCheckOpen, setProviderCheckOpen] = useState(false); const [providerIconDetecting, setProviderIconDetecting] = useState(false); const [providerSetupStep, setProviderSetupStep] = useState("provider"); const providerReady = isOnboardingProviderReady(config); @@ -105,7 +106,8 @@ export function OnboardingView({ ? providerDraftHasReadyCredentialPool(providerDraft) : providerDraft.apiKey.trim() ); - const providerModelsReady = mergeProviderModelLists(providerDraft.selectedModels, splitLines(providerDraft.modelsText)).length > 0; + const providerCheckModels = mergeProviderModelLists(providerDraft.selectedModels, splitLines(providerDraft.modelsText)); + const providerModelsReady = providerCheckModels.length > 0; const providerSetupIndex = Math.max(0, providerSetupStepIds.indexOf(providerSetupStep)); const previousProviderSetupStep = activeStep === "provider" ? providerSetupStepIds[providerSetupIndex - 1] : undefined; const nextProviderSetupStep = activeStep === "provider" ? providerSetupStepIds[providerSetupIndex + 1] : undefined; @@ -260,7 +262,7 @@ export function OnboardingView({ error={providerError} activeStep={providerSetupStep} mode={providerReady ? "edit" : "add"} - onCheck={onCheckProvider} + onCheck={async () => setProviderCheckOpen(true)} onChange={onChangeProvider} onIconDetectingChange={setProviderIconDetecting} onSelectStep={(step) => { @@ -330,6 +332,15 @@ export function OnboardingView({ + + {providerCheckOpen ? ( + setProviderCheckOpen(false)} + /> + ) : null} ); } diff --git a/packages/ui/src/pages/home/components/providers.tsx b/packages/ui/src/pages/home/components/providers.tsx index 725878dd..199d94c2 100644 --- a/packages/ui/src/pages/home/components/providers.tsx +++ b/packages/ui/src/pages/home/components/providers.tsx @@ -1827,14 +1827,14 @@ function ProviderConnectionStatusRow({ return (
{loading ? : state === "success" ? : } @@ -2532,7 +2532,7 @@ export function AddProviderForm({
- {error ?
{error}
: null} + {error ?
{error}
: null} ); } @@ -3174,11 +3174,8 @@ export function AddProviderDialog({ }) { const t = useAppText(); const [checkConfirmOpen, setCheckConfirmOpen] = useState(false); - const [checkConfirmBusy, setCheckConfirmBusy] = useState(false); const [iconDetecting, setIconDetecting] = useState(false); const [submitting, setSubmitting] = useState(false); - const [checkModelSelection, setCheckModelSelection] = useState([]); - const [checkResult, setCheckResult] = useState(); const [activeStep, setActiveStep] = useState("provider"); const checkModels = mergeProviderModelLists(draft.selectedModels, splitLines(draft.modelsText)); const submitLoading = probeLoading || connectivityLoading || iconDetecting || submitting; @@ -3247,33 +3244,6 @@ export function AddProviderDialog({ setActiveStep(nextStep); } - function openCheckConfirm() { - setCheckModelSelection(checkModels); - setCheckResult(undefined); - setCheckConfirmOpen(true); - } - - async function confirmCheck() { - if (!onCheck) { - return; - } - setCheckConfirmBusy(true); - try { - setCheckResult(await onCheck(checkModelSelection)); - } finally { - setCheckConfirmBusy(false); - } - } - - function toggleCheckModel(model: string) { - setCheckModelSelection((current) => - current.includes(model) - ? current.filter((item) => item !== model) - : mergeProviderModelLists(current, [model]) - ); - setCheckResult(undefined); - } - async function submit() { if (submitDisabled) { return; @@ -3329,7 +3299,7 @@ export function AddProviderDialog({ hideSetupProgress={!wizardMode} importProvider={importProvider} mode={mode} - onCheck={onCheck ? async () => openCheckConfirm() : undefined} + onCheck={onCheck ? async () => setCheckConfirmOpen(true) : undefined} onChange={onChange} onIconDetectingChange={setIconDetecting} onSelectStep={wizardMode ? selectSetupStep : undefined} @@ -3366,91 +3336,141 @@ export function AddProviderDialog({ - {checkConfirmOpen ? ( - !open && !checkConfirmBusy && setCheckConfirmOpen(false)}> - - -
- {t("Check Connection")} -
- -
- -
-
-
- - {t("This check sends real model requests with your provider API key and may consume account balance.")} -
-
- {t("Generated output is limited to 1 token for connectivity checks.")} -
-
- -
-
-
{t("Models to check")}
-
- - -
-
-
-
- {checkModels.map((model) => { - const checked = checkModelSelection.includes(model); - return ( - - ); - })} -
-
-
- - {checkResult ? : null} -
-
- - - - -
-
+ {checkConfirmOpen && onCheck ? ( + setCheckConfirmOpen(false)} + /> ) : null} ); } +/** + * Confirmation step for the connectivity check. The check spends real provider credits, so every + * surface that offers "Check Connection" — the add/edit dialog and the onboarding wizard — must go + * through this dialog rather than calling onCheck directly. + */ +export function ProviderConnectivityCheckDialog({ + connectivityLoading, + models, + onCheck, + onClose +}: { + connectivityLoading: boolean; + models: string[]; + onCheck: (models: string[]) => Promise; + onClose: () => void; +}) { + const t = useAppText(); + const [busy, setBusy] = useState(false); + const [selection, setSelection] = useState(() => mergeProviderModelLists(models)); + const [result, setResult] = useState(); + const running = busy || connectivityLoading; + + async function confirmCheck() { + setBusy(true); + try { + setResult(await onCheck(selection)); + } finally { + setBusy(false); + } + } + + function toggleCheckModel(model: string) { + setSelection((current) => + current.includes(model) + ? current.filter((item) => item !== model) + : mergeProviderModelLists(current, [model]) + ); + setResult(undefined); + } + + return ( + !open && !busy && onClose()}> + + +
+ {t("Check Connection")} +
+ +
+ +
+
+
+ + {t("This check sends real model requests with your provider API key and may consume account balance.")} +
+
+ {t("Generated output is limited to 1 token for connectivity checks.")} +
+
+ +
+
+
{t("Models to check")}
+
+ + +
+
+
+
+ {models.map((model) => { + const checked = selection.includes(model); + return ( + + ); + })} +
+
+
+ + {result ? : null} +
+
+ + + + +
+
+ ); +} + function ProviderConnectivityResultPanel({ result }: { result: ProviderConnectivityCheckReport }) { const t = useAppText(); @@ -3646,7 +3666,7 @@ function ProviderModelPicker({ observer?.disconnect(); window.removeEventListener("resize", updateWidth); }; - }, [loading]); + }, []); useEffect(() => { if (!customModelEditing) { @@ -3666,26 +3686,28 @@ function ProviderModelPicker({ {loading ? : catalog.length} - {!loading ? ( -
-
- - onQueryChange(event.target.value)} - placeholder={t("Search provider models")} - value={query} - /> -
+
+
+ + onQueryChange(event.target.value)} + placeholder={t("Search provider models")} + value={query} + />
- ) : null} +
{loading ? ( ) : visibleCatalogModels.length === 0 ? (
- {catalog.length === 0 ? t("No provider models") : t("No matching models")} +
{catalog.length === 0 ? t("No provider models") : t("No matching models")}
+ {catalog.length === 0 ? ( +
{t("This provider did not return a model list. Add model IDs with Custom model.")}
+ ) : null}
) : (
@@ -3738,10 +3760,9 @@ function ProviderModelPicker({
0 ? "secondary" : "outline"}>{selectedModels.length}
- {!loading ? ( -
-
- +
+
+ {customModelEditing ? ( )} - -
+
- ) : null} +
- {loading ? ( - - ) : ( - - )} +
); } -function ProviderModelListSkeleton({ compact = false }: { compact?: boolean }) { +function ProviderModelListSkeleton() { const t = useAppText(); return ( @@ -3910,7 +3926,7 @@ function ProviderModelListSkeleton({ compact = false }: { compact?: boolean }) { "provider-skeleton-shimmer h-3 rounded-full", index % 3 === 0 ? "w-7/12" : index % 3 === 1 ? "w-9/12" : "w-5/12" )} /> - {!compact && index % 2 === 0 ?
: null} + {index % 2 === 0 ?
: null}
diff --git a/packages/ui/src/pages/home/shared/i18n.tsx b/packages/ui/src/pages/home/shared/i18n.tsx index fd1f0e09..92463750 100644 --- a/packages/ui/src/pages/home/shared/i18n.tsx +++ b/packages/ui/src/pages/home/shared/i18n.tsx @@ -359,6 +359,7 @@ export const appCopy: Record = { "Models detected from this provider": "Models detected from this provider", "No models added": "No models added", "No provider models": "No provider models", + "This provider did not return a model list. Add model IDs with Custom model.": "This provider did not return a model list. Add model IDs with Custom model.", "No available models": "No available models", "No local login state was found for this agent.": "No local login state was found for this agent.", "No providers": "No providers", @@ -1948,6 +1949,7 @@ export const appCopy: Record = { "No available models": "没有可用模型", "No models added": "未添加模型", "No provider models": "没有供应商模型", + "This provider did not return a model list. Add model IDs with Custom model.": "该供应商未返回模型列表,请使用“自定义模型”手动添加模型 ID。", "No protocol detection yet": "尚未检测协议", "No response fields": "没有响应字段", "No unavailable models": "没有不可用模型", diff --git a/packages/ui/test/integration/providers.test.ts b/packages/ui/test/integration/providers.test.ts index ed72c43b..1623b352 100644 --- a/packages/ui/test/integration/providers.test.ts +++ b/packages/ui/test/integration/providers.test.ts @@ -7,7 +7,7 @@ import { geminiProviderPreset } from "@ccr/core/providers/presets/gemini/index.t import { minimaxChinaProviderPreset } from "@ccr/core/providers/presets/minimax/index.ts"; import { moonshotGlobalProviderPreset } from "@ccr/core/providers/presets/moonshot/index.ts"; import { qiniuAiProviderPreset } from "@ccr/core/providers/presets/qiniu-ai/index.ts"; -import { AddProviderDialog, AddProviderForm, ProvidersView, uniqueProviderProbeProtocolRows } from "@ccr/ui/pages/home/components/providers.tsx"; +import { AddProviderDialog, AddProviderForm, ProviderConnectivityCheckDialog, ProvidersView, uniqueProviderProbeProtocolRows } from "@ccr/ui/pages/home/components/providers.tsx"; import { applyProviderProbeResult, createProviderConfigFromDeepLink, @@ -627,9 +627,66 @@ test("AddProviderForm shows skeleton rows while provider models load", () => { assert.match(html, /aria-busy="true"/); assert.match(html, /Loading provider models/); assert.match(html, /provider-skeleton-shimmer/); - assert.doesNotMatch(html, /Custom model/); - assert.doesNotMatch(html, /No models added/); assert.doesNotMatch(html, /No provider models/); + // The added-models panel holds local draft state, so it keeps rendering its real contents and + // controls while the provider catalog probe is still running. + assert.match(html, /Custom model/); + assert.match(html, /No models added/); +}); + +test("AddProviderForm explains an empty provider catalog once the probe settles", () => { + const draft = { + ...createProviderDraft([]), + apiKey: "sk-test", + baseUrl: "https://api.example/v1", + name: "Example", + presetId: customProviderPresetId + }; + const html = renderToStaticMarkup( + React.createElement(AddProviderForm, { + activeStep: "models", + draft, + error: "", + mode: "add", + onChange: () => undefined, + probeLoading: false, + providers: [] + }) + ); + + assert.match(html, /No provider models/); + assert.match(html, /Add model IDs with Custom model/); +}); + +test("connectivity check confirmation warns about spending provider credits", () => { + const html = renderToStaticMarkup( + React.createElement(ProviderConnectivityCheckDialog, { + connectivityLoading: false, + models: ["example-model", "example-model-mini"], + onCheck: async () => ({ failed: [], passed: [], results: [] }), + onClose: () => undefined + }) + ); + + assert.match(html, /This check sends real model requests with your provider API key and may consume account balance\./); + assert.match(html, /Models to check/); + assert.match(html, /example-model-mini/); + assert.match(html, /Start check/); +}); + +test("AddProviderForm marks the provider error banner as an alert", () => { + const html = renderToStaticMarkup( + React.createElement(AddProviderForm, { + draft: createProviderDraft([]), + error: "Invalid API key.", + mode: "add", + onChange: () => undefined, + probeLoading: false, + providers: [] + }) + ); + + assert.match(html, /role="alert"[^>]*>[\s\S]*Invalid API key\./); }); test("provider connectivity API key follows selected credential mode", () => {