diff --git a/frontend/src/i18n.tsx b/frontend/src/i18n.tsx index e150aa1..7236bfb 100644 --- a/frontend/src/i18n.tsx +++ b/frontend/src/i18n.tsx @@ -24,6 +24,8 @@ const english = { "删除": "Delete", "名称": "Name", "模型": "Model", + "API 类型": "API type", + "请选择 API 类型": "Select an API type", "版本": "Version", "未知": "Unknown", "暂无": "None", diff --git a/frontend/src/pages/ProfilesPage.test.tsx b/frontend/src/pages/ProfilesPage.test.tsx index da691f7..b5d489a 100644 --- a/frontend/src/pages/ProfilesPage.test.tsx +++ b/frontend/src/pages/ProfilesPage.test.tsx @@ -128,19 +128,50 @@ describe("ProfilesPage", () => { expect(screen.getByTestId("profile-unused").textContent).toContain("暂无 Agent 使用"); }); - it("sends Profile creation through onboarding instead of an inline form", async () => { - // The old form collected a Provider, model and Agent list without ever - // testing the key. Onboarding collects the same fields in order, probes the - // connection, and the install writes the Profile. - const save = vi.spyOn(api, "saveProfile"); + it("creates a Profile inline without entering onboarding", async () => { + const save = vi.spyOn(api, "saveProfile").mockResolvedValue(profile({ + id: "profile-ppio", + label: "Codex Profile", + })); renderPage([]); expect(screen.getByText(/还没有 Profile/)).toBeTruthy(); fireEvent.click(screen.getByRole("button", { name: "新增 Profile" })); - expect(await screen.findByRole("heading", { name: "onboarding" })).toBeTruthy(); - // A stale run's Agent and model must not be inherited by the new Profile. - expect(dispatch).toHaveBeenCalledWith({ type: "START_SETUP" }); + expect(screen.queryByRole("heading", { name: "onboarding" })).toBeNull(); + expect(screen.getByLabelText("Profile ID")).toHaveValue("profile-ppio"); + expect(screen.getByRole("combobox", { name: "API 类型" })).toHaveTextContent("请选择 API 类型"); + expect(dispatch).not.toHaveBeenCalled(); + fireEvent.change(screen.getByLabelText("模型"), { target: { value: "model-a" } }); + fireEvent.click(screen.getByRole("combobox", { name: "API 类型" })); + fireEvent.click(screen.getByRole("option", { name: "OpenAI Responses" })); + fireEvent.click(screen.getByRole("button", { name: "保存 Profile" })); + + await waitFor(() => expect(save).toHaveBeenCalledWith(expect.objectContaining({ + id: "profile-ppio", + provider: "ppio", + model: "model-a", + protocol: "responses", + apiKey: "", + }))); + }); + + it("chooses the next available Profile ID", () => { + renderPage([profile({ id: "codex-ppio" })]); + fireEvent.click(screen.getByRole("button", { name: "新增 Profile" })); + + expect(screen.getByLabelText("Profile ID")).toHaveValue("profile-ppio"); + }); + + it("requires a manually selected API type", async () => { + const save = vi.spyOn(api, "saveProfile").mockResolvedValue(profile({ id: "profile-ppio" })); + renderPage([]); + fireEvent.click(screen.getByRole("button", { name: "新增 Profile" })); + expect(screen.getByRole("button", { name: "保存 Profile" })).toBeDisabled(); + fireEvent.change(screen.getByLabelText("模型"), { target: { value: "model-a" } }); expect(save).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("combobox", { name: "API 类型" })); + fireEvent.click(screen.getByRole("option", { name: "OpenAI Chat Completions" })); + expect(screen.getByRole("button", { name: "保存 Profile" })).not.toBeDisabled(); }); it("points at the Provider page when its key is missing", async () => { diff --git a/frontend/src/pages/ProfilesPage.tsx b/frontend/src/pages/ProfilesPage.tsx index 45b1aed..43e00d6 100644 --- a/frontend/src/pages/ProfilesPage.tsx +++ b/frontend/src/pages/ProfilesPage.tsx @@ -7,8 +7,10 @@ import { PageScaffold } from "../components/PageScaffold"; import { ProviderSegment } from "../components/ProviderSegment"; import { useI18n } from "../i18n"; import { taskCanceller, taskKey, useTaskCenter, useTaskRoute } from "../state/TaskCenterContext"; +import { byProviderCreatedAt } from "../state/ranking"; import { useWizard } from "../state/WizardContext"; -import type { ProfileSummary, ProviderId } from "../types/api"; +import { PROTOCOL_LABELS, type ProfileSummary, type ProtocolId, type ProviderId } from "../types/api"; +import { SelectField } from "../components/SelectField"; // A Profile no longer carries its own key: the Provider it points at owns one, // and asking twice for the same secret was the bug worth deleting. @@ -34,8 +36,8 @@ function editDraft(profile: ProfileSummary, protocol: string): ProfileDraft { export function ProfilesPage() { const navigate = useNavigate(); - const { locale, t } = useI18n(); - const { state, dispatch, refreshStatus } = useWizard(); + const { t } = useI18n(); + const { state, refreshStatus } = useWizard(); const { startTask, finishTask, setTaskCanceller } = useTaskCenter(); const route = useTaskRoute(); const status = state.status; @@ -59,14 +61,24 @@ export function ProfilesPage() { const providerHasKey = Boolean(editor && status.providers[editor.provider]?.has_key); // label is absent on purpose: the backend fills it in from the existing value // or the ID, so demanding it here was stricter than the write path. - const canSave = Boolean(editor?.id.trim() && editor.model.trim()); + const canSave = Boolean(editor?.id.trim() && editor.model.trim() && editor.protocol); - // Creating a Profile goes through onboarding: it collects the Agent, Provider, - // model and name in order, tests the saved Provider connection, and the - // install writes the Profile itself. - const startSetup = () => { - dispatch({ type: "START_SETUP" }); - navigate("/setup/agents"); + const openCreate = () => { + const [provider = "ppio", providerMeta] = byProviderCreatedAt(status.providers)[0] || []; + const baseID = `profile-${provider}`.toLowerCase().replace(/[^a-z0-9_-]/g, "-"); + const ids = new Set(profiles.map((profile) => profile.id.toLowerCase())); + let id = baseID; + let suffix = 2; + while (ids.has(id)) id = `${baseID}-${suffix++}`; + setFailure(""); + setEditor({ + id, + label: `${providerMeta?.name || provider} Profile`, + provider, + model: "", + protocol: "", + originalId: "", + }); }; const save = async (event: FormEvent) => { @@ -76,7 +88,7 @@ export function ProfilesPage() { setFailure(""); try { await api.saveProfile({ - id: editor.id.trim(), + id: editor.id.trim().toLowerCase(), label: editor.label.trim(), provider: editor.provider, apiBaseUrl: "", @@ -151,7 +163,7 @@ export function ProfilesPage() { return (
- @@ -160,7 +172,7 @@ export function ProfilesPage() { {editor ? (
void save(event)}>
- {t("编辑 {name}", { name: editor.label || editor.id })} + {editor.originalId ? t("编辑 {name}", { name: editor.label || editor.id }) : t("创建 Profile")} @@ -194,6 +206,21 @@ export function ProfilesPage() { /> {t("留空则使用 Profile ID")}
+
+
+ + setEditor({ ...editor, protocol })} + options={[ + { value: "", label: t("请选择 API 类型") }, + ...(Object.keys(PROTOCOL_LABELS) as ProtocolId[]).map((protocol) => ({ value: protocol, label: PROTOCOL_LABELS[protocol] })), + ]} + /> +
+
{t("还没有 Profile")} - {t("走一遍安装引导,它会保存 Provider、模型和 API mode。")} + {t("在这里创建 Profile,再将它应用到所选 Agent。")}
) : (
diff --git a/frontend/src/state/wizardReducer.test.ts b/frontend/src/state/wizardReducer.test.ts index e536438..0cb4707 100644 --- a/frontend/src/state/wizardReducer.test.ts +++ b/frontend/src/state/wizardReducer.test.ts @@ -42,6 +42,16 @@ describe("wizardReducer", () => { expect(state.statusError).toBe("offline"); }); + it("defaults setup to the newest Provider", () => { + const providers = { + ppio: { name: "PPIO", home: "", base_url: "", custom: false, created_at: "" }, + newer: { name: "Newer", home: "", base_url: "", custom: true, created_at: "2026-02-01T00:00:00Z" }, + } satisfies StatusResponse["providers"]; + const loaded = wizardReducer(initialWizardState, { type: "STATUS_LOADED", status: { ...status, providers } }); + expect(loaded.provider).toBe("newer"); + expect(wizardReducer(loaded, { type: "START_SETUP" }).provider).toBe("newer"); + }); + it("selects exactly one Agent and cannot be emptied by re-selecting", () => { // Onboarding installs one Agent per run, and every step after this one // requires a selection: a toggle-off would only produce a dead end. diff --git a/frontend/src/state/wizardReducer.ts b/frontend/src/state/wizardReducer.ts index c2e8f55..834e8af 100644 --- a/frontend/src/state/wizardReducer.ts +++ b/frontend/src/state/wizardReducer.ts @@ -7,6 +7,7 @@ import type { StatusResponse, InstallOutput, } from "../types/api"; +import { byProviderCreatedAt } from "./ranking"; export type AsyncState = "idle" | "loading" | "success" | "error"; export type SetupKind = "cli" | "desktop"; @@ -81,6 +82,10 @@ export const initialWizardState: WizardState = { activationNext: "", }; +function latestProvider(status: StatusResponse | null): ProviderId { + return byProviderCreatedAt(status?.providers ?? {})[0]?.[0] || "ppio"; +} + export type WizardAction = | { type: "STATUS_LOADING" } | { type: "STATUS_LOADED"; status: StatusResponse } @@ -150,7 +155,13 @@ export function wizardReducer(state: WizardState, action: WizardAction): WizardS case "STATUS_LOADING": return { ...state, statusState: "loading", statusError: "" }; case "STATUS_LOADED": - return { ...state, status: action.status, statusState: "success", statusError: "" }; + return { + ...state, + status: action.status, + statusState: "success", + statusError: "", + ...(state.status === null ? { provider: latestProvider(action.status) } : {}), + }; case "STATUS_FAILED": return { ...state, statusState: "error", statusError: action.message }; case "START_DESKTOP_SETUP": @@ -160,6 +171,7 @@ export function wizardReducer(state: WizardState, action: WizardAction): WizardS statusState: state.statusState, statusError: state.statusError, setupKind: "desktop", + provider: latestProvider(state.status), }; case "SELECT_AGENT": // Single select, and re-clicking the current row keeps it selected: the @@ -221,6 +233,7 @@ export function wizardReducer(state: WizardState, action: WizardAction): WizardS statusState: state.statusState, statusError: state.statusError, setupKind: "cli", + provider: latestProvider(state.status), profileId: action.profileId ?? "", profileLabel: action.profileLabel ?? "", }; diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 879f5b5..18227ef 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -808,6 +808,8 @@ gap: 22px; } +.review-columns + .field-stack { margin-top: 22px; } + .review-group { display: grid; gap: 8px; @@ -1589,6 +1591,8 @@ .provider-editor-grid > *, .profile-editor-grid > * { min-width: 0; } +.profile-editor-grid > .field-stack { align-self: start; } + .provider-editor-wide, .profile-editor-wide { grid-column: 1 / -1; }