merge: 集成共享 Agent Browser 调试能力
需求:将 Agent Browser 独立功能提交合并到包含 AI 设计 Workspace 的最新主线。 实现:保留双方模块初始化、Host API 路由和 README 产品说明。 # Conflicts: # electron/api/context.ts
This commit is contained in:
370
electron/api/routes/agent-browser.ts
Normal file
370
electron/api/routes/agent-browser.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
import { realpath } from 'node:fs/promises';
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import type { AgentBrowserBounds, AgentBrowserFaultShape } from '../../../shared/agent-browser';
|
||||
import { normalizeProjectPath } from '../../opencode/project-store';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { hasRendererCapability } from '../renderer-capability';
|
||||
import { parseJsonBody, sendJson } from '../route-utils';
|
||||
|
||||
type AgentBrowserBody = {
|
||||
project_path?: unknown;
|
||||
url?: unknown;
|
||||
action?: unknown;
|
||||
visible?: unknown;
|
||||
bounds?: unknown;
|
||||
method?: unknown;
|
||||
params?: unknown;
|
||||
session_ref?: unknown;
|
||||
timeout_ms?: unknown;
|
||||
after?: unknown;
|
||||
methods?: unknown;
|
||||
limit?: unknown;
|
||||
wait_ms?: unknown;
|
||||
handle?: unknown;
|
||||
offset?: unknown;
|
||||
max_bytes?: unknown;
|
||||
};
|
||||
|
||||
class AgentBrowserRouteError extends Error {
|
||||
constructor(
|
||||
readonly code: AgentBrowserFaultShape['code'],
|
||||
message: string,
|
||||
readonly status = 400,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
function nonEmptyString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function finiteInteger(value: unknown): number | undefined {
|
||||
return typeof value === 'number' && Number.isFinite(value)
|
||||
? Math.trunc(value)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function parseBounds(value: unknown): AgentBrowserBounds | undefined {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
||||
const record = value as Record<string, unknown>;
|
||||
const x = finiteInteger(record.x);
|
||||
const y = finiteInteger(record.y);
|
||||
const width = finiteInteger(record.width);
|
||||
const height = finiteInteger(record.height);
|
||||
if (x === undefined || y === undefined || width === undefined || height === undefined) {
|
||||
throw new AgentBrowserRouteError('INVALID_REQUEST', '浏览器区域坐标不完整。');
|
||||
}
|
||||
if (width < 1 || height < 1) {
|
||||
throw new AgentBrowserRouteError('VIEWPORT_NOT_READY', '浏览器区域尚未准备好。');
|
||||
}
|
||||
return { x, y, width, height };
|
||||
}
|
||||
|
||||
function parseStringArray(value: unknown): string[] | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
|
||||
throw new AgentBrowserRouteError('INVALID_REQUEST', 'CDP 事件过滤器格式无效。');
|
||||
}
|
||||
return value.map((item) => item.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
async function resolveActiveProject(ctx: HostApiContext, requestedPath?: unknown) {
|
||||
const activeProject = await ctx.opencodeProjectStore.getActiveProject();
|
||||
if (!activeProject) {
|
||||
throw new AgentBrowserRouteError('PROJECT_NOT_ACTIVE', '请先打开一个项目。', 409);
|
||||
}
|
||||
|
||||
let activeRealPath: string;
|
||||
try {
|
||||
activeRealPath = await realpath(activeProject.path);
|
||||
} catch {
|
||||
throw new AgentBrowserRouteError('PROJECT_NOT_ACTIVE', '当前项目目录不可用。', 409);
|
||||
}
|
||||
|
||||
const requested = nonEmptyString(requestedPath);
|
||||
if (!requested) {
|
||||
throw new AgentBrowserRouteError('INVALID_REQUEST', '缺少当前项目路径。');
|
||||
}
|
||||
let requestedRealPath: string;
|
||||
try {
|
||||
requestedRealPath = await realpath(requested);
|
||||
} catch {
|
||||
throw new AgentBrowserRouteError('PROJECT_MISMATCH', '请求的项目目录不可用。', 403);
|
||||
}
|
||||
if (normalizeProjectPath(requestedRealPath) !== normalizeProjectPath(activeRealPath)) {
|
||||
throw new AgentBrowserRouteError('PROJECT_MISMATCH', '智能体只能调试当前项目。', 403);
|
||||
}
|
||||
|
||||
return {
|
||||
...activeProject,
|
||||
path: activeRealPath,
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureProjectStillActive(
|
||||
ctx: HostApiContext,
|
||||
project: { id: string; path: string },
|
||||
): Promise<void> {
|
||||
const activeProject = await ctx.opencodeProjectStore.getActiveProject();
|
||||
let activeRealPath: string | null = null;
|
||||
if (activeProject?.id === project.id) {
|
||||
try {
|
||||
activeRealPath = await realpath(activeProject.path);
|
||||
} catch {
|
||||
activeRealPath = null;
|
||||
}
|
||||
}
|
||||
if (
|
||||
activeProject?.id === project.id
|
||||
&& activeRealPath
|
||||
&& normalizeProjectPath(activeRealPath) === normalizeProjectPath(project.path)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await ctx.agentBrowser?.close(project.path);
|
||||
} catch {
|
||||
// A new project's browser may already own the module; never close it.
|
||||
}
|
||||
throw new AgentBrowserRouteError(
|
||||
'PROJECT_MISMATCH',
|
||||
'项目已切换,本次浏览器操作已取消。',
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
function requireService(ctx: HostApiContext) {
|
||||
if (!ctx.agentBrowser) {
|
||||
throw new AgentBrowserRouteError('CLOSED', '开发浏览器尚未初始化。', 503);
|
||||
}
|
||||
return ctx.agentBrowser;
|
||||
}
|
||||
|
||||
function requireRendererPresentation(req: IncomingMessage): void {
|
||||
if (!hasRendererCapability(req)) {
|
||||
throw new AgentBrowserRouteError(
|
||||
'TARGET_DENIED',
|
||||
'浏览器显示区域只能由 Makelore 界面控制。',
|
||||
403,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function emitState(ctx: HostApiContext, eventName: 'agent-browser:show' | 'agent-browser:state', payload: unknown): void {
|
||||
ctx.eventBus.emit(eventName, payload);
|
||||
const webContents = ctx.mainWindow?.webContents;
|
||||
if (webContents && !webContents.isDestroyed()) {
|
||||
webContents.send(eventName, payload);
|
||||
}
|
||||
}
|
||||
|
||||
function faultFrom(error: unknown): AgentBrowserFaultShape | null {
|
||||
if (!error || typeof error !== 'object') return null;
|
||||
const record = error as Partial<AgentBrowserFaultShape>;
|
||||
if (typeof record.code !== 'string' || typeof record.message !== 'string') return null;
|
||||
return {
|
||||
code: record.code,
|
||||
message: record.message,
|
||||
retryable: record.retryable === true,
|
||||
...(typeof record.generation === 'number' ? { generation: record.generation } : {}),
|
||||
...(record.outcome === 'unknown' ? { outcome: 'unknown' } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function faultStatus(code: AgentBrowserFaultShape['code']): number {
|
||||
if (code === 'PROJECT_MISMATCH' || code === 'TARGET_DENIED' || code === 'CDP_METHOD_BLOCKED') return 403;
|
||||
if (code === 'BROWSER_NOT_OPEN' || code === 'PAYLOAD_NOT_FOUND' || code === 'TARGET_GONE') return 404;
|
||||
if (code === 'CURSOR_EXPIRED') return 410;
|
||||
if (code === 'DEVTOOLS_CONFLICT' || code === 'DEBUGGER_BUSY' || code === 'PROJECT_NOT_ACTIVE') return 409;
|
||||
if (code === 'CDP_TIMEOUT') return 504;
|
||||
if (code === 'ATTACH_FAILED' || code === 'RENDERER_CRASHED' || code === 'CLOSED') return 503;
|
||||
return 400;
|
||||
}
|
||||
|
||||
async function readBody(req: IncomingMessage): Promise<AgentBrowserBody> {
|
||||
return await parseJsonBody<AgentBrowserBody>(req);
|
||||
}
|
||||
|
||||
export async function handleAgentBrowserRoutes(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
ctx: HostApiContext,
|
||||
): Promise<boolean> {
|
||||
if (!url.pathname.startsWith('/api/agent-browser')) return false;
|
||||
|
||||
try {
|
||||
const service = requireService(ctx);
|
||||
|
||||
if (url.pathname === '/api/agent-browser/state' && req.method === 'GET') {
|
||||
const project = await resolveActiveProject(ctx, url.searchParams.get('project_path'));
|
||||
const browser = await service.getSnapshot(project.path);
|
||||
await ensureProjectStillActive(ctx, project);
|
||||
sendJson(res, 200, { success: true, browser });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/agent-browser/open' && req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
const project = await resolveActiveProject(ctx, body.project_path);
|
||||
const rendererPresentation = hasRendererCapability(req);
|
||||
if (body.bounds !== undefined && !rendererPresentation) {
|
||||
requireRendererPresentation(req);
|
||||
}
|
||||
const targetUrl = nonEmptyString(body.url);
|
||||
if (!targetUrl) throw new AgentBrowserRouteError('INVALID_URL', '请输入要打开的网页地址。');
|
||||
const browser = await service.open({
|
||||
projectId: project.id,
|
||||
projectPath: project.path,
|
||||
url: targetUrl,
|
||||
bounds: parseBounds(body.bounds),
|
||||
visible: rendererPresentation && body.visible !== false,
|
||||
});
|
||||
await ensureProjectStillActive(ctx, project);
|
||||
emitState(ctx, 'agent-browser:show', browser);
|
||||
sendJson(res, 200, { success: true, browser });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/agent-browser/present' && req.method === 'POST') {
|
||||
requireRendererPresentation(req);
|
||||
const body = await readBody(req);
|
||||
const project = await resolveActiveProject(ctx, body.project_path);
|
||||
const browser = await service.present({
|
||||
projectPath: project.path,
|
||||
visible: body.visible === true,
|
||||
bounds: parseBounds(body.bounds),
|
||||
});
|
||||
await ensureProjectStillActive(ctx, project);
|
||||
sendJson(res, 200, { success: true, browser });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/agent-browser/navigate' && req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
const project = await resolveActiveProject(ctx, body.project_path);
|
||||
const action = nonEmptyString(body.action);
|
||||
if (action !== 'url' && action !== 'back' && action !== 'forward' && action !== 'reload') {
|
||||
throw new AgentBrowserRouteError('INVALID_REQUEST', '浏览器导航动作无效。');
|
||||
}
|
||||
const browser = await service.navigate({
|
||||
projectPath: project.path,
|
||||
action,
|
||||
url: nonEmptyString(body.url),
|
||||
});
|
||||
await ensureProjectStillActive(ctx, project);
|
||||
emitState(ctx, 'agent-browser:state', browser);
|
||||
sendJson(res, 200, { success: true, browser });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/agent-browser/cdp/send' && req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
const project = await resolveActiveProject(ctx, body.project_path);
|
||||
const method = nonEmptyString(body.method);
|
||||
if (!method) throw new AgentBrowserRouteError('INVALID_REQUEST', '缺少 CDP method。');
|
||||
const params = body.params === undefined
|
||||
? undefined
|
||||
: body.params && typeof body.params === 'object' && !Array.isArray(body.params)
|
||||
? body.params as Record<string, unknown>
|
||||
: (() => {
|
||||
throw new AgentBrowserRouteError('INVALID_REQUEST', 'CDP params 必须是对象。');
|
||||
})();
|
||||
const result = await service.sendCdp({
|
||||
projectPath: project.path,
|
||||
method,
|
||||
params,
|
||||
sessionRef: nonEmptyString(body.session_ref),
|
||||
timeoutMs: finiteInteger(body.timeout_ms),
|
||||
});
|
||||
await ensureProjectStillActive(ctx, project);
|
||||
sendJson(res, 200, { success: true, result });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/agent-browser/cdp/events' && req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
const project = await resolveActiveProject(ctx, body.project_path);
|
||||
const page = await service.readEvents({
|
||||
projectPath: project.path,
|
||||
after: finiteInteger(body.after),
|
||||
methods: parseStringArray(body.methods),
|
||||
limit: finiteInteger(body.limit),
|
||||
waitMs: finiteInteger(body.wait_ms),
|
||||
});
|
||||
await ensureProjectStillActive(ctx, project);
|
||||
sendJson(res, 200, { success: true, page });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/agent-browser/payload/read' && req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
const project = await resolveActiveProject(ctx, body.project_path);
|
||||
const handle = nonEmptyString(body.handle);
|
||||
if (!handle) throw new AgentBrowserRouteError('INVALID_REQUEST', '缺少 payload handle。');
|
||||
const chunk = await service.readPayload({
|
||||
projectPath: project.path,
|
||||
handle,
|
||||
offset: finiteInteger(body.offset),
|
||||
maxBytes: finiteInteger(body.max_bytes),
|
||||
});
|
||||
await ensureProjectStillActive(ctx, project);
|
||||
sendJson(res, 200, { success: true, chunk });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/agent-browser/close' && req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
const project = await resolveActiveProject(ctx, body.project_path);
|
||||
const browser = await service.close(project.path);
|
||||
await ensureProjectStillActive(ctx, project);
|
||||
emitState(ctx, 'agent-browser:state', {
|
||||
...browser,
|
||||
projectId: project.id,
|
||||
projectPath: project.path,
|
||||
});
|
||||
sendJson(res, 200, { success: true, browser });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/agent-browser/reset-profile' && req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
const project = await resolveActiveProject(ctx, body.project_path);
|
||||
const browser = await service.resetProfile(project.path);
|
||||
await ensureProjectStillActive(ctx, project);
|
||||
emitState(ctx, 'agent-browser:state', {
|
||||
...browser,
|
||||
projectId: project.id,
|
||||
projectPath: project.path,
|
||||
});
|
||||
sendJson(res, 200, { success: true, browser });
|
||||
return true;
|
||||
}
|
||||
|
||||
sendJson(res, 404, {
|
||||
success: false,
|
||||
code: 'INVALID_REQUEST',
|
||||
error: `No Agent Browser route for ${req.method} ${url.pathname}`,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
const routeError = error instanceof AgentBrowserRouteError ? error : null;
|
||||
const fault = routeError
|
||||
? {
|
||||
code: routeError.code,
|
||||
message: routeError.message,
|
||||
retryable: false,
|
||||
}
|
||||
: faultFrom(error);
|
||||
if (fault) {
|
||||
sendJson(res, routeError?.status ?? faultStatus(fault.code), {
|
||||
success: false,
|
||||
error: fault.message,
|
||||
...fault,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -585,6 +585,24 @@ async function findProjectById(ctx: HostApiContext, projectId: string) {
|
||||
return projects.find((project) => project.id === projectId) ?? null;
|
||||
}
|
||||
|
||||
async function closeAgentBrowserForProject(
|
||||
ctx: HostApiContext,
|
||||
projectPath: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await ctx.agentBrowser?.close(projectPath);
|
||||
} catch (error) {
|
||||
if (
|
||||
error
|
||||
&& typeof error === 'object'
|
||||
&& (error as { code?: unknown }).code === 'PROJECT_MISMATCH'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
type ProjectActivationCheck =
|
||||
| { ok: true }
|
||||
| { ok: false; status: Exclude<ProjectConfigReadResult['status'], 'valid'> | 'incomplete'; error: string };
|
||||
@@ -1161,7 +1179,14 @@ export async function handleOpencodeRoutes(
|
||||
try {
|
||||
const body = await parseJsonBody<{ projectId?: string }>(req);
|
||||
if (!body.projectId) throw new Error('Missing project id');
|
||||
const activeProject = await ctx.opencodeProjectStore.getActiveProject();
|
||||
if (activeProject?.id === body.projectId) {
|
||||
await closeAgentBrowserForProject(ctx, activeProject.path);
|
||||
}
|
||||
await ctx.opencodeProjectStore.removeProject(body.projectId);
|
||||
if (activeProject?.id === body.projectId) {
|
||||
await closeAgentBrowserForProject(ctx, activeProject.path);
|
||||
}
|
||||
await sendProjectSnapshot(res, ctx, { success: true, removedProjectId: body.projectId });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
@@ -1193,7 +1218,14 @@ export async function handleOpencodeRoutes(
|
||||
sendProjectActivationFailure(res, projectCheck);
|
||||
return true;
|
||||
}
|
||||
const activeProject = await ctx.opencodeProjectStore.getActiveProject();
|
||||
if (activeProject?.id !== body.projectId && activeProject) {
|
||||
await closeAgentBrowserForProject(ctx, activeProject.path);
|
||||
}
|
||||
const project = await ctx.opencodeProjectStore.setActiveProject(body.projectId);
|
||||
if (activeProject?.id !== body.projectId && activeProject) {
|
||||
await closeAgentBrowserForProject(ctx, activeProject.path);
|
||||
}
|
||||
await sendProjectSnapshot(res, ctx, { success: true, project });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
|
||||
Reference in New Issue
Block a user