Guide onboarding through granular provider setup steps

This commit is contained in:
musistudio 2026-07-24 23:02:28 +08:00
parent 084b660cd4
commit 9f704fc911
6 changed files with 212 additions and 139 deletions

View file

@ -1259,8 +1259,7 @@ function App() {
} }
useEffect(() => { useEffect(() => {
const providerFormVisible = providerAddOpen || (activeView === "onboarding" && onboardingStep === "provider"); if (!window.ccr || !providerAddOpen) {
if (!window.ccr || !providerFormVisible) {
return; return;
} }
if (providerDraft.protocolDetectionMode === "manual") { if (providerDraft.protocolDetectionMode === "manual") {

View file

@ -1,12 +1,12 @@
import { import {
AddProfileDraft, AddProviderDraft, AppConfig, Button, Check, ChevronLeft, AddProfileDraft, AddProviderDraft, AppConfig, Button, Check, ChevronLeft,
ChevronRight, CircleAlert, cn, GatewayProviderProbeResult, GatewayStatus, Gauge, getNextOnboardingStep, ChevronRight, cn, findProviderPreset, GatewayProviderProbeResult, GatewayStatus, Gauge, getNextOnboardingStep,
isOnboardingProfileReady, isOnboardingProviderReady, Layers3, LucideIcon, motion, motionEase, isOnboardingProfileReady, isOnboardingProviderReady, Layers3, LucideIcon, mergeProviderModelLists, motion, motionEase,
LoaderCircle, onboardingMascotSpriteUrl, OnboardingReadinessOptions, OnboardingStepId, onboardingStepOrder, ProviderConnectivityCheckReport, reducedMotionTransition, useAppText, useReducedMotion, LoaderCircle, onboardingMascotSpriteUrl, OnboardingReadinessOptions, OnboardingStepId, onboardingStepOrder, providerDraftHasReadyCredentialPool, ProviderConnectivityCheckReport, reducedMotionTransition, splitLines, useAppText, useEffect, useReducedMotion,
useState, useState,
UserRound, X UserRound, X
} from "../shared/index"; } from "../shared/index";
import { AddProviderForm } from "./providers"; import { AddProviderForm, providerSetupStepIds, type ProviderSetupStepId } from "./providers";
import { AddProfileForm } from "./profiles"; import { AddProfileForm } from "./profiles";
type OnboardingMascotTone = "cyan" | "orange" | "violet"; type OnboardingMascotTone = "cyan" | "orange" | "violet";
@ -108,6 +108,7 @@ export function OnboardingView({
const t = useAppText(); const t = useAppText();
const shouldReduceMotion = useReducedMotion(); const shouldReduceMotion = useReducedMotion();
const [providerIconDetecting, setProviderIconDetecting] = useState(false); const [providerIconDetecting, setProviderIconDetecting] = useState(false);
const [providerSetupStep, setProviderSetupStep] = useState<ProviderSetupStepId>("provider");
const providerReady = isOnboardingProviderReady(config); const providerReady = isOnboardingProviderReady(config);
const profileReady = isOnboardingProfileReady(config, readiness); const profileReady = isOnboardingProfileReady(config, readiness);
const serviceReady = gatewayStatus.state === "running"; const serviceReady = gatewayStatus.state === "running";
@ -116,25 +117,72 @@ export function OnboardingView({
const activeDetails = onboardingStepDetails[activeStep]; const activeDetails = onboardingStepDetails[activeStep];
const previousStep = onboardingStepOrder[activeIndex - 1]; const previousStep = onboardingStepOrder[activeIndex - 1];
const nextStep = getNextOnboardingStep(activeStep, config, readiness); const nextStep = getNextOnboardingStep(activeStep, config, readiness);
const localAgentProviderImport = providerDraft.providerPlugins.length > 0;
const providerIdentityReady = Boolean(findProviderPreset(providerDraft.presetId) || providerDraft.baseUrl.trim());
const providerCredentialReady = localAgentProviderImport || Boolean(
providerDraft.credentialMode === "pool"
? providerDraftHasReadyCredentialPool(providerDraft)
: providerDraft.apiKey.trim()
);
const providerModelsReady = mergeProviderModelLists(providerDraft.selectedModels, splitLines(providerDraft.modelsText)).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;
const providerSubmitLoading = activeStep === "provider" && (providerProbeLoading || providerConnectivityLoading || providerIconDetecting); const providerSubmitLoading = activeStep === "provider" && (providerProbeLoading || providerConnectivityLoading || providerIconDetecting);
const nextDisabled = activeStep === "provider" const nextDisabled = activeStep === "provider"
? providerSubmitLoading || !(providerReady || canSubmitProvider) ? providerSubmitLoading || (nextProviderSetupStep
? !isProviderSetupStepReady(providerSetupStep)
: !(providerReady || canSubmitProvider))
: activeStep === "profile" : activeStep === "profile"
? !(profileReady || (providerReady && canSubmitProfile)) ? !(profileReady || (providerReady && canSubmitProfile))
: !routeReady; : !routeReady;
const nextGuidance = onboardingNextGuidance({
activeStep, useEffect(() => {
canSubmitProfile, if (activeStep !== "provider" || isProviderSetupStepUnlocked(providerSetupStep)) {
canSubmitProvider, return;
profileReady, }
providerReady, setProviderSetupStep(getLatestUnlockedProviderSetupStep());
providerSubmitLoading, }, [activeStep, providerCredentialReady, providerIdentityReady, providerModelsReady, providerSetupStep]);
routeReady,
serviceReady function isProviderSetupStepReady(step: ProviderSetupStepId): boolean {
}); switch (step) {
case "provider":
return providerIdentityReady;
case "credentials":
return providerCredentialReady;
case "models":
return providerModelsReady;
case "verify":
return true;
}
}
function isProviderSetupStepUnlocked(step: ProviderSetupStepId): boolean {
switch (step) {
case "provider":
return true;
case "credentials":
return providerIdentityReady;
case "models":
return providerIdentityReady && providerCredentialReady;
case "verify":
return providerIdentityReady && providerCredentialReady && providerModelsReady;
}
}
function getLatestUnlockedProviderSetupStep(): ProviderSetupStepId {
return [...providerSetupStepIds].reverse().find(isProviderSetupStepUnlocked) ?? "provider";
}
function goToPreviousStep() { function goToPreviousStep() {
if (activeStep === "provider" && previousProviderSetupStep) {
setProviderSetupStep(previousProviderSetupStep);
return;
}
if (previousStep) { if (previousStep) {
if (previousStep === "provider") {
setProviderSetupStep(getLatestUnlockedProviderSetupStep());
}
onSelectStep(previousStep); onSelectStep(previousStep);
} }
} }
@ -151,6 +199,12 @@ export function OnboardingView({
if (providerSubmitLoading) { if (providerSubmitLoading) {
return; return;
} }
if (nextProviderSetupStep) {
if (isProviderSetupStepReady(providerSetupStep)) {
setProviderSetupStep(nextProviderSetupStep);
}
return;
}
if (canSubmitProvider) { if (canSubmitProvider) {
const saved = await onSubmitProvider(); const saved = await onSubmitProvider();
if (saved) { if (saved) {
@ -188,10 +242,10 @@ export function OnboardingView({
style={{ transformPerspective: 900 }} style={{ transformPerspective: 900 }}
transition={shouldReduceMotion ? reducedMotionTransition : { duration: 0.28, ease: motionEase }} transition={shouldReduceMotion ? reducedMotionTransition : { duration: 0.28, ease: motionEase }}
> >
<OnboardingProgress activeIndex={activeIndex} /> <OnboardingProgress activeStep={activeStep} providerSetupStep={providerSetupStep} />
<div className="flex min-h-0 min-w-0 flex-1 flex-col p-4 sm:p-5"> <div className="flex min-h-0 min-w-0 flex-1 flex-col p-4 sm:p-5">
<div className="flex h-8 shrink-0 items-center"> <div className="flex h-8 shrink-0 items-center">
{previousStep ? ( {previousStep || previousProviderSetupStep ? (
<Button <Button
className="inline-flex h-8 items-center gap-1.5 rounded-md px-1 text-[13px] font-medium text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/25" className="inline-flex h-8 items-center gap-1.5 rounded-md px-1 text-[13px] font-medium text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/25"
onClick={goToPreviousStep} onClick={goToPreviousStep}
@ -210,13 +264,6 @@ export function OnboardingView({
<h2 className="text-[20px] font-semibold tracking-normal">{t(activeDetails.title)}</h2> <h2 className="text-[20px] font-semibold tracking-normal">{t(activeDetails.title)}</h2>
<p className="mt-1 text-[12px] leading-5 text-muted-foreground">{t(activeDetails.description)}</p> <p className="mt-1 text-[12px] leading-5 text-muted-foreground">{t(activeDetails.description)}</p>
</div> </div>
<OnboardingReadinessChecklist
activeStep={activeStep}
profileReady={profileReady}
providerReady={providerReady}
routeReady={routeReady}
serviceReady={serviceReady}
/>
</div> </div>
<div className="onboarding-step-panels mt-5 min-h-0 flex-1 overflow-hidden"> <div className="onboarding-step-panels mt-5 min-h-0 flex-1 overflow-hidden">
@ -230,10 +277,16 @@ export function OnboardingView({
connectivityProbe={providerConnectivityProbe} connectivityProbe={providerConnectivityProbe}
draft={providerDraft} draft={providerDraft}
error={providerError} error={providerError}
activeStep={providerSetupStep}
mode={providerReady ? "edit" : "add"} mode={providerReady ? "edit" : "add"}
onCheck={onCheckProvider} onCheck={onCheckProvider}
onChange={onChangeProvider} onChange={onChangeProvider}
onIconDetectingChange={setProviderIconDetecting} onIconDetectingChange={setProviderIconDetecting}
onSelectStep={(step) => {
if (isProviderSetupStepUnlocked(step)) {
setProviderSetupStep(step);
}
}}
probe={providerProbe} probe={providerProbe}
probeLoading={providerProbeLoading} probeLoading={providerProbeLoading}
providerPlugins={config.providerPlugins ?? []} providerPlugins={config.providerPlugins ?? []}
@ -284,15 +337,7 @@ export function OnboardingView({
</div> </div>
</div> </div>
<div className="mt-5 flex shrink-0 items-center justify-between gap-3 border-t border-border/60 pt-4 max-[640px]:flex-col max-[640px]:items-stretch"> <div className="mt-5 flex shrink-0 items-center justify-end gap-3 border-t border-border/60 pt-4 max-[640px]:items-stretch">
<div className="min-w-0 flex-1">
{nextGuidance ? (
<div className="flex min-w-0 items-start gap-2 rounded-md border border-border bg-muted/25 px-3 py-2 text-[12px] leading-5 text-muted-foreground">
<CircleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span className="min-w-0">{t(nextGuidance)}</span>
</div>
) : null}
</div>
<Button disabled={nextDisabled} onClick={() => void goToNextStep()} type="button"> <Button disabled={nextDisabled} onClick={() => void goToNextStep()} type="button">
{providerSubmitLoading ? <LoaderCircle className="h-4 w-4 animate-spin" /> : activeStep === "enter" ? <Check className="h-4 w-4" /> : null} {providerSubmitLoading ? <LoaderCircle className="h-4 w-4 animate-spin" /> : activeStep === "enter" ? <Check className="h-4 w-4" /> : null}
{providerSubmitLoading ? t("Loading") : activeStep === "enter" ? t("Let's start") : t("Next step")} {providerSubmitLoading ? t("Loading") : activeStep === "enter" ? t("Let's start") : t("Next step")}
@ -307,121 +352,42 @@ export function OnboardingView({
); );
} }
function onboardingNextGuidance({ const onboardingProgressItems: Array<{ key: ProviderSetupStepId | "profile" | "enter"; label: string }> = [
activeStep, { key: "provider", label: "Choose provider" },
canSubmitProfile, { key: "credentials", label: "Add credentials" },
canSubmitProvider, { key: "models", label: "Pick models" },
profileReady, { key: "verify", label: "Verify connection" },
providerReady, { key: "profile", label: "Connect agent" },
providerSubmitLoading, { key: "enter", label: "Let's start" }
routeReady, ];
serviceReady
}: {
activeStep: OnboardingStepId;
canSubmitProfile: boolean;
canSubmitProvider: boolean;
profileReady: boolean;
providerReady: boolean;
providerSubmitLoading: boolean;
routeReady: boolean;
serviceReady: boolean;
}): string {
if (providerSubmitLoading) {
return "CCR is checking this provider. Wait for the check to finish before continuing.";
}
if (activeStep === "provider") {
if (canSubmitProvider) {
return "Save this provider to continue.";
}
if (!providerReady) {
return "Choose a provider preset or endpoint, enter an API key, and add at least one model.";
}
}
if (activeStep === "profile") {
if (!providerReady) {
return "Configure a provider before creating an agent profile.";
}
if (canSubmitProfile) {
return "Save this agent profile to continue.";
}
if (!profileReady) {
return "Choose an agent, model, and required profile settings.";
}
}
if (activeStep === "enter") {
if (!routeReady) {
return "Finish the provider and agent profile steps before entering the app.";
}
if (!serviceReady) {
return "The gateway is configured. Start the service from the toolbar when you are ready to test traffic.";
}
return "Send a request from your agent, then confirm it appears in Logs.";
}
return "";
}
function OnboardingReadinessChecklist({ function OnboardingProgress({
activeStep, activeStep,
profileReady, providerSetupStep
providerReady,
routeReady,
serviceReady
}: { }: {
activeStep: OnboardingStepId; activeStep: OnboardingStepId;
profileReady: boolean; providerSetupStep: ProviderSetupStepId;
providerReady: boolean;
routeReady: boolean;
serviceReady: boolean;
}) { }) {
const t = useAppText(); const t = useAppText();
const items = [ const activeKey = activeStep === "provider" ? providerSetupStep : activeStep;
{ active: activeStep === "provider", label: "Provider", ready: providerReady }, const activeIndex = Math.max(0, onboardingProgressItems.findIndex((item) => item.key === activeKey));
{ active: activeStep === "profile", label: "Agent profile", ready: profileReady }, const stepCount = onboardingProgressItems.length;
{ active: activeStep === "enter", label: "Gateway service", ready: serviceReady },
{ active: activeStep === "enter" && routeReady, label: "Ready to test", ready: routeReady }
];
return (
<div className="mt-2 flex max-w-full flex-wrap items-center justify-center gap-1.5" aria-label={t("Setup readiness")}>
{items.map((item) => (
<span
className={cn(
"inline-flex h-6 max-w-full items-center gap-1.5 rounded-full border px-2 text-[11px] font-medium",
item.ready
? "border-emerald-500/25 bg-emerald-500/10 text-emerald-700 dark:text-emerald-200"
: item.active
? "border-primary/25 bg-primary/10 text-primary"
: "border-border bg-muted/30 text-muted-foreground"
)}
key={item.label}
>
{item.ready ? <Check className="h-3 w-3 shrink-0" /> : <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-current opacity-70" />}
<span className="truncate">{t(item.label)}</span>
</span>
))}
</div>
);
}
function OnboardingProgress({ activeIndex }: { activeIndex: number }) {
const t = useAppText();
const stepCount = onboardingStepOrder.length;
const progressWidth = `${((activeIndex + 1) / stepCount) * 100}%`; const progressWidth = `${((activeIndex + 1) / stepCount) * 100}%`;
return ( return (
<div className="relative shrink-0 border-b border-border/60 bg-card/95" aria-label={`${t("Step")} ${activeIndex + 1} / ${stepCount}`}> <div className="relative shrink-0 border-b border-border/60 bg-card/95" aria-label={`${t("Step")} ${activeIndex + 1} / ${stepCount}`}>
<div className="mx-auto flex h-11 max-w-[520px] items-center justify-center px-3 text-[13px] font-medium"> <div className="mx-auto flex h-11 max-w-[920px] items-center justify-start overflow-x-auto px-3 text-[12px] font-medium sm:justify-center">
{onboardingStepOrder.map((step, index) => ( {onboardingProgressItems.map((item, index) => (
<div className="flex min-w-0 items-center" key={step}> <div className="flex shrink-0 items-center" key={item.key}>
<span <span
className={cn( className={cn(
"max-w-[136px] truncate", "max-w-[128px] truncate max-[560px]:max-w-[96px]",
index === activeIndex ? "text-foreground" : "text-muted-foreground" index === activeIndex ? "text-foreground" : "text-muted-foreground"
)} )}
> >
{t(onboardingStepDetails[step].title)} {t(item.label)}
</span> </span>
{index < stepCount - 1 ? <ChevronRight className="mx-5 h-4 w-4 shrink-0 text-muted-foreground/70 max-[560px]:mx-2" /> : null} {index < stepCount - 1 ? <ChevronRight className="mx-2.5 h-4 w-4 shrink-0 text-muted-foreground/70 max-[560px]:mx-1.5" /> : null}
</div> </div>
))} ))}
</div> </div>

View file

@ -759,7 +759,7 @@ export function AddProfileForm({
].filter(Boolean).length; ].filter(Boolean).length;
const advancedSummary = advancedIssueCount > 0 const advancedSummary = advancedIssueCount > 0
? t("Advanced settings need attention") ? t("Advanced settings need attention")
: t("Paths, provider identity, bot, compact, and env"); : t("Paths, bot, compact, and env");
const handleAppPathDrop = useCallback((event: ReactDragEvent<HTMLElement>) => { const handleAppPathDrop = useCallback((event: ReactDragEvent<HTMLElement>) => {
if (!showAppPathField) { if (!showAppPathField) {
return; return;

View file

@ -1593,9 +1593,9 @@ function localAgentProviderAlreadyImported(
}); });
} }
type ProviderSetupStepId = "provider" | "credentials" | "models" | "verify"; export type ProviderSetupStepId = "provider" | "credentials" | "models" | "verify";
const providerSetupStepIds: ProviderSetupStepId[] = ["provider", "credentials", "models", "verify"]; export const providerSetupStepIds: ProviderSetupStepId[] = ["provider", "credentials", "models", "verify"];
function ProviderSetupProgress({ function ProviderSetupProgress({
activeStep, activeStep,

View file

@ -431,7 +431,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Idle seconds must be between 30 and 86400.": "Idle seconds must be between 30 and 86400.", "Idle seconds must be between 30 and 86400.": "Idle seconds must be between 30 and 86400.",
"No request logs match the current filters.": "No request logs match the current filters.", "No request logs match the current filters.": "No request logs match the current filters.",
"No request logs yet.": "No request logs yet.", "No request logs yet.": "No request logs yet.",
"Paths, provider identity, bot, compact, and env": "Paths, provider identity, bot, compact, and env", "Paths, bot, compact, and env": "Paths, bot, compact, and env",
"Request logs are off": "Request logs are off", "Request logs are off": "Request logs are off",
"Request logs record gateway requests and make payload inspection available.": "Request logs record gateway requests and make payload inspection available.", "Request logs record gateway requests and make payload inspection available.": "Request logs record gateway requests and make payload inspection available.",
"Select an existing bot or turn Bot off.": "Select an existing bot or turn Bot off.", "Select an existing bot or turn Bot off.": "Select an existing bot or turn Bot off.",
@ -1314,7 +1314,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Retry": "继续重试", "Retry": "继续重试",
"Retry attempts": "重试尝试", "Retry attempts": "重试尝试",
"Weighted order": "按权重排序", "Weighted order": "按权重排序",
"Paths, provider identity, bot, compact, and env": "路径、供应商身份、Bot、压缩和环境变量", "Paths, bot, compact, and env": "路径、Bot、压缩和环境变量",
"Ready to route": "可以开始路由", "Ready to route": "可以开始路由",
"Restart proxy": "重启代理", "Restart proxy": "重启代理",
"Route": "路由", "Route": "路由",

View file

@ -0,0 +1,108 @@
import assert from "node:assert/strict";
import test from "node:test";
import * as React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { OnboardingView } from "@ccr/ui/pages/home/components/onboarding.tsx";
import { AppI18nContext, appCopy } from "@ccr/ui/pages/home/shared/i18n.tsx";
import {
createProfileDraft,
createProviderDraft,
fallbackGatewayStatus
} from "@ccr/ui/pages/home/shared/index.tsx";
import { appConfigFixture } from "../fixtures/index.ts";
test("Onboarding provider step renders only the active provider setup section", () => {
const config = appConfigFixture();
const providerDraft = {
...createProviderDraft([]),
baseUrl: "https://gateway.example/v1",
modelsText: "example-model",
name: "Example Provider"
};
const html = renderToStaticMarkup(
<AppI18nContext.Provider value={appCopy.en}>
<OnboardingView
activeStep="provider"
canSubmitProfile={false}
canSubmitProvider={false}
config={config}
endpoint="http://127.0.0.1:3456"
gatewayStatus={fallbackGatewayStatus}
onCheckProvider={async () => ({ failed: [], passed: [], results: [] })}
onChangeProfile={() => undefined}
onChangeProvider={() => undefined}
onComplete={() => undefined}
onSelectStep={() => undefined}
onSubmitProfile={async () => false}
onSubmitProvider={async () => false}
profileDraft={createProfileDraft()}
profileError=""
providerConnectivityLoading={false}
providerDraft={providerDraft}
providerError=""
providerProbeLoading={false}
/>
</AppI18nContext.Provider>
);
assert.match(html, /Choose provider/);
assert.match(html, /Add credentials/);
assert.match(html, /Pick models/);
assert.match(html, /Verify connection/);
assert.match(html, /Connect agent/);
assert.match(html, /Let&#x27;s start/);
assert.match(html, /aria-label="Step 1 \/ 6"/);
assert.match(html, /1 \/ 4/);
assert.doesNotMatch(html, /Setup readiness/);
assert.doesNotMatch(html, /Choose a provider preset or endpoint, enter an API key, and add at least one model/);
assert.doesNotMatch(html, /Choose how this provider authenticates model requests/);
assert.doesNotMatch(html, /Choose the models that should be available through this provider/);
assert.doesNotMatch(html, /Run a real model request before relying on this provider/);
});
test("Onboarding profile step does not prompt to save before continuing", () => {
const config = appConfigFixture();
config.Providers = [{
api_base_url: "https://gateway.example/v1",
api_key: "sk-test",
models: ["example-model"],
name: "Example Provider",
type: "openai_chat_completions"
}];
const providerDraft = {
...createProviderDraft([]),
baseUrl: "https://gateway.example/v1",
modelsText: "example-model",
name: "Example Provider"
};
const html = renderToStaticMarkup(
<AppI18nContext.Provider value={appCopy.en}>
<OnboardingView
activeStep="profile"
canSubmitProfile
canSubmitProvider={false}
config={config}
endpoint="http://127.0.0.1:3456"
gatewayStatus={fallbackGatewayStatus}
onCheckProvider={async () => ({ failed: [], passed: [], results: [] })}
onChangeProfile={() => undefined}
onChangeProvider={() => undefined}
onComplete={() => undefined}
onSelectStep={() => undefined}
onSubmitProfile={async () => false}
onSubmitProvider={async () => false}
profileDraft={createProfileDraft()}
profileError=""
providerConnectivityLoading={false}
providerDraft={providerDraft}
providerError=""
providerProbeLoading={false}
/>
</AppI18nContext.Provider>
);
assert.doesNotMatch(html, /Save this agent profile to continue/);
assert.doesNotMatch(html, /Choose an agent, model, and required profile settings/);
assert.doesNotMatch(html, /provider identity/);
assert.match(html, /Paths, bot, compact, and env/);
});