From 68489b5c9998c5fcceb29bc8c2eefb37c0367242 Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:42:13 +0800 Subject: [PATCH 1/2] feat: fetch models in profile creation page --- frontend/src/components/ModelPicker.tsx | 68 ++++++++++--------- .../src/components/ProviderModelPicker.tsx | 51 ++++++++++++++ frontend/src/pages/AgentProfilePage.tsx | 14 ++-- frontend/src/pages/AgentSelectionPage.tsx | 6 +- .../pages/EnvironmentOverviewPage.test.tsx | 4 +- .../src/pages/EnvironmentOverviewPage.tsx | 2 +- frontend/src/pages/ProfilesPage.test.tsx | 24 +++++++ frontend/src/pages/ProfilesPage.tsx | 14 ++-- frontend/src/state/wizardReducer.test.ts | 4 ++ frontend/src/state/wizardReducer.ts | 2 +- 10 files changed, 141 insertions(+), 48 deletions(-) create mode 100644 frontend/src/components/ProviderModelPicker.tsx diff --git a/frontend/src/components/ModelPicker.tsx b/frontend/src/components/ModelPicker.tsx index efe54d8..e3f41b7 100644 --- a/frontend/src/components/ModelPicker.tsx +++ b/frontend/src/components/ModelPicker.tsx @@ -1,5 +1,5 @@ import { Check, Search } from "lucide-react"; -import { useMemo, useState } from "react"; +import { useMemo } from "react"; import { useI18n } from "../i18n"; @@ -7,50 +7,52 @@ interface ModelPickerProps { models: string[]; value: string; onChange: (value: string) => void; + inputId?: string; + inputLabel?: string; + required?: boolean; } -export function ModelPicker({ models, value, onChange }: ModelPickerProps) { +export function ModelPicker({ models, value, onChange, inputId = "manual-model", inputLabel, required }: ModelPickerProps) { const { t } = useI18n(); - const [query, setQuery] = useState(""); const filtered = useMemo( - () => models.filter((model) => model.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase())), - [models, query], + () => models.filter((model) => model.toLocaleLowerCase().includes(value.trim().toLocaleLowerCase())), + [models, value], ); return (
- {models.length ? ( - <> +
+ + {models.length ? (
- setQuery(event.target.value)} placeholder={t("搜索模型")} aria-label={t("搜索模型")} /> + onChange(event.target.value)} placeholder={t("搜索模型")} aria-label={inputLabel || t("搜索模型")} required={required} />
-
- {filtered.map((model) => ( - - ))} - {!filtered.length ?
{t("没有匹配的模型")}
: null} -
- - ) : null} - -
- - onChange(event.target.value)} placeholder={t("例如 gpt-4.1")} /> + ) : ( + onChange(event.target.value)} placeholder={t("例如 gpt-4.1")} required={required} /> + )}
+ {models.length ? ( +
+ {filtered.map((model) => ( + + ))} + {!filtered.length ?
{t("没有匹配的模型")}
: null} +
+ ) : null}
); } diff --git a/frontend/src/components/ProviderModelPicker.tsx b/frontend/src/components/ProviderModelPicker.tsx new file mode 100644 index 0000000..26691df --- /dev/null +++ b/frontend/src/components/ProviderModelPicker.tsx @@ -0,0 +1,51 @@ +import { useEffect, useState } from "react"; + +import { api, describeError } from "../backend/api"; +import { useI18n } from "../i18n"; +import { ModelPicker } from "./ModelPicker"; + +interface ProviderModelPickerProps { + provider: string; + protocol: string; + hasKey: boolean; + value: string; + onChange: (value: string) => void; + inputId: string; +} + +export function ProviderModelPicker({ provider, protocol, hasKey, value, onChange, inputId }: ProviderModelPickerProps) { + const { t } = useI18n(); + const [models, setModels] = useState([]); + const [loading, setLoading] = useState(false); + const [message, setMessage] = useState(""); + const [success, setSuccess] = useState(false); + + useEffect(() => { + if (!protocol || !hasKey) return; + let cancelled = false; + setLoading(true); + setMessage(""); + void api.models({ provider, apiBaseUrl: "", apiKey: "" }).then((result) => { + if (cancelled) return; + setModels(result.models ?? []); + setSuccess(result.ok); + setMessage(result.message.replace(/^Found (\d+) models\.$/, (_, count: string) => t("找到 {count} 个模型", { count }))); + }).catch((error) => { + if (cancelled) return; + setModels([]); + setSuccess(false); + setMessage(describeError(error, t("无法获取模型列表")).message); + }).finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { cancelled = true; }; + }, [hasKey, protocol, provider, t]); + + return ( +
+ {loading ?
{t("正在读取模型列表")}
: null} + {message && !loading ?
{message}
: null} + +
+ ); +} diff --git a/frontend/src/pages/AgentProfilePage.tsx b/frontend/src/pages/AgentProfilePage.tsx index 8a0fa85..206505a 100644 --- a/frontend/src/pages/AgentProfilePage.tsx +++ b/frontend/src/pages/AgentProfilePage.tsx @@ -4,6 +4,7 @@ import { useNavigate, useParams } from "react-router-dom"; import { api, describeError } from "../backend/api"; import { PageScaffold } from "../components/PageScaffold"; +import { ProviderModelPicker } from "../components/ProviderModelPicker"; import { ProviderSegment } from "../components/ProviderSegment"; import { useI18n } from "../i18n"; import { desktopApps, desktopProfileUsable, desktopProfiles, desktopProtocol, profileAgentIdForDesktop } from "../state/desktopSetup"; @@ -210,10 +211,15 @@ export function AgentProfilePage() { protocol={protocol} />
-
- - setDraft({ ...draft, model: event.target.value })} placeholder={t("例如 deepseek/deepseek-v4-pro")} required /> -
+ setDraft({ ...draft, model })} + inputId="agent-profile-model" + />

