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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions cmd/oneagent-desktop/main_wails.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -135,7 +134,6 @@ export interface ProfileSummary {
"model": string | null;
"protocol": string;
"activatedAt": string | null;
"hasKey": boolean;
"createdAt"?: string;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -15,6 +16,7 @@ export {
ProviderService,
RuntimeService,
StatusService,
TransferService,
UpdateService
};

Expand All @@ -24,6 +26,7 @@ export type {
AgentInstallResult,
DesktopAgentProfileRequest,
DesktopAgentRequest,
FilePathRequest,
InstallRequest,
InstallResponse,
InstallRuntimeRequest,
Expand All @@ -39,5 +42,6 @@ export type {
ProviderMutationResponse,
SaveProfileRequest,
SaveProviderRequest,
UpdateRequest
UpdateRequest,
WriteFileRequest
} from "./models.js";
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ export interface DesktopAgentRequest {
"agent_id": string;
}

export interface FilePathRequest {
"path": string;
}

export interface InstallRequest {
"agents": string[] | null;
"provider": string;
Expand Down Expand Up @@ -164,3 +168,8 @@ export interface SaveProviderRequest {
export interface UpdateRequest {
"agent_id": string;
}

export interface WriteFileRequest {
"path": string;
"data": string;
}
Original file line number Diff line number Diff line change
@@ -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<string> {
return $Call.ByID(3148699519, request);
}

export function Write(request: $models.WriteFileRequest): $CancellablePromise<void> {
return $Call.ByID(3458483966, request);
}
6 changes: 3 additions & 3 deletions frontend/e2e/wails.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(() => {
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -81,6 +83,9 @@ function WorkspaceRoutes() {
<Route path="/providers" element={<ProvidersPage />} />
<Route path="/providers/new" element={<ProvidersPage create />} />
<Route path="/profiles" element={<ProfilesPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/settings/transfer" element={<TransferPage />} />
<Route path="/transfer" element={<Navigate to="/settings/transfer" replace />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</AppWindow>
Expand Down
14 changes: 13 additions & 1 deletion frontend/src/backend/wails.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => ({
Expand Down Expand Up @@ -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";
Expand All @@ -89,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;
Expand All @@ -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);
Expand All @@ -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" });
Expand All @@ -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 () => {
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/backend/wails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -174,6 +175,8 @@ export const wailsApi = {
getSettings: (): Promise<Settings> => call(() => RuntimeService.GetSettings()) as Promise<Settings>,
saveSettings: (settings: Settings): Promise<Settings> =>
call(() => RuntimeService.SaveSettings(settings)) as Promise<Settings>,
readTransferFile: (path: string): Promise<string> => call(() => TransferService.Read({ path })) as Promise<string>,
writeTransferFile: (path: string, data: string): Promise<void> => call(() => TransferService.Write({ path, data })).then(() => undefined),
listProfiles: (): Promise<ProfileSummary[]> => call(() => ProfileService.ListProfiles()) as Promise<ProfileSummary[]>,
deleteProfile: (id: string): Promise<void> =>
call(() => ProfileService.DeleteProfile({ id })).then(() => undefined),
Expand Down
36 changes: 8 additions & 28 deletions frontend/src/components/NavigationSidebar.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 (
<aside className="navigation-sidebar">
<div className="brand-lockup">
Expand All @@ -39,31 +37,13 @@ export function NavigationSidebar() {
))}
</nav>

{/* First of the bottom group, so its margin-top: auto pushes appearance
and language down together. The task centre is viewport-docked. */}
<ThemePicker />

{/* One picker, where there used to be two selects differing only in their
option text -- CSS showed one and hid the other per breakpoint. The
short labels now live in the option list, which stays readable at the
72px rail because the list is ours and is not clipped to the trigger. */}
<div className="language-picker">
<Languages size={16} aria-hidden="true" />
<span>{t("语言")}</span>
<SelectField
className="language-select"
label={t("语言")}
value={locale}
onChange={(next) => setLocale(next as "zh-CN" | "en")}
options={[
{ value: "zh-CN", label: "中文" },
{ value: "en", label: "English" },
]}
/>
<div className="sidebar-bottom">
<TaskCenter />
<NavLink to="/settings" className={({ isActive }) => `sidebar-link${isActive ? " is-active" : ""}`}>
<Settings size={18} strokeWidth={1.8} />
<span>{t("设置")}</span>
</NavLink>
</div>

{/* The task centre is fixed to the viewport's lower-left corner. */}
<TaskCenter />
</aside>
);
}
24 changes: 24 additions & 0 deletions frontend/src/i18n.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,29 @@ 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",
"全选": "Select all",
"取消全选": "Clear selection",
"Profile 依赖": "Required by profile",
"语言": "Language",
"外观": "Appearance",
"跟随系统": "System",
Expand Down Expand Up @@ -237,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",
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/pages/EnvironmentOverviewPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/pages/ProfileSelectionPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
};
Expand Down
10 changes: 8 additions & 2 deletions frontend/src/pages/ProfilesPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,6 @@ function profile(over: Partial<ProfileSummary> = {}): ProfileSummary {
model: "deepseek/deepseek-v3",
baseUrl: null,
protocol: "responses",
hasKey: true,
activatedAt: null,
...over,
};
Expand Down Expand Up @@ -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(
<MemoryRouter initialEntries={["/profiles"]}>
<Routes><Route path="/profiles" element={<ProfilesPage />} /><Route path="/overview" element={<h1>overview</h1>} /></Routes>
</MemoryRouter>,
);
fireEvent.click(screen.getByRole("button", { name: "应用到 Agent" }));

await waitFor(() => expect(install).toHaveBeenCalledWith(expect.objectContaining({
Expand Down
7 changes: 5 additions & 2 deletions frontend/src/pages/ProfilesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -317,7 +320,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 (
<article className="profile-card" key={profile.id} data-testid={`profile-${profile.id}`}>
Expand Down
16 changes: 16 additions & 0 deletions frontend/src/pages/SettingsPage.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<ThemeProvider><MemoryRouter initialEntries={["/settings"]}><Routes><Route path="/settings" element={<SettingsPage />} /><Route path="/settings/transfer" element={<h1>transfer child</h1>} /></Routes></MemoryRouter></ThemeProvider>);
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();
});
});
Loading
Loading