Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -60,6 +61,7 @@ function WorkspaceRoutes() {
<Routes>
<Route path="/" element={<LandingRoute />} />
<Route path="/setup/agents" element={<AgentSelectionPage />} />
<Route path="/setup/profile" element={<SetupGuard stage="provider"><ProfileSelectionPage /></SetupGuard>} />
<Route path="/setup/desktop/agents" element={<Navigate to="/setup/agents" replace />} />
{/* Kept as redirects for bookmarks from the previous desktop wizard. */}
<Route path="/setup/desktop/profile" element={<Navigate to="/setup/provider" replace />} />
Expand Down
9 changes: 7 additions & 2 deletions frontend/src/components/SetupStepper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: "确认" },
Expand All @@ -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.
Expand All @@ -27,12 +30,14 @@ export function SetupStepper() {
<ol className="setup-stepper" aria-label={t("激活步骤")}>
{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 (
<li
key={step.label}
className={`stepper-item${active ? " is-active" : ""}${complete ? " is-complete" : ""}`}
className={`stepper-item${active ? " is-active" : ""}${complete ? " is-complete" : ""}${skipped ? " is-skipped" : ""}`}
aria-current={active ? "step" : undefined}
>
<span className="stepper-marker">{complete ? <Check size={14} /> : number}</span>
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/i18n.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 7 additions & 1 deletion frontend/src/pages/AgentSelectionPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down
9 changes: 8 additions & 1 deletion frontend/src/pages/DesktopAgentSelectionPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,20 @@ 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();
const { t } = useI18n();
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" });
Expand All @@ -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")}
>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/pages/ModelSelectionPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
75 changes: 75 additions & 0 deletions frontend/src/pages/ProfileSelectionPage.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<PageScaffold
title={t("Profile选择")}
description={t("选择一个已有 Profile,或新建 Profile。")}
stepper
onBack={() => navigate("/setup/agents")}
primaryLabel={t("继续")}
onPrimary={choose}
primaryDisabled={!selected}
secondaryAction={<button className="button button-secondary" type="button" onClick={() => { dispatch({ type: "START_NEW_PROFILE" }); navigate("/setup/provider"); }}><Plus size={15} />{t("新建 Profile")}</button>}
>
<div className="profile-list">
{profiles.map((profile) => {
const active = selectedId === profile.id;
return (
<article className={`profile-card profile-choice${active ? " is-selected" : ""}`} key={profile.id}>
<label className="profile-choice-main">
<input type="radio" name="setup-profile" checked={active} onChange={() => setSelectedId(profile.id)} aria-label={t("选择 {name}", { name: profile.label })} />
<span className="profile-title"><strong>{profile.label}</strong><small>{profile.id}</small></span>
{active ? <Check size={16} aria-hidden="true" /> : null}
</label>
<p>{status.providers[profile.provider]?.name || profile.provider} · {profile.model}</p>
</article>
);
})}
</div>
</PageScaffold>
);
}
6 changes: 3 additions & 3 deletions frontend/src/pages/ReviewPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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={<span className="secure-note"><ShieldCheck size={15} />{t("覆盖前会自动创建时间戳备份")}</span>}
Expand Down Expand Up @@ -84,7 +84,7 @@ export function ReviewPage() {
</ReviewGroup>
</div>

<div className="field-stack">
{!state.reusedProfile ? <div className="field-stack">
<label htmlFor="review-profile-label">{t("配置模板名称")}</label>
<input
id="review-profile-label"
Expand All @@ -93,7 +93,7 @@ export function ReviewPage() {
onChange={(event) => dispatch({ type: "SET_PROFILE_LABEL", value: event.target.value })}
/>
<small>{t("这次安装会保存为一个配置模板,之后可以直接应用")}</small>
</div>
</div> : null}
</PageScaffold>
);
}
16 changes: 16 additions & 0 deletions frontend/src/state/wizardReducer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
26 changes: 26 additions & 0 deletions frontend/src/state/wizardReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -60,6 +62,8 @@ export const initialWizardState: WizardState = {
probeModel: "",
profileId: "",
profileLabel: "",
reusedProfile: false,
profileStepSkipped: false,
desktopProfileId: "",
hasApiKey: false,
connection: null,
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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 ? {
Expand All @@ -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":
Expand All @@ -206,6 +231,7 @@ export function wizardReducer(state: WizardState, action: WizardAction): WizardS
probeModel: "",
profileId: "",
profileLabel: "",
reusedProfile: false,
connection: null,
connectionState: "idle",
keyVerified: false,
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/styles/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down