From 159bf47b3f3cc6b0ad37da9b02b014eab2d4aa41 Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:29:13 +0800 Subject: [PATCH 1/9] feat: profile and provider export and import --- cmd/oneagent-desktop/main_wails.go | 1 + .../OneAgent/internal/binding/index.ts | 6 +- .../OneAgent/internal/binding/models.ts | 9 ++ .../internal/binding/transferservice.ts | 18 +++ frontend/src/App.tsx | 5 + frontend/src/backend/wails.test.ts | 12 ++ frontend/src/backend/wails.ts | 3 + frontend/src/components/NavigationSidebar.tsx | 36 +---- frontend/src/i18n.tsx | 21 +++ frontend/src/pages/SettingsPage.test.tsx | 16 ++ frontend/src/pages/SettingsPage.tsx | 39 +++++ frontend/src/pages/TransferPage.test.tsx | 59 +++++++ frontend/src/pages/TransferPage.tsx | 152 ++++++++++++++++++ frontend/src/state/settingsTransfer.test.ts | 13 ++ frontend/src/state/settingsTransfer.ts | 40 +++++ frontend/src/styles/app.css | 120 ++++++-------- internal/binding/services.go | 2 + internal/binding/services_test.go | 1 + internal/binding/transfer.go | 52 ++++++ internal/binding/transfer_test.go | 28 ++++ 20 files changed, 537 insertions(+), 96 deletions(-) create mode 100644 frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/transferservice.ts create mode 100644 frontend/src/pages/SettingsPage.test.tsx create mode 100644 frontend/src/pages/SettingsPage.tsx create mode 100644 frontend/src/pages/TransferPage.test.tsx create mode 100644 frontend/src/pages/TransferPage.tsx create mode 100644 frontend/src/state/settingsTransfer.test.ts create mode 100644 frontend/src/state/settingsTransfer.ts create mode 100644 internal/binding/transfer.go create mode 100644 internal/binding/transfer_test.go diff --git a/cmd/oneagent-desktop/main_wails.go b/cmd/oneagent-desktop/main_wails.go index b788ed28..388bf065 100644 --- a/cmd/oneagent-desktop/main_wails.go +++ b/cmd/oneagent-desktop/main_wails.go @@ -86,6 +86,7 @@ func main() { application.NewServiceWithOptions(services.Profile, application.ServiceOptions{MarshalError: oneerrors.Marshal}), application.NewServiceWithOptions(services.Runtime, application.ServiceOptions{MarshalError: oneerrors.Marshal}), application.NewServiceWithOptions(services.DesktopAgent, application.ServiceOptions{MarshalError: oneerrors.Marshal}), + application.NewServiceWithOptions(services.Transfer, application.ServiceOptions{MarshalError: oneerrors.Marshal}), }, MarshalError: oneerrors.Marshal, Assets: application.AssetOptions{ 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 4c199d28..a4ce7dfc 100644 --- a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/index.ts +++ b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/index.ts @@ -7,6 +7,7 @@ import * as ProfileService from "./profileservice.js"; import * as ProviderService from "./providerservice.js"; import * as RuntimeService from "./runtimeservice.js"; import * as StatusService from "./statusservice.js"; +import * as TransferService from "./transferservice.js"; import * as UpdateService from "./updateservice.js"; export { AgentService, @@ -15,6 +16,7 @@ export { ProviderService, RuntimeService, StatusService, + TransferService, UpdateService }; @@ -24,6 +26,7 @@ export type { AgentInstallResult, DesktopAgentProfileRequest, DesktopAgentRequest, + FilePathRequest, InstallRequest, InstallResponse, InstallRuntimeRequest, @@ -39,5 +42,6 @@ export type { ProviderMutationResponse, SaveProfileRequest, SaveProviderRequest, - UpdateRequest + UpdateRequest, + WriteFileRequest } from "./models.js"; 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 3d4ba5b0..4ea67767 100644 --- a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/models.ts +++ b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/models.ts @@ -44,6 +44,10 @@ export interface DesktopAgentRequest { "agent_id": string; } +export interface FilePathRequest { + "path": string; +} + export interface InstallRequest { "agents": string[] | null; "provider": string; @@ -164,3 +168,8 @@ export interface SaveProviderRequest { export interface UpdateRequest { "agent_id": string; } + +export interface WriteFileRequest { + "path": string; + "data": string; +} diff --git a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/transferservice.ts b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/transferservice.ts new file mode 100644 index 00000000..8d9a29dd --- /dev/null +++ b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/transferservice.ts @@ -0,0 +1,18 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wailsio/runtime"; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import * as $models from "./models.js"; + +export function Read(request: $models.FilePathRequest): $CancellablePromise { + return $Call.ByID(3148699519, request); +} + +export function Write(request: $models.WriteFileRequest): $CancellablePromise { + return $Call.ByID(3458483966, request); +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c76763c3..cd488d38 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,6 +12,8 @@ import { ProfileSelectionPage } from "./pages/ProfileSelectionPage"; import { ProviderKeyPage } from "./pages/ProviderKeyPage"; import { ProvidersPage } from "./pages/ProvidersPage"; import { ReviewPage } from "./pages/ReviewPage"; +import { SettingsPage } from "./pages/SettingsPage"; +import { TransferPage } from "./pages/TransferPage"; import { I18nProvider, useI18n } from "./i18n"; import { TaskCenterProvider, useTaskCenter } from "./state/TaskCenterContext"; import { ThemeProvider } from "./state/ThemeContext"; @@ -81,6 +83,9 @@ function WorkspaceRoutes() { } /> } /> } /> + } /> + } /> + } /> } /> diff --git a/frontend/src/backend/wails.test.ts b/frontend/src/backend/wails.test.ts index 85a7ecc2..0ea58510 100644 --- a/frontend/src/backend/wails.test.ts +++ b/frontend/src/backend/wails.test.ts @@ -25,6 +25,8 @@ const bridge = vi.hoisted(() => ({ updateDownload: vi.fn(), updateRestart: vi.fn(), eventsOn: vi.fn(), + readTransfer: vi.fn(), + writeTransfer: vi.fn(), })); vi.mock("@wailsio/runtime", async (importOriginal) => ({ @@ -69,6 +71,10 @@ vi.mock("../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/updatese DownloadAndInstall: bridge.updateDownload, Restart: bridge.updateRestart, })); +vi.mock("../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/transferservice.js", () => ({ + Read: bridge.readTransfer, + Write: bridge.writeTransfer, +})); import { CancellablePromise } from "@wailsio/runtime"; import { INSTALL_OUTPUT_EVENT, normalizeWailsError, onInstallOutput, wailsApi } from "./wails"; @@ -112,6 +118,8 @@ describe("Wails backend adapter", () => { bridge.desktopInstall.mockResolvedValue(desktopAction); bridge.desktopOpen.mockResolvedValue(undefined); bridge.desktopConfigure.mockResolvedValue(desktopProfile); + bridge.readTransfer.mockResolvedValue("contents"); + bridge.writeTransfer.mockResolvedValue(undefined); await expect(wailsApi.status()).resolves.toBe(status); await expect(wailsApi.desktopAgentStatus("chatgpt-desktop")).resolves.toBe(desktopStatus); @@ -130,6 +138,8 @@ describe("Wails backend adapter", () => { 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); + await expect(wailsApi.readTransferFile("/tmp/import.json")).resolves.toBe("contents"); + await expect(wailsApi.writeTransferFile("/tmp/export.json", "contents")).resolves.toBeUndefined(); expect(bridge.probe).toHaveBeenCalledWith({ provider: "custom", api_base_url: "https://proxy.test/v1", api_key: "secret", model: "m", agents: null }); expect(bridge.getProvider).toHaveBeenCalledWith({ id: "acme" }); @@ -144,6 +154,8 @@ describe("Wails backend adapter", () => { 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" }); + expect(bridge.readTransfer).toHaveBeenCalledWith({ path: "/tmp/import.json" }); + expect(bridge.writeTransfer).toHaveBeenCalledWith({ path: "/tmp/export.json", data: "contents" }); }); 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 0ff12ee3..bfa83931 100644 --- a/frontend/src/backend/wails.ts +++ b/frontend/src/backend/wails.ts @@ -6,6 +6,7 @@ import * as ProfileService from "../../bindings/github.com/MaimoryLab/OneAgent/i import * as ProviderService from "../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/providerservice.js"; import * as RuntimeService from "../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/runtimeservice.js"; import * as StatusService from "../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/statusservice.js"; +import * as TransferService from "../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/transferservice.js"; import * as UpdateService from "../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/updateservice.js"; import type { ActivateAgentResponse, @@ -174,6 +175,8 @@ export const wailsApi = { getSettings: (): Promise => call(() => RuntimeService.GetSettings()) as Promise, saveSettings: (settings: Settings): Promise => call(() => RuntimeService.SaveSettings(settings)) as Promise, + readTransferFile: (path: string): Promise => call(() => TransferService.Read({ path })) as Promise, + writeTransferFile: (path: string, data: string): Promise => call(() => TransferService.Write({ path, data })).then(() => undefined), listProfiles: (): Promise => call(() => ProfileService.ListProfiles()) as Promise, deleteProfile: (id: string): Promise => call(() => ProfileService.DeleteProfile({ id })).then(() => undefined), diff --git a/frontend/src/components/NavigationSidebar.tsx b/frontend/src/components/NavigationSidebar.tsx index 3a13a909..3dc3f35a 100644 --- a/frontend/src/components/NavigationSidebar.tsx +++ b/frontend/src/components/NavigationSidebar.tsx @@ -1,10 +1,8 @@ -import { Boxes, FolderCog, Gauge, Languages, Layers3 } from "lucide-react"; +import { Boxes, FolderCog, Gauge, Layers3, Settings } from "lucide-react"; import { NavLink } from "react-router-dom"; import { type TranslationKey, useI18n } from "../i18n"; -import { SelectField } from "./SelectField"; import { TaskCenter } from "./TaskCenter"; -import { ThemePicker } from "./ThemePicker"; // Only real destinations belong here. /setup/* are wizard steps behind // SetupGuard: listing them made the sidebar look broken, because clicking one @@ -16,7 +14,7 @@ const navItems: Array<{ to: string; label: TranslationKey | "Provider"; icon: ty ]; export function NavigationSidebar() { - const { locale, setLocale, t } = useI18n(); + const { t } = useI18n(); return ( ); } diff --git a/frontend/src/i18n.tsx b/frontend/src/i18n.tsx index 90c4f0a6..323a459d 100644 --- a/frontend/src/i18n.tsx +++ b/frontend/src/i18n.tsx @@ -6,6 +6,27 @@ const english = { "工作区": "Workspace", "激活环境": "Environment", "配置模板": "Profiles", + "导入导出": "Import and export", + "设置": "Settings", + "管理界面偏好与配置迁移": "Manage interface preferences and configuration transfer", + "界面": "Interface", + "数据": "Data", + "选择要迁移的 Provider 和 Profile": "Choose the providers and profiles to transfer", + "导入": "Import", + "导出": "Export", + "导入完成": "Import complete", + "导出完成": "Export complete", + "导入失败": "Import failed", + "导出失败": "Export failed", + "导出设置": "Export settings", + "加密": "Encrypt", + "不加密": "Do not encrypt", + "请输入导出密码": "Enter an export password", + "请输入导入密码": "Enter the import password", + "选择导出位置": "Choose export location", + "选择导入文件": "Choose an import file", + "已选择 {count} 项": "{count} selected", + "Profile 依赖": "Required by profile", "语言": "Language", "外观": "Appearance", "跟随系统": "System", diff --git a/frontend/src/pages/SettingsPage.test.tsx b/frontend/src/pages/SettingsPage.test.tsx new file mode 100644 index 00000000..175658fd --- /dev/null +++ b/frontend/src/pages/SettingsPage.test.tsx @@ -0,0 +1,16 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { describe, expect, it } from "vitest"; + +import { ThemeProvider } from "../state/ThemeContext"; +import { SettingsPage } from "./SettingsPage"; + +describe("SettingsPage", () => { + it("contains appearance and language settings and opens the transfer child page", () => { + render(} />transfer child} />); + expect(screen.getByRole("combobox", { name: "外观" })).toBeTruthy(); + expect(screen.getByRole("combobox", { name: "语言" })).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: /导入导出/ })); + expect(screen.getByRole("heading", { name: "transfer child" })).toBeTruthy(); + }); +}); diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx new file mode 100644 index 00000000..51333eb1 --- /dev/null +++ b/frontend/src/pages/SettingsPage.tsx @@ -0,0 +1,39 @@ +import { ChevronRight, Import, Languages } from "lucide-react"; +import { useNavigate } from "react-router-dom"; + +import { PageScaffold } from "../components/PageScaffold"; +import { SelectField } from "../components/SelectField"; +import { ThemePicker } from "../components/ThemePicker"; +import { useI18n } from "../i18n"; + +export function SettingsPage() { + const navigate = useNavigate(); + const { locale, setLocale, t } = useI18n(); + return ( + +
+

{t("界面")}

+
+
+
+
+
+

{t("数据")}

+ +
+
+ ); +} diff --git a/frontend/src/pages/TransferPage.test.tsx b/frontend/src/pages/TransferPage.test.tsx new file mode 100644 index 00000000..93135d55 --- /dev/null +++ b/frontend/src/pages/TransferPage.test.tsx @@ -0,0 +1,59 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { api } from "../backend/api"; +import type { ProviderEntry, StatusResponse } from "../types/api"; +import { TransferPage } from "./TransferPage"; + +const dialogs = vi.hoisted(() => ({ Question: vi.fn(), SaveFile: vi.fn(), OpenFile: vi.fn() })); +vi.mock("@wailsio/runtime", async (importOriginal) => ({ ...await importOriginal(), Dialogs: dialogs })); + +const refreshStatus = vi.fn<() => Promise>(); +const status = { + apiVersion: 1, platform: { os: "macos", arch: "arm64", shell: "bash" }, runtimes: [], + capabilities: { canInstall: {}, missingRuntime: {}, supportedAgentIds: [] }, agents: {}, catalog: [], groups: [], + providers: { ppio: { name: "PPIO", home: "", base_url: "https://api.example.test", has_key: true } }, + mirrors: [], paths: {}, backups: {}, environment: null, environmentError: null, desktopAgents: [], activeProfile: null, firstRun: false, + profiles: [{ id: "team", label: "团队", provider: "ppio", model: "model", protocol: "responses", baseUrl: null, activatedAt: null, hasKey: true }], +} satisfies StatusResponse; + +vi.mock("../state/WizardContext", () => ({ useWizard: () => ({ state: { status }, refreshStatus }) })); + +describe("TransferPage", () => { + afterEach(() => vi.restoreAllMocks()); + + it("includes the provider required by a selected profile and uses the native save dialog", async () => { + const provider = { id: "ppio", name: "PPIO", home: "", base_url: "https://api.example.test", anthropic_base_url: "", api_key: "secret", built_in: true } satisfies ProviderEntry; + vi.spyOn(api, "getProvider").mockResolvedValue(provider); + const write = vi.spyOn(api, "writeTransferFile").mockResolvedValue(); + dialogs.Question.mockResolvedValue("不加密"); + dialogs.SaveFile.mockResolvedValue("/tmp/selected.json"); + + render(); + fireEvent.click(screen.getByRole("checkbox", { name: /团队/ })); + const required = screen.getAllByRole("checkbox", { name: /PPIO/ }).find((checkbox) => checkbox.hasAttribute("disabled")); + expect(required).toBeChecked(); + expect(required).toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: "导出" })); + + await waitFor(() => expect(write).toHaveBeenCalledOnce()); + expect(dialogs.SaveFile).toHaveBeenCalledOnce(); + expect(JSON.parse(write.mock.calls[0][1])).toMatchObject({ profiles: [{ id: "team" }], providers: [{ id: "ppio" }] }); + }); + + it("continues encrypted export through the in-app password form", async () => { + vi.spyOn(api, "getProvider").mockResolvedValue({ id: "ppio", name: "PPIO", home: "", base_url: "https://api.example.test", anthropic_base_url: "", api_key: "secret", built_in: true }); + const write = vi.spyOn(api, "writeTransferFile").mockResolvedValue(); + dialogs.Question.mockResolvedValue("加密"); + dialogs.SaveFile.mockResolvedValue("/tmp/selected.json"); + render(); + fireEvent.click(screen.getByRole("checkbox", { name: /团队/ })); + fireEvent.click(screen.getByRole("button", { name: "导出" })); + const password = await screen.findByDisplayValue(""); + fireEvent.change(password, { target: { value: "passphrase" } }); + fireEvent.click(screen.getByRole("button", { name: "确认" })); + await waitFor(() => expect(write).toHaveBeenCalledOnce()); + expect(JSON.parse(write.mock.calls[0][1]).encrypted).toBeTruthy(); + }); +}); diff --git a/frontend/src/pages/TransferPage.tsx b/frontend/src/pages/TransferPage.tsx new file mode 100644 index 00000000..18e757fb --- /dev/null +++ b/frontend/src/pages/TransferPage.tsx @@ -0,0 +1,152 @@ +import { Dialogs } from "@wailsio/runtime"; +import { Upload } from "lucide-react"; +import { useMemo, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import { api, describeError } from "../backend/api"; +import { PageScaffold } from "../components/PageScaffold"; +import { useI18n } from "../i18n"; +import { byProviderCreatedAt } from "../state/ranking"; +import { makeTransfer, parseTransfer, stringifyTransfer } from "../state/settingsTransfer"; +import { useWizard } from "../state/WizardContext"; + +const toggle = (selected: Set, id: string) => { + const next = new Set(selected); + if (next.has(id)) next.delete(id); else next.add(id); + return next; +}; + +export function TransferPage() { + const { t } = useI18n(); + const navigate = useNavigate(); + const { state, refreshStatus } = useWizard(); + const status = state.status; + const [selectedProviders, setSelectedProviders] = useState(new Set()); + const [selectedProfiles, setSelectedProfiles] = useState(new Set()); + const [busy, setBusy] = useState(false); + const [failure, setFailure] = useState(""); + const [success, setSuccess] = useState(""); + const [passwordRequest, setPasswordRequest] = useState<"export" | "import" | null>(null); + const [passwordValue, setPasswordValue] = useState(""); + const passwordResolver = useRef<((value: string | null) => void) | null>(null); + + const profiles = status?.profiles ?? []; + const providers = status ? byProviderCreatedAt(status.providers) : []; + const requiredProviders = useMemo(() => new Set(profiles.filter((profile) => selectedProfiles.has(profile.id)).map((profile) => profile.provider)), [profiles, selectedProfiles]); + const exportProviders = new Set([...selectedProviders, ...requiredProviders]); + const canExport = selectedProfiles.size > 0 || exportProviders.size > 0; + + const askPassword = (mode: "export" | "import") => new Promise((resolve) => { + passwordResolver.current = resolve; + setPasswordValue(""); + setPasswordRequest(mode); + }); + const finishPassword = (value: string | null) => { + passwordResolver.current?.(value); + passwordResolver.current = null; + setPasswordRequest(null); + setPasswordValue(""); + }; + + const exportFile = async () => { + if (!canExport) return; + setBusy(true); + setFailure(""); + setSuccess(""); + try { + const choice = await Dialogs.Question({ + Title: t("导出设置"), + Message: "是否加密apikey", + Buttons: [{ Label: t("加密"), IsDefault: true }, { Label: t("不加密") }, { Label: t("取消"), IsCancel: true }], + }); + if (choice === t("取消") || !choice) return; + const encrypt = choice === t("加密"); + const password = encrypt ? await askPassword("export") : ""; + if (encrypt && !password) return; + const path = await Dialogs.SaveFile({ + Title: t("选择导出位置"), + Filename: "oneagent-settings.json", + CanCreateDirectories: true, + Filters: [{ DisplayName: "JSON", Pattern: "*.json" }], + }); + if (!path) return; + const entries = await Promise.all([...exportProviders].map((id) => api.getProvider(id))); + const selected = profiles.filter((profile) => selectedProfiles.has(profile.id)); + await api.writeTransferFile(path, stringifyTransfer(await makeTransfer(selected, entries, encrypt, password || ""))); + setSuccess(t("导出完成")); + } catch (error) { + setFailure(describeError(error, t("导出失败")).message); + } finally { + setBusy(false); + } + }; + + const importFile = async () => { + setBusy(true); + setFailure(""); + setSuccess(""); + try { + const path = await Dialogs.OpenFile({ Title: t("选择导入文件"), Filters: [{ DisplayName: "JSON", Pattern: "*.json" }] }); + if (!path || Array.isArray(path)) return; + const raw = await api.readTransferFile(path); + const encrypted = (JSON.parse(raw) as { encrypted?: unknown }).encrypted; + const password = encrypted ? await askPassword("import") : ""; + if (encrypted && !password) return; + const data = await parseTransfer(raw, password || ""); + for (const provider of data.providers ?? []) await api.saveProvider(provider); + for (const profile of data.profiles ?? []) await api.saveProfile({ id: profile.id, label: profile.label, provider: profile.provider, apiBaseUrl: "", apiKey: "", model: profile.model || "", configMode: "provider", protocol: profile.protocol || "" }); + await refreshStatus(); + setSuccess(t("导入完成")); + } catch (error) { + setFailure(describeError(error, t("导入失败")).message); + } finally { + setBusy(false); + } + }; + + if (!status) return
{t("正在读取环境状态")}
; + + return ( + navigate("/settings")} + secondaryAction={} + primaryLabel={busy ? t("正在处理") : t("导出")} + primaryDisabled={!canExport} + primaryBusy={busy} + onPrimary={() => void exportFile()} + > + {failure ?
{failure}
: null} + {success ?
{success}
: null} + {passwordRequest ? ( + +
{ event.preventDefault(); finishPassword(passwordValue.trim() || null); }}> +

{passwordRequest === "export" ? t("请输入导出密码") : t("请输入导入密码")}

+ setPasswordValue(event.target.value)} required /> +
+
+
+ ) : null} +
+
+

Provider

{t("已选择 {count} 项", { count: exportProviders.size })}

+
+ {providers.map(([id, provider]) => { + const required = requiredProviders.has(id); + return ; + })} +
+
+
+

Profile

{t("已选择 {count} 项", { count: selectedProfiles.size })}

+
+ {profiles.map((profile) => )} +
+
+
+
+ ); +} diff --git a/frontend/src/state/settingsTransfer.test.ts b/frontend/src/state/settingsTransfer.test.ts new file mode 100644 index 00000000..70bf8abf --- /dev/null +++ b/frontend/src/state/settingsTransfer.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; + +import { makeTransfer, parseTransfer } from "./settingsTransfer"; + +describe("settings transfer", () => { + it("round-trips encrypted provider keys", async () => { + const providers = [{ id: "demo", name: "Demo", home: "", base_url: "https://example.test", anthropic_base_url: "", api_key: "secret", built_in: false }]; + const file = await makeTransfer([], providers, true, "password"); + expect(file.encrypted).toBeTruthy(); + expect(await parseTransfer(JSON.stringify(file), "password")).toMatchObject({ providers }); + await expect(parseTransfer(JSON.stringify(file), "wrong")).rejects.toThrow(); + }); +}); diff --git a/frontend/src/state/settingsTransfer.ts b/frontend/src/state/settingsTransfer.ts new file mode 100644 index 00000000..fca4264f --- /dev/null +++ b/frontend/src/state/settingsTransfer.ts @@ -0,0 +1,40 @@ +import type { ProfileSummary, ProviderEntry } from "../types/api"; + +type TransferFile = { + version: 1; + profiles?: ProfileSummary[]; + providers?: ProviderEntry[]; + encrypted?: { salt: string; iv: string; data: string }; +}; + +const encode = (value: Uint8Array) => btoa(String.fromCharCode(...value)); +const decode = (value: string) => Uint8Array.from(atob(value), (char) => char.charCodeAt(0)); + +async function keyFrom(password: string, salt: Uint8Array) { + const material = await crypto.subtle.importKey("raw", new TextEncoder().encode(password), "PBKDF2", false, ["deriveKey"]); + return crypto.subtle.deriveKey({ name: "PBKDF2", salt: salt as BufferSource, iterations: 100000, hash: "SHA-256" }, material, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]); +} + +export async function makeTransfer(profiles: ProfileSummary[], providers: ProviderEntry[], encrypt: boolean, password = ""): Promise { + const file: TransferFile = { version: 1, profiles, providers: encrypt ? undefined : providers }; + if (encrypt) { + const salt = crypto.getRandomValues(new Uint8Array(16)); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const data = await crypto.subtle.encrypt({ name: "AES-GCM", iv: iv as BufferSource }, await keyFrom(password, salt), new TextEncoder().encode(JSON.stringify(providers))); + file.encrypted = { salt: encode(salt), iv: encode(iv), data: encode(new Uint8Array(data)) }; + } + return file; +} + +export async function parseTransfer(text: string, password = ""): Promise { + const file = JSON.parse(text) as TransferFile; + if (file.version !== 1 || (!file.profiles && !file.providers && !file.encrypted)) throw new Error("文件格式无效"); + if (file.encrypted) { + const { salt, iv, data } = file.encrypted; + const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv: decode(iv) as BufferSource }, await keyFrom(password, decode(salt)), decode(data) as BufferSource); + file.providers = JSON.parse(new TextDecoder().decode(plain)) as ProviderEntry[]; + } + return file; +} + +export const stringifyTransfer = (file: TransferFile) => JSON.stringify(file, null, 2); diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 37f06cea..1d0d064d 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -13,20 +13,17 @@ background: var(--window-bg); } -/* The bottom padding clears the viewport-docked task centre. It is not spacing: - .task-center is position: fixed and contributes no height here, so without it - the theme and language rows -- pushed down by margin-top: auto -- sit behind - the overlay. They stay visible and focusable while every click lands on the - overlay instead, which reads as a control that vanished. */ .navigation-sidebar { min-height: 0; - padding: 22px 12px calc(16px + var(--task-center-reserve)); + padding: 22px 12px 16px; display: flex; flex-direction: column; border-right: 1px solid var(--border); background: var(--sidebar-bg); } +.sidebar-bottom { margin-top: auto; display: grid; gap: 4px; } + .brand-lockup { min-height: 50px; padding: 0 10px 18px; @@ -106,13 +103,6 @@ text-transform: uppercase; } -/* The appearance and language rows are one group. `margin-top: auto` sits on the - first of them so the pair is pushed to the bottom together; putting it on the - language row would let the theme row float up on its own. */ -.theme-picker { - margin-top: auto; -} - .theme-picker, .language-picker { min-height: 38px; @@ -132,9 +122,7 @@ flex: 1; } -/* The two sidebar pickers are the same control at the same size. A lighter - border than SelectField's default, because these sit on the sidebar's tinted - background rather than on a form. */ +/* Appearance and language use the same compact select treatment. */ .theme-select, .language-select { width: 82px; @@ -142,11 +130,12 @@ } .theme-select .select-field-trigger, -.language-select .select-field-trigger { - min-height: 26px; - padding: 0 6px; - border-color: var(--border); - font-size: 12px; +.language-select .select-field-trigger, +.provider-picker .select-field-trigger { + min-height: 42px; + padding: 0 12px; + border-color: var(--border-strong); + font-size: 13px; } /* The trigger is 82px, but a list constrained to it would clip "跟随系统". It @@ -209,6 +198,45 @@ .management-page { padding-top: 50px; } +.transfer-page { padding-top: 24px; } +.transfer-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; } +.transfer-section { min-width: 0; } +.transfer-section > header { display: flex; align-items: end; justify-content: space-between; margin-bottom: 10px; } +.transfer-section h2 { margin: 0; font-size: 15px; } +.transfer-section header p { margin: 3px 0 0; color: var(--text-secondary); font-size: 12px; } +.transfer-list { border-top: 1px solid var(--border); } +.transfer-row { min-height: 54px; display: flex; align-items: center; gap: 11px; border-bottom: 1px solid var(--border); cursor: pointer; } +.transfer-row:has(input:disabled) { cursor: default; } +.transfer-row input { flex: 0 0 auto; } +.transfer-row span { min-width: 0; display: grid; gap: 2px; } +.transfer-row strong, .transfer-row small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.transfer-row strong { font-size: 13px; } +.transfer-row small { color: var(--text-secondary); font-size: 11px; } +.transfer-row em { margin-left: auto; color: var(--blue); font-size: 11px; font-style: normal; white-space: nowrap; } +.transfer-password-dialog { width: min(360px, calc(100vw - 32px)); padding: 20px; border: 1px solid var(--border-strong); border-radius: var(--radius-control); background: var(--window-bg); color: var(--text-primary); box-shadow: 0 18px 50px rgba(0, 0, 0, 0.24); } +.transfer-password-dialog::backdrop { background: rgba(0, 0, 0, 0.28); } +.transfer-password-dialog form { display: grid; gap: 16px; } +.transfer-password-dialog h2 { margin: 0; font-size: 15px; } +.transfer-password-dialog input { width: 100%; } +.transfer-password-dialog footer { display: flex; justify-content: flex-end; gap: 8px; } + +.settings-page { padding-top: 24px; display: grid; align-content: start; gap: 28px; } +.settings-section { width: 100%; max-width: none; } +.settings-section h2 { margin: 0 0 10px; color: var(--text-secondary); font-size: 12px; font-weight: 600; } +.settings-row, +.settings-link { min-height: 54px; border-top: 1px solid var(--border); } +.settings-section > :last-child { border-bottom: 1px solid var(--border); } +.settings-row { display: flex; align-items: center; } +.settings-row .theme-picker, +.settings-row.language-picker { width: 100%; margin: 0; padding: 0 10px; } +.settings-row .theme-select, +.settings-row .language-select { width: 180px; } +.settings-link { width: 100%; padding: 0 10px; display: flex; align-items: center; gap: 12px; border-right: 0; border-left: 0; background: transparent; color: var(--text-primary); cursor: pointer; text-align: left; } +.settings-link > span { min-width: 0; display: grid; flex: 1; gap: 3px; } +.settings-link strong { font-size: 13px; } +.settings-link small { color: var(--text-secondary); font-size: 11px; } +.settings-link > svg:last-child { color: var(--text-tertiary); } + .page-footer { padding: 0 42px; border-top: 1px solid var(--border); @@ -653,13 +681,6 @@ gap: 8px; } -/* 42px to match the add button beside it and the text fields on the same form, - rather than SelectField's 38px default. */ -.provider-picker .select-field-trigger { - min-height: 42px; - padding: 0 12px; -} - .provider-add-button { width: 42px; height: 42px; @@ -1013,45 +1034,12 @@ @media (max-width: 900px) { :root { --sidebar-width: 72px; } .brand-lockup { justify-content: center; padding-inline: 0; } - /* Every text label in the rail is hidden at 72px. Each surviving control - keeps its own aria-label, so the accessible name does not depend on the - visible text. Anything added to the sidebar with a text label belongs in - this list: 12px of column forces CJK to one glyph per line, which is how - "外观" and "任务中心" ended up stacked vertically. */ + /* Every text label in the rail is hidden at 72px. */ .brand-lockup > div, .sidebar-link span, - .sidebar-section-label, - .language-picker > span, - .language-picker > svg, - .theme-picker > span, - .theme-picker > svg { display: none; } + .sidebar-section-label { display: none; } .sidebar-link { justify-content: center; padding: 0; } .sidebar-link.is-active::before { left: -12px; } - /* Centre what is left, now that the flex: 1 spacer spans are gone. */ - .language-picker, - .theme-picker { padding: 0; justify-content: center; } - - /* 48px of column fits the arrow alone. The value is dropped from the trigger - rather than truncated to an unreadable sliver -- the option list still shows - it in full, and each control keeps its aria-label either way. */ - .theme-select, - .language-select { - width: 40px; - } - - .theme-select .select-field-value, - .language-select .select-field-value { display: none; } - - .theme-select .select-field-trigger, - .language-select .select-field-trigger { justify-content: center; padding: 0; } - - /* The trigger is now narrower than the list needs, and the sidebar is only - 72px wide, so the list breaks out to the right of the rail instead. */ - .theme-select .select-field-list, - .language-select .select-field-list { - left: 0; - max-width: none; - } } @media (max-height: 760px) { @@ -2004,11 +1992,9 @@ } .task-center { - position: fixed; - left: 12px; - bottom: 16px; + position: relative; z-index: 20; - width: calc(var(--sidebar-width) - 24px); + width: 100%; border: 1px solid var(--border); border-radius: var(--radius-control); background: var(--window-bg); diff --git a/internal/binding/services.go b/internal/binding/services.go index f998bfed..77c087ac 100644 --- a/internal/binding/services.go +++ b/internal/binding/services.go @@ -21,6 +21,7 @@ type Services struct { Profile *ProfileService Runtime *RuntimeService DesktopAgent *DesktopAgentService + Transfer *TransferService } type ServicesOptions struct { @@ -36,6 +37,7 @@ func NewServicesWithOptions(core *app.UseCases, opener BrowserOpener, options Se Profile: NewProfileService(core), Runtime: &RuntimeService{core: core, onOutput: options.InstallOutput}, DesktopAgent: NewDesktopAgentService(core, options.InstallOutput), + Transfer: &TransferService{}, } } diff --git a/internal/binding/services_test.go b/internal/binding/services_test.go index 2615e7e2..844152c2 100644 --- a/internal/binding/services_test.go +++ b/internal/binding/services_test.go @@ -50,6 +50,7 @@ func TestServiceMethodAllowlist(t *testing.T) { {&ProfileService{}, []string{"DeleteProfile", "ListProfiles", "SaveProfile"}}, {&RuntimeService{}, []string{"GetSettings", "InstallRuntime", "ListRuntimes", "SaveSettings"}}, {&DesktopAgentService{}, []string{"Configure", "GetStatus", "Install", "Open"}}, + {&TransferService{}, []string{"Read", "Write"}}, {&UpdateService{}, []string{"Check", "DownloadAndInstall", "Restart"}}, } for _, test := range tests { diff --git a/internal/binding/transfer.go b/internal/binding/transfer.go new file mode 100644 index 00000000..95444f6d --- /dev/null +++ b/internal/binding/transfer.go @@ -0,0 +1,52 @@ +package binding + +import ( + "context" + "os" + "strings" + + oneerrors "github.com/MaimoryLab/OneAgent/internal/errors" +) + +type TransferService struct{} + +type FilePathRequest struct { + Path string `json:"path"` +} + +type WriteFileRequest struct { + Path string `json:"path"` + Data string `json:"data"` +} + +func (s *TransferService) Read(ctx context.Context, request FilePathRequest) (string, error) { + if err := contextError(ctx); err != nil { + return "", err + } + path := strings.TrimSpace(request.Path) + if path == "" { + return "", oneerrors.New(oneerrors.InvalidRequest, "file path is required") + } + data, err := os.ReadFile(path) + if err != nil { + return "", oneerrors.New(oneerrors.InvalidRequest, "Cannot read import file", oneerrors.WithCause(err)) + } + return string(data), nil +} + +func (s *TransferService) Write(ctx context.Context, request WriteFileRequest) error { + if err := contextError(ctx); err != nil { + return err + } + path := strings.TrimSpace(request.Path) + if path == "" { + return oneerrors.New(oneerrors.InvalidRequest, "file path is required") + } + if err := os.WriteFile(path, []byte(request.Data), 0o600); err != nil { + return oneerrors.New(oneerrors.ConfigWriteFailed, "Cannot write export file", oneerrors.WithCause(err)) + } + if err := os.Chmod(path, 0o600); err != nil { + return oneerrors.New(oneerrors.ConfigWriteFailed, "Cannot secure export file", oneerrors.WithCause(err)) + } + return nil +} diff --git a/internal/binding/transfer_test.go b/internal/binding/transfer_test.go new file mode 100644 index 00000000..8b6fa01d --- /dev/null +++ b/internal/binding/transfer_test.go @@ -0,0 +1,28 @@ +package binding + +import ( + "context" + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestTransferServiceWritesAndReadsSelectedFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "settings.json") + service := &TransferService{} + if err := service.Write(context.Background(), WriteFileRequest{Path: path, Data: `{"version":1}`}); err != nil { + t.Fatal(err) + } + data, err := service.Read(context.Background(), FilePathRequest{Path: path}) + if err != nil || data != `{"version":1}` { + t.Fatalf("Read() = %q, %v", data, err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 { + t.Fatalf("mode = %v", info.Mode().Perm()) + } +} From 4bdc93dbfc0fb0c23184705c420638e0217fe893 Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:41:14 +0800 Subject: [PATCH 2/9] fix: regulate export file schema --- frontend/src/pages/TransferPage.test.tsx | 4 +- frontend/src/state/settingsTransfer.test.ts | 5 +- frontend/src/state/settingsTransfer.ts | 70 +++++++++++++++------ frontend/src/styles/app.css | 2 +- internal/app/status.go | 2 +- internal/app/status_test.go | 2 +- internal/binding/services_test.go | 2 +- internal/profile/store.go | 19 +++--- internal/profile/store_test.go | 11 ++-- internal/profile/write.go | 4 -- internal/profile/write_test.go | 9 ++- 11 files changed, 86 insertions(+), 44 deletions(-) diff --git a/frontend/src/pages/TransferPage.test.tsx b/frontend/src/pages/TransferPage.test.tsx index 93135d55..65bb8d75 100644 --- a/frontend/src/pages/TransferPage.test.tsx +++ b/frontend/src/pages/TransferPage.test.tsx @@ -54,6 +54,8 @@ describe("TransferPage", () => { fireEvent.change(password, { target: { value: "passphrase" } }); fireEvent.click(screen.getByRole("button", { name: "确认" })); await waitFor(() => expect(write).toHaveBeenCalledOnce()); - expect(JSON.parse(write.mock.calls[0][1]).encrypted).toBeTruthy(); + const exported = JSON.parse(write.mock.calls[0][1]); + expect(exported.encrypted).toHaveLength(1); + expect(exported.providers[0].key_encrypted).toBe(0); }); }); diff --git a/frontend/src/state/settingsTransfer.test.ts b/frontend/src/state/settingsTransfer.test.ts index 70bf8abf..ed0e7018 100644 --- a/frontend/src/state/settingsTransfer.test.ts +++ b/frontend/src/state/settingsTransfer.test.ts @@ -6,8 +6,11 @@ describe("settings transfer", () => { it("round-trips encrypted provider keys", async () => { const providers = [{ id: "demo", name: "Demo", home: "", base_url: "https://example.test", anthropic_base_url: "", api_key: "secret", built_in: false }]; const file = await makeTransfer([], providers, true, "password"); - expect(file.encrypted).toBeTruthy(); + expect(file.encrypted).toHaveLength(1); + expect(file.providers[0]).toMatchObject({ key_encrypted: 0 }); + expect(file.profiles).toEqual([]); expect(await parseTransfer(JSON.stringify(file), "password")).toMatchObject({ providers }); + expect(file.timestamp).toBeTruthy(); await expect(parseTransfer(JSON.stringify(file), "wrong")).rejects.toThrow(); }); }); diff --git a/frontend/src/state/settingsTransfer.ts b/frontend/src/state/settingsTransfer.ts index fca4264f..d30b79cb 100644 --- a/frontend/src/state/settingsTransfer.ts +++ b/frontend/src/state/settingsTransfer.ts @@ -1,10 +1,14 @@ import type { ProfileSummary, ProviderEntry } from "../types/api"; -type TransferFile = { +type TransferProfile = Omit; +type EncryptedKey = { salt: string; iv: string; data: string }; +type TransferProvider = Omit & { apikey?: string; key_encrypted?: number }; +export type TransferFile = { version: 1; - profiles?: ProfileSummary[]; - providers?: ProviderEntry[]; - encrypted?: { salt: string; iv: string; data: string }; + timestamp: string; + providers: TransferProvider[]; + profiles: TransferProfile[]; + encrypted: EncryptedKey[]; }; const encode = (value: Uint8Array) => btoa(String.fromCharCode(...value)); @@ -15,26 +19,54 @@ async function keyFrom(password: string, salt: Uint8Array) { return crypto.subtle.deriveKey({ name: "PBKDF2", salt: salt as BufferSource, iterations: 100000, hash: "SHA-256" }, material, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]); } +async function encryptKey(value: string, password: string): Promise { + const salt = crypto.getRandomValues(new Uint8Array(16)); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const data = await crypto.subtle.encrypt({ name: "AES-GCM", iv: iv as BufferSource }, await keyFrom(password, salt), new TextEncoder().encode(value)); + return { salt: encode(salt), iv: encode(iv), data: encode(new Uint8Array(data)) }; +} + +async function decryptKey(value: EncryptedKey, password: string): Promise { + const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv: decode(value.iv) as BufferSource }, await keyFrom(password, decode(value.salt)), decode(value.data) as BufferSource); + return new TextDecoder().decode(plain); +} + export async function makeTransfer(profiles: ProfileSummary[], providers: ProviderEntry[], encrypt: boolean, password = ""): Promise { - const file: TransferFile = { version: 1, profiles, providers: encrypt ? undefined : providers }; - if (encrypt) { - const salt = crypto.getRandomValues(new Uint8Array(16)); - const iv = crypto.getRandomValues(new Uint8Array(12)); - const data = await crypto.subtle.encrypt({ name: "AES-GCM", iv: iv as BufferSource }, await keyFrom(password, salt), new TextEncoder().encode(JSON.stringify(providers))); - file.encrypted = { salt: encode(salt), iv: encode(iv), data: encode(new Uint8Array(data)) }; + const encrypted: EncryptedKey[] = []; + const transferProviders: TransferProvider[] = []; + for (const provider of providers) { + const { api_key, ...publicProvider } = provider; + const item: TransferProvider = { ...publicProvider }; + if (encrypt && api_key) item.key_encrypted = encrypted.push(await encryptKey(api_key, password)) - 1; + else item.apikey = api_key || ""; + transferProviders.push(item); } - return file; + return { + version: 1, + timestamp: new Date().toISOString(), + providers: transferProviders, + profiles: profiles.map(({ baseUrl: _baseUrl, ...profile }) => profile), + encrypted, + }; } -export async function parseTransfer(text: string, password = ""): Promise { - const file = JSON.parse(text) as TransferFile; - if (file.version !== 1 || (!file.profiles && !file.providers && !file.encrypted)) throw new Error("文件格式无效"); - if (file.encrypted) { - const { salt, iv, data } = file.encrypted; - const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv: decode(iv) as BufferSource }, await keyFrom(password, decode(salt)), decode(data) as BufferSource); - file.providers = JSON.parse(new TextDecoder().decode(plain)) as ProviderEntry[]; +export async function parseTransfer(text: string, password = ""): Promise<{ providers: ProviderEntry[]; profiles: TransferProfile[]; timestamp: string }> { + const file = JSON.parse(text) as Partial & { encrypted?: EncryptedKey[] | { salt: string; iv: string; data: string } }; + if (file.version !== 1 || !Array.isArray(file.providers) || !Array.isArray(file.profiles)) throw new Error("文件格式无效"); + if (!Array.isArray(file.encrypted)) throw new Error("文件格式无效"); + const encrypted = file.encrypted; + const profiles = file.profiles.map((profile) => { + if (Object.hasOwn(profile, "base_url") || Object.hasOwn(profile, "baseUrl")) throw new Error("Profile 文件格式无效"); + return profile; + }); + const providers: ProviderEntry[] = []; + for (const provider of file.providers) { + if (Object.hasOwn(provider, "api_key")) throw new Error("Provider 文件格式无效"); + const { apikey, key_encrypted, ...publicProvider } = provider; + const apiKey = typeof key_encrypted === "number" ? await decryptKey(encrypted[key_encrypted], password) : apikey || ""; + providers.push({ ...publicProvider, api_key: apiKey }); } - return file; + return { providers, profiles, timestamp: file.timestamp || "" }; } export const stringifyTransfer = (file: TransferFile) => JSON.stringify(file, null, 2); diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 1d0d064d..af58f61a 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -22,7 +22,7 @@ background: var(--sidebar-bg); } -.sidebar-bottom { margin-top: auto; display: grid; gap: 4px; } +.sidebar-bottom { margin-top: auto; display: grid; gap: 10px; } .brand-lockup { min-height: 50px; diff --git a/internal/app/status.go b/internal/app/status.go index 082519b8..6cc70a18 100644 --- a/internal/app/status.go +++ b/internal/app/status.go @@ -635,7 +635,7 @@ func profileSummary(item profileStore.Profile) ProfileSummary { ID: summary.ID, Label: summary.Label, Provider: summary.Provider, - BaseURL: summary.BaseURL, + BaseURL: nil, Model: summary.Model, Protocol: summary.Protocol, ActivatedAt: summary.ActivatedAt, diff --git a/internal/app/status_test.go b/internal/app/status_test.go index f97cae49..ec4c8924 100644 --- a/internal/app/status_test.go +++ b/internal/app/status_test.go @@ -84,7 +84,7 @@ func TestStatusProjectsProfilesAndActiveEnvironmentWithoutSecrets(t *testing.T) if err := os.MkdirAll(profilesDir, 0o700); err != nil { t.Fatal(err) } - profileJSON := `{"schema_version":2,"id":"team","label":"Team","provider":"ppio","base_url":"https://api.ppio.com/openai","model":"model-a","config_mode":"provider","agent_ids":["codex","opencode"],"created_at":"created","activated_at":"active","api_key":"must-not-escape"}` + profileJSON := `{"schema_version":2,"id":"team","label":"Team","provider":"ppio","model":"model-a","config_mode":"provider","agent_ids":["codex","opencode"],"created_at":"created","activated_at":"active","api_key":"must-not-escape"}` if err := os.WriteFile(filepath.Join(profilesDir, "team.json"), []byte(profileJSON), 0o600); err != nil { t.Fatal(err) } diff --git a/internal/binding/services_test.go b/internal/binding/services_test.go index 844152c2..2fd2e3a0 100644 --- a/internal/binding/services_test.go +++ b/internal/binding/services_test.go @@ -190,7 +190,7 @@ func TestProfileServiceListsPublicSummaries(t *testing.T) { if err := os.MkdirAll(profilesDir, 0o700); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(profilesDir, "team.json"), []byte(`{"schema_version":2,"id":"team","label":"Team","provider":"ppio","base_url":null,"model":"model","config_mode":"provider","agent_ids":["codex"]}`), 0o600); err != nil { + if err := os.WriteFile(filepath.Join(profilesDir, "team.json"), []byte(`{"schema_version":2,"id":"team","label":"Team","provider":"ppio","model":"model","config_mode":"provider","agent_ids":["codex"]}`), 0o600); err != nil { t.Fatal(err) } secretDir := filepath.Join(home, ".oneagent", "secrets") diff --git a/internal/profile/store.go b/internal/profile/store.go index ba7e1705..8d0135e4 100644 --- a/internal/profile/store.go +++ b/internal/profile/store.go @@ -63,7 +63,7 @@ type storedProfile struct { ID string `json:"id"` Label string `json:"label"` Provider string `json:"provider"` - BaseURL *string `json:"base_url"` + BaseURL *string `json:"base_url,omitempty"` Model *string `json:"model"` ConfigMode string `json:"config_mode"` Protocol string `json:"protocol,omitempty"` @@ -226,7 +226,7 @@ func (p Profile) Summary() Summary { ID: p.ID, Label: valueOr(p.Label, p.ID), Provider: p.Provider, - BaseURL: p.BaseURL, + BaseURL: nil, Model: p.Model, Protocol: p.Protocol, ActivatedAt: p.ActivatedAt, @@ -241,7 +241,6 @@ func (p Profile) environment() map[string]any { "id": p.ID, "label": p.Label, "provider": p.Provider, - "base_url": optionalStringValue(p.BaseURL), "model": optionalStringValue(p.Model), "config_mode": p.ConfigMode, "protocol": p.Protocol, @@ -266,12 +265,14 @@ func decodeStored(data []byte) (Profile, error) { ID: stored.ID, Label: stored.Label, Provider: stored.Provider, - BaseURL: stored.BaseURL, - Model: stored.Model, - ConfigMode: stored.ConfigMode, - Protocol: stored.Protocol, - CreatedAt: stored.CreatedAt, - ActivatedAt: stored.ActivatedAt, + // Legacy base_url is intentionally ignored. Provider endpoints are resolved + // from the referenced Provider at use time. + BaseURL: nil, + Model: stored.Model, + ConfigMode: stored.ConfigMode, + Protocol: stored.Protocol, + CreatedAt: stored.CreatedAt, + ActivatedAt: stored.ActivatedAt, }, nil } diff --git a/internal/profile/store_test.go b/internal/profile/store_test.go index a4960330..b02d48d2 100644 --- a/internal/profile/store_test.go +++ b/internal/profile/store_test.go @@ -46,8 +46,8 @@ func TestEmptyStoreAndStableListProjection(t *testing.T) { if result := store.LoadActive(); result.Profile != nil || result.ID != "" || result.Error != "" { t.Fatalf("empty LoadActive() = %#v", result) } - writeProfileFixture(t, store, "b-profile", `{"schema_version":2,"id":"b-profile","label":"B","provider":"novita","base_url":null,"model":"m2","config_mode":"provider","agent_ids":["aider"],"created_at":"t2","activated_at":null}`) - writeProfileFixture(t, store, "a-profile", `{"schema_version":2,"id":"a-profile","label":"A","provider":"ppio","base_url":"https://api.ppio.com/openai","model":"m1","config_mode":"provider","agent_ids":["codex"],"created_at":"t1","activated_at":"t1"}`) + writeProfileFixture(t, store, "b-profile", `{"schema_version":2,"id":"b-profile","label":"B","provider":"novita","model":"m2","config_mode":"provider","agent_ids":["aider"],"created_at":"t2","activated_at":null}`) + writeProfileFixture(t, store, "a-profile", `{"schema_version":2,"id":"a-profile","label":"A","provider":"ppio","model":"m1","config_mode":"provider","agent_ids":["codex"],"created_at":"t1","activated_at":"t1"}`) secret, err := store.SecretPath("a-profile") if err != nil { t.Fatal(err) @@ -66,14 +66,14 @@ func TestEmptyStoreAndStableListProjection(t *testing.T) { t.Fatalf("stable profile list = %#v", profiles) } summary := profiles[0].Summary() - if summary.HasKey != true || summary.BaseURL == nil || *summary.BaseURL != "https://api.ppio.com/openai" { + if summary.HasKey != true || summary.BaseURL != nil { t.Fatalf("summary = %#v", summary) } } func TestLoadActiveV2PreservesPointerAndStripsSecrets(t *testing.T) { store := NewStore(t.TempDir(), "linux") - writeProfileFixture(t, store, "team", `{"schema_version":2,"id":"team","label":"Team","provider":"ppio","base_url":"https://api.ppio.com/openai","model":"m","config_mode":"provider","agent_ids":["codex"],"created_at":"created","activated_at":"active","api_key":"must-not-escape"}`) + writeProfileFixture(t, store, "team", `{"schema_version":2,"id":"team","label":"Team","provider":"ppio","model":"m","config_mode":"provider","agent_ids":["codex"],"created_at":"created","activated_at":"active","api_key":"must-not-escape"}`) if err := os.MkdirAll(store.Root(), 0o700); err != nil { t.Fatal(err) } @@ -87,6 +87,9 @@ func TestLoadActiveV2PreservesPointerAndStripsSecrets(t *testing.T) { if result.Environment["api_key"] != nil { t.Fatalf("environment exposed a secret: %#v", result.Environment) } + if result.Environment["base_url"] != nil { + t.Fatalf("environment retained a Provider endpoint: %#v", result.Environment) + } if result.Environment["provider"] != "ppio" || result.Environment["model"] != "m" { t.Fatalf("environment projection = %#v", result.Environment) } diff --git a/internal/profile/write.go b/internal/profile/write.go index dfcd1462..adb099cd 100644 --- a/internal/profile/write.go +++ b/internal/profile/write.go @@ -79,7 +79,6 @@ func (s Store) Save(ctx context.Context, request SaveRequest) (Profile, error) { ID: request.ID, Label: label, Provider: request.Provider, - BaseURL: optionalPointer(request.BaseURL, base), Model: stringPointer(model), ConfigMode: mode, Protocol: strings.TrimSpace(request.Protocol), @@ -152,7 +151,6 @@ func (s Store) WriteActive(ctx context.Context, request ActiveRequest) (string, profileID = current.ID } } - var baseURL *string var model *string resolvedBase := "" providerID := "existing-account" @@ -165,7 +163,6 @@ func (s Store) WriteActive(ctx context.Context, request ActiveRequest) (string, resolvedBase = base providerID = request.Provider mode = "provider" - baseURL = stringPointer(base) modelValue := strings.TrimSpace(request.Model) if modelValue == "" { return "", oneerrors.New(oneerrors.InvalidRequest, "model is required") @@ -183,7 +180,6 @@ func (s Store) WriteActive(ctx context.Context, request ActiveRequest) (string, ID: profileID, Label: valueOr(current.Label, valueOr(strings.TrimSpace(request.Label), profileID)), Provider: providerID, - BaseURL: baseURL, Model: model, ConfigMode: mode, Protocol: strings.TrimSpace(request.Protocol), diff --git a/internal/profile/write_test.go b/internal/profile/write_test.go index fa2c636d..d9ea70e6 100644 --- a/internal/profile/write_test.go +++ b/internal/profile/write_test.go @@ -95,9 +95,14 @@ func TestSaveProfileValidatesInputAndCustomBase(t *testing.T) { BaseURL: "http://127.0.0.1:9000/", Model: "m", Protocol: "openai", }) - if err != nil || profile.BaseURL == nil || *profile.BaseURL != "http://127.0.0.1:9000" { + if err != nil || profile.BaseURL != nil { t.Fatalf("custom profile = %#v, err=%v", profile, err) } + profilePath, _ := store.ProfilePath("custom-local") + data, readErr := os.ReadFile(profilePath) + if readErr != nil || strings.Contains(string(data), "base_url") { + t.Fatalf("saved profile retained base_url: %s, %v", data, readErr) + } } func TestDeleteProfileRemovesRecordSecretAndActivePointer(t *testing.T) { @@ -138,7 +143,7 @@ func TestWriteActiveReplacesProfileAndSupportsExistingAccount(t *testing.T) { if active.Error != "" || active.Profile == nil || active.Profile.Protocol != "openai" { t.Fatalf("updated active profile = %#v", active) } - if active.Profile.BaseURL == nil || *active.Profile.BaseURL != "https://api.ppio.com/openai" { + if active.Profile.BaseURL != nil { t.Fatalf("active base URL = %#v", active.Profile.BaseURL) } if _, err := store.WriteActive(context.Background(), ActiveRequest{ From 670e3f6d6ab7de4f14b45db5329db66f06296a00 Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:52:24 +0800 Subject: [PATCH 3/9] chore: drop profile base_url and apikey supports --- .../OneAgent/internal/app/models.ts | 4 +- frontend/src/backend/wails.test.ts | 2 +- .../pages/EnvironmentOverviewPage.test.tsx | 2 +- frontend/src/pages/ProfileSelectionPage.tsx | 2 +- frontend/src/pages/ProfilesPage.test.tsx | 10 ++- frontend/src/pages/ProfilesPage.tsx | 2 +- frontend/src/pages/TransferPage.test.tsx | 2 +- frontend/src/state/desktopSetup.test.ts | 2 +- frontend/src/state/desktopSetup.ts | 2 +- frontend/src/state/ranking.test.ts | 4 +- frontend/src/state/settingsTransfer.test.ts | 11 +++ frontend/src/state/settingsTransfer.ts | 11 ++- internal/app/status.go | 5 +- internal/app/status_test.go | 4 +- internal/binding/services_test.go | 4 +- internal/profile/store.go | 12 --- internal/profile/store_test.go | 4 +- internal/profile/write.go | 76 +------------------ internal/profile/write_test.go | 37 ++------- 19 files changed, 52 insertions(+), 144 deletions(-) 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 3eea2ef6..834244fe 100644 --- a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/app/models.ts +++ b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/app/models.ts @@ -124,8 +124,7 @@ export interface InstallRuntimeResult { } /** - * ProfileSummary is intentionally a public projection. It has no credential - * field; hasKey only reports whether a secret exists in the secure store. + * ProfileSummary is intentionally a public projection with no credential field. */ export interface ProfileSummary { "id": string; @@ -135,7 +134,6 @@ export interface ProfileSummary { "model": string | null; "protocol": string; "activatedAt": string | null; - "hasKey": boolean; "createdAt"?: string; } diff --git a/frontend/src/backend/wails.test.ts b/frontend/src/backend/wails.test.ts index 0ea58510..9c878e70 100644 --- a/frontend/src/backend/wails.test.ts +++ b/frontend/src/backend/wails.test.ts @@ -95,7 +95,7 @@ describe("Wails backend adapter", () => { const probe = { ok: true, reachable: true, status: 204, message: "ok", error_code: null, retryable: false } satisfies ProbeResponse; const models = { ...probe, models: ["model-a"] } satisfies ModelsResponse; 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", protocol: "responses", activatedAt: null, hasKey: true } satisfies ProfileSummary; + const profile = { id: "team", label: "Team", provider: "ppio", baseUrl: null, model: "m", protocol: "responses", activatedAt: null } 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: "chatgpt-desktop", name: "ChatGPT Desktop", installed: false, supported: true, version: null, source: "macos-dmg", protocol: "responses", profileAgentId: "codex", profileId: null } satisfies DesktopAgentStatus; const desktopAction = { status: "installer-started", message: "started", refreshNeeded: true, app: desktopStatus } satisfies DesktopAgentActionResult; diff --git a/frontend/src/pages/EnvironmentOverviewPage.test.tsx b/frontend/src/pages/EnvironmentOverviewPage.test.tsx index 11ddba65..43b1360c 100644 --- a/frontend/src/pages/EnvironmentOverviewPage.test.tsx +++ b/frontend/src/pages/EnvironmentOverviewPage.test.tsx @@ -57,7 +57,7 @@ function status(): StatusResponse { mirrors: [], paths: {}, backups: {}, - profiles: [{ id: "team", label: "团队默认", provider: "ppio", baseUrl: null, model: "model-a", protocol: "responses", activatedAt: null, hasKey: true }], + profiles: [{ id: "team", label: "团队默认", provider: "ppio", baseUrl: null, model: "model-a", protocol: "responses", activatedAt: null }], activeProfile: "team", firstRun: false, environment: null, diff --git a/frontend/src/pages/ProfileSelectionPage.tsx b/frontend/src/pages/ProfileSelectionPage.tsx index 607957f4..70f867e1 100644 --- a/frontend/src/pages/ProfileSelectionPage.tsx +++ b/frontend/src/pages/ProfileSelectionPage.tsx @@ -46,7 +46,7 @@ export function ProfileSelectionPage() { profileId: selected.id, profileLabel: selected.label, model: selected.model || "", - keyVerified: Boolean(status.providers[selected.provider]?.has_key || selected.hasKey), + keyVerified: Boolean(status.providers[selected.provider]?.has_key), }); navigate("/setup/review"); }; diff --git a/frontend/src/pages/ProfilesPage.test.tsx b/frontend/src/pages/ProfilesPage.test.tsx index 088947da..bf73fd80 100644 --- a/frontend/src/pages/ProfilesPage.test.tsx +++ b/frontend/src/pages/ProfilesPage.test.tsx @@ -75,7 +75,6 @@ function profile(over: Partial = {}): ProfileSummary { model: "deepseek/deepseek-v3", baseUrl: null, protocol: "responses", - hasKey: true, activatedAt: null, ...over, }; @@ -278,7 +277,14 @@ describe("ProfilesPage", () => { probe: null, probes: {}, }); - renderPage([profile()]); + mockState = { status: statusWith([profile()]), statusState: "success" }; + if (!mockState.status) throw new Error("missing status"); + mockState.status.providers.ppio.has_key = true; + render( + + } />overview} /> + , + ); fireEvent.click(screen.getByRole("button", { name: "应用到 Agent" })); await waitFor(() => expect(install).toHaveBeenCalledWith(expect.objectContaining({ diff --git a/frontend/src/pages/ProfilesPage.tsx b/frontend/src/pages/ProfilesPage.tsx index 16736047..50f7497e 100644 --- a/frontend/src/pages/ProfilesPage.tsx +++ b/frontend/src/pages/ProfilesPage.tsx @@ -317,7 +317,7 @@ export function ProfilesPage() { const users = configurableAgents.filter((agent) => status.agents[agent.id]?.profileId === profile.id); const canApply = Boolean( profile.model && agents.length - && (status.providers[profile.provider]?.has_key || profile.hasKey), + && status.providers[profile.provider]?.has_key, ); return (
diff --git a/frontend/src/pages/TransferPage.test.tsx b/frontend/src/pages/TransferPage.test.tsx index 65bb8d75..6a1bb8b0 100644 --- a/frontend/src/pages/TransferPage.test.tsx +++ b/frontend/src/pages/TransferPage.test.tsx @@ -15,7 +15,7 @@ const status = { capabilities: { canInstall: {}, missingRuntime: {}, supportedAgentIds: [] }, agents: {}, catalog: [], groups: [], providers: { ppio: { name: "PPIO", home: "", base_url: "https://api.example.test", has_key: true } }, mirrors: [], paths: {}, backups: {}, environment: null, environmentError: null, desktopAgents: [], activeProfile: null, firstRun: false, - profiles: [{ id: "team", label: "团队", provider: "ppio", model: "model", protocol: "responses", baseUrl: null, activatedAt: null, hasKey: true }], + profiles: [{ id: "team", label: "团队", provider: "ppio", model: "model", protocol: "responses", baseUrl: null, activatedAt: null }], } satisfies StatusResponse; vi.mock("../state/WizardContext", () => ({ useWizard: () => ({ state: { status }, refreshStatus }) })); diff --git a/frontend/src/state/desktopSetup.test.ts b/frontend/src/state/desktopSetup.test.ts index d00c2870..f4110348 100644 --- a/frontend/src/state/desktopSetup.test.ts +++ b/frontend/src/state/desktopSetup.test.ts @@ -19,7 +19,7 @@ function app(overrides: Partial = {}): DesktopAgentStatus { } function profile(id: string, protocol: ProfileSummary["protocol"]): ProfileSummary { - return { id, label: id, provider: "ppio", baseUrl: null, model: "model-a", protocol, activatedAt: null, hasKey: true }; + return { id, label: id, provider: "ppio", baseUrl: null, model: "model-a", protocol, activatedAt: null }; } function status(profiles: ProfileSummary[], agents: StatusResponse["agents"] = {}): StatusResponse { diff --git a/frontend/src/state/desktopSetup.ts b/frontend/src/state/desktopSetup.ts index 4b52928e..66a42632 100644 --- a/frontend/src/state/desktopSetup.ts +++ b/frontend/src/state/desktopSetup.ts @@ -34,7 +34,7 @@ export function desktopProfiles(status: StatusResponse, app: DesktopAgentStatus) 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)); + return Boolean(provider && profile.model?.trim() && provider.has_key); } export function desktopProfileIsShared(app: DesktopAgentStatus): boolean { diff --git a/frontend/src/state/ranking.test.ts b/frontend/src/state/ranking.test.ts index d7dce19f..91dc3ecd 100644 --- a/frontend/src/state/ranking.test.ts +++ b/frontend/src/state/ranking.test.ts @@ -58,8 +58,8 @@ describe("ranking", () => { it("puts newest profiles first", () => { const sorted = byProfileCreatedAt([ - { id: "old", label: "Old", provider: "ppio", baseUrl: null, model: "m", protocol: "openai", activatedAt: null, hasKey: true, createdAt: "2026-01-01T00:00:00Z" }, - { id: "new", label: "New", provider: "ppio", baseUrl: null, model: "m", protocol: "openai", activatedAt: null, hasKey: true, createdAt: "2026-02-01T00:00:00Z" }, + { id: "old", label: "Old", provider: "ppio", baseUrl: null, model: "m", protocol: "openai", activatedAt: null, createdAt: "2026-01-01T00:00:00Z" }, + { id: "new", label: "New", provider: "ppio", baseUrl: null, model: "m", protocol: "openai", activatedAt: null, createdAt: "2026-02-01T00:00:00Z" }, ]); expect(sorted.map((profile) => profile.id)).toEqual(["new", "old"]); }); diff --git a/frontend/src/state/settingsTransfer.test.ts b/frontend/src/state/settingsTransfer.test.ts index ed0e7018..d08ac104 100644 --- a/frontend/src/state/settingsTransfer.test.ts +++ b/frontend/src/state/settingsTransfer.test.ts @@ -13,4 +13,15 @@ describe("settings transfer", () => { expect(file.timestamp).toBeTruthy(); await expect(parseTransfer(JSON.stringify(file), "wrong")).rejects.toThrow(); }); + + it("drops legacy profile endpoint and local key fields", async () => { + const parsed = await parseTransfer(JSON.stringify({ + version: 1, + timestamp: "2026-01-01T00:00:00Z", + providers: [], + profiles: [{ id: "team", label: "Team", provider: "demo", model: "m", protocol: "openai", base_url: "https://old", api_key: "legacy", hasKey: true }], + encrypted: [], + })); + expect(parsed.profiles[0]).toEqual({ id: "team", label: "Team", provider: "demo", model: "m", protocol: "openai" }); + }); }); diff --git a/frontend/src/state/settingsTransfer.ts b/frontend/src/state/settingsTransfer.ts index d30b79cb..75c46af6 100644 --- a/frontend/src/state/settingsTransfer.ts +++ b/frontend/src/state/settingsTransfer.ts @@ -56,8 +56,15 @@ export async function parseTransfer(text: string, password = ""): Promise<{ prov if (!Array.isArray(file.encrypted)) throw new Error("文件格式无效"); const encrypted = file.encrypted; const profiles = file.profiles.map((profile) => { - if (Object.hasOwn(profile, "base_url") || Object.hasOwn(profile, "baseUrl")) throw new Error("Profile 文件格式无效"); - return profile; + const { + base_url: _baseUrl, + baseUrl: _camelBaseUrl, + api_key: _apiKey, + apiKey: _camelApiKey, + hasKey: _hasKey, + ...rest + } = profile as TransferProfile & Record; + return rest as TransferProfile; }); const providers: ProviderEntry[] = []; for (const provider of file.providers) { diff --git a/internal/app/status.go b/internal/app/status.go index 6cc70a18..a34bc9d9 100644 --- a/internal/app/status.go +++ b/internal/app/status.go @@ -233,8 +233,7 @@ type DetectedConfig struct { Unreadable *string `json:"unreadable"` } -// ProfileSummary is intentionally a public projection. It has no credential -// field; hasKey only reports whether a secret exists in the secure store. +// ProfileSummary is intentionally a public projection with no credential field. type ProfileSummary struct { ID string `json:"id"` Label string `json:"label"` @@ -243,7 +242,6 @@ type ProfileSummary struct { Model *string `json:"model"` Protocol string `json:"protocol"` ActivatedAt *string `json:"activatedAt"` - HasKey bool `json:"hasKey"` CreatedAt string `json:"createdAt,omitempty"` } @@ -639,7 +637,6 @@ func profileSummary(item profileStore.Profile) ProfileSummary { Model: summary.Model, Protocol: summary.Protocol, ActivatedAt: summary.ActivatedAt, - HasKey: summary.HasKey, CreatedAt: summary.CreatedAt, } } diff --git a/internal/app/status_test.go b/internal/app/status_test.go index ec4c8924..0180577f 100644 --- a/internal/app/status_test.go +++ b/internal/app/status_test.go @@ -107,7 +107,7 @@ func TestStatusProjectsProfilesAndActiveEnvironmentWithoutSecrets(t *testing.T) if err != nil { t.Fatal(err) } - if len(status.Profiles) != 1 || status.Profiles[0].ID != "team" || !status.Profiles[0].HasKey { + if len(status.Profiles) != 1 || status.Profiles[0].ID != "team" { t.Fatalf("profile summaries = %#v", status.Profiles) } if status.ActiveProfile == nil || *status.ActiveProfile != "team" { @@ -148,7 +148,7 @@ func TestSaveProfileUseCaseWritesOnlyPublicSummary(t *testing.T) { if err != nil { t.Fatal(err) } - if summary.ID != "team" || !summary.HasKey || summary.Protocol != "openai" { + if summary.ID != "team" || summary.Protocol != "openai" { t.Fatalf("saved summary = %#v", summary) } if err := core.providers.SaveKey(context.Background(), "ppio", "new-provider-key"); err != nil { diff --git a/internal/binding/services_test.go b/internal/binding/services_test.go index 2fd2e3a0..bea35ac7 100644 --- a/internal/binding/services_test.go +++ b/internal/binding/services_test.go @@ -207,7 +207,7 @@ func TestProfileServiceListsPublicSummaries(t *testing.T) { }) service := NewProfileService(core) profiles, err := service.ListProfiles(context.Background()) - if err != nil || len(profiles) != 1 || profiles[0].ID != "team" || !profiles[0].HasKey { + if err != nil || len(profiles) != 1 || profiles[0].ID != "team" { t.Fatalf("profiles = %#v, err=%v", profiles, err) } wire, err := json.Marshal(profiles) @@ -231,7 +231,7 @@ func TestProfileServiceSavesWithoutReturningSecret(t *testing.T) { APIKey: "sk-secret", ConfigMode: "provider", }) - if err != nil || summary.ID != "team" || !summary.HasKey { + if err != nil || summary.ID != "team" { t.Fatalf("saved profile = %#v, err=%v", summary, err) } wire, err := json.Marshal(summary) diff --git a/internal/profile/store.go b/internal/profile/store.go index 8d0135e4..323a1493 100644 --- a/internal/profile/store.go +++ b/internal/profile/store.go @@ -36,7 +36,6 @@ type Profile struct { Protocol string CreatedAt string ActivatedAt *string - HasKey bool } type Summary struct { @@ -47,7 +46,6 @@ type Summary struct { Model *string Protocol string ActivatedAt *string - HasKey bool CreatedAt string } @@ -145,15 +143,6 @@ func (s Store) List() ([]Profile, error) { if err := ValidateID(profile.ID); err != nil { return nil, fmt.Errorf("Profile %s is corrupt: %w", strings.TrimSuffix(entry.Name(), ".json"), err) } - secret, err := s.SecretPath(profile.ID) - if err != nil { - return nil, err - } - _, statErr := os.Stat(secret) - if statErr != nil && !os.IsNotExist(statErr) { - return nil, fmt.Errorf("cannot inspect Profile secret %s: %w", profile.ID, statErr) - } - profile.HasKey = statErr == nil profiles = append(profiles, profile) } return profiles, nil @@ -230,7 +219,6 @@ func (p Profile) Summary() Summary { Model: p.Model, Protocol: p.Protocol, ActivatedAt: p.ActivatedAt, - HasKey: p.HasKey, CreatedAt: p.CreatedAt, } } diff --git a/internal/profile/store_test.go b/internal/profile/store_test.go index b02d48d2..c06672c3 100644 --- a/internal/profile/store_test.go +++ b/internal/profile/store_test.go @@ -62,11 +62,11 @@ func TestEmptyStoreAndStableListProjection(t *testing.T) { if err != nil { t.Fatal(err) } - if len(profiles) != 2 || profiles[0].ID != "a-profile" || profiles[1].ID != "b-profile" || !profiles[0].HasKey || profiles[1].HasKey { + if len(profiles) != 2 || profiles[0].ID != "a-profile" || profiles[1].ID != "b-profile" { t.Fatalf("stable profile list = %#v", profiles) } summary := profiles[0].Summary() - if summary.HasKey != true || summary.BaseURL != nil { + if summary.BaseURL != nil { t.Fatalf("summary = %#v", summary) } } diff --git a/internal/profile/write.go b/internal/profile/write.go index adb099cd..9e521977 100644 --- a/internal/profile/write.go +++ b/internal/profile/write.go @@ -57,14 +57,10 @@ func (s Store) Save(ctx context.Context, request SaveRequest) (Profile, error) { if err != nil { return Profile{}, err } - base, err := provider.ProviderBase(request.Provider, request.BaseURL) - if err != nil { + if _, err := provider.ProviderBase(request.Provider, request.BaseURL); err != nil { return Profile{}, err } existing := s.existing(request.ID) - if existing.ID != "" && existing.Provider != request.Provider && request.APIKey == "" && !request.ProviderKeyAvailable && s.secretExists(request.ID) { - return Profile{}, oneerrors.New(oneerrors.InvalidRequest, "API key is required when changing a Profile provider") - } now := s.clock().UTC().Format(time.RFC3339) label := strings.TrimSpace(request.Label) if label == "" { @@ -88,13 +84,7 @@ func (s Store) Save(ctx context.Context, request SaveRequest) (Profile, error) { if err := s.writeStored(ctx, stored); err != nil { return Profile{}, err } - if request.APIKey != "" && mode == "provider" { - if err := s.writeSecret(ctx, request.ID, request.APIKey, base); err != nil { - return Profile{}, err - } - } profile := profileFromStored(stored) - profile.HasKey = s.secretExists(request.ID) return profile, nil } @@ -152,15 +142,13 @@ func (s Store) WriteActive(ctx context.Context, request ActiveRequest) (string, } } var model *string - resolvedBase := "" providerID := "existing-account" mode := "existing-account" if request.Configure { - base, providerErr := provider.ProviderBase(request.Provider, request.BaseURL) + _, providerErr := provider.ProviderBase(request.Provider, request.BaseURL) if providerErr != nil { return "", providerErr } - resolvedBase = base providerID = request.Provider mode = "provider" modelValue := strings.TrimSpace(request.Model) @@ -189,11 +177,6 @@ func (s Store) WriteActive(ctx context.Context, request ActiveRequest) (string, if err := s.writeStored(ctx, stored); err != nil { return "", err } - if request.Configure && request.APIKey != "" { - if err := s.writeSecret(ctx, profileID, request.APIKey, resolvedBase); err != nil { - return "", err - } - } pointer := activePointer{SchemaVersion: 2, Active: profileID} data, err := json.MarshalIndent(pointer, "", " ") if err != nil { @@ -220,16 +203,6 @@ func (s Store) writeStored(ctx context.Context, stored storedProfile) error { return err } -func (s Store) writeSecret(ctx context.Context, id, apiKey, base string) error { - path, err := s.SecretPath(id) - if err != nil { - return err - } - content := secretContent(s.OS, apiKey, base) - _, err = s.filesystem().AtomicWrite(ctx, path, []byte(content), true) - return err -} - func (s Store) existing(id string) Profile { path, err := s.ProfilePath(id) if err != nil { @@ -246,15 +219,6 @@ func (s Store) existing(id string) Profile { return profile } -func (s Store) secretExists(id string) bool { - path, err := s.SecretPath(id) - if err != nil { - return false - } - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} - func (s Store) filesystem() securefs.Store { if s.FS != nil { return *s.FS @@ -294,42 +258,6 @@ func configMode(value string) (string, error) { return value, nil } -func optionalPointer(input, resolved string) *string { - if input == "" { - return nil - } - return stringPointer(resolved) -} - -func secretContent(osID, apiKey, base string) string { - if osID == "windows" { - return "$env:ONEAGENT_API_KEY = '" + powershellQuote(apiKey) + "'\n" + - "$env:ONEAGENT_API_BASE_URL = '" + powershellQuote(base) + "'\n" - } - return "export ONEAGENT_API_KEY=" + shellQuote(apiKey) + "\n" + - "export ONEAGENT_API_BASE_URL=" + shellQuote(base) + "\n" -} - -func shellQuote(value string) string { - if value != "" { - safe := true - for _, character := range value { - if !strings.ContainsRune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_@%+=:,./-", character) { - safe = false - break - } - } - if safe { - return value - } - } - return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" -} - -func powershellQuote(value string) string { - return strings.ReplaceAll(value, "'", "''") -} - func requestContext(ctx context.Context) error { if err := ctx.Err(); err != nil { return oneerrors.New(oneerrors.Timeout, "Profile request was cancelled", oneerrors.WithRetryable(true), oneerrors.WithCause(err)) diff --git a/internal/profile/write_test.go b/internal/profile/write_test.go index d9ea70e6..22e4e29a 100644 --- a/internal/profile/write_test.go +++ b/internal/profile/write_test.go @@ -37,7 +37,7 @@ func TestSaveProfileIsolatesSecretAndPreservesHistory(t *testing.T) { if err != nil { t.Fatal(err) } - if profile.ID != "ppio-deepseek" || !profile.HasKey || profile.Protocol != "openai" { + if profile.ID != "ppio-deepseek" || profile.Protocol != "openai" { t.Fatalf("saved profile = %#v", profile) } profilePath, _ := store.ProfilePath("ppio-deepseek") @@ -48,12 +48,7 @@ func TestSaveProfileIsolatesSecretAndPreservesHistory(t *testing.T) { if strings.Contains(string(profileData), secret) || strings.Contains(string(profileData), "api_key") { t.Fatalf("profile file leaked secret material: %s", profileData) } - secretPath, _ := store.SecretPath("ppio-deepseek") - secretData, err := os.ReadFile(secretPath) - if err != nil || !strings.Contains(string(secretData), "ONEAGENT_API_KEY") { - t.Fatalf("secret file = %q, err=%v", secretData, err) - } - if got, err := store.List(); err != nil || len(got) != 1 || !got[0].HasKey { + if got, err := store.List(); err != nil || len(got) != 1 { t.Fatalf("List() = %#v, %v", got, err) } created := profile.CreatedAt @@ -66,16 +61,15 @@ func TestSaveProfileIsolatesSecretAndPreservesHistory(t *testing.T) { if err != nil { t.Fatal(err) } - if updated.Label != "Team PPIO" || updated.CreatedAt != created || updated.Model == nil || *updated.Model != "model-b" || !updated.HasKey { + if updated.Label != "Team PPIO" || updated.CreatedAt != created || updated.Model == nil || *updated.Model != "model-b" { t.Fatalf("updated profile = %#v", updated) } if _, err := store.Save(context.Background(), SaveRequest{ ID: "ppio-deepseek", Provider: "novita", Model: "model-b", Protocol: "openai", - }); err == nil || oneerrors.As(err).Code != oneerrors.InvalidRequest { - t.Fatalf("provider change without a new key returned %v", err) + }); err != nil { + t.Fatalf("provider change without a local key failed: %v", err) } assertProfileMode(t, profilePath, 0o600) - assertProfileMode(t, secretPath, 0o600) } func TestSaveProfileValidatesInputAndCustomBase(t *testing.T) { @@ -182,27 +176,6 @@ func TestProfileWritesHonorCancellation(t *testing.T) { } } -func TestWindowsSecretUsesPowerShellQuoting(t *testing.T) { - store := testStore(t, t.TempDir(), "windows") - // Replace the default securefs command runner with a no-op runner while - // preserving the Windows ACL argument construction. - filesystem := securefs.New(securefs.Options{ - OS: "windows", - Username: "tester", - Run: func(context.Context, []string) error { return nil }, - Now: fixedProfileClock, - }) - store = Store{Home: store.Home, OS: "windows", FS: &filesystem, Now: fixedProfileClock} - if _, err := store.Save(context.Background(), SaveRequest{ID: "win", Provider: "ppio", Model: "m", APIKey: "key'value"}); err != nil { - t.Fatal(err) - } - path, _ := store.SecretPath("win") - data, _ := os.ReadFile(path) - if !strings.Contains(string(data), "$env:ONEAGENT_API_KEY = 'key''value'") { - t.Fatalf("PowerShell secret = %q", data) - } -} - func assertProfileMode(t *testing.T, path string, want os.FileMode) { t.Helper() info, err := os.Stat(path) From 4d233d5595ba1644e83b738cd5b3189d3b9f1f10 Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:55:35 +0800 Subject: [PATCH 4/9] feat: select all --- frontend/src/i18n.tsx | 2 ++ frontend/src/pages/TransferPage.test.tsx | 8 ++++++++ frontend/src/pages/TransferPage.tsx | 18 +++++++++++++++++- frontend/src/styles/app.css | 1 + 4 files changed, 28 insertions(+), 1 deletion(-) diff --git a/frontend/src/i18n.tsx b/frontend/src/i18n.tsx index 323a459d..dfc8635b 100644 --- a/frontend/src/i18n.tsx +++ b/frontend/src/i18n.tsx @@ -26,6 +26,8 @@ const english = { "选择导出位置": "Choose export location", "选择导入文件": "Choose an import file", "已选择 {count} 项": "{count} selected", + "全选": "Select all", + "取消全选": "Clear selection", "Profile 依赖": "Required by profile", "语言": "Language", "外观": "Appearance", diff --git a/frontend/src/pages/TransferPage.test.tsx b/frontend/src/pages/TransferPage.test.tsx index 6a1bb8b0..bf71647e 100644 --- a/frontend/src/pages/TransferPage.test.tsx +++ b/frontend/src/pages/TransferPage.test.tsx @@ -42,6 +42,14 @@ describe("TransferPage", () => { expect(JSON.parse(write.mock.calls[0][1])).toMatchObject({ profiles: [{ id: "team" }], providers: [{ id: "ppio" }] }); }); + it("selects all providers and profiles", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "全选" })); + expect(screen.getByRole("checkbox", { name: /团队/ })).toBeChecked(); + expect(screen.getAllByRole("checkbox", { name: /PPIO/ }).every((checkbox) => checkbox.checked)).toBe(true); + expect(screen.getByRole("button", { name: "取消全选" })).toBeTruthy(); + }); + it("continues encrypted export through the in-app password form", async () => { vi.spyOn(api, "getProvider").mockResolvedValue({ id: "ppio", name: "PPIO", home: "", base_url: "https://api.example.test", anthropic_base_url: "", api_key: "secret", built_in: true }); const write = vi.spyOn(api, "writeTransferFile").mockResolvedValue(); diff --git a/frontend/src/pages/TransferPage.tsx b/frontend/src/pages/TransferPage.tsx index 18e757fb..c3820590 100644 --- a/frontend/src/pages/TransferPage.tsx +++ b/frontend/src/pages/TransferPage.tsx @@ -1,5 +1,5 @@ import { Dialogs } from "@wailsio/runtime"; -import { Upload } from "lucide-react"; +import { CheckCheck, Upload } from "lucide-react"; import { useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; @@ -35,6 +35,17 @@ export function TransferPage() { const requiredProviders = useMemo(() => new Set(profiles.filter((profile) => selectedProfiles.has(profile.id)).map((profile) => profile.provider)), [profiles, selectedProfiles]); const exportProviders = new Set([...selectedProviders, ...requiredProviders]); const canExport = selectedProfiles.size > 0 || exportProviders.size > 0; + const allSelected = providers.length > 0 && profiles.length > 0 + && selectedProviders.size === providers.length && selectedProfiles.size === profiles.length; + const toggleAll = () => { + if (allSelected) { + setSelectedProviders(new Set()); + setSelectedProfiles(new Set()); + return; + } + setSelectedProviders(new Set(providers.map(([id]) => id))); + setSelectedProfiles(new Set(profiles.map((profile) => profile.id))); + }; const askPassword = (mode: "export" | "import") => new Promise((resolve) => { passwordResolver.current = resolve; @@ -130,6 +141,11 @@ export function TransferPage() { ) : null} +
+ +

Provider

{t("已选择 {count} 项", { count: exportProviders.size })}

diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index af58f61a..5c255b62 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -201,6 +201,7 @@ .transfer-page { padding-top: 24px; } .transfer-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; } .transfer-section { min-width: 0; } +.transfer-actions { display: flex; justify-content: flex-end; margin-bottom: 12px; } .transfer-section > header { display: flex; align-items: end; justify-content: space-between; margin-bottom: 10px; } .transfer-section h2 { margin: 0; font-size: 15px; } .transfer-section header p { margin: 3px 0 0; color: var(--text-secondary); font-size: 12px; } From 02fd658cbfe0019d98761585593f2b8250a42c2f Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:57:51 +0800 Subject: [PATCH 5/9] fix: frontend check --- frontend/src/pages/TransferPage.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/TransferPage.test.tsx b/frontend/src/pages/TransferPage.test.tsx index bf71647e..c45655ea 100644 --- a/frontend/src/pages/TransferPage.test.tsx +++ b/frontend/src/pages/TransferPage.test.tsx @@ -46,7 +46,7 @@ describe("TransferPage", () => { render(); fireEvent.click(screen.getByRole("button", { name: "全选" })); expect(screen.getByRole("checkbox", { name: /团队/ })).toBeChecked(); - expect(screen.getAllByRole("checkbox", { name: /PPIO/ }).every((checkbox) => checkbox.checked)).toBe(true); + screen.getAllByRole("checkbox", { name: /PPIO/ }).forEach((checkbox) => expect(checkbox).toBeChecked()); expect(screen.getByRole("button", { name: "取消全选" })).toBeTruthy(); }); From bb8abe74aec3e9d84bf838dedb91c8ef27f58546 Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:02:03 +0800 Subject: [PATCH 6/9] fix: task name for profile apply --- frontend/src/i18n.tsx | 1 + frontend/src/pages/ProfilesPage.tsx | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/src/i18n.tsx b/frontend/src/i18n.tsx index dfc8635b..5768de7d 100644 --- a/frontend/src/i18n.tsx +++ b/frontend/src/i18n.tsx @@ -260,6 +260,7 @@ const english = { "本机已有": "Already on this machine", "安装": "Install", "安装 {name}": "Install {name}", + "应用 {profile} 到 {agent}": "Apply {profile} to {agent}", "安装中": "Installing", "运行时会安装到 {dir},并写入登录 PATH,不需要管理员权限": "Runtimes install into {dir} and are added to your login PATH. No administrator rights needed", "运行时会安装到 OneAgent 的托管目录,并写入登录 PATH,不需要管理员权限": "Runtimes install into OneAgent's managed directory and are added to your login PATH. No administrator rights needed", diff --git a/frontend/src/pages/ProfilesPage.tsx b/frontend/src/pages/ProfilesPage.tsx index 50f7497e..24480914 100644 --- a/frontend/src/pages/ProfilesPage.tsx +++ b/frontend/src/pages/ProfilesPage.tsx @@ -135,7 +135,10 @@ export function ProfilesPage() { id: taskKey("install", agentId), kind: "install", target: agentId, - title: t("安装 {name}", { name: status.catalog.find((agent) => agent.id === agentId)?.name || agentId }), + title: t("应用 {profile} 到 {agent}", { + profile: profile.label || profile.id, + agent: status.catalog.find((agent) => agent.id === agentId)?.name || agentId, + }), route, progressTarget: status.capabilities.missingRuntime[agentId], group, From d55665cbe6e3ff70ea9366ae5755ab5f793d64c1 Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:06:50 +0800 Subject: [PATCH 7/9] fix: use struct conversion for storedProfile --- internal/profile/write.go | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/internal/profile/write.go b/internal/profile/write.go index 9e521977..61af2adf 100644 --- a/internal/profile/write.go +++ b/internal/profile/write.go @@ -234,18 +234,7 @@ func (s Store) clock() time.Time { } func profileFromStored(stored storedProfile) Profile { - return Profile{ - SchemaVersion: stored.SchemaVersion, - ID: stored.ID, - Label: stored.Label, - Provider: stored.Provider, - BaseURL: stored.BaseURL, - Model: stored.Model, - ConfigMode: stored.ConfigMode, - Protocol: stored.Protocol, - CreatedAt: stored.CreatedAt, - ActivatedAt: stored.ActivatedAt, - } + return Profile(stored) } func configMode(value string) (string, error) { From 3eac55ca3491539e41fa5f0f7f2be22e79c2ba1e Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:08:26 +0800 Subject: [PATCH 8/9] fix: migrate e2e test for language and theme --- frontend/e2e/wails.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/e2e/wails.spec.ts b/frontend/e2e/wails.spec.ts index 0a6996c7..5ccbf85d 100644 --- a/frontend/e2e/wails.spec.ts +++ b/frontend/e2e/wails.spec.ts @@ -8,10 +8,10 @@ import { expect, test } from "@playwright/test"; // combobox role is unchanged, so they are still found the same way, but choosing // takes the two steps a user takes. test("language selection switches to English and persists", async ({ page }) => { - await page.goto("/#/overview"); + await page.goto("/#/settings"); await page.getByRole("combobox", { name: "语言" }).click(); await page.getByRole("option", { name: "English" }).click(); - await expect(page.getByRole("heading", { name: "Environment overview" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible(); await page.reload(); // The trigger shows the current value, which is also the check that the choice @@ -34,7 +34,7 @@ test("every sidebar control at the bottom is actually clickable", async ({ page // rail below it, where the task centre and the selects change size. for (const viewport of [{ width: 1180, height: 760 }, { width: 860, height: 600 }]) { await page.setViewportSize(viewport); - await page.goto("/#/overview"); + await page.goto("/#/settings"); const label = `${viewport.width}x${viewport.height}`; const covered = await page.evaluate(() => { From 07f067170aa1b4504e6310f2bb910ca9674881c2 Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:10:01 +0800 Subject: [PATCH 9/9] ci: skip installing go for staticcheck --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c30d73d1..1ae65b5f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,8 @@ jobs: # libraries. The default package graph still checks the portable Go code; # target-specific release builds run in build-artifacts.yml. - uses: dominikh/staticcheck-action@v1.4.1 + with: + install-go: false compliance: name: Release compliance