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", () => {