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
14 changes: 14 additions & 0 deletions apps/desktop/main/execution-engines/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,20 @@ export function registerExecutionEngineIpc(input: {
if (args.length !== 0) invalid();
return (client) => client.listProfiles();
});
bind(DESKTOP_IPC_CHANNELS.executionEnginesDiscover, (args) => {
if (args.length !== 0) invalid();
return (client) => client.discover();
});
bind(DESKTOP_IPC_CHANNELS.executionEnginesInstall, (args) => {
if (args.length !== 1) invalid();
const engineId = readEngineId(args[0]);
return (client) => client.install(engineId);
});
bind(DESKTOP_IPC_CHANNELS.executionEnginesInstallStatus, (args) => {
if (args.length !== 1) invalid();
const engineId = readEngineId(args[0]);
return (client) => client.installStatus(engineId);
});
bind(DESKTOP_IPC_CHANNELS.executionEngineProfileGet, (args) => {
if (args.length !== 1) invalid();
const engineId = readEngineId(args[0]);
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/preload/main-window.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { contextBridge, ipcRenderer } from "electron";
import { createLocalFileCapabilities } from "../shared/local-file-activation.js";
import type { ExternalExecutionEngineId } from "@ling-stack/contracts";
import type {
ComputerUseHostLayout,
ComputerUsePipDeckOwner,
Expand Down Expand Up @@ -153,6 +154,9 @@ const capabilities: LingDesktopCapabilities = {
revoke: () => ipcRenderer.invoke(DESKTOP_IPC_CHANNELS.mobileAccessRevoke),
},
executionEngines: {
discover: () => ipcRenderer.invoke(DESKTOP_IPC_CHANNELS.executionEnginesDiscover),
install: (engineId: ExternalExecutionEngineId) => ipcRenderer.invoke(DESKTOP_IPC_CHANNELS.executionEnginesInstall, engineId),
installStatus: (engineId: ExternalExecutionEngineId) => ipcRenderer.invoke(DESKTOP_IPC_CHANNELS.executionEnginesInstallStatus, engineId),
listProfiles: () => ipcRenderer.invoke(DESKTOP_IPC_CHANNELS.executionEnginesList),
getProfile: (engineId) => ipcRenderer.invoke(DESKTOP_IPC_CHANNELS.executionEngineProfileGet, engineId),
saveProfile: (engineId, request) => ipcRenderer.invoke(DESKTOP_IPC_CHANNELS.executionEngineProfileSave, engineId, request),
Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/shared/desktop-capabilities.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ComputerUseRuntimeStatus, ExecutionEngineConnectionProbe, ExecutionEngineInstallationProbe, ExecutionEngineLoginMethod, ExecutionEngineLoginOperation, ExecutionEngineProfileListResponse, ExecutionEngineProfileResponse,
import type { ComputerUseRuntimeStatus, EngineDiscoveryReport, ManagedInstallStatus, ExecutionEngineConnectionProbe, ExecutionEngineInstallationProbe, ExecutionEngineLoginMethod, ExecutionEngineLoginOperation, ExecutionEngineProfileListResponse, ExecutionEngineProfileResponse,
ExternalExecutionEngineId, SaveExecutionEngineProfileRequest, SaveExecutionEngineSecretRequest } from "@ling-stack/contracts";

export interface DesktopExecutionEngineCapabilities {
Expand All @@ -12,6 +12,9 @@ export interface DesktopExecutionEngineCapabilities {
startLogin(engineId: ExternalExecutionEngineId, expectedRevision: number, method: ExecutionEngineLoginMethod): Promise<ExecutionEngineLoginOperation>;
getLogin(engineId: ExternalExecutionEngineId, operationId: string): Promise<ExecutionEngineLoginOperation>;
cancelLogin(engineId: ExternalExecutionEngineId, operationId: string): Promise<ExecutionEngineLoginOperation>;
discover(): Promise<EngineDiscoveryReport>;
install(engineId: ExternalExecutionEngineId): Promise<{ readonly install: ManagedInstallStatus }>;
installStatus(engineId: ExternalExecutionEngineId): Promise<{ readonly install: ManagedInstallStatus }>;
}
import {
SUPPORTED_LOCALES,
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/shared/ipc-channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ export const DESKTOP_THEME_PACKAGE_IPC_CHANNELS = {

export const DESKTOP_IPC_CHANNELS = {
executionEnginesList: "ling-desktop:execution-engines:list",
executionEnginesDiscover: "ling-desktop:execution-engines:discover",
executionEnginesInstall: "ling-desktop:execution-engines:install",
executionEnginesInstallStatus: "ling-desktop:execution-engines:install-status",
executionEngineProfileGet: "ling-desktop:execution-engines:profile-get",
executionEngineProfileSave: "ling-desktop:execution-engines:profile-save",
executionEngineProfileSecretSave: "ling-desktop:execution-engines:profile-secret-save",
Expand Down
50 changes: 50 additions & 0 deletions apps/web/e2e/ui2-engine-lifecycle.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { expect, test, type Page } from "@playwright/test";
import { mkdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { startExecutionEngineFixture } from "./support/execution-engine-fixture.mjs";

const evidenceDirectory = fileURLToPath(new URL(
"../../../docs/design/ui2.0升级/evidence/settings/execution-engines/",
import.meta.url,
));

async function captureEvidence(page: Page, name: string): Promise<void> {
await mkdir(evidenceDirectory, { recursive: true });
await page.screenshot({ path: `${evidenceDirectory}${name}` });
}

/** Sidebar tooltips intercept pointer hit tests; dispatch the click directly. */
async function clickSettingsEntry(page: Page): Promise<void> {
await page.locator('button[aria-label="Settings"], button[aria-label="设置"]').waitFor({ state: "attached" });
await page.evaluate(() => {
const button = [...document.querySelectorAll("button")]
.find((candidate) => ["Settings", "设置"].includes(candidate.getAttribute("aria-label") ?? ""));
if (!(button instanceof HTMLElement)) throw new Error("settings entry not found");
button.click();
});
}

test("engine settings discovers installed engines and offers managed install", async ({ page }) => {
test.setTimeout(90_000);
const fixture = await startExecutionEngineFixture({ port: 17891, origin: "http://127.0.0.1:4174" });
try {
const enrollment = await fixture.enroll();
await page.goto("/", { waitUntil: "domcontentloaded" });
await clickSettingsEntry(page);
const laterButton = page.getByRole("button", { name: /稍后再说|Later|Maybe later/ }).first();
if (await laterButton.isVisible().catch(() => false)) await laterButton.click();
await page.getByLabel(/Settings sections|设置分组/).getByRole("button", { name: /Execution engines|执行引擎/ }).click();

const codeInput = page.getByLabel(/Authorization code|授权码/);
await expect(codeInput).toBeVisible();
await codeInput.fill(enrollment.code);
await page.getByRole("button", { name: /Confirm|确认/ }).click();

const codexRow = page.locator('[data-engine-id="codex-app-server"]');
await expect(codexRow).toBeVisible();
await expect(codexRow).toContainText(/使用检测到的|Use detected/, { timeout: 30_000 });
await captureEvidence(page, "1440x960-engine-discovery-en-US.png");
} finally {
await fixture.close();
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { ExecutionEngineConnectionProbe, ExternalExecutionEngineId } from "
import { ArrowRight, Cable, CircleHelp, KeyRound, LogIn, Pencil, PlugZap, Unplug } from "lucide-react";
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import { daemonBaseUrl } from "../../../api/client.js";
import { getManagedEnginePackage } from "@ling-stack/contracts";
import { useDesktopTitlebarPlacement } from "../../../app/DesktopTitlebarSurface.js";
import { useI18n } from "../../../i18n.js";
import { Button, Field, InlineFeedback, Input, Skeleton } from "../../../shared/ui/index.js";
Expand Down Expand Up @@ -33,7 +34,7 @@ export function ExecutionEnginesSettingsPage({ cwd }: { readonly cwd: string }):
const login = useSyncExternalStore(loginController.subscribe, loginController.getSnapshot);
const [code, setCode] = useState("");
const returnFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => { void manager.load(); return () => { manager.detach(); void loginController.cancel(); }; }, [manager]);
useEffect(() => { void manager.load().then(() => manager.discover()); return () => { manager.detach(); void loginController.cancel(); }; }, [manager]);
useEffect(() => {
const revalidate = () => {
if (document.visibilityState === "visible" && !loginController.getSnapshot()) void manager.revalidate();
Expand Down Expand Up @@ -95,6 +96,7 @@ export function ExecutionEnginesSettingsPage({ cwd }: { readonly cwd: string }):
: profile?.status === "disconnected" ? <span>{t("Not connected")}</span> : null}
</div>
<div className="engine-settings-actions">
{ready && !profile ? <EngineDiscoveryActions engineId={engine.id} manager={manager} /> : null}
{ready && !profile ? <EngineSetupGuide engineId={engine.id} /> : null}
{profile && loginMethod ? <Button size="sm" leadingIcon={loginMethod === "apiKey" ? <KeyRound /> : <LogIn />}
disabled={!ready || Boolean(busy) || Boolean(login)} onClick={(event) => {
Expand Down Expand Up @@ -151,3 +153,42 @@ function ProbeResult({ probe, engineId }: { readonly probe: ExecutionEngineConne
{needsGuide ? <EngineSetupGuide engineId={engineId} /> : null}
</div>;
}

function EngineDiscoveryActions({ engineId, manager }: {
readonly engineId: ExternalExecutionEngineId;
readonly manager: ExecutionEngineManager;
}): React.JSX.Element | null {
const { t } = useI18n();
const state = useSyncExternalStore(manager.subscribe, manager.getSnapshot);
const candidate = state.discovery?.candidates.find((item) => item.engineId === engineId);
const install = state.installs[engineId];
const pkg = getManagedEnginePackage(engineId);
const busy = Boolean(state.busy[engineId]);
if (install && install.phase !== "idle") {
const label = install.phase === "downloading" ? t("Downloading...")
: install.phase === "verifying" ? t("Verifying...")
: install.phase === "installed" ? t("Installed")
: t("Install");
return (
<div className="engine-discovery-actions">
<Button size="sm" leadingIcon={<Cable />} disabled={install.phase === "downloading" || install.phase === "verifying"}
loading={install.phase === "downloading" || install.phase === "verifying"} loadingLabel={label}
onClick={() => void manager.installEngine(engineId)}>{label}</Button>
{install.phase === "failed" && install.error ? <EngineManagementError code={install.error} /> : null}
</div>
);
}
return (
<div className="engine-discovery-actions">
{candidate?.status === "detected" && candidate.executablePath ? (
<Button size="sm" variant="ghost" leadingIcon={<PlugZap />} disabled={busy} onClick={() => {
manager.edit(engineId);
manager.updateDraft({ executablePath: candidate.executablePath! });
}}>{t("Use detected {version}", { version: candidate.version ?? "" })}</Button>
) : null}
{pkg?.installable ? (
<Button size="sm" leadingIcon={<Cable />} disabled={busy} onClick={() => void manager.installEngine(engineId)}>{t("Install")}</Button>
) : candidate?.manualGuidance ? <span className="engine-discovery-guidance">{candidate.manualGuidance}</span> : null}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import type { ExecutionEngineManagementClient } from "@ling-stack/api-client";
import type {
ExecutionEngineConnectionProbe, ExecutionEngineLoginMethod, ExecutionEngineLoginOperation,
ExecutionEngineProfile, ExecutionEngineProfileConfiguration, ExternalExecutionEngineId,
EngineDiscoveryReport, ExecutionEngineConnectionProbe, ExecutionEngineLoginMethod, ExecutionEngineLoginOperation,
ExecutionEngineProfile, ExecutionEngineProfileConfiguration, ExternalExecutionEngineId, ManagedInstallStatus,
} from "@ling-stack/contracts";

export type EngineManagementApi = Pick<ExecutionEngineManagementClient,
"listProfiles" | "getProfile" | "saveProfile" | "saveProfileWithSecret" | "disconnectProfile" | "probeConnection"
| "startLogin" | "getLogin" | "cancelLogin">;
| "startLogin" | "getLogin" | "cancelLogin" | "discover" | "install" | "installStatus">;

export type EngineManagementTransport =
| { readonly kind: "desktop"; readonly api: EngineManagementApi }
Expand All @@ -23,6 +23,8 @@ export interface EngineProfileEditor {
export interface EngineManagementState {
readonly phase: "loading" | "ready" | "authorization_required" | "unavailable" | "error";
readonly profiles: readonly ExecutionEngineProfile[];
readonly discovery?: EngineDiscoveryReport;
readonly installs: Readonly<Partial<Record<ExternalExecutionEngineId, ManagedInstallStatus>>>
readonly editor?: EngineProfileEditor;
readonly busy: Readonly<Partial<Record<ExternalExecutionEngineId, "save" | "reload" | "probe" | "disconnect">>>;
readonly probes: Readonly<Partial<Record<ExternalExecutionEngineId, ExecutionEngineConnectionProbe>>>;
Expand All @@ -35,7 +37,7 @@ export interface EngineManagementState {
export class ExecutionEngineManager {
readonly transport: EngineManagementTransport;
readonly #listeners = new Set<() => void>();
#state: EngineManagementState = { phase: "loading", profiles: [], busy: {}, probes: {}, errors: {} };
#state: EngineManagementState = { phase: "loading", profiles: [], installs: {}, busy: {}, probes: {}, errors: {} };
#loadRevision = 0;
#scope = 0;
#revalidation?: Promise<void>;
Expand Down Expand Up @@ -65,6 +67,7 @@ export class ExecutionEngineManager {
&& profile.revision === probe.profileRevision && profile.configurationFingerprint === probe.configurationFingerprint;
}));
this.publish({ phase: "ready", profiles, probes, error: undefined });
void this.discover();
} catch (error) {
if (revision !== this.#loadRevision || scope !== this.#scope) return;
const code = engineManagementErrorCode(error);
Expand All @@ -82,6 +85,41 @@ export class ExecutionEngineManager {
finally { if (this.#revalidation === pending) this.#revalidation = undefined; }
}

/** Read-only discovery of existing engine installations on the host. */
async discover(): Promise<void> {
if (this.#state.phase !== "ready" || this.transport.kind === "unavailable") return;
try {
const discovery = await this.transport.api.discover();
this.publish({ discovery });
} catch {
// Discovery is best-effort; the manual path stays available without it.
}
}

/** Managed install of the pinned engine package, then open the prefilled profile draft. */
async installEngine(engineId: ExternalExecutionEngineId): Promise<void> {
if (this.#state.phase !== "ready" || this.transport.kind === "unavailable" || this.#state.busy[engineId]) return;
const publish = (install: ManagedInstallStatus): void => {
this.publish({ installs: { ...this.#state.installs, [engineId]: install } });
};
try {
publish((await this.transport.api.install(engineId)).install);
let current = this.#state.installs[engineId];
while (current && (current.phase === "downloading" || current.phase === "verifying")) {
await new Promise((resolve) => setTimeout(resolve, 1500));
publish((await this.transport.api.installStatus(engineId)).install);
current = this.#state.installs[engineId];
}
if (current?.phase === "installed" && current.executablePath) {
this.edit(engineId);
this.updateDraft({ executablePath: current.executablePath });
}
} catch (error) {
const code = engineManagementErrorCode(error);
this.publish({ installs: { ...this.#state.installs, [engineId]: { engineId, phase: "failed", error: code, updatedAt: new Date().toISOString() } }, errors: { ...this.#state.errors, [engineId]: code } });
}
}

edit(engineId: ExternalExecutionEngineId): void {
if (this.#state.phase !== "ready" || this.#state.editor || this.#state.busy[engineId]) return;
const profile = this.#state.profiles.find((item) => item.configuration.engineId === engineId);
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/i18n/locales/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2870,6 +2870,10 @@ export const enUS = {
"No secret": "No secret",
"Sync tools": "Sync tools",
"Check connection": "Check connection",
"Downloading...": "Downloading...",
"Verifying...": "Verifying...",
"Installed": "Installed",
"Use detected {version}": "Use detected {version}",
"Sync tool list": "Sync tool list",
"Connection check succeeded": "Connection check succeeded",
"Connection check failed": "Connection check failed",
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/i18n/locales/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,10 @@ export const zhCN = {
"No secret": "未配置密钥",
"Sync tools": "同步工具",
"Check connection": "检查连接",
"Downloading...": "正在下载…",
"Verifying...": "正在校验…",
"Installed": "已安装",
"Use detected {version}": "使用检测到的 {version}",
"Sync tool list": "同步工具列表",
"Connection check succeeded": "连接检查成功",
"Connection check failed": "连接检查失败",
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 18 additions & 0 deletions packages/api-client/src/execution-engines.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { ExecutionEngineConnectionProbe, ExecutionEngineInstallationProbe, ExecutionEngineLoginMethod, ExecutionEngineLoginOperation, ExecutionEngineManagementSession, ExecutionEngineProfileListResponse,
ExecutionEngineProfileResponse, ExternalExecutionEngineId, SaveExecutionEngineProfileRequest, SaveExecutionEngineSecretRequest } from "@ling-stack/contracts";
import { isExecutionEngineSecretValue } from "@ling-stack/contracts";
import type { EngineDiscoveryReport, ManagedInstallStatus } from "@ling-stack/contracts";
import { LingDaemonError } from "./errors.js";

export interface ExecutionEngineManagementClientOptions {
Expand Down Expand Up @@ -52,6 +53,18 @@ export class ExecutionEngineManagementClient {
return this.request("", "GET");
}

discover(): Promise<EngineDiscoveryReport> {
return this.request("/discovery", "GET");
}

install(engineId: ExternalExecutionEngineId): Promise<{ readonly install: ManagedInstallStatus }> {
return this.request(enginePath(engineId) + "/install", "POST", {});
}

installStatus(engineId: ExternalExecutionEngineId): Promise<{ readonly install: ManagedInstallStatus }> {
return this.request(enginePath(engineId) + "/install", "GET");
}

async getProfile(engineId: ExternalExecutionEngineId): Promise<ExecutionEngineProfileResponse> {
return this.request(profilePath(engineId), "GET");
}
Expand Down Expand Up @@ -153,6 +166,11 @@ export class ExecutionEngineManagementClient {
}
}

function enginePath(engineId: ExternalExecutionEngineId): string {
profilePath(engineId);
return `/${engineId}`;
}

function profilePath(engineId: ExternalExecutionEngineId): string {
if (!["codex-app-server", "claude-agent-sdk", "kimi-acp", "openclaw-acp"].includes(engineId)) throw failure("engine_request_invalid", 400);
return `/${engineId}/profile`;
Expand Down
Loading
Loading