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
68 changes: 35 additions & 33 deletions frontend/src/components/ModelPicker.tsx
Original file line number Diff line number Diff line change
@@ -1,56 +1,58 @@
import { Check, Search } from "lucide-react";
import { useMemo, useState } from "react";
import { useMemo } from "react";

import { useI18n } from "../i18n";

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 (
<div className="model-picker">
{models.length ? (
<>
<div className="field-stack manual-model-field">
<label htmlFor={inputId}>{inputLabel || t("手动输入模型 ID")}</label>
{models.length ? (
<div className="search-field">
<Search size={17} />
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={t("搜索模型")} aria-label={t("搜索模型")} />
<input id={inputId} value={value} onChange={(event) => onChange(event.target.value)} placeholder={t("搜索模型")} aria-label={inputLabel || t("搜索模型")} required={required} />
</div>
<div className="model-list" role="radiogroup" aria-label={t("模型列表")}>
{filtered.map((model) => (
<button
type="button"
role="radio"
aria-checked={model === value}
className={`model-row${model === value ? " is-selected" : ""}`}
key={model}
onClick={() => onChange(model)}
>
<span>
<strong>{model}</strong>
<small>OpenAI-compatible model</small>
</span>
{model === value ? <Check size={17} /> : null}
</button>
))}
{!filtered.length ? <div className="empty-row">{t("没有匹配的模型")}</div> : null}
</div>
</>
) : null}

<div className="field-stack manual-model-field">
<label htmlFor="manual-model">{models.length ? t("或手动输入模型 ID") : t("手动输入模型 ID")}</label>
<input id="manual-model" className="text-field" value={value} onChange={(event) => onChange(event.target.value)} placeholder={t("例如 gpt-4.1")} />
) : (
<input id={inputId} className="text-field" value={value} onChange={(event) => onChange(event.target.value)} placeholder={t("例如 gpt-4.1")} required={required} />
)}
</div>
{models.length ? (
<div className="model-list" role="radiogroup" aria-label={t("模型列表")}>
{filtered.map((model) => (
<button
type="button"
role="radio"
aria-checked={model === value}
className={`model-row${model === value ? " is-selected" : ""}`}
key={model}
onClick={() => onChange(model)}
>
<span>
<strong>{model}</strong>
<small>OpenAI-compatible model</small>
</span>
{model === value ? <Check size={17} /> : null}
</button>
))}
{!filtered.length ? <div className="empty-row">{t("没有匹配的模型")}</div> : null}
</div>
) : null}
</div>
);
}
51 changes: 51 additions & 0 deletions frontend/src/components/ProviderModelPicker.tsx
Original file line number Diff line number Diff line change
@@ -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<string[]>([]);
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 (
<div className="field-stack profile-editor-wide">
{loading ? <div className="loading-block"><span className="spinner" />{t("正在读取模型列表")}</div> : null}
{message && !loading ? <div className={`notice ${success ? "notice-success" : "notice-warning"}`}>{message}</div> : null}
<ModelPicker models={models} value={value} onChange={onChange} inputId={inputId} inputLabel={t("模型")} required />
</div>
);
}
14 changes: 10 additions & 4 deletions frontend/src/pages/AgentProfilePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -210,10 +211,15 @@ export function AgentProfilePage() {
protocol={protocol}
/>
</div>
<div className="field-stack profile-editor-wide">
<label htmlFor="agent-profile-model">{t("模型")}</label>
<input id="agent-profile-model" value={draft.model} onChange={(event) => setDraft({ ...draft, model: event.target.value })} placeholder={t("例如 deepseek/deepseek-v4-pro")} required />
</div>
<ProviderModelPicker
key={`${protocol}:${draft.provider}`}
provider={draft.provider}
protocol={protocol}
hasKey={Boolean(status.providers[draft.provider]?.has_key)}
value={draft.model}
onChange={(model) => setDraft({ ...draft, model })}
inputId="agent-profile-model"
/>
</div>
<p className="profile-key-hint">
{status.providers[draft.provider]?.has_key
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/pages/AgentSelectionPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -75,8 +75,8 @@ export function AgentSelectionPage() {
<PackageCheck size={19} aria-hidden="true" />
</div>
<div className="agent-tabs" role="tablist" aria-label={t("Agent 类型")}>
<button className={`agent-tab${state.setupKind !== "desktop" ? " is-active" : ""}`} role="tab" aria-selected={state.setupKind !== "desktop"} type="button" onClick={() => dispatch({ type: "START_SETUP" })}>{t("命令行 Agent")}</button>
<button className={`agent-tab${state.setupKind === "desktop" ? " is-active" : ""}`} role="tab" aria-selected={state.setupKind === "desktop"} type="button" onClick={() => dispatch({ type: "START_DESKTOP_SETUP" })}>{t("桌面 Agent")}</button>
<button className={`agent-tab${state.setupKind !== "desktop" ? " is-active" : ""}`} role="tab" aria-selected={state.setupKind !== "desktop"} type="button" onClick={() => dispatch({ type: "START_SETUP" })}>{t("命令行 Agent")}</button>
</div>
{state.setupKind !== "desktop" ? renderRows(agents) : (
<div className="agent-list agent-selection-list">
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/pages/EnvironmentOverviewPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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();
});
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/pages/EnvironmentOverviewPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
};

Expand Down
24 changes: 24 additions & 0 deletions frontend/src/pages/ProfilesPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<MemoryRouter><ProfilesPage /></MemoryRouter>);
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,
Expand Down
14 changes: 10 additions & 4 deletions frontend/src/pages/ProfilesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -283,10 +284,15 @@ export function ProfilesPage() {
protocol={editor.protocol as ProtocolId}
/>
</div>
<div className="field-stack profile-editor-wide">
<label htmlFor="profile-model">{t("模型")}</label>
<input id="profile-model" value={editor.model} onChange={(event) => setEditor({ ...editor, model: event.target.value })} placeholder={t("例如 deepseek/deepseek-v4-pro")} required />
</div>
<ProviderModelPicker
key={`${editor.protocol}:${editor.provider}`}
provider={editor.provider}
protocol={editor.protocol}
hasKey={providerHasKey}
value={editor.model}
onChange={(model) => 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. */}
<p className="profile-key-hint profile-editor-wide">
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/state/desktopSetup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { DesktopAgentStatus, ProfileSummary, StatusResponse } from "../type

export function desktopApps(status: StatusResponse): DesktopAgentStatus[] {
const seen = new Set<string>();
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;
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/state/wizardReducer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/state/wizardReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export const initialWizardState: WizardState = {
status: null,
statusState: "idle",
statusError: "",
setupKind: "cli",
setupKind: "desktop",
selectedAgentIds: [],
provider: "ppio",
probeModel: "",
Expand Down
Loading