{status.providers[draft.provider]?.has_key diff --git a/frontend/src/pages/AgentSelectionPage.tsx b/frontend/src/pages/AgentSelectionPage.tsx index 041d86a..48e4bf1 100644 --- a/frontend/src/pages/AgentSelectionPage.tsx +++ b/frontend/src/pages/AgentSelectionPage.tsx @@ -6,12 +6,12 @@ import { AgentRow } from "../components/AgentRow"; import { MirrorSetting } from "../components/MirrorSetting"; import { PageScaffold } from "../components/PageScaffold"; import { RuntimePrompt } from "../components/RuntimePrompt"; +import { StatusBadge } from "../components/StatusBadge"; import { useI18n } from "../i18n"; -import { byRank } from "../state/ranking"; import { desktopApps, desktopProtocol } from "../state/desktopSetup"; +import { byRank } from "../state/ranking"; import { useWizard } from "../state/WizardContext"; import type { AgentCatalogItem } from "../types/api"; -import { StatusBadge } from "../components/StatusBadge"; export function AgentSelectionPage() { const navigate = useNavigate(); @@ -75,8 +75,8 @@ export function AgentSelectionPage() {

- +
{state.setupKind !== "desktop" ? renderRows(agents) : (
diff --git a/frontend/src/pages/EnvironmentOverviewPage.test.tsx b/frontend/src/pages/EnvironmentOverviewPage.test.tsx index 1b1f284..3861c7b 100644 --- a/frontend/src/pages/EnvironmentOverviewPage.test.tsx +++ b/frontend/src/pages/EnvironmentOverviewPage.test.tsx @@ -117,7 +117,7 @@ describe("EnvironmentOverviewPage", () => { fireEvent.click(screen.getByRole("button", { name: "安装 Agent" })); expect(await screen.findByRole("heading", { name: "onboarding" })).toBeTruthy(); // A second run must not inherit the previous Agent, model or log. - expect(dispatch).toHaveBeenCalledWith({ type: "START_SETUP" }); + expect(dispatch).toHaveBeenCalledWith({ type: "START_DESKTOP_SETUP" }); }); it("counts and shows only installed desktop Agents", async () => { @@ -139,7 +139,7 @@ describe("EnvironmentOverviewPage", () => { expect(screen.getAllByRole("button", { name: "安装 Agent" })).toHaveLength(1); fireEvent.click(screen.getByRole("button", { name: "安装 Agent" })); - expect(dispatch).toHaveBeenCalledWith({ type: "START_SETUP" }); + expect(dispatch).toHaveBeenCalledWith({ type: "START_DESKTOP_SETUP" }); expect(dispatch).not.toHaveBeenCalledWith(expect.objectContaining({ type: "SELECT_AGENT" })); expect(await screen.findByRole("heading", { name: "onboarding" })).toBeTruthy(); }); diff --git a/frontend/src/pages/EnvironmentOverviewPage.tsx b/frontend/src/pages/EnvironmentOverviewPage.tsx index 6cb349c..6501eab 100644 --- a/frontend/src/pages/EnvironmentOverviewPage.tsx +++ b/frontend/src/pages/EnvironmentOverviewPage.tsx @@ -21,7 +21,7 @@ export function EnvironmentOverviewPage() { if (agentId) navigate(`/agents/${agentId}`); }; const openInstall = () => { - dispatch({ type: "START_SETUP" }); + dispatch({ type: "START_DESKTOP_SETUP" }); navigate("/setup/agents"); }; diff --git a/frontend/src/pages/ProfilesPage.test.tsx b/frontend/src/pages/ProfilesPage.test.tsx index b12618a..61bc20f 100644 --- a/frontend/src/pages/ProfilesPage.test.tsx +++ b/frontend/src/pages/ProfilesPage.test.tsx @@ -308,6 +308,30 @@ describe("ProfilesPage", () => { expect(screen.getByLabelText("模型")).toHaveValue(""); }); + it("discovers and searches the selected Provider's models", async () => { + const models = vi.spyOn(api, "models").mockResolvedValue({ + ok: true, + reachable: true, + status: 200, + message: "Found 2 models.", + error_code: null, + retryable: false, + models: ["model-alpha", "model-beta"], + }); + mockState = { status: statusWith([]), statusState: "success" }; + if (!mockState.status) throw new Error("missing status"); + mockState.status.providers.ppio.has_key = true; + render(); + fireEvent.click(screen.getByRole("button", { name: "新增 Profile" })); + fireEvent.click(screen.getByRole("combobox", { name: "API 类型" })); + fireEvent.click(screen.getByRole("option", { name: "OpenAI Responses" })); + await waitFor(() => expect(models).toHaveBeenCalledWith({ provider: "ppio", apiBaseUrl: "", apiKey: "" })); + expect(screen.getByText("model-alpha")).toBeTruthy(); + fireEvent.change(screen.getByLabelText("模型"), { target: { value: "beta" } }); + expect(screen.queryByText("model-alpha")).toBeNull(); + expect(screen.getByText("model-beta")).toBeTruthy(); + }); + it("applies one Profile to all of its Agents", async () => { const activate = vi.spyOn(api, "activateAgent").mockResolvedValue({ ok: true, diff --git a/frontend/src/pages/ProfilesPage.tsx b/frontend/src/pages/ProfilesPage.tsx index ee7d2ae..a2dc857 100644 --- a/frontend/src/pages/ProfilesPage.tsx +++ b/frontend/src/pages/ProfilesPage.tsx @@ -4,6 +4,7 @@ import { useNavigate } from "react-router-dom"; import { api, describeError, isCancellationError } from "../backend/api"; import { PageScaffold } from "../components/PageScaffold"; +import { ProviderModelPicker } from "../components/ProviderModelPicker"; import { ProviderSegment } from "../components/ProviderSegment"; import { SelectField } from "../components/SelectField"; import { useI18n } from "../i18n"; @@ -283,10 +284,15 @@ export function ProfilesPage() { protocol={editor.protocol as ProtocolId} />
-
- - setEditor({ ...editor, model: event.target.value })} placeholder={t("例如 deepseek/deepseek-v4-pro")} required /> -
+ setEditor({ ...editor, model })} + inputId="profile-model" + /> {/* The key is the Provider's, so this only reports whether that Provider has one and links to where it is set. */}

diff --git a/frontend/src/state/wizardReducer.test.ts b/frontend/src/state/wizardReducer.test.ts index 34faf53..36a1050 100644 --- a/frontend/src/state/wizardReducer.test.ts +++ b/frontend/src/state/wizardReducer.test.ts @@ -33,6 +33,10 @@ const successProbe = { } satisfies ProbeResponse; describe("wizardReducer", () => { + it("defaults Agent selection to desktop", () => { + expect(initialWizardState.setupKind).toBe("desktop"); + }); + it("loads status and reports status errors", () => { let state = wizardReducer(initialWizardState, { type: "STATUS_LOADING" }); expect(state.statusState).toBe("loading"); diff --git a/frontend/src/state/wizardReducer.ts b/frontend/src/state/wizardReducer.ts index a61dfa7..8d4ff36 100644 --- a/frontend/src/state/wizardReducer.ts +++ b/frontend/src/state/wizardReducer.ts @@ -57,7 +57,7 @@ export const initialWizardState: WizardState = { status: null, statusState: "idle", statusError: "", - setupKind: "cli", + setupKind: "desktop", selectedAgentIds: [], provider: "ppio", probeModel: "", From 0dae5d5eb213f68b94a066e83d33f5def434147d Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:45:37 +0800 Subject: [PATCH 2/2] fix: allow empty list in desktop agents --- frontend/src/state/desktopSetup.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/state/desktopSetup.ts b/frontend/src/state/desktopSetup.ts index 66a4263..8de43e6 100644 --- a/frontend/src/state/desktopSetup.ts +++ b/frontend/src/state/desktopSetup.ts @@ -2,7 +2,7 @@ import type { DesktopAgentStatus, ProfileSummary, StatusResponse } from "../type export function desktopApps(status: StatusResponse): DesktopAgentStatus[] { const seen = new Set(); - return status.desktopAgents.filter((app) => { + return (status.desktopAgents ?? []).filter((app) => { if (!app?.id || seen.has(app.id)) return false; seen.add(app.id); return true;