diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c1c4e945..e04619b6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,6 +7,7 @@ import { AgentSelectionPage } from "./pages/AgentSelectionPage"; import { EnvironmentOverviewPage } from "./pages/EnvironmentOverviewPage"; import { ModelSelectionPage } from "./pages/ModelSelectionPage"; import { ProfilesPage } from "./pages/ProfilesPage"; +import { ProfileSelectionPage } from "./pages/ProfileSelectionPage"; import { ProviderKeyPage } from "./pages/ProviderKeyPage"; import { ProvidersPage } from "./pages/ProvidersPage"; import { ReviewPage } from "./pages/ReviewPage"; @@ -60,6 +61,7 @@ function WorkspaceRoutes() { } /> } /> + } /> } /> {/* Kept as redirects for bookmarks from the previous desktop wizard. */} } /> diff --git a/frontend/src/components/SetupStepper.tsx b/frontend/src/components/SetupStepper.tsx index 0e48beb0..3258895f 100644 --- a/frontend/src/components/SetupStepper.tsx +++ b/frontend/src/components/SetupStepper.tsx @@ -2,11 +2,13 @@ import { Check } from "lucide-react"; import { useLocation } from "react-router-dom"; import { type TranslationKey, useI18n } from "../i18n"; +import { useWizard } from "../state/WizardContext"; // Single source of truth for the onboarding sequence: order and labels. Pages // no longer pass step numbers; the current step is derived from the route. const steps: Array<{ path: string; label: TranslationKey | "Agent" | "Provider" }> = [ { path: "/setup/agents", label: "Agent" }, + { path: "/setup/profile", label: "Profile选择" }, { path: "/setup/provider", label: "Provider" }, { path: "/setup/model", label: "模型" }, { path: "/setup/review", label: "确认" }, @@ -16,6 +18,7 @@ const steps: Array<{ path: string; label: TranslationKey | "Agent" | "Provider" export function SetupStepper() { const { t } = useI18n(); const { pathname } = useLocation(); + const { state } = useWizard(); const activeSteps = steps; // The desktop picker is the same first step as the CLI picker. The legacy // desktop URLs are redirected by App, so they never create a second wizard. @@ -27,12 +30,14 @@ export function SetupStepper() {
    {activeSteps.map((step, index) => { const number = index + 1; - const complete = number < current; + const skipped = (state.profileStepSkipped && step.path === "/setup/profile") + || (state.reusedProfile && (step.path === "/setup/provider" || step.path === "/setup/model")); + const complete = number < current && !skipped; const active = number === current; return (
  1. {complete ? : number} diff --git a/frontend/src/i18n.tsx b/frontend/src/i18n.tsx index 6d998d90..0a70189a 100644 --- a/frontend/src/i18n.tsx +++ b/frontend/src/i18n.tsx @@ -94,6 +94,9 @@ const english = { "安装官方桌面应用": "Install the official desktop application", "检测到本机已有此应用": "This application is already installed", "选择配置模板": "Select a profile", + "Profile选择": "Select profile", + "选择一个已有 Profile,或新建 Profile。": "Choose an existing profile or create a new one.", + "新建 Profile": "Create profile", "创建 Profile": "Create profile", "选择一个 Profile": "Select a profile", "还没有可用的 Profile": "No usable profiles yet", diff --git a/frontend/src/pages/AgentSelectionPage.tsx b/frontend/src/pages/AgentSelectionPage.tsx index 80e08419..05e67b16 100644 --- a/frontend/src/pages/AgentSelectionPage.tsx +++ b/frontend/src/pages/AgentSelectionPage.tsx @@ -39,6 +39,12 @@ export function AgentSelectionPage() { ); const selectedAgent = state.selectedAgentIds[0] ?? ""; const selectedName = state.status?.catalog.find((item) => item.id === selectedAgent)?.name ?? selectedAgent; + const continueSetup = () => { + const protocol = state.status?.catalog.find((item) => item.id === selectedAgent)?.protocol; + const hasProfile = Boolean(protocol && state.status?.profiles.some((profile) => profile.protocol === protocol)); + dispatch({ type: "SET_PROFILE_STEP_SKIPPED", value: !hasProfile }); + navigate(hasProfile ? "/setup/profile" : "/setup/provider"); + }; // Desktop installation uses this same route and the same five-step shell; // its first-step row is different because the desktop app is not in the CLI @@ -51,7 +57,7 @@ export function AgentSelectionPage() { description={t("选择这次要安装并配置的开发工具,每次安装一个。")} stepper primaryLabel={t("继续")} - onPrimary={() => navigate("/setup/provider")} + onPrimary={continueSetup} primaryDisabled={!selectedAgent || state.statusState === "loading"} footerNote={selectedAgent ? selectedName : t("选择一个 Agent")} bodyClassName="agent-selection-body" diff --git a/frontend/src/pages/DesktopAgentSelectionPage.tsx b/frontend/src/pages/DesktopAgentSelectionPage.tsx index 478bfefa..c9ba1ac2 100644 --- a/frontend/src/pages/DesktopAgentSelectionPage.tsx +++ b/frontend/src/pages/DesktopAgentSelectionPage.tsx @@ -6,6 +6,7 @@ import { PageScaffold } from "../components/PageScaffold"; import { StatusBadge } from "../components/StatusBadge"; import { useI18n } from "../i18n"; import { useWizard } from "../state/WizardContext"; +import { profileAgentIdForDesktop } from "../state/desktopSetup"; export function DesktopAgentSelectionPage() { const navigate = useNavigate(); @@ -13,6 +14,12 @@ export function DesktopAgentSelectionPage() { const { state, dispatch } = useWizard(); const app = state.status?.desktopAgent; const selected = state.selectedAgentIds[0] === app?.id; + const continueSetup = () => { + const protocol = state.status?.catalog.find((item) => item.id === profileAgentIdForDesktop(app!))?.protocol; + const hasProfile = Boolean(protocol && state.status?.profiles.some((profile) => profile.protocol === protocol)); + dispatch({ type: "SET_PROFILE_STEP_SKIPPED", value: !hasProfile }); + navigate(hasProfile ? "/setup/profile" : "/setup/provider"); + }; useEffect(() => { if (state.setupKind !== "desktop") dispatch({ type: "START_DESKTOP_SETUP" }); @@ -32,7 +39,7 @@ export function DesktopAgentSelectionPage() { description={t("选择要安装的桌面 Agent,每次安装一个。")} stepper primaryLabel={t("继续")} - onPrimary={() => navigate("/setup/provider")} + onPrimary={continueSetup} primaryDisabled={!selected || !app.supported} footerNote={selected ? app.name : t("选择一个 Agent")} > diff --git a/frontend/src/pages/ModelSelectionPage.tsx b/frontend/src/pages/ModelSelectionPage.tsx index 628bbc88..850e22dd 100644 --- a/frontend/src/pages/ModelSelectionPage.tsx +++ b/frontend/src/pages/ModelSelectionPage.tsx @@ -42,7 +42,7 @@ export function ModelSelectionPage() { title={t("选择模型")} description={t("从当前 Key 可访问的模型中选择,接口不支持时可直接输入模型 ID。")} stepper - onBack={() => navigate("/setup/provider")} + onBack={() => navigate(state.profileId ? "/setup/profile" : "/setup/provider")} primaryLabel={t("继续")} onPrimary={() => navigate("/setup/review")} primaryDisabled={!state.model.trim() || state.modelsState === "loading"} diff --git a/frontend/src/pages/ProfileSelectionPage.tsx b/frontend/src/pages/ProfileSelectionPage.tsx new file mode 100644 index 00000000..756f9d61 --- /dev/null +++ b/frontend/src/pages/ProfileSelectionPage.tsx @@ -0,0 +1,75 @@ +import { Check, Plus } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import { PageScaffold } from "../components/PageScaffold"; +import { useI18n } from "../i18n"; +import { useWizard } from "../state/WizardContext"; +import { profileAgentIdForDesktop } from "../state/desktopSetup"; + +export function ProfileSelectionPage() { + const navigate = useNavigate(); + const { t } = useI18n(); + const { state, dispatch } = useWizard(); + const [selectedId, setSelectedId] = useState(""); + const agentId = state.setupKind === "desktop" && state.status?.desktopAgent + ? profileAgentIdForDesktop(state.status.desktopAgent) + : state.selectedAgentIds[0] || ""; + const protocol = state.status?.catalog.find((agent) => agent.id === agentId)?.protocol; + const profiles = useMemo( + () => state.status?.profiles.filter((profile) => profile.protocol === protocol && profile.model?.trim()) ?? [], + [protocol, state.status?.profiles], + ); + const selected = profiles.find((profile) => profile.id === selectedId); + + useEffect(() => { + if (state.status && !profiles.length) navigate("/setup/provider", { replace: true }); + }, [navigate, profiles.length, state.status]); + + if (!state.status || !profiles.length) { + return null; + } + const status = state.status; + + const choose = () => { + if (!selected) return; + dispatch({ + type: "SELECT_PROFILE", + provider: selected.provider, + profileId: selected.id, + profileLabel: selected.label, + model: selected.model || "", + keyVerified: Boolean(status.providers[selected.provider]?.has_key || selected.hasKey), + }); + navigate("/setup/review"); + }; + + return ( + navigate("/setup/agents")} + primaryLabel={t("继续")} + onPrimary={choose} + primaryDisabled={!selected} + secondaryAction={} + > +
    + {profiles.map((profile) => { + const active = selectedId === profile.id; + return ( +
    + +

    {status.providers[profile.provider]?.name || profile.provider} · {profile.model}

    +
    + ); + })} +
    +
    + ); +} diff --git a/frontend/src/pages/ReviewPage.tsx b/frontend/src/pages/ReviewPage.tsx index ea37e60e..402659f3 100644 --- a/frontend/src/pages/ReviewPage.tsx +++ b/frontend/src/pages/ReviewPage.tsx @@ -50,7 +50,7 @@ export function ReviewPage() { title={t("确认激活")} description={t("核对安装、配置和备份范围。API Key 不会显示在此页。")} stepper - onBack={() => navigate("/setup/model")} + onBack={() => navigate(state.profileId ? "/setup/profile" : "/setup/model")} primaryLabel={t("开始安装")} onPrimary={startActivation} footerNote={{t("覆盖前会自动创建时间戳备份")}} @@ -84,7 +84,7 @@ export function ReviewPage() { -
    + {!state.reusedProfile ?
    dispatch({ type: "SET_PROFILE_LABEL", value: event.target.value })} /> {t("这次安装会保存为一个配置模板,之后可以直接应用")} -
    +
    : null} ); } diff --git a/frontend/src/state/wizardReducer.test.ts b/frontend/src/state/wizardReducer.test.ts index c3b4b659..6be94151 100644 --- a/frontend/src/state/wizardReducer.test.ts +++ b/frontend/src/state/wizardReducer.test.ts @@ -164,6 +164,22 @@ describe("wizardReducer", () => { expect(wizardReducer(initialWizardState, { type: "SET_PROFILE_ID", value: "codex-ppio" }).profileId).toBe("codex-ppio"); }); + it("loads an existing profile without reopening model discovery", () => { + const selected = wizardReducer(initialWizardState, { + type: "SELECT_PROFILE", + provider: "ppio", + profileId: "team", + profileLabel: "Team", + model: "model-a", + keyVerified: true, + }); + expect(selected.profileId).toBe("team"); + expect(selected.profileLabel).toBe("Team"); + expect(selected.model).toBe("model-a"); + expect(selected.keyVerified).toBe(true); + expect(selected.connectionState).toBe("success"); + }); + it("maps connection states", () => { let state = wizardReducer(initialWizardState, { type: "CONNECTION_LOADING" }); expect(state.connectionState).toBe("loading"); diff --git a/frontend/src/state/wizardReducer.ts b/frontend/src/state/wizardReducer.ts index 2ed1207b..34ae6071 100644 --- a/frontend/src/state/wizardReducer.ts +++ b/frontend/src/state/wizardReducer.ts @@ -26,6 +26,8 @@ export interface WizardState { * Agent and Provider". */ profileId: string; profileLabel: string; + reusedProfile: boolean; + profileStepSkipped: boolean; desktopProfileId: string; hasApiKey: boolean; connection: ProbeResponse | null; @@ -60,6 +62,8 @@ export const initialWizardState: WizardState = { probeModel: "", profileId: "", profileLabel: "", + reusedProfile: false, + profileStepSkipped: false, desktopProfileId: "", hasApiKey: false, connection: null, @@ -87,6 +91,9 @@ export type WizardAction = | { type: "SET_PROBE_MODEL"; value: string } | { type: "SET_PROFILE_ID"; value: string } | { type: "SET_PROFILE_LABEL"; value: string } + | { type: "SELECT_PROFILE"; provider: ProviderId; profileId: string; profileLabel: string; model: string; keyVerified: boolean } + | { type: "START_NEW_PROFILE" } + | { type: "SET_PROFILE_STEP_SKIPPED"; value: boolean } | { type: "SET_DESKTOP_PROFILE"; value: string } | { type: "START_SETUP"; profileId?: string; profileLabel?: string } | { type: "SET_HAS_API_KEY"; value: boolean } @@ -168,6 +175,7 @@ export function wizardReducer(state: WizardState, action: WizardAction): WizardS // Provider. Do not carry a prior run's profile into a new pairing. profileId: state.selectedAgentIds[0] === action.agentId ? state.profileId : "", profileLabel: state.selectedAgentIds[0] === action.agentId ? state.profileLabel : "", + ...(changed ? { reusedProfile: false } : {}), // Provider probes are protocol-specific, so a different Agent needs a // fresh verdict before the model step can continue. ...(changed ? { @@ -185,6 +193,23 @@ export function wizardReducer(state: WizardState, action: WizardAction): WizardS return { ...state, profileId: action.value }; case "SET_PROFILE_LABEL": return { ...state, profileLabel: action.value }; + case "START_NEW_PROFILE": + return { ...state, profileId: "", profileLabel: "", model: "", reusedProfile: false, keyVerified: false, connection: null, connectionState: "idle", models: [], modelsState: "idle", modelsMessage: "" }; + case "SET_PROFILE_STEP_SKIPPED": + return { ...state, profileStepSkipped: action.value }; + case "SELECT_PROFILE": + return { + ...state, + provider: action.provider, + profileId: action.profileId, + profileLabel: action.profileLabel, + reusedProfile: true, + profileStepSkipped: false, + model: action.model, + keyVerified: action.keyVerified, + connection: null, + connectionState: action.keyVerified ? "success" : "idle", + }; case "SET_DESKTOP_PROFILE": return { ...state, desktopProfileId: action.value }; case "START_SETUP": @@ -206,6 +231,7 @@ export function wizardReducer(state: WizardState, action: WizardAction): WizardS probeModel: "", profileId: "", profileLabel: "", + reusedProfile: false, connection: null, connectionState: "idle", keyVerified: false, diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 21cf4b11..e8c1ccba 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -234,11 +234,11 @@ } .setup-stepper { - max-width: 620px; + width: min(100%, 760px); margin: 22px 0 0; padding: 0; display: grid; - grid-template-columns: repeat(5, minmax(0, 1fr)); + grid-template-columns: repeat(6, minmax(0, 1fr)); list-style: none; }