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/i18n.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ const english = {
"删除": "Delete",
"名称": "Name",
"模型": "Model",
"API 类型": "API type",
"请选择 API 类型": "Select an API type",
"版本": "Version",
"未知": "Unknown",
"暂无": "None",
Expand Down
47 changes: 39 additions & 8 deletions frontend/src/pages/ProfilesPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
55 changes: 41 additions & 14 deletions frontend/src/pages/ProfilesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;
Expand All @@ -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) => {
Expand All @@ -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: "",
Expand Down Expand Up @@ -151,7 +163,7 @@ export function ProfilesPage() {
return (
<PageScaffold title={t("配置模板")} description={t("在这里创建 Profile,再将它应用到所选 Agent。")}>
<div className="profile-toolbar">
<button className="button button-secondary" type="button" onClick={startSetup}>
<button className="button button-secondary" type="button" onClick={openCreate} disabled={Boolean(editor)}>
<Plus size={15} />
{t("新增 Profile")}
</button>
Expand All @@ -160,7 +172,7 @@ export function ProfilesPage() {
{editor ? (
<form className="profile-editor" onSubmit={(event) => void save(event)}>
<header>
<strong>{t("编辑 {name}", { name: editor.label || editor.id })}</strong>
<strong>{editor.originalId ? t("编辑 {name}", { name: editor.label || editor.id }) : t("创建 Profile")}</strong>
<button className="icon-button" type="button" onClick={() => setEditor(null)} aria-label={t("关闭编辑")} title={t("关闭编辑")}>
<X size={16} />
</button>
Expand Down Expand Up @@ -194,6 +206,21 @@ export function ProfilesPage() {
/>
<small id="profile-label-hint">{t("留空则使用 Profile ID")}</small>
</div>
<div className="profile-editor-wide">
<div className="field-stack">
<label htmlFor="profile-protocol">{t("API 类型")}</label>
<SelectField
id="profile-protocol"
label={t("API 类型")}
value={editor.protocol}
onChange={(protocol) => setEditor({ ...editor, protocol })}
options={[
{ value: "", label: t("请选择 API 类型") },
...(Object.keys(PROTOCOL_LABELS) as ProtocolId[]).map((protocol) => ({ value: protocol, label: PROTOCOL_LABELS[protocol] })),
]}
/>
</div>
</div>
<div className="profile-editor-wide">
<ProviderSegment
value={editor.provider}
Expand Down Expand Up @@ -235,7 +262,7 @@ export function ProfilesPage() {
<div className="empty-overview">
<Layers size={26} />
<strong>{t("还没有 Profile")}</strong>
<span>{t("走一遍安装引导,它会保存 Provider、模型和 API mode。")}</span>
<span>{t("在这里创建 Profile,再将它应用到所选 Agent。")}</span>
</div>
) : (
<div className="profile-list">
Expand Down
10 changes: 10 additions & 0 deletions frontend/src/state/wizardReducer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 14 additions & 1 deletion frontend/src/state/wizardReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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":
Expand All @@ -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
Expand Down Expand Up @@ -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 ?? "",
};
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/styles/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -808,6 +808,8 @@
gap: 22px;
}

.review-columns + .field-stack { margin-top: 22px; }

.review-group {
display: grid;
gap: 8px;
Expand Down Expand Up @@ -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; }

Expand Down
Loading