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
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export type {
AgentStatus,
Capabilities,
DesktopAgentActionResult,
DesktopAgentProfileResult,
DesktopAgentStatus,
DetectedConfig,
InstallRuntimeResult,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -69,6 +84,8 @@ export interface DesktopAgentStatus {
"source": string;
"configPath"?: string;
"configSharedWith"?: string;
"profileAgentId": string;
"profileId": string | null;
"packageFamily"?: string;
"inspectionUnavailable"?: string | null;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<app$0.DesktopAgentProfileResult> {
return $Call.ByID(1718807223, request);
}

export function GetStatus(): $CancellablePromise<app$0.DesktopAgentStatus> {
return $Call.ByID(2070814877);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export type {
ActivateRequest,
ActivateResponse,
AgentInstallResult,
DesktopAgentProfileRequest,
InstallRequest,
InstallResponse,
InstallRuntimeRequest,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions frontend/e2e/wails.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
32 changes: 24 additions & 8 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 <Navigate to="/setup/agents" replace />;
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 <Navigate to="/setup/provider" replace />;
}
if ((stage === "review" || stage === "activation") && !state.model) {
Expand All @@ -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 <Navigate to="/setup/desktop/agents" replace />;
}
if (stage === "install" && !state.desktopProfileId) {
return <Navigate to="/setup/desktop/profile" replace />;
}
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
Expand All @@ -62,6 +75,9 @@ function WorkspaceRoutes() {
<Routes>
<Route path="/" element={<LandingRoute />} />
<Route path="/setup/agents" element={<AgentSelectionPage />} />
<Route path="/setup/desktop/agents" element={<DesktopAgentSelectionPage />} />
<Route path="/setup/desktop/profile" element={<DesktopSetupGuard stage="profile"><DesktopProfilePage /></DesktopSetupGuard>} />
<Route path="/setup/desktop/install" element={<DesktopSetupGuard stage="install"><DesktopInstallPage /></DesktopSetupGuard>} />
<Route path="/setup/provider" element={<SetupGuard stage="provider"><ProviderKeyPage /></SetupGuard>} />
<Route path="/setup/model" element={<SetupGuard stage="model"><ModelSelectionPage /></SetupGuard>} />
<Route path="/setup/review" element={<SetupGuard stage="review"><ReviewPage /></SetupGuard>} />
Expand Down
10 changes: 8 additions & 2 deletions frontend/src/backend/wails.test.ts
Original file line number Diff line number Diff line change
@@ -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(() => ({
Expand All @@ -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(),
Expand All @@ -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,
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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"] }));
});

Expand Down
3 changes: 3 additions & 0 deletions frontend/src/backend/wails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import * as StatusService from "../../bindings/github.com/MaimoryLab/OneAgent/in
import type {
ActivateAgentResponse,
DesktopAgentActionResult,
DesktopAgentProfileResult,
DesktopAgentStatus,
InstallRequest,
InstallOutput,
Expand Down Expand Up @@ -93,6 +94,8 @@ export const wailsApi = {
openDesktopAgent: (): Promise<void> => call(() => DesktopAgentService.Open()).then(() => undefined),
openDesktopAgentInstaller: (): Promise<DesktopAgentActionResult> =>
call(() => DesktopAgentService.OpenInstaller()) as Promise<DesktopAgentActionResult>,
configureDesktopAgent: (agentId: string, profileId: string): Promise<DesktopAgentProfileResult> =>
call(() => DesktopAgentService.Configure({ agent_id: agentId, profile_id: profileId })) as Promise<DesktopAgentProfileResult>,
probe: (input: { provider: ProviderId; apiBaseUrl: string; apiKey: string; model: string; agents?: string[] }): Promise<ProbeResponse> =>
call(() => ProviderService.Probe({
provider: input.provider,
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/components/DesktopAppSection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,4 +98,10 @@ describe("DesktopAppSection", () => {
expect(screen.getByText("应用状态检测不可用")).toBeTruthy();
expect(screen.queryByText("已检测到应用,但版本信息不可用")).toBeNull();
});

it("can omit an uninstalled app from the overview", () => {
render(<DesktopAppSection app={app()} onChanged={vi.fn()} showUninstalled={false} />);

expect(screen.queryByRole("heading", { name: "桌面 Agent" })).toBeNull();
});
});
21 changes: 18 additions & 3 deletions frontend/src/components/DesktopAppSection.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -11,18 +11,21 @@ import { StatusBadge } from "./StatusBadge";
interface DesktopAppSectionProps {
app: DesktopAgentStatus;
onChanged: () => void | Promise<void>;
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<Action | "">("");
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";
Expand Down Expand Up @@ -90,9 +93,21 @@ export function DesktopAppSection({ app: desktopApp, onChanged }: DesktopAppSect
<span title={desktopApp.path}>{desktopApp.path}</span>
</div>
) : null}
{desktopApp.profileId ? (
<div className="desktop-app-fact">
<small>Profile</small>
<span title={desktopApp.profileId}>{desktopApp.profileId}</span>
</div>
) : null}
<div className="desktop-app-actions">
{desktopApp.installed ? (
<>
{onConfigure ? (
<button className="button button-secondary" type="button" onClick={onConfigure} disabled={Boolean(pending)}>
<SlidersHorizontal size={15} />
{t("配置")}
</button>
) : null}
<button className="button button-secondary" type="button" onClick={() => void run("open")} disabled={Boolean(pending)}>
{pending === "open" ? <RefreshCw size={15} className="spin" aria-hidden="true" /> : <AppWindow size={15} aria-hidden="true" />}
{t("打开")}
Expand Down
12 changes: 10 additions & 2 deletions frontend/src/components/SetupStepper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,22 @@ const steps: Array<{ path: string; label: TranslationKey | "Agent" | "Provider"
{ path: "/setup/activation", label: "安装" },
];

const desktopSteps: Array<{ path: string; label: TranslationKey | "Agent" }> = [
{ path: "/setup/desktop/agents", label: "Agent" },
{ path: "/setup/desktop/profile", label: "配置模板" },
{ path: "/setup/desktop/install", label: "安装" },
];

export function SetupStepper() {
const { t } = useI18n();
const { pathname } = useLocation();
const current = steps.findIndex((step) => step.path === pathname) + 1;
const desktop = pathname.startsWith("/setup/desktop/");
const activeSteps = desktop ? desktopSteps : steps;
const current = activeSteps.findIndex((step) => step.path === pathname) + 1;

return (
<ol className="setup-stepper" aria-label={t("激活步骤")}>
{steps.map((step, index) => {
{activeSteps.map((step, index) => {
const number = index + 1;
const complete = number < current;
const active = number === current;
Expand Down
Loading