From 6dfffc0e187568e17aeb89ea3bfbcec9d4a2e80f Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:47:30 +0800 Subject: [PATCH] refactor: use step-by-step guide for desktop agents --- .../MaimoryLab/OneAgent/internal/app/index.ts | 1 + .../OneAgent/internal/app/models.ts | 17 ++ .../internal/binding/desktopagentservice.ts | 12 ++ .../OneAgent/internal/binding/index.ts | 1 + .../OneAgent/internal/binding/models.ts | 5 + frontend/e2e/wails.spec.ts | 4 +- frontend/src/App.tsx | 32 +++- frontend/src/backend/wails.test.ts | 10 +- frontend/src/backend/wails.ts | 3 + .../src/components/DesktopAppSection.test.tsx | 6 + frontend/src/components/DesktopAppSection.tsx | 21 +- frontend/src/components/SetupStepper.tsx | 12 +- .../src/components/icons/assets/cursor.svg | 1 + .../src/components/icons/assets/hermes.png | Bin 0 -> 4100 bytes .../src/components/icons/assets/openclaw.svg | 60 ++++++ frontend/src/i18n.tsx | 31 ++- frontend/src/pages/ActivationPage.tsx | 11 +- .../src/pages/DesktopAgentSelectionPage.tsx | 70 +++++++ frontend/src/pages/DesktopInstallPage.tsx | 95 +++++++++ frontend/src/pages/DesktopProfilePage.tsx | 180 ++++++++++++++++++ .../pages/EnvironmentOverviewPage.test.tsx | 2 +- .../src/pages/EnvironmentOverviewPage.tsx | 21 +- frontend/src/pages/ModelSelectionPage.tsx | 7 +- frontend/src/pages/ProviderKeyPage.test.tsx | 29 ++- frontend/src/pages/ProviderKeyPage.tsx | 41 ++-- frontend/src/pages/ReviewPage.test.tsx | 10 +- frontend/src/pages/ReviewPage.tsx | 8 +- frontend/src/state/desktopSetup.test.ts | 49 +++++ frontend/src/state/desktopSetup.ts | 35 ++++ frontend/src/state/wizardReducer.ts | 24 ++- frontend/src/styles/app.css | 45 ++++- frontend/src/types/api.ts | 13 +- internal/app/desktopapp.go | 131 ++++++++++++- internal/app/desktopapp_test.go | 58 ++++++ .../testdata/status-empty-linux-arm64.json | 2 + internal/binding/services.go | 17 ++ internal/binding/services_test.go | 2 +- internal/desktopapp/desktopapp.go | 16 ++ internal/desktopapp/desktopapp_test.go | 15 ++ 39 files changed, 1020 insertions(+), 77 deletions(-) create mode 100644 frontend/src/components/icons/assets/cursor.svg create mode 100644 frontend/src/components/icons/assets/hermes.png create mode 100644 frontend/src/components/icons/assets/openclaw.svg create mode 100644 frontend/src/pages/DesktopAgentSelectionPage.tsx create mode 100644 frontend/src/pages/DesktopInstallPage.tsx create mode 100644 frontend/src/pages/DesktopProfilePage.tsx create mode 100644 frontend/src/state/desktopSetup.test.ts create mode 100644 frontend/src/state/desktopSetup.ts diff --git a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/app/index.ts b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/app/index.ts index 44c8bab..772be99 100644 --- a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/app/index.ts +++ b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/app/index.ts @@ -5,6 +5,7 @@ export type { AgentStatus, Capabilities, DesktopAgentActionResult, + DesktopAgentProfileResult, DesktopAgentStatus, DetectedConfig, InstallRuntimeResult, diff --git a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/app/models.ts b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/app/models.ts index 9c0c15c..61035b5 100644 --- a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/app/models.ts +++ b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/app/models.ts @@ -54,6 +54,21 @@ export interface DesktopAgentActionResult { "app": DesktopAgentStatus; } +/** + * DesktopAgentProfileResult is the non-secret result of applying a saved + * profile to a desktop Agent. ChatGPT Desktop returns the Codex config result; + * other desktop Agents only need their own profile membership recorded until + * their vendor-specific config writer is added. + */ +export interface DesktopAgentProfileResult { + "agent": string; + "profileId": string; + "profileAgentId": string; + "config"?: string; + "restart"?: string; + "message": string; +} + /** * DesktopAgentStatus is the public projection of the current desktop agent. It is * deliberately separate from AgentStatus: desktop and command-line agents may @@ -69,6 +84,8 @@ export interface DesktopAgentStatus { "source": string; "configPath"?: string; "configSharedWith"?: string; + "profileAgentId": string; + "profileId": string | null; "packageFamily"?: string; "inspectionUnavailable"?: string | null; } diff --git a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/desktopagentservice.ts b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/desktopagentservice.ts index e3e1466..5aa4466 100644 --- a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/desktopagentservice.ts +++ b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/desktopagentservice.ts @@ -15,6 +15,18 @@ import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wails // @ts-ignore: Unused imports import * as app$0 from "../app/models.js"; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import * as $models from "./models.js"; + +/** + * Configure applies a saved Profile to the selected desktop Agent. The profile + * ID is the only user-supplied value; secrets stay in the Go profile store. + */ +export function Configure(request: $models.DesktopAgentProfileRequest): $CancellablePromise { + return $Call.ByID(1718807223, request); +} + export function GetStatus(): $CancellablePromise { return $Call.ByID(2070814877); } diff --git a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/index.ts b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/index.ts index 3349f96..7f7d360 100644 --- a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/index.ts +++ b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/index.ts @@ -20,6 +20,7 @@ export type { ActivateRequest, ActivateResponse, AgentInstallResult, + DesktopAgentProfileRequest, InstallRequest, InstallResponse, InstallRuntimeRequest, diff --git a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/models.ts b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/models.ts index 108c6e4..fc8ec37 100644 --- a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/models.ts +++ b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/models.ts @@ -35,6 +35,11 @@ export interface AgentInstallResult { "retryable": boolean; } +export interface DesktopAgentProfileRequest { + "agent_id": string; + "profile_id": string; +} + export interface InstallRequest { "agents": string[] | null; "profile_agents": string[] | null; diff --git a/frontend/e2e/wails.spec.ts b/frontend/e2e/wails.spec.ts index 652bca3..6de07d4 100644 --- a/frontend/e2e/wails.spec.ts +++ b/frontend/e2e/wails.spec.ts @@ -29,8 +29,8 @@ test("onboarding installs one Agent end to end and writes its Profile", async ({ // Onboarding is the only way to create a Profile: it collects the Agent, key // and model in order and the install writes the Profile itself. await page.goto("/#/overview"); - await expect(page.getByText("尚未安装任何 Agent")).toBeVisible(); - await page.getByRole("button", { name: "安装 Agent" }).click(); + await expect(page.getByText("尚未安装任何命令行 Agent")).toBeVisible(); + await page.getByRole("button", { name: "安装命令行 Agent" }).click(); await expect(page.getByRole("heading", { name: "选择 Agent" })).toBeVisible(); await page.getByLabel("选择 Codex").check(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4114f88..a235f80 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,9 @@ import { AppWindow } from "./components/AppWindow"; import { ActivationPage } from "./pages/ActivationPage"; import { AgentDetailPage } from "./pages/AgentDetailPage"; import { AgentSelectionPage } from "./pages/AgentSelectionPage"; +import { DesktopAgentSelectionPage } from "./pages/DesktopAgentSelectionPage"; +import { DesktopInstallPage } from "./pages/DesktopInstallPage"; +import { DesktopProfilePage } from "./pages/DesktopProfilePage"; import { EnvironmentOverviewPage } from "./pages/EnvironmentOverviewPage"; import { ModelSelectionPage } from "./pages/ModelSelectionPage"; import { ProfilesPage } from "./pages/ProfilesPage"; @@ -18,15 +21,13 @@ import { WizardProvider, useWizard } from "./state/WizardContext"; function SetupGuard({ stage, children }: { stage: "provider" | "model" | "review" | "activation"; children: React.ReactNode }) { const { state } = useWizard(); if (!state.selectedAgentIds.length) return ; - if (stage === "model" && (!state.hasApiKey || !state.keyVerified)) { - // A non-empty key alone proves nothing: only a successful probe unlocks the - // model step. Editing the key or switching Provider clears keyVerified, so a - // stale verdict cannot reach this guard. + const providerHasKey = Boolean(state.status?.providers[state.provider]?.has_key); + if (stage === "model" && (!providerHasKey || !state.keyVerified)) { + // Provider settings own the key; a successful probe unlocks the model step. // - // Only this step is gated on the key. A finished install clears it on - // purpose, and gating the later steps too would eject the user from the - // results page the moment it succeeded; ReviewPage's start handler sends - // them back for a key instead. + // Only this step is gated on the key. Gating the later steps too would + // eject the user from the results page after an install; ReviewPage checks + // the Provider again when a new install is started. return ; } if ((stage === "review" || stage === "activation") && !state.model) { @@ -40,6 +41,18 @@ function SetupGuard({ stage, children }: { stage: "provider" | "model" | "review return children; } +function DesktopSetupGuard({ stage, children }: { stage: "profile" | "install"; children: React.ReactNode }) { + const { state } = useWizard(); + const desktopID = state.status?.desktopAgent?.id; + if (state.setupKind !== "desktop" || !desktopID || state.selectedAgentIds[0] !== desktopID) { + return ; + } + if (stage === "install" && !state.desktopProfileId) { + return ; + } + return children; +} + /** * Landing route. A machine with no ~/.oneagent has nothing to show on the * overview, so it opens onboarding instead. The decision waits for the status @@ -62,6 +75,9 @@ function WorkspaceRoutes() { } /> } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/backend/wails.test.ts b/frontend/src/backend/wails.test.ts index 86ca372..61ebba8 100644 --- a/frontend/src/backend/wails.test.ts +++ b/frontend/src/backend/wails.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import type { DesktopAgentActionResult, DesktopAgentStatus, InstallResponse, ModelsResponse, ProbeResponse, ProfileSummary, ProviderEntry, StatusResponse } from "../types/api"; +import type { DesktopAgentActionResult, DesktopAgentProfileResult, DesktopAgentStatus, InstallResponse, ModelsResponse, ProbeResponse, ProfileSummary, ProviderEntry, StatusResponse } from "../types/api"; import { LOCALE_STORAGE_KEY } from "../i18n"; const bridge = vi.hoisted(() => ({ @@ -18,6 +18,7 @@ const bridge = vi.hoisted(() => ({ desktopInstall: vi.fn(), desktopOpen: vi.fn(), desktopInstaller: vi.fn(), + desktopConfigure: vi.fn(), profiles: vi.fn(), saveProfile: vi.fn(), eventsOn: vi.fn(), @@ -43,6 +44,7 @@ vi.mock("../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/desktopa Install: bridge.desktopInstall, Open: bridge.desktopOpen, OpenInstaller: bridge.desktopInstaller, + Configure: bridge.desktopConfigure, })); vi.mock("../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/profileservice.js", () => ({ ListProfiles: bridge.profiles, @@ -69,8 +71,9 @@ describe("Wails backend adapter", () => { const install = { ok: true, code: 0, results: [], log: "", next: "", probe: null } satisfies InstallResponse; const profile = { id: "team", label: "Team", provider: "ppio", baseUrl: null, model: "m", agentIds: ["codex"], activatedAt: null, hasKey: true } satisfies ProfileSummary; const provider = { id: "acme", name: "Acme", home: "", base_url: "https://api.acme.test", anthropic_base_url: "", api_key: "secret", built_in: false } satisfies ProviderEntry; - const desktopStatus = { id: "desktop-agent", name: "ChatGPT Desktop", installed: false, supported: true, version: null, source: "macos-dmg" } satisfies DesktopAgentStatus; + const desktopStatus = { id: "desktop-agent", name: "ChatGPT Desktop", installed: false, supported: true, version: null, source: "macos-dmg", profileAgentId: "codex", profileId: null } satisfies DesktopAgentStatus; const desktopAction = { status: "installer-started", message: "started", refreshNeeded: true, app: desktopStatus } satisfies DesktopAgentActionResult; + const desktopProfile = { agent: "desktop-agent", profileId: "team", profileAgentId: "codex", config: "/c", restart: "restart", message: "applied" } satisfies DesktopAgentProfileResult; bridge.status.mockResolvedValue(status); bridge.probe.mockResolvedValue(probe); @@ -88,12 +91,14 @@ describe("Wails backend adapter", () => { bridge.desktopInstall.mockResolvedValue(desktopAction); bridge.desktopOpen.mockResolvedValue(undefined); bridge.desktopInstaller.mockResolvedValue(desktopAction); + bridge.desktopConfigure.mockResolvedValue(desktopProfile); await expect(wailsApi.status()).resolves.toBe(status); await expect(wailsApi.desktopAgentStatus()).resolves.toBe(desktopStatus); await expect(wailsApi.installDesktopAgent()).resolves.toBe(desktopAction); await expect(wailsApi.openDesktopAgent()).resolves.toBeUndefined(); await expect(wailsApi.openDesktopAgentInstaller()).resolves.toBe(desktopAction); + await expect(wailsApi.configureDesktopAgent("desktop-agent", "team")).resolves.toBe(desktopProfile); await expect(wailsApi.probe({ provider: "custom", apiBaseUrl: "https://proxy.test/v1", apiKey: "secret", model: "m", agents: [] })).resolves.toBe(probe); await expect(wailsApi.models({ provider: "ppio", apiBaseUrl: "", apiKey: "secret" })).resolves.toBe(models); await expect(wailsApi.getProvider("acme")).resolves.toBe(provider); @@ -117,6 +122,7 @@ describe("Wails backend adapter", () => { expect(bridge.desktopInstall).toHaveBeenCalledWith(); expect(bridge.desktopOpen).toHaveBeenCalledWith(); expect(bridge.desktopInstaller).toHaveBeenCalledWith(); + expect(bridge.desktopConfigure).toHaveBeenCalledWith({ agent_id: "desktop-agent", profile_id: "team" }); expect(bridge.saveProfile).toHaveBeenCalledWith(expect.objectContaining({ api_base_url: "", api_key: "secret", agent_ids: ["codex"] })); }); diff --git a/frontend/src/backend/wails.ts b/frontend/src/backend/wails.ts index c96409b..6bc8aa3 100644 --- a/frontend/src/backend/wails.ts +++ b/frontend/src/backend/wails.ts @@ -9,6 +9,7 @@ import * as StatusService from "../../bindings/github.com/MaimoryLab/OneAgent/in import type { ActivateAgentResponse, DesktopAgentActionResult, + DesktopAgentProfileResult, DesktopAgentStatus, InstallRequest, InstallOutput, @@ -93,6 +94,8 @@ export const wailsApi = { openDesktopAgent: (): Promise => call(() => DesktopAgentService.Open()).then(() => undefined), openDesktopAgentInstaller: (): Promise => call(() => DesktopAgentService.OpenInstaller()) as Promise, + configureDesktopAgent: (agentId: string, profileId: string): Promise => + call(() => DesktopAgentService.Configure({ agent_id: agentId, profile_id: profileId })) as Promise, probe: (input: { provider: ProviderId; apiBaseUrl: string; apiKey: string; model: string; agents?: string[] }): Promise => call(() => ProviderService.Probe({ provider: input.provider, diff --git a/frontend/src/components/DesktopAppSection.test.tsx b/frontend/src/components/DesktopAppSection.test.tsx index 4b4d06c..da094ab 100644 --- a/frontend/src/components/DesktopAppSection.test.tsx +++ b/frontend/src/components/DesktopAppSection.test.tsx @@ -98,4 +98,10 @@ describe("DesktopAppSection", () => { expect(screen.getByText("应用状态检测不可用")).toBeTruthy(); expect(screen.queryByText("已检测到应用,但版本信息不可用")).toBeNull(); }); + + it("can omit an uninstalled app from the overview", () => { + render(); + + expect(screen.queryByRole("heading", { name: "桌面 Agent" })).toBeNull(); + }); }); diff --git a/frontend/src/components/DesktopAppSection.tsx b/frontend/src/components/DesktopAppSection.tsx index 06ef0b1..fcbc067 100644 --- a/frontend/src/components/DesktopAppSection.tsx +++ b/frontend/src/components/DesktopAppSection.tsx @@ -1,4 +1,4 @@ -import { AppWindow, Download, RefreshCw, TriangleAlert } from "lucide-react"; +import { AppWindow, Download, RefreshCw, SlidersHorizontal, TriangleAlert } from "lucide-react"; import { useState } from "react"; import { api, describeError } from "../backend/api"; @@ -11,18 +11,21 @@ import { StatusBadge } from "./StatusBadge"; interface DesktopAppSectionProps { app: DesktopAgentStatus; onChanged: () => void | Promise; + onConfigure?: () => void; + /** The overview passes false so uninstalled apps are not rendered there. */ + showUninstalled?: boolean; } type Action = "install" | "open" | "installer"; -export function DesktopAppSection({ app: desktopApp, onChanged }: DesktopAppSectionProps) { +export function DesktopAppSection({ app: desktopApp, onChanged, onConfigure, showUninstalled = true }: DesktopAppSectionProps) { const { t } = useI18n(); const { resetProgress } = useTaskCenter(); const [pending, setPending] = useState(""); const [failure, setFailure] = useState(""); const [notice, setNotice] = useState(""); - if (!desktopApp?.supported) return null; + if (!desktopApp?.supported || (!showUninstalled && !desktopApp.installed)) return null; const run = async (action: Action) => { const downloading = action === "install" || action === "installer"; @@ -90,9 +93,21 @@ export function DesktopAppSection({ app: desktopApp, onChanged }: DesktopAppSect {desktopApp.path} ) : null} + {desktopApp.profileId ? ( +
+ Profile + {desktopApp.profileId} +
+ ) : null}
{desktopApp.installed ? ( <> + {onConfigure ? ( + + ) : null} : null} + + ); +} diff --git a/frontend/src/pages/DesktopProfilePage.tsx b/frontend/src/pages/DesktopProfilePage.tsx new file mode 100644 index 0000000..bdc1a7f --- /dev/null +++ b/frontend/src/pages/DesktopProfilePage.tsx @@ -0,0 +1,180 @@ +import { Check, Plus, Save, X } from "lucide-react"; +import { useEffect, useState, type FormEvent } from "react"; +import { useNavigate } from "react-router-dom"; + +import { api, describeError } from "../backend/api"; +import { PageScaffold } from "../components/PageScaffold"; +import { useI18n } from "../i18n"; +import { desktopProfileIsShared, desktopProfileUsable, desktopProfiles, profileAgentIdForDesktop } from "../state/desktopSetup"; +import { useWizard } from "../state/WizardContext"; +import type { ProviderId } from "../types/api"; + +interface Draft { + id: string; + label: string; + provider: ProviderId; + model: string; +} + +export function DesktopProfilePage() { + const navigate = useNavigate(); + const { t } = useI18n(); + const { state, dispatch, refreshStatus } = useWizard(); + const app = state.status?.desktopAgent; + const [draft, setDraft] = useState(null); + const [saving, setSaving] = useState(false); + const [failure, setFailure] = useState(""); + + const profiles = app && state.status ? desktopProfiles(state.status, app) : []; + const owner = app ? profileAgentIdForDesktop(app) : ""; + const status = state.status; + const selectedProfile = status && state.desktopProfileId + ? profiles.find((profile) => profile.id === state.desktopProfileId && desktopProfileUsable(status, profile)) + : undefined; + + useEffect(() => { + const appProfileId = app?.profileId; + const current = app && status && appProfileId + ? desktopProfiles(status, app).find((profile) => profile.id === appProfileId && desktopProfileUsable(status, profile)) + : undefined; + if (!state.desktopProfileId && current && appProfileId) { + dispatch({ type: "SET_DESKTOP_PROFILE", value: appProfileId }); + } + }, [app, dispatch, state.desktopProfileId, status]); + + if (!app || !state.status || !state.selectedAgentIds.includes(app.id)) { + return ( + navigate("/setup/desktop/agents")}> +
{t("请先选择一个桌面 Agent")}
+
+ ); + } + + const defaultProvider = Object.keys(state.status.providers)[0] || "ppio"; + const canCreate = Boolean(draft?.id.trim() && draft?.model.trim() && draft?.provider); + + const openCreate = () => { + setFailure(""); + setDraft({ + id: `${owner}-profile`, + label: `${app.name} Profile`, + provider: defaultProvider, + model: state.status?.agents[owner]?.model || "", + }); + }; + + const save = async (event: FormEvent) => { + event.preventDefault(); + if (!draft || !canCreate) return; + setSaving(true); + setFailure(""); + try { + const saved = await api.saveProfile({ + id: draft.id.trim().toLowerCase(), + label: draft.label.trim(), + provider: draft.provider, + apiBaseUrl: "", + apiKey: "", + model: draft.model.trim(), + configMode: "provider", + agentIds: [owner], + }); + await refreshStatus(); + dispatch({ type: "SET_DESKTOP_PROFILE", value: saved.id }); + setDraft(null); + } catch (error) { + setFailure(describeError(error, t("无法保存 Profile")).message); + } finally { + setSaving(false); + } + }; + + return ( + navigate("/setup/desktop/agents")} + primaryLabel={t("继续")} + onPrimary={() => navigate("/setup/desktop/install")} + primaryDisabled={!selectedProfile} + footerNote={selectedProfile?.label || t("选择一个 Profile")} + > + {failure ?
{failure}
: null} +
+ +
+ + {draft ? ( +
void save(event)}> +
+ {t("创建 Profile")} + +
+
+
+ + setDraft({ ...draft, id: event.target.value })} required /> +
+
+ + setDraft({ ...draft, label: event.target.value })} /> +
+
+ + +
+
+ + setDraft({ ...draft, model: event.target.value })} placeholder={t("例如 deepseek/deepseek-v3")} required /> +
+
+

{t("将使用 Provider 已保存的 Key。")}

+ +
+ ) : null} + + {profiles.length ? ( +
+ {profiles.map((profile) => { + const provider = state.status?.providers[profile.provider]; + const usable = status ? desktopProfileUsable(status, profile) : false; + const active = state.desktopProfileId === profile.id; + return ( + + ); + })} +
+ ) : ( +
+ {t("还没有可用的 Profile")} + {desktopProfileIsShared(app) ? t("先创建一个包含 Codex 的 Profile。") : t("先创建一个属于该桌面 Agent 的 Profile。")} +
+ )} +
+ ); +} diff --git a/frontend/src/pages/EnvironmentOverviewPage.test.tsx b/frontend/src/pages/EnvironmentOverviewPage.test.tsx index 6d836a2..00502fa 100644 --- a/frontend/src/pages/EnvironmentOverviewPage.test.tsx +++ b/frontend/src/pages/EnvironmentOverviewPage.test.tsx @@ -84,7 +84,7 @@ describe("EnvironmentOverviewPage", () => { mockState = { status: empty, statusState: "success", statusError: "" }; renderPage(); expect(screen.getByText("尚未安装任何 Agent")).toBeTruthy(); - fireEvent.click(screen.getByRole("button", { name: "安装 Agent" })); + 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" }); diff --git a/frontend/src/pages/EnvironmentOverviewPage.tsx b/frontend/src/pages/EnvironmentOverviewPage.tsx index e06caeb..485edba 100644 --- a/frontend/src/pages/EnvironmentOverviewPage.tsx +++ b/frontend/src/pages/EnvironmentOverviewPage.tsx @@ -1,4 +1,4 @@ -import { PackageOpen, Plus, RefreshCw } from "lucide-react"; +import { AppWindow, PackageOpen, Plus, RefreshCw } from "lucide-react"; import { useNavigate } from "react-router-dom"; import { AgentManageRow } from "../components/AgentManageRow"; @@ -19,6 +19,10 @@ export function EnvironmentOverviewPage() { dispatch({ type: "START_SETUP" }); navigate("/setup/agents"); }; + const startDesktopSetup = () => { + dispatch({ type: "START_DESKTOP_SETUP" }); + navigate("/setup/desktop/agents"); + }; if (state.statusState === "loading" && !status) { return ( @@ -43,6 +47,7 @@ export function EnvironmentOverviewPage() { const installed = [...status.catalog] .sort((first, second) => first.rank - second.rank) .filter((item) => status.agents[item.id]?.installed); + const desktopInstalled = Boolean(status.desktopAgent?.installed); const profiles = new Map(status.profiles.map((profile) => [profile.id, profile.label])); return ( @@ -60,10 +65,16 @@ export function EnvironmentOverviewPage() { {t("刷新状态")} - {installed.length ? ( + {installed.length || desktopInstalled ? ( + ) : null} + {status?.desktopAgent?.supported && !status.desktopAgent.installed ? ( + ) : null} @@ -89,7 +100,7 @@ export function EnvironmentOverviewPage() { })}
- ) : ( + ) : desktopInstalled ? null : (
{t("尚未安装任何 Agent")} @@ -101,7 +112,7 @@ export function EnvironmentOverviewPage() {
)} - + ); diff --git a/frontend/src/pages/ModelSelectionPage.tsx b/frontend/src/pages/ModelSelectionPage.tsx index 8495a02..628bbc8 100644 --- a/frontend/src/pages/ModelSelectionPage.tsx +++ b/frontend/src/pages/ModelSelectionPage.tsx @@ -11,7 +11,7 @@ import { useWizard } from "../state/WizardContext"; export function ModelSelectionPage() { const navigate = useNavigate(); const { t } = useI18n(); - const { state, dispatch, secret } = useWizard(); + const { state, dispatch } = useWizard(); const requested = useRef(false); const loadModels = useCallback(async () => { @@ -20,13 +20,14 @@ export function ModelSelectionPage() { const result = await api.models({ provider: state.provider, apiBaseUrl: "", - apiKey: secret.keyRef.current, + // The backend resolves the empty request key from the selected Provider. + apiKey: "", }); dispatch({ type: "MODELS_RESULT", result }); } catch (error) { dispatch({ type: "MODELS_FAILED", message: describeError(error, t("无法获取模型列表")).message }); } - }, [dispatch, secret.keyRef, state.provider, t]); + }, [dispatch, state.provider, t]); useEffect(() => { // SetupGuard has already verified the key; only model loading lives here. diff --git a/frontend/src/pages/ProviderKeyPage.test.tsx b/frontend/src/pages/ProviderKeyPage.test.tsx index 2fbc7f9..843f7fe 100644 --- a/frontend/src/pages/ProviderKeyPage.test.tsx +++ b/frontend/src/pages/ProviderKeyPage.test.tsx @@ -1,9 +1,10 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { api } from "../backend/api"; import { initialWizardState, wizardReducer, type WizardAction, type WizardState } from "../state/wizardReducer"; +import type { StatusResponse } from "../types/api"; import { ProviderKeyPage } from "./ProviderKeyPage"; let state: WizardState; @@ -12,6 +13,11 @@ const dispatch = vi.fn((action: WizardAction) => { state = wizardReducer(state, action); }); +const status = { + providers: { ppio: { name: "PPIO", home: "", base_url: "https://api.ppinfra.com/openai", has_key: true } }, + catalog: [], +} as unknown as StatusResponse; + vi.mock("../state/WizardContext", () => ({ useWizard: () => ({ state, @@ -21,8 +27,11 @@ vi.mock("../state/WizardContext", () => ({ })); describe("ProviderKeyPage", () => { + afterEach(() => vi.restoreAllMocks()); + it("uses a custom model name for the connection test", async () => { - state = { ...initialWizardState, hasApiKey: true }; + state = { ...initialWizardState, status, statusState: "success", hasApiKey: false }; + keyRef.current = "test-key"; dispatch.mockClear(); const probe = vi.spyOn(api, "probe").mockResolvedValue({ ok: true, @@ -36,10 +45,24 @@ describe("ProviderKeyPage", () => { fireEvent.change(screen.getByLabelText("自定义模型名称(可选)"), { target: { value: "vendor/custom-model" } }); page.rerender(); + expect(screen.queryByLabelText("API Key")).toBeNull(); fireEvent.click(screen.getByRole("button", { name: "测试连接" })); await waitFor(() => - expect(probe).toHaveBeenCalledWith(expect.objectContaining({ model: "vendor/custom-model", apiKey: "test-key" })), + expect(probe).toHaveBeenCalledWith(expect.objectContaining({ model: "vendor/custom-model", apiKey: "" })), ); }); + + it("blocks probing when the Provider has no saved key", () => { + state = { + ...initialWizardState, + status: { ...status, providers: { ppio: { ...status.providers.ppio, has_key: false } } }, + statusState: "success", + }; + render(); + + expect(screen.getByRole("button", { name: "测试连接" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "前往 Provider" })).toBeTruthy(); + expect(screen.queryByLabelText("API Key")).toBeNull(); + }); }); diff --git a/frontend/src/pages/ProviderKeyPage.tsx b/frontend/src/pages/ProviderKeyPage.tsx index f9dd0e2..0d3652f 100644 --- a/frontend/src/pages/ProviderKeyPage.tsx +++ b/frontend/src/pages/ProviderKeyPage.tsx @@ -1,41 +1,29 @@ import { ExternalLink, FlaskConical, Link2 } from "lucide-react"; -import { useEffect, useMemo } from "react"; +import { useMemo } from "react"; import { useNavigate } from "react-router-dom"; import { api, describeError } from "../backend/api"; import { ConnectionStatus } from "../components/ConnectionStatus"; import { PageScaffold } from "../components/PageScaffold"; import { ProviderSegment } from "../components/ProviderSegment"; -import { SecureKeyField } from "../components/SecureKeyField"; import { useI18n } from "../i18n"; import { useWizard } from "../state/WizardContext"; -import { PROTOCOL_LABELS } from "../types/api"; import type { ProtocolId, ProviderId } from "../types/api"; +import { PROTOCOL_LABELS } from "../types/api"; export function ProviderKeyPage() { const navigate = useNavigate(); const { t } = useI18n(); - const { state, dispatch, secret } = useWizard(); + const { state, dispatch } = useWizard(); const providerMeta = state.status?.providers[state.provider]; const apiBaseUrl = providerMeta?.base_url || ""; - const canProbe = state.hasApiKey; + const providerHasKey = Boolean(providerMeta?.has_key); + const canProbe = providerHasKey; // Continuing requires a successful probe, not just a non-empty key: a wrong // key must not reach the model step. canProbe stays separate so the test // button remains clickable while the verdict is still outstanding. const canContinue = canProbe && state.keyVerified; - useEffect(() => { - if (!providerMeta?.has_key) return; - let active = true; - void api.getProvider(state.provider) - .then((entry) => { - if (active) secret.setApiKey(entry.api_key); - }) - .catch((error) => { - if (active) dispatch({ type: "CONNECTION_FAILED", failure: describeError(error, t("无法读取已保存的 API Key")) }); - }); - return () => { active = false; }; - }, [dispatch, providerMeta?.has_key, secret.setApiKey, state.provider]); // The selected Agents decide which protocols get tested; a model that serves // Chat Completions may still refuse Responses, so do not imply a single one. const protocols = useMemo(() => { @@ -54,7 +42,6 @@ export function ProviderKeyPage() { }, [apiBaseUrl, protocols]); const changeProvider = (provider: ProviderId) => { - secret.clearApiKey(); dispatch({ type: "SET_PROVIDER", value: provider }); }; @@ -64,7 +51,8 @@ export function ProviderKeyPage() { const result = await api.probe({ provider: state.provider, apiBaseUrl: "", - apiKey: secret.keyRef.current, + // The backend resolves an empty request key from the saved Provider. + apiKey: "", // A user-supplied ID lets providers without model discovery validate // the model that will actually be configured. model: state.model, @@ -88,7 +76,7 @@ export function ProviderKeyPage() { return ( navigate("/setup/agents")} primaryLabel={t("继续选择模型")} @@ -104,6 +92,15 @@ export function ProviderKeyPage() { />
+ {!providerHasKey ? ( +
+ {t("这个 Provider 还没有 Key,先到 Provider 页面填写。")} + +
+ ) : null}
{providerMeta?.name} @@ -130,8 +127,6 @@ export function ProviderKeyPage() { {t("填写后将用此模型测试连接;留空时自动选择。")}
- -
- {canProbe && state.connectionState === "idle" && ( + {providerHasKey && state.connectionState === "idle" && ( {t("连接测试通过后才能继续选择模型。")} )}
diff --git a/frontend/src/pages/ReviewPage.test.tsx b/frontend/src/pages/ReviewPage.test.tsx index 28a1db2..9380a81 100644 --- a/frontend/src/pages/ReviewPage.test.tsx +++ b/frontend/src/pages/ReviewPage.test.tsx @@ -42,7 +42,7 @@ function renderPage(over: Partial = {}) { selectedAgentIds: ["codex"], provider: "ppio", model: "deepseek/deepseek-v3", - hasApiKey: true, + hasApiKey: false, keyVerified: true, ...over, }; @@ -78,9 +78,11 @@ describe("ReviewPage", () => { }); it("sends the user back for a key instead of installing without one", async () => { - // A finished run clears the key. Submitting an empty one would fail deep in - // the install; the provider step collects it again first. - renderPage({ hasApiKey: false }); + // The wizard reuses Provider credentials, so the missing-key check comes + // from the Provider status rather than a frontend secret ref. + renderPage({ + status: { ...status, providers: { ppio: { ...status.providers.ppio, has_key: false } } }, + }); fireEvent.click(screen.getByRole("button", { name: /开始安装/ })); expect(await screen.findByRole("heading", { name: "provider step" })).toBeTruthy(); expect(dispatch).not.toHaveBeenCalledWith({ type: "REQUEST_ACTIVATION" }); diff --git a/frontend/src/pages/ReviewPage.tsx b/frontend/src/pages/ReviewPage.tsx index f5fe257..5fa1774 100644 --- a/frontend/src/pages/ReviewPage.tsx +++ b/frontend/src/pages/ReviewPage.tsx @@ -19,6 +19,7 @@ export function ReviewPage() { const automatic = selectedCatalog.filter((agent) => agent.configMode === "auto"); const guideOnly = selectedCatalog.filter((agent) => agent.guideOnly); const providerName = state.status?.providers[state.provider]?.name || state.provider; + const providerHasKey = Boolean(state.status?.providers[state.provider]?.has_key); // Default name only; the user can override it before installing. The id is // derived from the Agent and Provider, which is unique per pairing and needs // no separate field. @@ -27,9 +28,10 @@ export function ReviewPage() { const profileLabel = state.profileLabel || defaultLabel; const startActivation = () => { - // A successful activation clears the key, so re-activating from here must - // collect it again instead of submitting an empty one. - if (!state.hasApiKey || !state.keyVerified) { + // The install request intentionally carries no key; the backend resolves + // it from the selected Provider. Keep the review gate in sync with that + // source of truth. + if (!providerHasKey || !state.keyVerified) { navigate("/setup/provider"); return; } diff --git a/frontend/src/state/desktopSetup.test.ts b/frontend/src/state/desktopSetup.test.ts new file mode 100644 index 0000000..0a426b2 --- /dev/null +++ b/frontend/src/state/desktopSetup.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; + +import type { DesktopAgentStatus, ProfileSummary, StatusResponse } from "../types/api"; +import { desktopProfileIsShared, desktopProfileUsable, desktopProfiles, profileAgentIdForDesktop } from "./desktopSetup"; + +function app(overrides: Partial = {}): DesktopAgentStatus { + return { + id: "desktop-agent", + name: "ChatGPT Desktop", + installed: true, + supported: true, + version: null, + source: "macos-dmg", + ...overrides, + }; +} + +function profile(id: string, agentIds: string[]): ProfileSummary { + return { id, label: id, provider: "ppio", baseUrl: null, model: "model-a", agentIds, activatedAt: null, hasKey: true }; +} + +function status(profiles: ProfileSummary[], agents: StatusResponse["agents"] = {}): StatusResponse { + return { profiles, agents, providers: {} } as StatusResponse; +} + +describe("desktop profile mapping", () => { + it("shares ChatGPT profiles with Codex but scopes other apps to themselves", () => { + const chatGPT = app({ profileAgentId: "stale-owner" }); + const workbuddy = app({ id: "workbuddy", name: "WorkBuddy", profileAgentId: "workbuddy" }); + expect(profileAgentIdForDesktop(chatGPT)).toBe("codex"); + expect(desktopProfileIsShared(chatGPT)).toBe(true); + expect(profileAgentIdForDesktop(workbuddy)).toBe("workbuddy"); + expect(desktopProfileIsShared(workbuddy)).toBe(false); + }); + + it("includes the active binding only for legacy profiles without AgentIDs", () => { + const chatGPT = app({ profileId: "legacy" }); + const chatGPTProfiles = [profile("legacy", []), profile("other", ["codex"]), profile("wrong-owner", ["workbuddy"])] as ProfileSummary[]; + expect(desktopProfiles(status(chatGPTProfiles, { codex: { profileId: "legacy" } as StatusResponse["agents"][string] }), chatGPT).map(({ id }) => id)).toEqual(["legacy", "other"]); + + const workbuddyProfiles = [profile("legacy", []), profile("wrong-owner", ["codex"]), profile("workbuddy", ["workbuddy"])] as ProfileSummary[]; + expect(desktopProfiles(status(workbuddyProfiles, { workbuddy: { profileId: "wrong-owner" } as StatusResponse["agents"][string] }), app({ id: "workbuddy" })).map(({ id }) => id)).toEqual(["workbuddy"]); + }); + + it("rejects a profile whose Provider no longer exists", () => { + const candidate = profile("team", ["codex"]); + expect(desktopProfileUsable(status([candidate]), candidate)).toBe(false); + }); +}); diff --git a/frontend/src/state/desktopSetup.ts b/frontend/src/state/desktopSetup.ts new file mode 100644 index 0000000..f8b899f --- /dev/null +++ b/frontend/src/state/desktopSetup.ts @@ -0,0 +1,35 @@ +import type { DesktopAgentStatus, ProfileSummary, StatusResponse } from "../types/api"; + +export const CHATGPT_DESKTOP_ID = "desktop-agent"; +export const CODEX_AGENT_ID = "codex"; + +/** + * Desktop apps own a profile unless their config contract explicitly points at + * another Agent. ChatGPT Desktop is the one current shared-config exception. + */ +export function profileAgentIdForDesktop(app: DesktopAgentStatus): string { + // ChatGPT Desktop is a contract-level exception. Do not let a stale or + // malformed projection make it look like it owns a separate profile. + if (app.id === CHATGPT_DESKTOP_ID) return CODEX_AGENT_ID; + return app.profileAgentId?.trim() || app.id; +} + +export function desktopProfiles(status: StatusResponse, app: DesktopAgentStatus): ProfileSummary[] { + const owner = profileAgentIdForDesktop(app); + const bound = status.agents[owner]?.profileId; + return status.profiles.filter((profile) => { + const agentIds = profile.agentIds ?? []; + // A binding is the only ownership signal available for legacy profiles + // that predate agent_ids. It must not override an explicit owner list. + return agentIds.includes(owner) || (agentIds.length === 0 && profile.id === bound); + }); +} + +export function desktopProfileUsable(status: StatusResponse, profile: ProfileSummary): boolean { + const provider = status.providers[profile.provider]; + return Boolean(provider && profile.model?.trim() && (profile.hasKey || provider.has_key)); +} + +export function desktopProfileIsShared(app: DesktopAgentStatus): boolean { + return profileAgentIdForDesktop(app) !== app.id; +} diff --git a/frontend/src/state/wizardReducer.ts b/frontend/src/state/wizardReducer.ts index 39440e3..ad2eed0 100644 --- a/frontend/src/state/wizardReducer.ts +++ b/frontend/src/state/wizardReducer.ts @@ -9,11 +9,13 @@ import type { } from "../types/api"; export type AsyncState = "idle" | "loading" | "success" | "error"; +export type SetupKind = "cli" | "desktop"; export interface WizardState { status: StatusResponse | null; statusState: AsyncState; statusError: string; + setupKind: SetupKind; /** Onboarding installs exactly one Agent; the array shape stays because the * install API and the activation page are both multi-Agent. */ selectedAgentIds: string[]; @@ -23,6 +25,7 @@ export interface WizardState { * Agent and Provider". */ profileId: string; profileLabel: string; + desktopProfileId: string; hasApiKey: boolean; connection: ProbeResponse | null; connectionState: AsyncState; @@ -50,11 +53,13 @@ export const initialWizardState: WizardState = { status: null, statusState: "idle", statusError: "", + setupKind: "cli", selectedAgentIds: [], installMissingAgents: true, provider: "ppio", profileId: "", profileLabel: "", + desktopProfileId: "", hasApiKey: false, connection: null, connectionState: "idle", @@ -75,11 +80,13 @@ export type WizardAction = | { type: "STATUS_LOADING" } | { type: "STATUS_LOADED"; status: StatusResponse } | { type: "STATUS_FAILED"; message: string } + | { type: "START_DESKTOP_SETUP" } | { type: "SELECT_AGENT"; agentId: string } | { type: "SET_INSTALL_MISSING"; value: boolean } | { type: "SET_PROVIDER"; value: ProviderId } | { type: "SET_PROFILE_ID"; value: string } | { type: "SET_PROFILE_LABEL"; value: string } + | { type: "SET_DESKTOP_PROFILE"; value: string } | { type: "START_SETUP"; profileId?: string; profileLabel?: string } | { type: "SET_HAS_API_KEY"; value: boolean } | { type: "CONNECTION_LOADING" } @@ -137,17 +144,31 @@ export function wizardReducer(state: WizardState, action: WizardAction): WizardS return { ...state, status: action.status, statusState: "success", statusError: "" }; case "STATUS_FAILED": return { ...state, statusState: "error", statusError: action.message }; + case "START_DESKTOP_SETUP": + return { + ...initialWizardState, + status: state.status, + statusState: state.statusState, + statusError: state.statusError, + setupKind: "desktop", + }; case "SELECT_AGENT": // Single select, and re-clicking the current row keeps it selected: the // step cannot continue with nothing chosen, so a toggle-off would only // ever produce a dead end. - return { ...state, selectedAgentIds: [action.agentId] }; + return { + ...state, + selectedAgentIds: [action.agentId], + desktopProfileId: state.selectedAgentIds[0] === action.agentId ? state.desktopProfileId : "", + }; case "SET_INSTALL_MISSING": return { ...state, installMissingAgents: action.value }; case "SET_PROFILE_ID": return { ...state, profileId: action.value }; case "SET_PROFILE_LABEL": return { ...state, profileLabel: action.value }; + case "SET_DESKTOP_PROFILE": + return { ...state, desktopProfileId: action.value }; case "START_SETUP": // Entering onboarding from the overview or the Profile page must not // inherit a previous run's Agent, model or install log. @@ -156,6 +177,7 @@ export function wizardReducer(state: WizardState, action: WizardAction): WizardS status: state.status, statusState: state.statusState, statusError: state.statusError, + setupKind: "cli", profileId: action.profileId ?? "", profileLabel: action.profileLabel ?? "", }; diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index a3764d4..80401ed 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -370,6 +370,39 @@ accent-color: var(--blue); } +.agent-row input[type="radio"], +.profile-choice input[type="radio"] { + width: 17px; + height: 17px; + margin: 0; + accent-color: var(--blue); +} + +.desktop-agent-choice { width: 100%; max-width: none; } +.desktop-agent-ready, +.desktop-install-callout { + margin: 0; + display: flex; + align-items: center; + gap: 7px; + color: var(--text-secondary); + font-size: 12px; +} + +.desktop-profile-editor { max-width: 760px; } +.desktop-profile-list { max-width: 760px; } +.profile-choice { + position: relative; + grid-template-columns: 20px minmax(0, 1fr) auto; + align-items: center; + cursor: pointer; +} +.profile-choice > p, +.profile-choice > .profile-key-hint { grid-column: 2 / -1; } +.profile-choice.is-selected { background: var(--blue-soft); box-shadow: inset 3px 0 var(--blue); } +.profile-choice.is-disabled { cursor: not-allowed; opacity: 0.58; } +.desktop-install-summary { max-width: 760px; } + .agent-icon, .choice-icon, .progress-icon { @@ -1069,6 +1102,7 @@ .desktop-app-identity > span:last-child, .desktop-app-fact { + align-self: start; display: grid; gap: 3px; } @@ -1096,7 +1130,11 @@ white-space: nowrap; } +.desktop-app-path { margin-left: 10px; } + .desktop-app-actions { + grid-column: 3 / -1; + grid-row: 2; display: flex; flex-wrap: wrap; justify-content: flex-end; @@ -1104,7 +1142,8 @@ } .desktop-app-note { - grid-column: 1 / -1; + grid-column: 1 / 3; + grid-row: 2; min-width: 0; margin: 0; color: var(--text-tertiary); @@ -1858,5 +1897,7 @@ .desktop-app-fact, .desktop-app-path, .desktop-app-actions { grid-column: 1 / 3; } - .desktop-app-actions { justify-content: flex-start; } + .desktop-app-path { margin-left: 0; } + .desktop-app-actions { grid-row: auto; justify-content: flex-start; } + .desktop-app-note { grid-column: 1 / 3; grid-row: auto; } } diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index aaead36..b3aedfd 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -26,8 +26,14 @@ export type AgentCatalogItem = Omit & { + /** Older Wails fixtures may omit the profile projection; the UI derives the + * ChatGPT -> Codex mapping when it is absent. */ + profileAgentId?: string; + profileId?: string | null; +}; +export type DesktopAgentActionResult = Omit & { app: DesktopAgentStatus }; +export type DesktopAgentProfileResult = AppModels.DesktopAgentProfileResult; export type InstallRuntimeResult = Omit & { runtimes: RuntimeStatus[] }; export type Settings = AppModels.Settings; export type ActivateAgentResponse = BindingModels.ActivateResponse; @@ -40,7 +46,7 @@ export type ProfileSummary = Omit & { agen export type StatusResponse = Omit< AppModels.StatusResponse, - "platform" | "capabilities" | "agents" | "catalog" | "groups" | "providers" | "mirrors" | "paths" | "backups" | "profiles" | "runtimes" + "platform" | "capabilities" | "agents" | "catalog" | "groups" | "providers" | "mirrors" | "paths" | "backups" | "profiles" | "runtimes" | "desktopAgent" > & { platform: Omit & { os: PlatformId }; capabilities: Omit & { @@ -58,6 +64,7 @@ export type StatusResponse = Omit< paths: Record; backups: Record; profiles: ProfileSummary[]; + desktopAgent: DesktopAgentStatus; }; export type ProbeResponse = Omit & { diff --git a/internal/app/desktopapp.go b/internal/app/desktopapp.go index f1e6571..ede7697 100644 --- a/internal/app/desktopapp.go +++ b/internal/app/desktopapp.go @@ -3,12 +3,14 @@ package app import ( "context" "errors" + "slices" "strings" "github.com/MaimoryLab/OneAgent/internal/catalog" "github.com/MaimoryLab/OneAgent/internal/desktopapp" oneerrors "github.com/MaimoryLab/OneAgent/internal/errors" "github.com/MaimoryLab/OneAgent/internal/process" + profileStore "github.com/MaimoryLab/OneAgent/internal/profile" ) // DesktopAgentStatus is the public projection of the current desktop agent. It is @@ -24,10 +26,25 @@ type DesktopAgentStatus struct { Source string `json:"source"` ConfigPath string `json:"configPath,omitempty"` ConfigSharedWith string `json:"configSharedWith,omitempty"` + ProfileAgentID string `json:"profileAgentId"` + ProfileID *string `json:"profileId"` PackageFamily string `json:"packageFamily,omitempty"` InspectionUnavailable *string `json:"inspectionUnavailable,omitempty"` } +// DesktopAgentProfileResult is the non-secret result of applying a saved +// profile to a desktop Agent. ChatGPT Desktop returns the Codex config result; +// other desktop Agents only need their own profile membership recorded until +// their vendor-specific config writer is added. +type DesktopAgentProfileResult struct { + AgentID string `json:"agent"` + ProfileID string `json:"profileId"` + ProfileAgentID string `json:"profileAgentId"` + Config string `json:"config,omitempty"` + Restart string `json:"restart,omitempty"` + Message string `json:"message"` +} + // DesktopAgentActionResult reports a local install or a downloaded installer // launch. Windows Store installation continues after its bootstrapper starts. type DesktopAgentActionResult struct { @@ -100,6 +117,99 @@ func (u *UseCases) OpenDesktopAgentInstaller(ctx context.Context, output process return u.publicDesktopAgentAction(result), nil } +// ConfigureDesktopAgent applies a saved profile without accepting a secret +// from the desktop-specific UI. ChatGPT Desktop shares Codex's config writer; +// all other desktop IDs are recorded as owners of their own profile. +func (u *UseCases) ConfigureDesktopAgent(ctx context.Context, agentID, profileID string) (DesktopAgentProfileResult, error) { + if u == nil { + return DesktopAgentProfileResult{}, oneerrors.New(oneerrors.InternalError, "Desktop agent service is not configured", oneerrors.WithStatus(501)) + } + if err := contextError(ctx, "Desktop agent configuration request was cancelled"); err != nil { + return DesktopAgentProfileResult{}, err + } + agentID = strings.TrimSpace(agentID) + profileID = strings.TrimSpace(profileID) + if agentID == "" || profileID == "" { + return DesktopAgentProfileResult{}, oneerrors.New(oneerrors.InvalidRequest, "desktop agent and profile are required") + } + var selected profileStore.Profile + for _, candidate := range u.profiles.List() { + if candidate.ID == profileID { + selected = candidate + break + } + } + if selected.ID == "" { + return DesktopAgentProfileResult{}, oneerrors.New(oneerrors.InvalidRequest, "Profile not found: "+profileID) + } + profileAgentID := desktopapp.ProfileAgentID(agentID) + assigned := slices.Contains(selected.AgentIDs, profileAgentID) + if !assigned && len(selected.AgentIDs) == 0 { + // Older profiles can omit AgentIDs while their per-Agent binding still + // identifies the active profile. Treat that binding as authoritative so + // the desktop and profile pages agree on what can be selected. + binding, bindingErr := u.profiles.ReadAgentBinding(profileAgentID) + assigned = bindingErr == nil && binding != nil && binding.ProfileRef == profileID + } + if !assigned { + // A profile selected for a desktop Agent must explicitly own that Agent. + // ChatGPT is the exception only in its *ID mapping*: its profile still + // belongs to Codex, never to a synthetic desktop ID. + return DesktopAgentProfileResult{}, oneerrors.New(oneerrors.InvalidRequest, "Profile is not assigned to this desktop agent") + } + if desktopapp.SharesProfile(agentID) { + result, err := u.ActivateAgent(ctx, ActivateAgentOptions{ + AgentID: profileAgentID, + Provider: selected.Provider, + APIBaseURL: stringPointerValue(selected.BaseURL), + Model: stringPointerValue(selected.Model), + ProfileID: profileID, + }) + if err != nil { + return DesktopAgentProfileResult{}, err + } + return DesktopAgentProfileResult{ + AgentID: agentID, ProfileID: profileID, ProfileAgentID: profileAgentID, + Config: result.Config, Restart: result.Restart, + Message: "Shared Codex profile applied", + }, nil + } + // Desktop vendors without a OneAgent config adapter still get a durable + // per-Agent selection. Their profile is ready for a future vendor writer, + // and the overview can report the exact selected profile instead of relying + // on directory order. + target, err := u.providers.Resolve(selected.Provider, stringPointerValue(selected.BaseURL)) + if err != nil { + return DesktopAgentProfileResult{}, err + } + model := stringPointerValue(selected.Model) + if strings.TrimSpace(model) == "" || strings.TrimSpace(target.BaseURL) == "" { + return DesktopAgentProfileResult{}, oneerrors.New(oneerrors.InvalidRequest, "Desktop Agent profile has no provider or model") + } + u.writeMu.Lock() + defer u.writeMu.Unlock() + _, err = u.profiles.WriteAgentBinding(ctx, agentID, profileStore.BindingWriteRequest{ + Provider: target.ID, + BaseURL: target.BaseURL, + Model: model, + ProfileRef: profileID, + }) + if err != nil { + return DesktopAgentProfileResult{}, err + } + return DesktopAgentProfileResult{ + AgentID: agentID, ProfileID: profileID, ProfileAgentID: profileAgentID, + Message: "Desktop Agent profile assigned", + }, nil +} + +func stringPointerValue(value *string) string { + if value == nil { + return "" + } + return *value +} + func (u *UseCases) desktopAppOptions(output process.OutputListener) desktopapp.Options { return desktopapp.Options{ Home: u.status.Home, @@ -122,10 +232,23 @@ func (u *UseCases) publicDesktopAgentStatus(value desktopapp.Status) DesktopAgen PackageFamily: value.PackageFamily, InspectionUnavailable: value.InspectionUnavailable, } - if manifest, err := catalog.LoadEmbedded(); err == nil { - shared := manifest.Agents[desktopapp.SharedConfigAgentID] - status.ConfigPath = configPath(u.status.Home, u.status.Platform.OS, shared) - status.ConfigSharedWith = shared.Name + status.ProfileAgentID = desktopapp.ProfileAgentID(value.ID) + if binding, err := u.profiles.ReadAgentBinding(status.ProfileAgentID); err == nil && binding != nil && binding.ProfileRef != "" { + status.ProfileID = nonEmptyPointer(binding.ProfileRef) + } else { + for _, profile := range u.profiles.List() { + if profile.ID != "" && slices.Contains(profile.AgentIDs, status.ProfileAgentID) { + status.ProfileID = nonEmptyPointer(profile.ID) + break + } + } + } + if desktopapp.SharesProfile(value.ID) { + if manifest, err := catalog.LoadEmbedded(); err == nil { + shared := manifest.Agents[desktopapp.SharedConfigAgentID] + status.ConfigPath = configPath(u.status.Home, u.status.Platform.OS, shared) + status.ConfigSharedWith = shared.Name + } } return status } diff --git a/internal/app/desktopapp_test.go b/internal/app/desktopapp_test.go index 4c51d08..be9da16 100644 --- a/internal/app/desktopapp_test.go +++ b/internal/app/desktopapp_test.go @@ -6,7 +6,9 @@ import ( "path/filepath" "testing" + "github.com/MaimoryLab/OneAgent/internal/desktopapp" "github.com/MaimoryLab/OneAgent/internal/platform" + profileStore "github.com/MaimoryLab/OneAgent/internal/profile" ) func TestDesktopAgentStatusIsUnsupportedOutsideDesktopPlatforms(t *testing.T) { @@ -22,11 +24,67 @@ func TestDesktopAgentStatusIsUnsupportedOutsideDesktopPlatforms(t *testing.T) { if status.ConfigPath != filepath.Join(home, ".codex", "config.toml") || status.ConfigSharedWith != "Codex" { t.Fatalf("shared config = %q with %q", status.ConfigPath, status.ConfigSharedWith) } + if status.ProfileAgentID != "codex" || status.ProfileID != nil { + t.Fatalf("profile projection = %#v", status) + } if _, err := os.Stat(filepath.Join(home, ".codex")); !os.IsNotExist(err) { t.Fatalf("status probe touched shared Codex config: %v", err) } } +func TestConfigureDesktopAgentRequiresItsOwnProfileForNonSharedApps(t *testing.T) { + home := t.TempDir() + core := NewUseCases(StatusOptions{Home: home, Platform: platform.For("linux", "amd64")}) + if _, err := core.SaveProfile(context.Background(), SaveProfileOptions{ + ID: "workbuddy", Label: "WorkBuddy", Provider: "ppio", Model: "model-a", ConfigMode: "provider", AgentIDs: []string{"codex"}, + }); err != nil { + t.Fatal(err) + } + if _, err := core.ConfigureDesktopAgent(context.Background(), "workbuddy", "workbuddy"); err == nil { + t.Fatal("a profile owned by Codex was accepted for another desktop Agent") + } + if _, err := core.SaveProfile(context.Background(), SaveProfileOptions{ + ID: "workbuddy-own", Label: "WorkBuddy", Provider: "ppio", Model: "model-a", ConfigMode: "provider", AgentIDs: []string{"workbuddy"}, + }); err != nil { + t.Fatal(err) + } + result, err := core.ConfigureDesktopAgent(context.Background(), "workbuddy", "workbuddy-own") + if err != nil || result.ProfileAgentID != "workbuddy" || result.ProfileID != "workbuddy-own" { + t.Fatalf("configure result = %#v, err=%v", result, err) + } + binding, err := core.ListAgentBindings(context.Background()) + if err != nil || binding["workbuddy"].ProfileRef != "workbuddy-own" { + t.Fatalf("desktop profile binding = %#v, err=%v", binding, err) + } +} + +func TestConfigureDesktopAgentDoesNotLetBindingOverrideExplicitProfileOwner(t *testing.T) { + home := t.TempDir() + core := NewUseCases(StatusOptions{Home: home, Platform: platform.For("linux", "amd64")}) + if _, err := core.SaveProfile(context.Background(), SaveProfileOptions{ + ID: "codex-owned", Label: "Codex", Provider: "ppio", Model: "model-a", ConfigMode: "provider", AgentIDs: []string{"codex"}, + }); err != nil { + t.Fatal(err) + } + if _, err := core.profiles.WriteAgentBinding(context.Background(), "workbuddy", profileStore.BindingWriteRequest{ + Provider: "ppio", BaseURL: "https://api.ppio.com/openai", Model: "model-a", ProfileRef: "codex-owned", + }); err != nil { + t.Fatal(err) + } + if _, err := core.ConfigureDesktopAgent(context.Background(), "workbuddy", "codex-owned"); err == nil { + t.Fatal("an explicit Codex-owned profile was accepted for WorkBuddy") + } +} + +func TestDesktopAgentStatusDoesNotClaimCodexSharingForOtherApps(t *testing.T) { + home := t.TempDir() + core := NewUseCases(StatusOptions{Home: home, Platform: platform.For("linux", "amd64")}) + status := core.publicDesktopAgentStatus(desktopapp.Status{ID: "workbuddy", Name: "WorkBuddy"}) + if status.ProfileAgentID != "workbuddy" || status.ConfigSharedWith != "" || status.ConfigPath != "" { + t.Fatalf("non-shared desktop projection = %#v", status) + } +} + func TestInstallDesktopAgentDoesNotWriteSharedCodexConfig(t *testing.T) { home := t.TempDir() core := NewUseCases(StatusOptions{Home: home, Platform: platform.For("linux", "amd64")}) diff --git a/internal/app/testdata/status-empty-linux-arm64.json b/internal/app/testdata/status-empty-linux-arm64.json index 3fa7704..1a67f97 100644 --- a/internal/app/testdata/status-empty-linux-arm64.json +++ b/internal/app/testdata/status-empty-linux-arm64.json @@ -593,6 +593,8 @@ "supported": false, "version": null, "source": "unknown", + "profileAgentId": "codex", + "profileId": null, "configPath": "${HOME}/.codex/config.toml", "configSharedWith": "Codex" }, diff --git a/internal/binding/services.go b/internal/binding/services.go index 6da1f0f..446a27e 100644 --- a/internal/binding/services.go +++ b/internal/binding/services.go @@ -90,6 +90,18 @@ func (s *DesktopAgentService) OpenInstaller(ctx context.Context) (app.DesktopAge return s.core.OpenDesktopAgentInstaller(ctx, s.onOutput) } +// Configure applies a saved Profile to the selected desktop Agent. The profile +// ID is the only user-supplied value; secrets stay in the Go profile store. +func (s *DesktopAgentService) Configure(ctx context.Context, request DesktopAgentProfileRequest) (app.DesktopAgentProfileResult, error) { + if err := contextError(ctx); err != nil { + return app.DesktopAgentProfileResult{}, err + } + if s == nil || s.core == nil { + return app.DesktopAgentProfileResult{}, notReady("Desktop agent service is not configured") + } + return s.core.ConfigureDesktopAgent(ctx, request.AgentID, request.ProfileID) +} + // RuntimeService exposes the Node.js and uv bootstrap. It reuses the install // output listener so the UI can render runtime byte progress alongside Agent // install output; runtime bootstrap does not emit a fake command line. @@ -422,6 +434,11 @@ type OpenRegistrationRequest struct { Agents []string `json:"agents"` } +type DesktopAgentProfileRequest struct { + AgentID string `json:"agent_id"` + ProfileID string `json:"profile_id"` +} + type ProviderIDRequest struct { ID string `json:"id"` } diff --git a/internal/binding/services_test.go b/internal/binding/services_test.go index 3fe8c9d..3b1aa03 100644 --- a/internal/binding/services_test.go +++ b/internal/binding/services_test.go @@ -47,7 +47,7 @@ func TestServiceMethodAllowlist(t *testing.T) { {&AgentService{}, []string{"Activate", "Install", "Launch"}}, {&ProfileService{}, []string{"ListProfiles", "SaveProfile"}}, {&RuntimeService{}, []string{"GetSettings", "InstallRuntime", "ListRuntimes", "SaveSettings"}}, - {&DesktopAgentService{}, []string{"GetStatus", "Install", "Open", "OpenInstaller"}}, + {&DesktopAgentService{}, []string{"Configure", "GetStatus", "Install", "Open", "OpenInstaller"}}, } for _, test := range tests { typeOf := reflect.TypeOf(test.service) diff --git a/internal/desktopapp/desktopapp.go b/internal/desktopapp/desktopapp.go index f7dda7e..1fc15b3 100644 --- a/internal/desktopapp/desktopapp.go +++ b/internal/desktopapp/desktopapp.go @@ -35,6 +35,22 @@ const ( WindowsInstallerURL = "https://get.microsoft.com/installer/download/9PLM9XGG6VKS?cid=website_cta_psi" ) +// ProfileAgentID is the Agent whose profile owns a desktop application's +// provider settings. ChatGPT Desktop reads Codex's configuration; a different +// desktop application gets its own profile namespace. +func ProfileAgentID(agentID string) string { + agentID = strings.TrimSpace(agentID) + if agentID == ID { + return SharedConfigAgentID + } + return agentID +} + +func SharesProfile(agentID string) bool { + agentID = strings.TrimSpace(agentID) + return ProfileAgentID(agentID) != agentID +} + const ( SourceMacOSDMG = "macos-dmg" SourceWindowsStore = "windows-store" diff --git a/internal/desktopapp/desktopapp_test.go b/internal/desktopapp/desktopapp_test.go index 004433e..cd6a4d8 100644 --- a/internal/desktopapp/desktopapp_test.go +++ b/internal/desktopapp/desktopapp_test.go @@ -340,3 +340,18 @@ func TestCompareVersionHandlesMissingComponents(t *testing.T) { t.Fatal("missing components should compare as zero") } } + +func TestProfileAgentIDKeepsChatGPTOnCodexAndScopesOtherApps(t *testing.T) { + if got := ProfileAgentID(ID); got != SharedConfigAgentID || !SharesProfile(ID) { + t.Fatalf("ChatGPT profile mapping = %q, shared=%v", got, SharesProfile(ID)) + } + if got := ProfileAgentID(" " + ID + " "); got != SharedConfigAgentID || !SharesProfile(" "+ID+" ") { + t.Fatalf("trimmed ChatGPT profile mapping = %q, shared=%v", got, SharesProfile(" "+ID+" ")) + } + if got := ProfileAgentID("workbuddy"); got != "workbuddy" || SharesProfile("workbuddy") { + t.Fatalf("other desktop mapping = %q, shared=%v", got, SharesProfile("workbuddy")) + } + if SharesProfile(" workbuddy ") { + t.Fatal("whitespace around a non-shared desktop ID changed its ownership") + } +}