diff --git a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/profileservice.ts b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/profileservice.ts index 7b851e68..6c89c0b2 100644 --- a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/profileservice.ts +++ b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/profileservice.ts @@ -13,6 +13,10 @@ import * as app$0 from "../app/models.js"; // @ts-ignore: Unused imports import * as $models from "./models.js"; +export function DeleteProfile(request: $models.ProviderIDRequest): $CancellablePromise<$models.ProviderMutationResponse> { + return $Call.ByID(965476673, request); +} + export function ListProfiles(): $CancellablePromise { return $Call.ByID(192725737); } diff --git a/frontend/src/backend/wails.test.ts b/frontend/src/backend/wails.test.ts index 295fbbdb..823efba7 100644 --- a/frontend/src/backend/wails.test.ts +++ b/frontend/src/backend/wails.test.ts @@ -19,6 +19,7 @@ const bridge = vi.hoisted(() => ({ desktopOpen: vi.fn(), desktopConfigure: vi.fn(), profiles: vi.fn(), + deleteProfile: vi.fn(), saveProfile: vi.fn(), updateCheck: vi.fn(), updateDownload: vi.fn(), @@ -52,6 +53,7 @@ vi.mock("../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/desktopa })); vi.mock("../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/profileservice.js", () => ({ ListProfiles: bridge.profiles, + DeleteProfile: bridge.deleteProfile, SaveProfile: bridge.saveProfile, })); vi.mock("../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/updateservice.js", () => ({ @@ -93,6 +95,7 @@ describe("Wails backend adapter", () => { bridge.activate.mockResolvedValue({ ok: true, agent: "codex", config: "/c", provider: "ppio", model: "m", restart: "restart", next: "next" }); bridge.launch.mockResolvedValue({ ok: true, agent: "codex", command: "codex" }); bridge.profiles.mockResolvedValue([profile]); + bridge.deleteProfile.mockResolvedValue({ ok: true }); bridge.saveProfile.mockResolvedValue(profile); bridge.getProvider.mockResolvedValue(provider); bridge.saveProvider.mockResolvedValue(provider); @@ -117,6 +120,7 @@ describe("Wails backend adapter", () => { await wailsApi.activateAgent("codex", { provider: "ppio", apiBaseUrl: "", apiKey: "secret", model: "m" }); await wailsApi.launchAgent("codex"); await expect(wailsApi.listProfiles()).resolves.toEqual([profile]); + await expect(wailsApi.deleteProfile("team")).resolves.toBeUndefined(); await expect(wailsApi.saveProfile({ id: "team", label: "Team", provider: "ppio", apiBaseUrl: "", apiKey: "secret", model: "m", configMode: "provider" })).resolves.toBe(profile); expect(bridge.probe).toHaveBeenCalledWith({ provider: "custom", api_base_url: "https://proxy.test/v1", api_key: "secret", model: "m", agents: null }); @@ -131,6 +135,7 @@ describe("Wails backend adapter", () => { expect(bridge.desktopOpen).toHaveBeenCalledWith({ agent_id: "chatgpt-desktop" }); expect(bridge.desktopConfigure).toHaveBeenCalledWith({ agent_id: "chatgpt-desktop", profile_id: "team" }); expect(bridge.saveProfile).toHaveBeenCalledWith(expect.objectContaining({ api_base_url: "", api_key: "secret" })); + expect(bridge.deleteProfile).toHaveBeenCalledWith({ id: "team" }); }); it("restores structured Wails errors without exposing raw bridge details", async () => { diff --git a/frontend/src/backend/wails.ts b/frontend/src/backend/wails.ts index 91d550ab..0ff12ee3 100644 --- a/frontend/src/backend/wails.ts +++ b/frontend/src/backend/wails.ts @@ -175,6 +175,8 @@ export const wailsApi = { saveSettings: (settings: Settings): Promise => call(() => RuntimeService.SaveSettings(settings)) as Promise, listProfiles: (): Promise => call(() => ProfileService.ListProfiles()) as Promise, + deleteProfile: (id: string): Promise => + call(() => ProfileService.DeleteProfile({ id })).then(() => undefined), saveProfile: (input: { id: string; label: string; diff --git a/frontend/src/i18n.tsx b/frontend/src/i18n.tsx index dbe58089..6072a271 100644 --- a/frontend/src/i18n.tsx +++ b/frontend/src/i18n.tsx @@ -275,6 +275,10 @@ const english = { "已写入配置": "Configuration written", "无法读取 Provider": "Could not load provider", "无法保存 Provider": "Could not save provider", + "删除 Profile“{name}”?": "Delete profile \"{name}\"?", + "无法删除 Profile": "Could not delete profile", + "Profile 正在被 {agents} 使用,无法删除": "Profile is used by {agents} and cannot be deleted", + "Provider 正在被 {agents} 使用,无法删除": "Provider is used by {agents} and cannot be deleted", "已重新应用到 {agents}": "Reapplied to {agents}", "{agents} 重新应用失败:{message}": "Could not reapply to {agents}: {message}", "删除 Provider“{name}”?": "Delete provider \"{name}\"?", @@ -304,6 +308,7 @@ const english = { "这个 Provider 还没有 Key,先到 Provider 页面填写。": "This provider has no key yet. Add one on the Provider page.", "前往 Provider": "Open Provider", "保存 Profile": "Save profile", + "删除 Profile": "Delete profile", "编辑 Profile": "Edit profile", "为 {name} 选择关联的 Profile。": "Choose the profile linked to {name}.", "这个 Provider 还没有 Key,请先到 Provider 页面填写。": "This provider has no key yet. Add one on the Provider page.", diff --git a/frontend/src/pages/ProfilesPage.test.tsx b/frontend/src/pages/ProfilesPage.test.tsx index effdff03..f7cc8c22 100644 --- a/frontend/src/pages/ProfilesPage.test.tsx +++ b/frontend/src/pages/ProfilesPage.test.tsx @@ -129,6 +129,22 @@ describe("ProfilesPage", () => { expect(screen.getByTestId("profile-unused").textContent).toContain("暂无 Agent 使用"); }); + it("deletes a Profile after confirmation", async () => { + vi.spyOn(window, "confirm").mockReturnValue(true); + const remove = vi.spyOn(api, "deleteProfile").mockResolvedValue(); + renderPage([profile({ id: "unused", label: "未使用" })]); + + fireEvent.click(screen.getByRole("button", { name: "删除 未使用" })); + await waitFor(() => expect(remove).toHaveBeenCalledWith("unused")); + expect(refreshStatus).toHaveBeenCalled(); + }); + + it("explains why an in-use Profile cannot be deleted", () => { + renderPage([profile()]); + fireEvent.click(screen.getByRole("button", { name: "删除 团队 PPIO" })); + expect(screen.getByText(/Profile 正在被.*使用,无法删除/)).toBeTruthy(); + }); + it("creates a Profile inline without entering onboarding", async () => { const save = vi.spyOn(api, "saveProfile").mockResolvedValue(profile({ id: "profile-ppio", diff --git a/frontend/src/pages/ProfilesPage.tsx b/frontend/src/pages/ProfilesPage.tsx index ba89e6ea..b4b5a2b6 100644 --- a/frontend/src/pages/ProfilesPage.tsx +++ b/frontend/src/pages/ProfilesPage.tsx @@ -1,4 +1,4 @@ -import { KeyRound, Layers, Pencil, Play, Plus, Save, X } from "lucide-react"; +import { KeyRound, Layers, Pencil, Play, Plus, Save, Trash2, X } from "lucide-react"; import { useState, type FormEvent } from "react"; import { useNavigate } from "react-router-dom"; @@ -36,7 +36,7 @@ function editDraft(profile: ProfileSummary, protocol: string): ProfileDraft { export function ProfilesPage() { const navigate = useNavigate(); - const { t } = useI18n(); + const { locale, t } = useI18n(); const { state, refreshStatus } = useWizard(); const { startTask, finishTask, setTaskCanceller } = useTaskCenter(); const route = useTaskRoute(); @@ -165,6 +165,26 @@ export function ProfilesPage() { } }; + const remove = async (profile: ProfileSummary, users: typeof configurableAgents) => { + if (users.length) { + setFailure(t("Profile 正在被 {agents} 使用,无法删除", { + agents: users.map((agent) => agent.name).join(locale === "en" ? ", " : "、"), + })); + return; + } + setBusy(true); + setFailure(""); + try { + await api.deleteProfile(profile.id); + if (editor?.originalId === profile.id) setEditor(null); + await refreshStatus(); + } catch (error) { + setFailure(describeError(error, t("无法删除 Profile")).message); + } finally { + setBusy(false); + } + }; + return (
@@ -293,6 +313,9 @@ export function ProfilesPage() { +

diff --git a/frontend/src/pages/ProvidersPage.test.tsx b/frontend/src/pages/ProvidersPage.test.tsx index 1bb17b04..47c39f4b 100644 --- a/frontend/src/pages/ProvidersPage.test.tsx +++ b/frontend/src/pages/ProvidersPage.test.tsx @@ -98,6 +98,17 @@ describe("ProvidersPage", () => { expect(ppio.textContent).not.toContain("claude-code"); }); + it("explains why an in-use Provider cannot be deleted", () => { + mockState = { status: statusWith({ codex: "acme" }), statusState: "success" }; + if (!mockState.status) throw new Error("missing status"); + mockState.status.providers.acme = { name: "Acme", home: "", base_url: "https://api.acme.test", custom: true }; + const remove = vi.spyOn(api, "deleteProvider"); + render(); + fireEvent.click(screen.getByRole("button", { name: "删除 Acme" })); + expect(screen.getByText(/Provider 正在被.*使用,无法删除/)).toBeTruthy(); + expect(remove).not.toHaveBeenCalled(); + }); + it("says so when no Agent uses a Provider", () => { renderPage({ codex: "ppio" }); expect(screen.getByTestId("provider-novita").textContent).toMatch(/暂无/); diff --git a/frontend/src/pages/ProvidersPage.tsx b/frontend/src/pages/ProvidersPage.tsx index f8b6a601..05660038 100644 --- a/frontend/src/pages/ProvidersPage.tsx +++ b/frontend/src/pages/ProvidersPage.tsx @@ -85,8 +85,13 @@ export function ProvidersPage({ create = false }: { create?: boolean }) { } }; - const remove = async (providerId: string, name: string) => { - if (!window.confirm(t("删除 Provider“{name}”?", { name }))) return; + const remove = async (providerId: string, name: string, users: string[]) => { + if (users.length) { + setFailure(t("Provider 正在被 {agents} 使用,无法删除", { + agents: users.map(nameOf).join(locale === "en" ? ", " : "、"), + })); + return; + } setBusy(true); setFailure(""); setApplied(""); @@ -198,7 +203,7 @@ export function ProvidersPage({ create = false }: { create?: boolean }) { {meta.custom ? ( - ) : null} diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index b9a10bc2..c8fce5a3 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -1543,7 +1543,7 @@ .agent-manage-actions { flex-shrink: 0; } .agent-manage-error { - margin: 0; + margin: 12px 0; padding: 8px 11px; border-radius: var(--radius-control); background: var(--red-soft); diff --git a/internal/app/provider.go b/internal/app/provider.go index a7f62648..f86b4a11 100644 --- a/internal/app/provider.go +++ b/internal/app/provider.go @@ -2,6 +2,7 @@ package app import ( "context" + "fmt" "sort" "strings" @@ -208,6 +209,16 @@ func (u *UseCases) DeleteProvider(ctx context.Context, providerID string) error } u.writeMu.Lock() defer u.writeMu.Unlock() + var users []string + for agentID, binding := range u.catalogAgentBindings(false) { + if binding.Provider == strings.TrimSpace(providerID) { + users = append(users, agentID) + } + } + if len(users) > 0 { + sort.Strings(users) + return oneerrors.New(oneerrors.InvalidRequest, fmt.Sprintf("Provider %s is used by Agent(s): %s", strings.TrimSpace(providerID), strings.Join(users, ", "))) + } return u.providers.Delete(ctx, providerID) } diff --git a/internal/app/provider_test.go b/internal/app/provider_test.go index f3168125..1800bdb0 100644 --- a/internal/app/provider_test.go +++ b/internal/app/provider_test.go @@ -13,6 +13,7 @@ import ( oneerrors "github.com/MaimoryLab/OneAgent/internal/errors" "github.com/MaimoryLab/OneAgent/internal/platform" + profileStore "github.com/MaimoryLab/OneAgent/internal/profile" "github.com/MaimoryLab/OneAgent/internal/provider" ) @@ -201,3 +202,76 @@ func TestSaveProviderSkipsReapplyWhenOnlyMetadataChanges(t *testing.T) { t.Fatalf("metadata-only save reapplied: %#v, err=%v", result, err) } } + +func TestDeleteProviderRejectsBoundAgents(t *testing.T) { + home := t.TempDir() + core := activationCore(t, home, provider.NewClient(nil), "linux") + if _, err := core.SaveProvider(context.Background(), provider.Entry{ + ID: "acme", Name: "Acme", BaseURL: "https://api.acme.test", APIKey: "key", + }); err != nil { + t.Fatal(err) + } + if _, err := core.ActivateAgent(context.Background(), ActivateAgentOptions{ + AgentID: "codex", Provider: "acme", Model: "model-a", + }); err != nil { + t.Fatal(err) + } + err := core.DeleteProvider(context.Background(), "acme") + if err == nil || oneerrors.As(err).Code != oneerrors.InvalidRequest || !strings.Contains(err.Error(), "codex") { + t.Fatalf("deleting in-use Provider returned %v", err) + } + if _, err := core.GetProvider(context.Background(), "acme"); err != nil { + t.Fatalf("guard deleted Provider: %v", err) + } +} + +func TestDeleteProfileRejectsBoundAgents(t *testing.T) { + home := t.TempDir() + core := activationCore(t, home, provider.NewClient(nil), "linux") + if _, err := core.SaveProfile(context.Background(), SaveProfileOptions{ + ID: "team", Provider: "ppio", Model: "model-a", ConfigMode: "provider", + }); err != nil { + t.Fatal(err) + } + if _, err := core.ActivateAgent(context.Background(), ActivateAgentOptions{ + AgentID: "codex", Provider: "ppio", APIKey: "key", Model: "model-a", ProfileID: "team", + }); err != nil { + t.Fatal(err) + } + err := core.DeleteProfile(context.Background(), "team") + if err == nil || oneerrors.As(err).Code != oneerrors.InvalidRequest || !strings.Contains(err.Error(), "codex") { + t.Fatalf("deleting in-use Profile returned %v", err) + } + if _, err := core.profiles.ProfilePath("team"); err != nil { + t.Fatal(err) + } + if len(core.profiles.List()) != 1 { + t.Fatal("in-use Profile was deleted") + } +} + +func TestDeleteIgnoresStaleBindingWithoutAgentConfig(t *testing.T) { + home := t.TempDir() + core := activationCore(t, home, provider.NewClient(nil), "linux") + if _, err := core.SaveProvider(context.Background(), provider.Entry{ + ID: "acme", Name: "Acme", BaseURL: "https://api.acme.test", APIKey: "key", + }); err != nil { + t.Fatal(err) + } + if _, err := core.SaveProfile(context.Background(), SaveProfileOptions{ + ID: "stale", Provider: "acme", Model: "model-a", ConfigMode: "provider", + }); err != nil { + t.Fatal(err) + } + if _, err := core.profiles.WriteAgentBinding(context.Background(), "workbuddy", profileStore.BindingWriteRequest{ + Provider: "acme", BaseURL: "https://api.acme.test", Model: "model-a", ProfileRef: "stale", + }); err != nil { + t.Fatal(err) + } + if err := core.DeleteProfile(context.Background(), "stale"); err != nil { + t.Fatalf("stale binding blocked Profile deletion: %v", err) + } + if err := core.DeleteProvider(context.Background(), "acme"); err != nil { + t.Fatalf("stale binding blocked Provider deletion: %v", err) + } +} diff --git a/internal/app/status.go b/internal/app/status.go index fb1e9aca..00e2eabf 100644 --- a/internal/app/status.go +++ b/internal/app/status.go @@ -4,10 +4,12 @@ package app import ( "context" + "fmt" "maps" "os" "path/filepath" "regexp" + "sort" "strings" "sync" "time" @@ -490,6 +492,47 @@ func (u *UseCases) SaveProfile(ctx context.Context, options SaveProfileOptions) return profileSummary(stored), nil } +func (u *UseCases) DeleteProfile(ctx context.Context, id string) error { + if u == nil { + return oneerrors.New(oneerrors.InternalError, "Profile service is not configured", oneerrors.WithStatus(501)) + } + if err := ctx.Err(); err != nil { + return oneerrors.New(oneerrors.Timeout, "Profile request was cancelled", oneerrors.WithRetryable(true), oneerrors.WithCause(err)) + } + u.writeMu.Lock() + defer u.writeMu.Unlock() + id = strings.TrimSpace(id) + var users []string + for agentID, binding := range u.catalogAgentBindings(true) { + if binding.ProfileRef == id { + users = append(users, agentID) + } + } + if len(users) > 0 { + sort.Strings(users) + return oneerrors.New(oneerrors.InvalidRequest, fmt.Sprintf("Profile %s is used by Agent(s): %s", id, strings.Join(users, ", "))) + } + return u.profiles.Delete(ctx, id) +} + +// catalogAgentBindings mirrors the management pages' user lists. Profiles only +// show auto-configured Agents; Providers show every catalog Agent. +func (u *UseCases) catalogAgentBindings(autoOnly bool) map[string]profileStore.AgentBinding { + manifest, err := catalog.LoadEmbedded() + if err != nil { + return map[string]profileStore.AgentBinding{} + } + result := map[string]profileStore.AgentBinding{} + for agentID, binding := range u.profiles.ListAgentBindings() { + agent, ok := manifest.Agents[agentID] + if !ok || (autoOnly && agent.ConfigMode != "auto") { + continue + } + result[agentID] = binding + } + return result +} + func (u *UseCases) profileStatus(ctx context.Context) ([]ProfileSummary, *string, any, *string) { u.writeMu.Lock() defer u.writeMu.Unlock() diff --git a/internal/binding/services.go b/internal/binding/services.go index 3bb99da3..474b728e 100644 --- a/internal/binding/services.go +++ b/internal/binding/services.go @@ -411,6 +411,19 @@ func (s *ProfileService) SaveProfile(ctx context.Context, request SaveProfileReq }) } +func (s *ProfileService) DeleteProfile(ctx context.Context, request ProviderIDRequest) (ProviderMutationResponse, error) { + if err := contextError(ctx); err != nil { + return ProviderMutationResponse{}, err + } + if s == nil || s.core == nil { + return ProviderMutationResponse{}, notReady("Profile service is not configured") + } + if err := s.core.DeleteProfile(ctx, request.ID); err != nil { + return ProviderMutationResponse{}, err + } + return ProviderMutationResponse{OK: true}, nil +} + type ProbeRequest struct { Provider string `json:"provider"` APIBaseURL string `json:"api_base_url"` diff --git a/internal/binding/services_test.go b/internal/binding/services_test.go index 7dd64b0f..549e1f5d 100644 --- a/internal/binding/services_test.go +++ b/internal/binding/services_test.go @@ -45,7 +45,7 @@ func TestServiceMethodAllowlist(t *testing.T) { {&StatusService{}, []string{"GetStatus"}}, {&ProviderService{}, []string{"DeleteProvider", "GetProvider", "ListModels", "OpenRegistration", "Probe", "SaveProvider"}}, {&AgentService{}, []string{"Activate", "Install", "Launch", "Update"}}, - {&ProfileService{}, []string{"ListProfiles", "SaveProfile"}}, + {&ProfileService{}, []string{"DeleteProfile", "ListProfiles", "SaveProfile"}}, {&RuntimeService{}, []string{"GetSettings", "InstallRuntime", "ListRuntimes", "SaveSettings"}}, {&DesktopAgentService{}, []string{"Configure", "GetStatus", "Install", "Open"}}, {&UpdateService{}, []string{"Check", "DownloadAndInstall", "Restart"}}, @@ -203,6 +203,27 @@ func TestProfileServiceSavesWithoutReturningSecret(t *testing.T) { } } +func TestProfileServiceDeletesProfile(t *testing.T) { + core := app.NewUseCases(app.StatusOptions{ + Home: t.TempDir(), Platform: platform.For("linux", "amd64"), + Lookup: func(string) (string, bool) { return "", false }, + }) + service := NewProfileService(core) + if _, err := service.SaveProfile(context.Background(), SaveProfileRequest{ + ID: "team", Provider: "ppio", Model: "model-a", ConfigMode: "provider", + }); err != nil { + t.Fatal(err) + } + deleted, err := service.DeleteProfile(context.Background(), ProviderIDRequest{ID: "team"}) + if err != nil || !deleted.OK { + t.Fatalf("delete Profile = %#v, err=%v", deleted, err) + } + profiles, err := service.ListProfiles(context.Background()) + if err != nil || len(profiles) != 0 { + t.Fatalf("profiles after delete = %#v, err=%v", profiles, err) + } +} + func TestAgentServiceActivatesThroughGoUseCase(t *testing.T) { home := t.TempDir() core := app.NewUseCases(app.StatusOptions{ diff --git a/internal/profile/write.go b/internal/profile/write.go index c2e3e105..dfcd1462 100644 --- a/internal/profile/write.go +++ b/internal/profile/write.go @@ -99,6 +99,36 @@ func (s Store) Save(ctx context.Context, request SaveRequest) (Profile, error) { return profile, nil } +// Delete removes a Profile and its sibling secret. Deleting the active Profile +// also clears the active pointer so status returns to an unconfigured state. +func (s Store) Delete(ctx context.Context, id string) error { + if err := requestContext(ctx); err != nil { + return err + } + if err := ValidateID(id); err != nil { + return err + } + profilePath, _ := s.ProfilePath(id) + if err := os.Remove(profilePath); err != nil { + if os.IsNotExist(err) { + return oneerrors.New(oneerrors.InvalidRequest, "Unknown Profile: "+id) + } + return writeError("Cannot delete Profile %s: %v", id, err) + } + if secretPath, err := s.SecretPath(id); err == nil { + if err := os.Remove(secretPath); err != nil && !os.IsNotExist(err) { + return writeError("Cannot delete Profile secret %s: %v", id, err) + } + } + active := s.LoadActive() + if active.ID == id { + if err := os.Remove(s.PointerPath()); err != nil && !os.IsNotExist(err) { + return writeError("Cannot clear active Profile: %v", err) + } + } + return nil +} + // WriteActive updates the v2 profile record and active pointer used by the // installation workflow. func (s Store) WriteActive(ctx context.Context, request ActiveRequest) (string, error) { diff --git a/internal/profile/write_test.go b/internal/profile/write_test.go index be865f2a..25bbd910 100644 --- a/internal/profile/write_test.go +++ b/internal/profile/write_test.go @@ -100,6 +100,28 @@ func TestSaveProfileValidatesInputAndCustomBase(t *testing.T) { } } +func TestDeleteProfileRemovesRecordSecretAndActivePointer(t *testing.T) { + store := testStore(t, t.TempDir(), "linux") + if _, err := store.WriteActive(context.Background(), ActiveRequest{ + ProfileID: "team", Configure: true, Provider: "ppio", Model: "m", APIKey: "sk", Protocol: "openai", + }); err != nil { + t.Fatal(err) + } + profilePath, _ := store.ProfilePath("team") + secretPath, _ := store.SecretPath("team") + if err := store.Delete(context.Background(), "team"); err != nil { + t.Fatal(err) + } + for _, path := range []string{profilePath, secretPath, store.PointerPath()} { + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Errorf("%s still exists: %v", path, err) + } + } + if err := store.Delete(context.Background(), "team"); err == nil || oneerrors.As(err).Code != oneerrors.InvalidRequest { + t.Fatalf("deleting missing Profile returned %v", err) + } +} + func TestWriteActiveReplacesProfileAndSupportsExistingAccount(t *testing.T) { store := testStore(t, t.TempDir(), "linux") if _, err := store.WriteActive(context.Background(), ActiveRequest{