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 @@ -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<app$0.ProfileSummary[] | null> {
return $Call.ByID(192725737);
}
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/backend/wails.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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", () => ({
Expand Down Expand Up @@ -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);
Expand All @@ -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 });
Expand All @@ -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 () => {
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/backend/wails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,8 @@ export const wailsApi = {
saveSettings: (settings: Settings): Promise<Settings> =>
call(() => RuntimeService.SaveSettings(settings)) as Promise<Settings>,
listProfiles: (): Promise<ProfileSummary[]> => call(() => ProfileService.ListProfiles()) as Promise<ProfileSummary[]>,
deleteProfile: (id: string): Promise<void> =>
call(() => ProfileService.DeleteProfile({ id })).then(() => undefined),
saveProfile: (input: {
id: string;
label: string;
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/i18n.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}\"?",
Expand Down Expand Up @@ -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.",
Expand Down
16 changes: 16 additions & 0 deletions frontend/src/pages/ProfilesPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
27 changes: 25 additions & 2 deletions frontend/src/pages/ProfilesPage.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 (
<PageScaffold title={t("配置模板")} description={t("在这里创建 Profile,再将它应用到所选 Agent。")}>
<div className="profile-toolbar">
Expand Down Expand Up @@ -293,6 +313,9 @@ export function ProfilesPage() {
<button className="icon-button" type="button" onClick={() => { setEditor(editDraft(profile, protocolOf(profile))); setFailure(""); }} aria-label={t("编辑 {name}", { name: profile.label })} title={t("编辑")}>
<Pencil size={14} />
</button>
<button className="icon-button is-danger" type="button" onClick={() => void remove(profile, users)} aria-label={t("删除 {name}", { name: profile.label })} title={users.length ? t("Profile 正在被 {agents} 使用,无法删除", { agents: users.map((agent) => agent.name).join(locale === "en" ? ", " : "、") }) : t("删除")}>
<Trash2 size={14} />
</button>
</span>
</header>
<p className="profile-target">
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/pages/ProvidersPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<MemoryRouter><ProvidersPage /></MemoryRouter>);
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(/暂无/);
Expand Down
11 changes: 8 additions & 3 deletions frontend/src/pages/ProvidersPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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("");
Expand Down Expand Up @@ -198,7 +203,7 @@ export function ProvidersPage({ create = false }: { create?: boolean }) {
<Pencil size={14} />
</button>
{meta.custom ? (
<button className="icon-button is-danger" type="button" onClick={() => void remove(providerId, meta.name)} aria-label={t("删除 {name}", { name: meta.name })} title={t("删除")}>
<button className="icon-button is-danger" type="button" onClick={() => void remove(providerId, meta.name, users)} aria-label={t("删除 {name}", { name: meta.name })} title={users.length ? t("Provider 正在被 {agents} 使用,无法删除", { agents: users.map(nameOf).join(locale === "en" ? ", " : "、") }) : t("删除")}>
<Trash2 size={14} />
</button>
) : null}
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/styles/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
11 changes: 11 additions & 0 deletions internal/app/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package app

import (
"context"
"fmt"
"sort"
"strings"

Expand Down Expand Up @@ -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)
}

Expand Down
74 changes: 74 additions & 0 deletions internal/app/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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)
}
}
43 changes: 43 additions & 0 deletions internal/app/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ package app

import (
"context"
"fmt"
"maps"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
Expand Down Expand Up @@ -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()
Expand Down
13 changes: 13 additions & 0 deletions internal/binding/services.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
Loading
Loading