From 9fed0cc7c45df6b4ef01a52abc5045de478faa9c Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Sun, 6 Sep 2026 12:50:18 +0800 Subject: [PATCH] fix(agent-browser): restore bounded presentation lifecycle --- ...20260906-agent-browser-failure-a7c91e4d.md | 230 ++++++++ README.md | 6 +- electron/agent-browser/module.ts | 185 +++++- electron/api/coding-composition.ts | 8 + electron/api/routes/agent-browser.ts | 69 ++- electron/coding-runtime/pi/extension-host.ts | 12 + .../pi/extensions/agent-browser.ts | 73 ++- electron/coding-runtime/pi/product-tools.ts | 21 +- electron/main/index.ts | 12 + src/lib/agent-browser.ts | 40 +- src/pages/Chat/AgentBrowserPanel.tsx | 553 ++++++++++++++++++ src/pages/Chat/CodingChatPanel.tsx | 15 + src/pages/Chat/CodingConversationHeader.tsx | 25 + tests/e2e/pi-coding-first-chat.spec.ts | 70 +++ tests/unit/agent-browser-core.test.ts | 70 +++ tests/unit/agent-browser-panel.test.tsx | 167 ++++++ tests/unit/agent-browser-routes.test.ts | 45 ++ ...oding-composition-background-sleep.test.ts | 30 + tests/unit/pi-extension-host.test.ts | 38 ++ tests/unit/pi-product-tools.test.ts | 94 +++ 20 files changed, 1714 insertions(+), 49 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260906-agent-browser-failure-a7c91e4d.md create mode 100644 src/pages/Chat/AgentBrowserPanel.tsx create mode 100644 tests/unit/agent-browser-panel.test.tsx diff --git a/.project-docs/30-worklog/tasks/20260906-agent-browser-failure-a7c91e4d.md b/.project-docs/30-worklog/tasks/20260906-agent-browser-failure-a7c91e4d.md new file mode 100644 index 0000000..6ad69b2 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260906-agent-browser-failure-a7c91e4d.md @@ -0,0 +1,230 @@ +# Task: Diagnose agent_browser call failures + +## Identity + +- Task ID: 20260906-agent-browser-failure-a7c91e4d +- Mode: Feature +- Branch: main +- Worktree: D:\Datas\OthersProjects\makelore +- Base commit: 5c61110f465cc4172f712ad435c06041c0aed1ec +- Owner: developer +- Status: Ready for Integration + +## Scope + +- Preserve the completed diagnosis of the parent Pi `agent_browser` failure and use + its deterministic reproduction as the implementation baseline. +- Restore a lightweight Coding-owned shared browser surface and route Pi opens + through a bounded Main/Renderer presentation handshake before inspection actions + continue. +- Preserve zero browser resources and zero browser polling while the surface is + closed; scope diagnostics to explicit Agent or user demand and keep event buffers + bounded. +- Close the browser on panel dismissal, failed presentation, module/project exit, + window hide, or Renderer loss, and preserve structured `AgentBrowserFault` details + across the Pi bridge. +- Add focused regression coverage plus typecheck, lint, unit, Electron UI, build, + and deterministic performance-boundary verification in proportion to the change. + +## Intent And Constraints + +- Preserve ADR-006: Pi `0.84.2` remains the sole Coding runtime; `agent_browser` + crosses the generated Makelore extension into the Main-owned browser service. +- Keep `agent_browser`, selected-model `makelore_web_search`, and the unrelated + release-preflight `electron/agent-browser` module distinct. +- Reproduce the exact failure before ranking or testing hypotheses. Redact credentials, + headers, prompts, tool arguments/results, account/session identities, and user paths + from reported evidence. +- Do not restart, abort, recover, or replay a live Conversation, and do not invoke a + paid Provider while implementing or verifying the repair. +- Establish a red-capable performance baseline before attributing cost. Prefer + profiler/timing/resource measurements over broad logging, and distinguish the + persistent interactive browser from the short-lived release-preflight browser. +- Implement only the smallest replacement surface needed by the current Pi/Main + architecture; do not restore OpenCode state, speculative compatibility layers, or + the pre-optimization polling behavior. +- The active legacy Web Search coordinator owns a separate old-base native-search + scope and does not semantically overlap this diagnosis. + +## Outcome + +- Restored a lightweight Coding right-side development-browser panel with an + explicit header toggle. An Agent `open` event expands the same panel + automatically; manual opening alone creates no `WebContentsView`. +- Added an event-driven Main/Pi presentation handshake. Pi opens the browser + hidden, requests the Renderer surface, and waits at most five seconds for + visible bounds for the same browser generation. Timeout, close, crash, + debugger loss, or generation replacement rejects the wait; failed + presentation destroys the browser instead of retaining an unusable renderer. +- Kept project paths Main-owned for the restored Renderer call sites. The panel + sends the active project id under the existing Renderer capability, and the + Host route resolves and revalidates the active real path before each operation. +- Replaced the single diagnostics toggle with owner leases. Agent diagnostics + stay enabled only for the current run, Renderer diagnostics only while the + Console/Network drawer is expanded, and the domains plus buffers are released + after the last owner exits. Event rendering remains capped at 500 records and + reads use a five-second long poll with bounded drain backoff. +- Closed the browser during idle `background_sleep` in addition to the existing + panel, project, window, Renderer, and application teardown paths. The panel + performs no interval polling and installs its document observer and resize + observer only while open; it does a single state refresh when the app regains + focus after a background close. +- Preserved `AgentBrowserFault` code, retryability, generation, and outcome across + the Pi extension bridge, so callers receive `VIEWPORT_NOT_READY` and other + actionable faults instead of the generic `Bridge request failed` symptom. +- Confirmed primary cause: the Pi product-tool path creates the Main-owned browser + without a Renderer presentation handshake. `PiAgentBrowserTool.open()` calls + `AgentBrowserModule.open()` directly with `visible: false` and no bounds. The + resulting live snapshot is therefore `state: attached`, `visible: false`, and + `bounds: null`. +- `navigate`, `send_cdp`, `read_events`, and `read_payload` enter + `requirePresented()` (directly or through `requireAttached()`), which rejects that + state with `VIEWPORT_NOT_READY`. This is intentional browser-module behavior: the + shared development browser pauses agent debugging while its viewport is hidden. +- The required presentation owner no longer exists in the current Coding Renderer. + Commit `5a275b9` removed `src/pages/Chat/AgentBrowserPanel.tsx` and its 619-line + unit test file as part of the legacy OpenCode removal. That panel was the only code + which measured the viewport and called `presentAgentBrowser()` with visible bounds. + Current source has no caller of `presentAgentBrowser()` and no consumer of + `agent-browser:show`; the installed Renderer bundle likewise contains that event + name only as the key/value pair in the generic event map. +- The Pi path also bypasses the Host API route that emits `agent-browser:show` after + an open. Therefore merely retaining the route event is insufficient: the Pi + product-tool open requires the same presentation notification/coordinator, and the + current Coding Renderer requires a replacement shared-browser surface. +- Confirmed secondary cause of the unhelpful symptom: the extension bridge receives + an HTTP 400 from `PiExtensionHost`, whose broad catch preserves only + `DevicePackageError`; it replaces `AgentBrowserFault` (including + `VIEWPORT_NOT_READY`) with the literal `Bridge request failed`. The generated Pi + extension correctly reports the text it receives, so the loss occurs in Main. +- The direct Pi browser path was introduced by commit `13ab383` already using + `visible: false`. While the old panel still existed, a user-presented viewport + could satisfy the gate; after `5a275b9` removed that panel, current supported UI + has no way to do so, making the failure deterministic for inspection actions. +- Historical performance concern is confirmed, but the deletion itself was not a + performance fix. Commit `927e133` first optimized the then-live browser surface: + it removed the 2.5-second state interval, replaced the 750 ms event interval with + a diagnostics-only 5-second long poll, limited the rendered event tail to 500, + activated the document-wide MutationObserver only while the panel was open, + disabled Runtime/Log/Network/Page CDP domains by default, cleared their buffers + when diagnostics closed, and destroyed the WebContentsView when the panel closed. + Five days later `5a275b9` deleted the already-optimized panel as part of the legacy + OpenCode hard cutover; its commit message and diff do not identify browser + performance as the deletion reason. +- A deterministic historical work-budget comparison reproduced the old amplification: + a collapsed-but-active browser retained one Chromium renderer, dispatched 24 + state requests per minute, kept the whole-document observer active, and captured + four CDP event domains continuously. An open panel dispatched another 80 event + reads per minute even when its diagnostics drawer was collapsed. The `927e133` + version passed the corresponding zero-background-work source assertions. +- Current source retains the Main-side diagnostics gating and teardown, but has a + lifecycle regression around the Pi path. Pi `open` can create a hidden renderer + indefinitely; it emits no presentation event, and `codingProducts.sleep()` stops + Pi workers/the Agent Server without closing the browser. An executable red test + expected browser close during `background_sleep` and observed zero calls. Window + hide, renderer exit/navigation, and project deactivation do close it, but merely + leaving the Programming module does not. +- No preserved profiler capture or hardware-normalized CPU/memory measurement was + found for the historical browser. Therefore the verified conclusions are about + retained resources and deterministic background work, not a fabricated MB, CPU, + startup-time, or frame-time improvement number. +- Falsified alternatives: + - Tool registration/authentication is not the cause: live `open` and `status` + calls cross the extension/Main seam successfully. + - General request-schema drift is not the cause: a minimal `read_events` request + has the exact supported shape. One historical malformed `read_payload` call is + isolated and cannot explain the other failures. + - Installed/source component drift is not the cause of this symptom: the running + installed package contains the same hidden-open, viewport gate, and bridge mask. + - A GPU-process warning is present in the application log, but no evidence links + it to these failures; the browser reaches `attached` and the deterministic + viewport rejection occurs before actionable CDP work. +- The three adopted pre-existing task records remain unchanged. Product changes + are limited to the current Agent Browser presentation, diagnostics, and + lifecycle path; the isolated release-preflight browsers remain unchanged. + +## Verification + +- Red baseline: the four focused implementation suites initially produced six + expected failures and 106 passes for the missing presentation wait, diagnostic + ownership, structured bridge fault, and background close behaviors. +- Focused post-change verification passed across browser core/routes/panel, Pi + product tools/extension host, Coding background sleep, and Coding feature UI; + the final panel suite has 3 passing tests, including zero Host API calls while + closed and one-shot foreground resynchronization. +- `pnpm run typecheck`: passed. +- `pnpm run lint:check`: passed with zero errors and five pre-existing warnings in + `src/pages/Home/index.tsx` and `src/pages/Makelore/index.tsx`; no changed file + produced a warning. +- `pnpm test`: 1,913 tests passed and 2 existing tests were skipped across the two + repository test phases. +- `node ./node_modules/@playwright/test/cli.js test + tests/e2e/pi-coding-first-chat.spec.ts`: all 4 Electron E2E tests passed, + including the restored browser toggle and diagnostics-off initial state. +- `pnpm run build:vite`: passed for Renderer, Electron Main, Preload, and utility + worker. Only existing Browserslist, mixed dynamic-import, and chunk-size + advisories were emitted. +- `pnpm run perf:budget`: passed. Initial JS gzip was 205,846 / 358,400 bytes, + initial CSS gzip 24,377 / 30,720 bytes, fonts 1,729,900 / 2,097,152 bytes, and + module media 241,362 / 1,048,576 bytes. +- `git diff --check`: passed. +- Deterministic captured-trace assertion was run twice against the latest local Pi + session, without exposing prompts, arguments, results, identities, or paths. Both + runs reproduced the same red condition: 3 successful opens and 14 subsequent + `Bridge request failed` results. +- Minimized captured sequence reproduced the failure as one successful `open` + followed by one failed `read_events`. Across the inspected session, 20 + `agent_browser` results comprised 6 successes (`open`/`status`) and 14 failures + (`read_events`, `send_cdp`, `navigate`, and one malformed `read_payload`). +- `pnpm exec vitest run tests/unit/pi-extension-bundle.test.ts + tests/unit/pi-product-tools.test.ts tests/unit/agent-browser-routes.test.ts + --maxWorkers=1` with the repository-pinned pnpm 10.33.4: 3 files, 35 tests passed. + These tests confirm registration/bridge schema/route forwarding but mock the + browser module and do not exercise the broken cross-layer sequence. +- `pnpm exec vitest run tests/unit/agent-browser-core.test.ts -t "keeps a view + hidden until open/present receives current bounds" --maxWorkers=1`: 1 passed, + 77 skipped. It confirms that open without bounds yields `visible: false`, + `bounds: null`, and that `sendCdp`/`readEvents` then reject with + `VIEWPORT_NOT_READY` until a visible presentation with bounds occurs. +- Read-only inspection of the running installed package confirmed all load-bearing + code literals and paths: Pi open is hidden, `requirePresented()` emits the + viewport fault, Main masks it, and the Renderer bundle has no show-event consumer. +- Git history/blame confirmed the Pi path originated in `13ab383` and the presentation + surface plus its tests were removed in `5a275b9` without a Coding replacement. +- Historical zero-idle budget check: pre-`927e133` intentionally failed with one + retained renderer, 24 collapsed-state requests/minute, 80 open-panel event + requests/minute, an always-on DOM observer, and always-on diagnostic CDP domains; + the `927e133` source passed all seven inverse assertions. +- Current lifecycle red test (`debug-agent-browser-background-release.test.ts`, + removed immediately after diagnosis) failed as expected: `background_sleep` + invoked the browser `close` spy 0 times instead of 1. +- Focused current-source verification passed: 2 files and 4 tests covering + diagnostics-off first-load events, hidden-until-present behavior, full close + teardown, and agent-only hidden open. The temporary diagnostic test was removed; + `git status` shows no product-source residue from the investigation. + +## Follow-ups + +- No required implementation follow-up remains. +- If hardware-normalized CPU, memory, or frame-time numbers are needed, capture a + controlled before/after profile through the existing `app:performance` snapshot. + The current evidence deliberately claims deterministic resource/work bounds and + bundle-budget compliance, not an invented runtime percentage. + +## Promotion Candidates + +- Target: `.project-docs/30-worklog/current-state.md` + - Summary: Pi `agent_browser` now has a Coding-owned visible-surface handshake; + presentation failure tears down the browser and structured browser faults cross + the Pi bridge unchanged. + - Evidence: focused browser/Pi/bridge tests, Electron E2E, and production build. + - Future impact: keep the visibility gate and presentation coordinator together; + do not reintroduce hidden actionable CDP access. +- Target: `.project-docs/30-worklog/current-state.md` + - Summary: the restored Agent Browser preserves the post-`927e133` zero-idle + lifecycle: no closed-state polling/observers/browser process, owner-scoped + diagnostics, bounded event retention, and idle background teardown. + - Evidence: panel/core/background regression tests, full unit suite, E2E, and the + passing bundle performance budget. + - Future impact: future browser UI changes must preserve these deterministic work + bounds and keep release-preflight browsers isolated from the interactive one. diff --git a/README.md b/README.md index 52b1050..2e441ef 100644 --- a/README.md +++ b/README.md @@ -106,9 +106,9 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p ### 共享开发浏览器 -- Electron Main 持有 sandboxed `WebContentsView`、项目级持久浏览器配置和按需 CDP 连接;被调试页面不获得 Makelore Preload、Node.js 能力或 Host API 凭证。 -- 用户和 Agent 操作同一个页面。Renderer 只负责显示、收起和布局;Agent 通过 Main 代理的页面级 CDP 工具导航、读取 Console/Network 和执行调试命令。 -- 非 Web 协议、文件注入、跨目标及宿主级命令会被阻止。面板关闭时销毁 `WebContentsView`、detach debugger 并释放页面;诊断域只在用户打开诊断视图时连接,面板重新打开时按 URL 和轻量历史元数据恢复。该能力独立于发布和部署。 +- Electron Main 持有 sandboxed `WebContentsView`、项目级持久浏览器配置和按需 CDP 连接;被调试页面不获得 Makelore Preload、Node.js 能力或 Host API 凭证。Renderer 只提交当前项目 id 和可见区域,真实项目路径仍由 Main 解析和校验。 +- 用户和 Agent 操作同一个页面。Agent 发起 `open` 后,Main 通知 Coding 右侧面板展开,并最多等待 5 秒取得当前 generation 的可见 bounds;展示失败会销毁当前视图并返回原始 `AgentBrowserFault` 代码,不会把隐藏页面伪装成可调试状态。Agent 通过 Main 代理的页面级 CDP 工具导航、读取 Console/Network 和执行调试命令。 +- 非 Web 协议、文件注入、跨目标及宿主级命令会被阻止。关闭面板、离开模块或项目、隐藏窗口、Renderer 丢失及后台休眠都会销毁 `WebContentsView`、detach debugger 并释放页面;关闭态没有浏览器轮询或 DOM observer。诊断域按 owner 计数,只在 Agent 当前运行或用户展开 Console/Network 时启用,运行结算/抽屉收起后释放;事件读取使用 5 秒长轮询、最小 drain backoff 和 500 条界面上限。该能力独立于发布和部署。 - 一键提交时,Makelore 会从待上传构建归档的同一组 Main-owned 内存字节启动临时回环站点,并在两个独立的临时 Chromium profile 中检查桌面和移动视口的主页面加载、运行错误、失败资源与白屏。临时页面不挂载到界面,不读取或写入用户浏览器的 Cookie、历史和登录态;检查结束后始终销毁并清理,也不要求用户预先打开开发预览。 - 客户端复用 Electron 内置 Chromium,不安装 Playwright 或额外浏览器。预检只改善提交前反馈,可被非官方客户端绕过,也不会上传“已通过”凭据;平台仍把源码、构建归档和清单视为不可信输入,逐字节重算并在人工审核后发布。安装包携带固定 npm 运行时,项目依赖和 Vite 版本由 `package-lock.json` 锁定;依赖准备需要本地网络。 diff --git a/electron/agent-browser/module.ts b/electron/agent-browser/module.ts index 6da3d03..bb7ced0 100644 --- a/electron/agent-browser/module.ts +++ b/electron/agent-browser/module.ts @@ -106,6 +106,7 @@ interface BrowserRecord { title: string; visible: boolean; diagnosticsEnabled: boolean; + diagnosticOwners: Set; bounds: AgentBrowserBounds | null; error?: { code: AgentBrowserErrorCode; @@ -148,6 +149,7 @@ export interface AgentBrowserOpenInput { bounds?: AgentBrowserBounds; visible?: boolean; injectProjectData?: boolean; + diagnosticsOwner?: string; } export interface AgentBrowserPresentInput { @@ -156,6 +158,12 @@ export interface AgentBrowserPresentInput { bounds?: AgentBrowserBounds; } +export interface AgentBrowserWaitForPresentationInput { + projectPath: string; + generation: number; + timeoutMs?: number; +} + export interface AgentBrowserNavigateInput { projectPath: string; action: 'url' | 'back' | 'forward' | 'reload'; @@ -213,6 +221,7 @@ export class AgentBrowserModule { private readonly payloadStore: AgentBrowserPayloadStore; private readonly cdpGuard: AgentBrowserCdpGuard; private readonly eventWaiters = new Set<() => void>(); + private readonly presentationWaiters = new Set<() => void>(); private readonly commandCancellers = new Set<(fault: AgentBrowserFault) => void>(); private readonly lifecycleListeners = new Set(); private record: BrowserRecord | null = null; @@ -341,16 +350,32 @@ export class AgentBrowserModule { } if (this.record) { const record = this.record; - if (bounds) { - this.applyPresentation(record, input.visible ?? true, bounds); + const diagnosticOwner = input.diagnosticsOwner?.trim(); + const addedDiagnosticOwner = Boolean( + diagnosticOwner && !record.diagnosticOwners.has(diagnosticOwner), + ); + try { + if (diagnosticOwner) { + await this.updateDiagnosticOwner(record, diagnosticOwner, true); + } + if (bounds) { + this.applyPresentation(record, input.visible ?? true, bounds); + } + if (record.url !== targetUrl || record.error) { + await this.navigateTo(record, targetUrl); + } + return this.snapshot(record); + } catch (error) { + if (addedDiagnosticOwner && diagnosticOwner) { + await this.updateDiagnosticOwner(record, diagnosticOwner, false).catch(() => undefined); + } + throw error; } - if (record.url !== targetUrl || record.error) { - await this.navigateTo(record, targetUrl); - } - return this.snapshot(record); } const view = this.adapter.createView(agentBrowserPartition(projectPath)); + const diagnosticOwners = new Set(); + if (input.diagnosticsOwner?.trim()) diagnosticOwners.add(input.diagnosticsOwner.trim()); const record: BrowserRecord = { browserId: randomUUID(), projectId: input.projectId, @@ -362,7 +387,8 @@ export class AgentBrowserModule { url: targetUrl, title: '', visible: Boolean(bounds) && (input.visible ?? true), - diagnosticsEnabled: false, + diagnosticsEnabled: diagnosticOwners.size > 0, + diagnosticOwners, bounds, eventBuffer: new AgentBrowserEventBuffer(), childSessions: new Set(), @@ -439,14 +465,73 @@ export class AgentBrowserModule { } const bounds = input.bounds ? normalizeBounds(input.bounds) : record.bounds; this.applyPresentation(record, input.visible, bounds); + this.notifyPresentationWaiters(); return this.snapshot(record); }); } - setDiagnostics(input: { projectPath: string; enabled: boolean }): Promise { + waitForPresentation( + input: AgentBrowserWaitForPresentationInput, + ): Promise { + this.assertAvailable(); + const timeoutMs = normalizeIntegerRange( + input.timeoutMs, + MAX_WAIT_MS, + 1, + MAX_CDP_TIMEOUT_MS, + 'timeoutMs', + ); + const immediate = this.presentationSnapshot(input.projectPath, input.generation); + if (immediate) return Promise.resolve(immediate); + + return new Promise((resolvePromise, rejectPromise) => { + let settled = false; + let timer: ReturnType | undefined; + const cleanup = () => { + this.presentationWaiters.delete(check); + if (timer) clearTimeout(timer); + }; + const resolve = (snapshot: AgentBrowserSnapshot) => { + if (settled) return; + settled = true; + cleanup(); + resolvePromise(snapshot); + }; + const reject = (error: unknown) => { + if (settled) return; + settled = true; + cleanup(); + rejectPromise(error); + }; + const check = () => { + try { + const snapshot = this.presentationSnapshot(input.projectPath, input.generation); + if (snapshot) resolve(snapshot); + } catch (error) { + reject(error); + } + }; + this.presentationWaiters.add(check); + timer = setTimeout(() => { + reject(new AgentBrowserFault( + 'VIEWPORT_NOT_READY', + '开发浏览器显示区域没有及时准备好。', + true, + input.generation, + )); + }, timeoutMs); + check(); + }); + } + + setDiagnostics(input: { + projectPath: string; + enabled: boolean; + owner?: string; + }): Promise { return this.serialize(async () => { const record = this.requireRecord(input.projectPath); - await this.configureDiagnostics(record, input.enabled); + await this.updateDiagnosticOwner(record, input.owner ?? 'renderer', input.enabled); return this.snapshot(record); }); } @@ -766,6 +851,7 @@ export class AgentBrowserModule { generation: record.generation, url: record.url, }); + this.notifyPresentationWaiters(); } record.state = 'attaching'; record.error = undefined; @@ -1023,6 +1109,7 @@ export class AgentBrowserModule { url: record.url, }); this.notifyEventWaiters(); + this.notifyPresentationWaiters(); }; this.addDebuggerListener(record, 'message', onDebuggerMessage); this.addDebuggerListener(record, 'detach', onDebuggerDetach); @@ -1088,6 +1175,7 @@ export class AgentBrowserModule { code: 'DEVTOOLS_CONFLICT', message: '原生 DevTools 已打开,智能体调试暂时暂停。', }; + this.notifyPresentationWaiters(); }); this.addWebContentsListener(record, 'devtools-closed', () => { if (record !== this.record || record.state === 'closing' || record.state === 'crashed') { @@ -1112,6 +1200,7 @@ export class AgentBrowserModule { record.generation, ); record.error = { code: fault.code, message: fault.message }; + this.notifyPresentationWaiters(); } }).catch(() => undefined); }); @@ -1134,6 +1223,7 @@ export class AgentBrowserModule { record.childSessions.clear(); record.ioHandles.clear(); this.notifyEventWaiters(); + this.notifyPresentationWaiters(); }); this.addWebContentsListener(record, 'destroyed', () => { if (record !== this.record || record.state === 'closing') return; @@ -1152,9 +1242,29 @@ export class AgentBrowserModule { }); record.eventBuffer.markGap('view-recreated'); this.notifyEventWaiters(); + this.notifyPresentationWaiters(); }); } + private async updateDiagnosticOwner( + record: BrowserRecord, + ownerValue: string, + enabled: boolean, + ): Promise { + const owner = ownerValue.trim() || 'renderer'; + const hadOwner = record.diagnosticOwners.has(owner); + if (hadOwner === enabled) return; + if (enabled) record.diagnosticOwners.add(owner); + else record.diagnosticOwners.delete(owner); + try { + await this.configureDiagnostics(record, record.diagnosticOwners.size > 0); + } catch (error) { + if (hadOwner) record.diagnosticOwners.add(owner); + else record.diagnosticOwners.delete(owner); + throw error; + } + } + private async configureDiagnostics(record: BrowserRecord, enabled: boolean): Promise { if (record !== this.record || record.view.webContents.isDestroyed()) { throw new AgentBrowserFault( @@ -1520,6 +1630,56 @@ export class AgentBrowserModule { return record; } + private presentationSnapshot( + projectPath: string, + generation: number, + ): AgentBrowserSnapshot | null { + const record = this.requireRecord(projectPath); + if (record.generation !== generation) { + throw new AgentBrowserFault( + 'CLOSED', + '开发浏览器已切换到新的页面实例。', + true, + generation, + ); + } + if (record.state === 'crashed') { + throw new AgentBrowserFault( + 'RENDERER_CRASHED', + '开发浏览器页面进程已退出。', + true, + generation, + ); + } + if (record.state === 'suspended_devtools') { + throw new AgentBrowserFault( + 'DEVTOOLS_CONFLICT', + '请先关闭当前页面的原生 DevTools。', + true, + generation, + ); + } + if (record.state === 'detached_fault') { + throw new AgentBrowserFault( + 'DEBUGGER_BUSY', + '开发浏览器调试器尚未就绪。', + true, + generation, + ); + } + if (record.state === 'closing') { + throw new AgentBrowserFault( + 'CLOSED', + '开发浏览器正在关闭。', + true, + generation, + ); + } + return record.state === 'attached' && record.visible && record.bounds + ? this.snapshot(record) + : null; + } + private assertProject(record: BrowserRecord, projectPath: string): void { if (!samePath(record.projectPath, normalizeRequiredPath(projectPath))) { throw new AgentBrowserFault( @@ -1541,6 +1701,7 @@ export class AgentBrowserModule { const record = expected ?? this.record; if (!record) { this.notifyEventWaiters(); + this.notifyPresentationWaiters(); return; } if (expected && this.record !== expected) return; @@ -1555,6 +1716,7 @@ export class AgentBrowserModule { }); this.removeListeners(record); this.record = null; + this.notifyPresentationWaiters(); const interrupted = new AgentBrowserFault( 'CLOSED', '开发浏览器已关闭。', @@ -1580,6 +1742,7 @@ export class AgentBrowserModule { this.adapter.destroy(record.view); record.childSessions.clear(); record.ioHandles.clear(); + record.diagnosticOwners.clear(); record.eventBuffer.clear(); this.payloadStore.clear(); this.notifyEventWaiters(); @@ -1852,6 +2015,10 @@ export class AgentBrowserModule { for (const wake of waiters) wake(); } + private notifyPresentationWaiters(): void { + for (const wake of [...this.presentationWaiters]) wake(); + } + private notifyLifecycle(event: AgentBrowserLifecycleEvent): void { for (const listener of this.lifecycleListeners) { try { diff --git a/electron/api/coding-composition.ts b/electron/api/coding-composition.ts index aa8ac13..7885b60 100644 --- a/electron/api/coding-composition.ts +++ b/electron/api/coding-composition.ts @@ -1,6 +1,7 @@ import { accessSync, constants } from 'node:fs'; import path from 'node:path'; import type { AgentBrowserModule } from '../agent-browser'; +import type { AgentBrowserSnapshot } from '../../shared/agent-browser'; import { CodingAttachmentStore } from '../coding-projects/attachment-store'; import { createCodingConversationStore } from '../coding-projects/conversation-store'; import { CodingProjectService } from '../coding-projects/project-service'; @@ -102,6 +103,8 @@ export interface CreateCodingCompositionOptions { accountCache?: AccountPluginCache; clientVersion?: string; policyClient?: PluginPolicyClient; + requestAgentBrowserPresentation?(snapshot: AgentBrowserSnapshot): void; + publishAgentBrowserState?(snapshot: AgentBrowserSnapshot): void; } type PiWorkerExecutableProbe = (candidate: string) => boolean; @@ -241,6 +244,8 @@ export function createCodingComposition( bundledSkillsDir: options.paths.bundledSkillsDir, modelToolRegistry, devicePackageTools, + requestAgentBrowserPresentation: options.requestAgentBrowserPresentation, + publishAgentBrowserState: options.publishAgentBrowserState, pluginSkillSources, getPluginSkillSources: async () => effectiveResolver ? (await effectiveResolver.getSkillSources()).map((source) => ({ @@ -543,6 +548,9 @@ export function createCodingComposition( runtime.dispose(conversationId, reason) ))); if (reason === 'background_sleep' && runtime.hasActiveWork()) return; + if (reason === 'background_sleep') { + await options.browser.close().catch(() => undefined); + } await agentServer.stop(); }, async shutdown() { diff --git a/electron/api/routes/agent-browser.ts b/electron/api/routes/agent-browser.ts index 2091af9..8422b70 100644 --- a/electron/api/routes/agent-browser.ts +++ b/electron/api/routes/agent-browser.ts @@ -7,6 +7,7 @@ import { hasRendererCapability } from '../renderer-capability'; import { parseJsonBody, sendJson } from '../route-utils'; type AgentBrowserBody = { + project_id?: unknown; project_path?: unknown; url?: unknown; action?: unknown; @@ -71,7 +72,12 @@ function parseStringArray(value: unknown): string[] | undefined { return value.map((item) => item.trim()).filter(Boolean); } -async function resolveActiveProject(ctx: HostApiContext, requestedPath?: unknown) { +async function resolveActiveProject( + ctx: HostApiContext, + requestedPath?: unknown, + requestedId?: unknown, + rendererPresentation = false, +) { const activeProject = await ctx.codingProjectStore.getActiveProject(); if (!activeProject) { throw new AgentBrowserRouteError('PROJECT_NOT_ACTIVE', '请先打开一个项目。', 409); @@ -84,6 +90,24 @@ async function resolveActiveProject(ctx: HostApiContext, requestedPath?: unknown throw new AgentBrowserRouteError('PROJECT_NOT_ACTIVE', '当前项目目录不可用。', 409); } + const projectId = nonEmptyString(requestedId); + if (projectId) { + if (!rendererPresentation) { + throw new AgentBrowserRouteError( + 'TARGET_DENIED', + '项目 ID 只能由 Makelore 界面用于浏览器操作。', + 403, + ); + } + if (projectId !== activeProject.id) { + throw new AgentBrowserRouteError('PROJECT_MISMATCH', '智能体只能调试当前项目。', 403); + } + return { + ...activeProject, + path: activeRealPath, + }; + } + const requested = nonEmptyString(requestedPath); if (!requested) { throw new AgentBrowserRouteError('INVALID_REQUEST', '缺少当前项目路径。'); @@ -188,6 +212,19 @@ async function readBody(req: IncomingMessage): Promise { return await parseJsonBody(req); } +function resolveRequestProject( + req: IncomingMessage, + ctx: HostApiContext, + body: AgentBrowserBody, +) { + return resolveActiveProject( + ctx, + body.project_path, + body.project_id, + hasRendererCapability(req), + ); +} + export async function handleAgentBrowserRoutes( req: IncomingMessage, res: ServerResponse, @@ -200,7 +237,12 @@ export async function handleAgentBrowserRoutes( 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 project = await resolveActiveProject( + ctx, + url.searchParams.get('project_path'), + url.searchParams.get('project_id'), + hasRendererCapability(req), + ); const browser = await service.getSnapshot(project.path); await ensureProjectStillActive(ctx, project); sendJson(res, 200, { success: true, browser }); @@ -209,8 +251,13 @@ export async function handleAgentBrowserRoutes( 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); + const project = await resolveActiveProject( + ctx, + body.project_path, + body.project_id, + rendererPresentation, + ); if (body.bounds !== undefined && !rendererPresentation) { requireRendererPresentation(req); } @@ -233,7 +280,7 @@ export async function handleAgentBrowserRoutes( 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 project = await resolveActiveProject(ctx, body.project_path, body.project_id, true); const browser = await service.present({ projectPath: project.path, visible: body.visible === true, @@ -248,7 +295,7 @@ export async function handleAgentBrowserRoutes( if (url.pathname === '/api/agent-browser/diagnostics' && req.method === 'POST') { requireRendererPresentation(req); const body = await readBody(req); - const project = await resolveActiveProject(ctx, body.project_path); + const project = await resolveActiveProject(ctx, body.project_path, body.project_id, true); const browser = await service.setDiagnostics({ projectPath: project.path, enabled: body.enabled === true, @@ -260,7 +307,7 @@ export async function handleAgentBrowserRoutes( if (url.pathname === '/api/agent-browser/navigate' && req.method === 'POST') { const body = await readBody(req); - const project = await resolveActiveProject(ctx, body.project_path); + const project = await resolveRequestProject(req, ctx, body); const action = nonEmptyString(body.action); if (action !== 'url' && action !== 'back' && action !== 'forward' && action !== 'reload') { throw new AgentBrowserRouteError('INVALID_REQUEST', '浏览器导航动作无效。'); @@ -278,7 +325,7 @@ export async function handleAgentBrowserRoutes( 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 project = await resolveRequestProject(req, ctx, body); const method = nonEmptyString(body.method); if (!method) throw new AgentBrowserRouteError('INVALID_REQUEST', '缺少 CDP method。'); const params = body.params === undefined @@ -302,7 +349,7 @@ export async function handleAgentBrowserRoutes( 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 project = await resolveRequestProject(req, ctx, body); const page = await service.readEvents({ projectPath: project.path, after: finiteInteger(body.after), @@ -317,7 +364,7 @@ export async function handleAgentBrowserRoutes( 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 project = await resolveRequestProject(req, ctx, body); const handle = nonEmptyString(body.handle); if (!handle) throw new AgentBrowserRouteError('INVALID_REQUEST', '缺少 payload handle。'); const chunk = await service.readPayload({ @@ -333,7 +380,7 @@ export async function handleAgentBrowserRoutes( if (url.pathname === '/api/agent-browser/close' && req.method === 'POST') { const body = await readBody(req); - const project = await resolveActiveProject(ctx, body.project_path); + const project = await resolveRequestProject(req, ctx, body); const browser = await service.close(project.path); await ensureProjectStillActive(ctx, project); emitState(ctx, 'agent-browser:state', { @@ -347,7 +394,7 @@ export async function handleAgentBrowserRoutes( 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 project = await resolveRequestProject(req, ctx, body); const browser = await service.resetProfile(project.path); await ensureProjectStillActive(ctx, project); emitState(ctx, 'agent-browser:state', { diff --git a/electron/coding-runtime/pi/extension-host.ts b/electron/coding-runtime/pi/extension-host.ts index 22059ae..9072a07 100644 --- a/electron/coding-runtime/pi/extension-host.ts +++ b/electron/coding-runtime/pi/extension-host.ts @@ -18,6 +18,7 @@ import type { CodingPluginToolDefinition } from '../../../shared/coding-plugins' import type { PiSkillEntry } from './resource-loader'; import type { EffectivePluginSnapshot } from '../../coding-plugins/effective-resolver'; import { DevicePackageError } from '../../coding-packages/device-package-manager'; +import { AgentBrowserFault } from '../../agent-browser/fault'; const MAX_REQUEST_BYTES = 64 * 1024; const PRODUCT_TOOL_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9._:-]{0,63}$/u; @@ -516,6 +517,17 @@ export class PiManagedExtensionHost { record.waiters.delete(value.resourceId); } } catch (error) { + if (!response.writableEnded && error instanceof AgentBrowserFault) { + const message = error.message.slice(0, 512); + this.respond(response, 400, { + code: error.code, + error: `${error.code}: ${message}`, + retryable: error.retryable, + ...(error.generation === undefined ? {} : { generation: error.generation }), + ...(error.outcome === undefined ? {} : { outcome: error.outcome }), + }); + return; + } if (!response.writableEnded && error instanceof DevicePackageError) { this.respond(response, 400, { code: error.code, diff --git a/electron/coding-runtime/pi/extensions/agent-browser.ts b/electron/coding-runtime/pi/extensions/agent-browser.ts index 2eadc71..d4f85b6 100644 --- a/electron/coding-runtime/pi/extensions/agent-browser.ts +++ b/electron/coding-runtime/pi/extensions/agent-browser.ts @@ -4,10 +4,17 @@ import type { CodingAttachmentStore } from '../../../coding-projects/attachment- import type { AgentBrowserDetailsV1 } from '../../contracts'; export interface AgentBrowserToolContext { + conversationId: string; + runId: string; projectId: string; projectPath: string; } +export type AgentBrowserPresentationRequester = (snapshot: AgentBrowserSnapshot) => void; +export type AgentBrowserStatePublisher = (snapshot: AgentBrowserSnapshot) => void; + +const PRESENTATION_TIMEOUT_MS = 5_000; + export interface AgentBrowserToolResult { content: Array<{ type: 'text'; text: string }>; details: AgentBrowserDetailsV1; @@ -70,11 +77,27 @@ async function readPayloadJson( } export class PiAgentBrowserTool { + private readonly diagnosticRuns = new Map(); + constructor( private readonly browser: AgentBrowserModule, private readonly attachments: CodingAttachmentStore, + private readonly requestPresentation?: AgentBrowserPresentationRequester, + private readonly publishState?: AgentBrowserStatePublisher, ) {} + async releaseRun(conversationId: string, runId: string): Promise { + const key = `${conversationId}:${runId}`; + const diagnostic = this.diagnosticRuns.get(key); + if (!diagnostic) return; + this.diagnosticRuns.delete(key); + await this.browser.setDiagnostics({ + projectPath: diagnostic.projectPath, + enabled: false, + owner: diagnostic.owner, + }).catch(() => undefined); + } + async execute( context: AgentBrowserToolContext, input: unknown, @@ -85,20 +108,63 @@ export class PiAgentBrowserTool { return result(action, publicSnapshot(await this.browser.getSnapshot(context.projectPath))); } if (action === 'open') { - const snapshot = await this.browser.open({ + const owner = `agent:${context.conversationId}:${context.runId}`; + let snapshot = await this.browser.open({ projectId: context.projectId, projectPath: context.projectPath, url: string(body.url, true) as string, visible: false, + diagnosticsOwner: owner, ...(body.injectProjectData === true ? { injectProjectData: true } : {}), }); + const runKey = `${context.conversationId}:${context.runId}`; + this.diagnosticRuns.set(runKey, { projectPath: context.projectPath, owner }); + if (this.requestPresentation) { + try { + this.requestPresentation(snapshot); + snapshot = await this.browser.waitForPresentation({ + projectPath: context.projectPath, + generation: snapshot.generation, + timeoutMs: PRESENTATION_TIMEOUT_MS, + }); + } catch (error) { + this.diagnosticRuns.delete(runKey); + const closed = await this.browser.close(context.projectPath).catch(() => null); + if (closed) { + this.publishState?.({ + ...closed, + projectId: context.projectId, + projectPath: context.projectPath, + }); + } + throw error; + } + } return result(action, publicSnapshot(snapshot)); } if (action === 'close') { - return result(action, publicSnapshot(await this.browser.close(context.projectPath))); + for (const [key, diagnostic] of this.diagnosticRuns) { + if (diagnostic.projectPath === context.projectPath) this.diagnosticRuns.delete(key); + } + const snapshot = await this.browser.close(context.projectPath); + this.publishState?.({ + ...snapshot, + projectId: context.projectId, + projectPath: context.projectPath, + }); + return result(action, publicSnapshot(snapshot)); } if (action === 'reset_profile') { - return result(action, publicSnapshot(await this.browser.resetProfile(context.projectPath))); + for (const [key, diagnostic] of this.diagnosticRuns) { + if (diagnostic.projectPath === context.projectPath) this.diagnosticRuns.delete(key); + } + const snapshot = await this.browser.resetProfile(context.projectPath); + this.publishState?.({ + ...snapshot, + projectId: context.projectId, + projectPath: context.projectPath, + }); + return result(action, publicSnapshot(snapshot)); } if (action === 'navigate') { const navigation = string(body.navigation, true); @@ -110,6 +176,7 @@ export class PiAgentBrowserTool { action: navigation as 'url' | 'back' | 'forward' | 'reload', ...(body.url === undefined ? {} : { url: string(body.url) }), }); + this.publishState?.(snapshot); return result(action, publicSnapshot(snapshot)); } if (action === 'read_events') { diff --git a/electron/coding-runtime/pi/product-tools.ts b/electron/coding-runtime/pi/product-tools.ts index 37997cf..08cba64 100644 --- a/electron/coding-runtime/pi/product-tools.ts +++ b/electron/coding-runtime/pi/product-tools.ts @@ -23,6 +23,10 @@ import type { EffectivePluginSnapshot } from '../../coding-plugins/effective-res import type { KnownToolDetails, RuntimeContextDetailsV1 } from '../contracts'; import { BUNDLED_CODING_SKILL_IDS } from '../../../shared/coding-skills'; import { PiAgentBrowserTool } from './extensions/agent-browser'; +import type { + AgentBrowserPresentationRequester, + AgentBrowserStatePublisher, +} from './extensions/agent-browser'; import { reportChangedFiles } from './extensions/changed-file'; import { projectTaskState } from './extensions/task-state'; import type { ModelToolRegistryPort } from './model-tools/model-tool-registry'; @@ -73,6 +77,8 @@ export interface PiProductToolsOptions { capabilityRegistry?: CodingCapabilityRegistry; modelToolRegistry?: ModelToolRegistryPort; devicePackageTools?: DevicePackageTools; + requestAgentBrowserPresentation?: AgentBrowserPresentationRequester; + publishAgentBrowserState?: AgentBrowserStatePublisher; } export class PiProductTools { @@ -82,7 +88,12 @@ export class PiProductTools { constructor(private readonly options: PiProductToolsOptions) { this.changeTracker = options.changeTracker ?? new ConversationChangeTracker(); - this.browser = new PiAgentBrowserTool(options.browser, options.attachments); + this.browser = new PiAgentBrowserTool( + options.browser, + options.attachments, + options.requestAgentBrowserPresentation, + options.publishAgentBrowserState, + ); this.capabilityRegistry = options.capabilityRegistry; } @@ -94,8 +105,12 @@ export class PiProductTools { return this.changeTracker.beginRun(input); } - settleRun(conversationId: string, runId: string) { - return this.changeTracker.settleRun(conversationId, runId); + async settleRun(conversationId: string, runId: string) { + try { + return await this.changeTracker.settleRun(conversationId, runId); + } finally { + await this.browser.releaseRun(conversationId, runId); + } } getChanges(conversationId: string): ConversationChangesSnapshot | null { diff --git a/electron/main/index.ts b/electron/main/index.ts index 2100f5c..ea23a8c 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -546,6 +546,18 @@ async function initialize(): Promise { storage: codingProjectStorage, projectStore: codingProjectStore, browser: agentBrowser, + requestAgentBrowserPresentation: (snapshot) => { + hostEventBus.emit('agent-browser:show', snapshot); + if (!window.isDestroyed() && !window.webContents.isDestroyed()) { + window.webContents.send('agent-browser:show', snapshot); + } + }, + publishAgentBrowserState: (snapshot) => { + hostEventBus.emit('agent-browser:state', snapshot); + if (!window.isDestroyed() && !window.webContents.isDestroyed()) { + window.webContents.send('agent-browser:state', snapshot); + } + }, getLocalProxyCredential: () => getHostApiToken() || undefined, acquireBackgroundLease: (lease) => backgroundLifecycle!.acquireLease(lease), paths: { diff --git a/src/lib/agent-browser.ts b/src/lib/agent-browser.ts index 780b926..8882643 100644 --- a/src/lib/agent-browser.ts +++ b/src/lib/agent-browser.ts @@ -80,9 +80,9 @@ function requireSuccess( } export async function getAgentBrowserState( - projectPath: string, + projectId: string, ): Promise { - const query = new URLSearchParams({ project_path: projectPath }); + const query = new URLSearchParams({ project_id: projectId }); const response = await hostApiFetch( `/api/agent-browser/state?${query.toString()}`, ); @@ -90,14 +90,14 @@ export async function getAgentBrowserState( } export async function openAgentBrowser(input: { - projectPath: string; + projectId: string; url: string; bounds?: AgentBrowserBounds; }): Promise { const response = await hostApiFetch( '/api/agent-browser/open', jsonBody({ - project_path: input.projectPath, + project_id: input.projectId, url: input.url, visible: true, ...(input.bounds ? { bounds: input.bounds } : {}), @@ -107,14 +107,14 @@ export async function openAgentBrowser(input: { } export async function presentAgentBrowser(input: { - projectPath: string; + projectId: string; visible: boolean; bounds?: AgentBrowserBounds; }): Promise { const response = await hostApiFetch( '/api/agent-browser/present', jsonBody({ - project_path: input.projectPath, + project_id: input.projectId, visible: input.visible, ...(input.bounds ? { bounds: input.bounds } : {}), }), @@ -123,13 +123,13 @@ export async function presentAgentBrowser(input: { } export async function setAgentBrowserDiagnostics(input: { - projectPath: string; + projectId: string; enabled: boolean; }): Promise { const response = await hostApiFetch( '/api/agent-browser/diagnostics', jsonBody({ - project_path: input.projectPath, + project_id: input.projectId, enabled: input.enabled, }), ); @@ -137,14 +137,14 @@ export async function setAgentBrowserDiagnostics(input: { } export async function navigateAgentBrowser(input: { - projectPath: string; + projectId: string; action: AgentBrowserNavigateAction; url?: string; }): Promise { const response = await hostApiFetch( '/api/agent-browser/navigate', jsonBody({ - project_path: input.projectPath, + project_id: input.projectId, action: input.action, ...(input.url ? { url: input.url } : {}), }), @@ -153,7 +153,7 @@ export async function navigateAgentBrowser(input: { } export async function sendAgentBrowserCdp(input: { - projectPath: string; + projectId: string; method: string; params?: Record; sessionRef?: string; @@ -162,7 +162,7 @@ export async function sendAgentBrowserCdp(input: { const response = await hostApiFetch( '/api/agent-browser/cdp/send', jsonBody({ - project_path: input.projectPath, + project_id: input.projectId, method: input.method, ...(input.params ? { params: input.params } : {}), ...(input.sessionRef ? { session_ref: input.sessionRef } : {}), @@ -173,7 +173,7 @@ export async function sendAgentBrowserCdp(input: { } export async function readAgentBrowserEvents(input: { - projectPath: string; + projectId: string; after?: number; methods?: string[]; limit?: number; @@ -182,7 +182,7 @@ export async function readAgentBrowserEvents(input: { const response = await hostApiFetch( '/api/agent-browser/cdp/events', jsonBody({ - project_path: input.projectPath, + project_id: input.projectId, ...(input.after !== undefined ? { after: input.after } : {}), ...(input.methods ? { methods: input.methods } : {}), ...(input.limit !== undefined ? { limit: input.limit } : {}), @@ -193,7 +193,7 @@ export async function readAgentBrowserEvents(input: { } export async function readAgentBrowserPayload(input: { - projectPath: string; + projectId: string; handle: string; offset?: number; maxBytes?: number; @@ -201,7 +201,7 @@ export async function readAgentBrowserPayload(input: { const response = await hostApiFetch( '/api/agent-browser/payload/read', jsonBody({ - project_path: input.projectPath, + project_id: input.projectId, handle: input.handle, ...(input.offset !== undefined ? { offset: input.offset } : {}), ...(input.maxBytes !== undefined ? { max_bytes: input.maxBytes } : {}), @@ -211,21 +211,21 @@ export async function readAgentBrowserPayload(input: { } export async function closeAgentBrowser( - projectPath: string, + projectId: string, ): Promise { const response = await hostApiFetch( '/api/agent-browser/close', - jsonBody({ project_path: projectPath }), + jsonBody({ project_id: projectId }), ); return requireSuccess(response).browser; } export async function resetAgentBrowserProfile( - projectPath: string, + projectId: string, ): Promise { const response = await hostApiFetch( '/api/agent-browser/reset-profile', - jsonBody({ project_path: projectPath }), + jsonBody({ project_id: projectId }), ); return requireSuccess(response).browser; } diff --git a/src/pages/Chat/AgentBrowserPanel.tsx b/src/pages/Chat/AgentBrowserPanel.tsx new file mode 100644 index 0000000..b8c5a82 --- /dev/null +++ b/src/pages/Chat/AgentBrowserPanel.tsx @@ -0,0 +1,553 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type FormEvent, +} from 'react'; +import { + ArrowLeft, + ArrowRight, + ChevronDown, + ChevronUp, + Globe2, + RefreshCw, + X, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + closeAgentBrowser, + deriveAgentBrowserDiagnostics, + getAgentBrowserState, + navigateAgentBrowser, + openAgentBrowser, + presentAgentBrowser, + readAgentBrowserEvents, + setAgentBrowserDiagnostics, +} from '@/lib/agent-browser'; +import { subscribeHostEvent } from '@/lib/host-events'; +import { cn } from '@/lib/utils'; +import type { + AgentBrowserBounds, + AgentBrowserCdpEvent, + AgentBrowserSnapshot, +} from '../../../shared/agent-browser'; + +type DiagnosticsTab = 'console' | 'network'; + +export interface AgentBrowserPanelProps { + projectId: string | null; + open: boolean; + onOpenChange(open: boolean): void; +} + +const EVENT_METHODS = [ + 'Runtime.consoleAPICalled', + 'Runtime.exceptionThrown', + 'Log.entryAdded', + 'Network.requestWillBeSent', + 'Network.responseReceived', + 'Network.loadingFinished', + 'Network.loadingFailed', +]; +const MAX_RENDERED_EVENTS = 500; +const EVENT_WAIT_MS = 5_000; +const EVENT_DRAIN_DELAY_MS = 100; +const EVENT_EMPTY_DELAY_MS = 250; + +function normalizeAddress(value: string): string | null { + const trimmed = value.trim(); + if (!trimmed) return null; + return /^[a-z][a-z\d+.-]*:\/\//i.test(trimmed) + ? trimmed + : `http://${trimmed}`; +} + +function readBounds(element: HTMLElement | null): AgentBrowserBounds | undefined { + if (!element) return undefined; + const rect = element.getBoundingClientRect(); + const bounds = { + x: Math.round(rect.left), + y: Math.round(rect.top), + width: Math.round(rect.width), + height: Math.round(rect.height), + }; + return bounds.width > 0 && bounds.height > 0 ? bounds : undefined; +} + +function browserIsActive(snapshot: AgentBrowserSnapshot | null): boolean { + return Boolean( + snapshot?.browserId + && snapshot.state !== 'closed' + && snapshot.state !== 'closing', + ); +} + +function modalOccludesBrowser(): boolean { + return Array.from( + document.querySelectorAll('[role="dialog"], [role="alertdialog"]'), + ).some((element) => { + if (element.hidden || element.getAttribute('data-state') === 'closed') return false; + const style = window.getComputedStyle(element); + return style.display !== 'none' && style.visibility !== 'hidden'; + }); +} + +function formatTime(timestamp: number): string { + const date = new Date(timestamp); + return Number.isNaN(date.getTime()) + ? '--:--:--' + : date.toLocaleTimeString('zh-CN', { hour12: false }); +} + +function consoleTone(level: string): string { + if (level === 'error' || level === 'assert') return 'text-destructive'; + if (level === 'warning' || level === 'warn') return 'text-amber-700'; + return 'text-foreground'; +} + +function statusTone(status?: number): string { + if (status === undefined) return 'text-muted-foreground'; + if (status >= 500) return 'text-destructive'; + if (status >= 400) return 'text-amber-700'; + return 'text-emerald-700'; +} + +export function AgentBrowserPanel({ + projectId, + open, + onOpenChange, +}: AgentBrowserPanelProps) { + const [snapshot, setSnapshot] = useState(null); + const [address, setAddress] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [occluded, setOccluded] = useState(false); + const [diagnosticsOpen, setDiagnosticsOpen] = useState(false); + const [activeTab, setActiveTab] = useState('console'); + const [events, setEvents] = useState([]); + const viewportRef = useRef(null); + const addressInputRef = useRef(null); + const eventCursorRef = useRef(0); + const wasOpenRef = useRef(open); + const browserActive = browserIsActive(snapshot); + const diagnostics = useMemo(() => deriveAgentBrowserDiagnostics(events), [events]); + + const applySnapshot = useCallback((next: AgentBrowserSnapshot) => { + setSnapshot(next); + if (next.url && document.activeElement !== addressInputRef.current) { + setAddress(next.url); + } + }, []); + + useEffect(() => { + setSnapshot(null); + setAddress(''); + setError(null); + setDiagnosticsOpen(false); + setEvents([]); + eventCursorRef.current = 0; + }, [projectId]); + + useEffect(() => { + if (!projectId) return undefined; + const unsubscribeShow = subscribeHostEvent( + 'agent-browser:show', + (next) => { + if (next.projectId !== projectId) return; + applySnapshot(next); + onOpenChange(true); + }, + ); + const unsubscribeState = subscribeHostEvent( + 'agent-browser:state', + (next) => { + if (next.projectId && next.projectId !== projectId) return; + applySnapshot(next); + }, + ); + return () => { + unsubscribeShow(); + unsubscribeState(); + }; + }, [applySnapshot, onOpenChange, projectId]); + + useEffect(() => { + if (!open || !projectId) return undefined; + let cancelled = false; + void getAgentBrowserState(projectId) + .then((next) => { + if (!cancelled) applySnapshot(next); + }) + .catch(() => { + if (!cancelled) setSnapshot(null); + }); + return () => { + cancelled = true; + }; + }, [applySnapshot, open, projectId]); + + useEffect(() => { + if (!open || !projectId) return undefined; + let cancelled = false; + const syncAfterResume = () => { + if (document.visibilityState === 'hidden') return; + void getAgentBrowserState(projectId) + .then((next) => { + if (!cancelled) applySnapshot(next); + }) + .catch(() => { + if (!cancelled) setSnapshot(null); + }); + }; + window.addEventListener('focus', syncAfterResume); + document.addEventListener('visibilitychange', syncAfterResume); + return () => { + cancelled = true; + window.removeEventListener('focus', syncAfterResume); + document.removeEventListener('visibilitychange', syncAfterResume); + }; + }, [applySnapshot, open, projectId]); + + useEffect(() => { + const wasOpen = wasOpenRef.current; + wasOpenRef.current = open; + if (!wasOpen || open || !projectId) return; + setSnapshot(null); + setDiagnosticsOpen(false); + setEvents([]); + eventCursorRef.current = 0; + void closeAgentBrowser(projectId).catch(() => undefined); + }, [open, projectId]); + + useEffect(() => () => { + if (projectId) void closeAgentBrowser(projectId).catch(() => undefined); + }, [projectId]); + + useEffect(() => { + eventCursorRef.current = 0; + setEvents([]); + }, [snapshot?.browserId, snapshot?.generation]); + + useEffect(() => { + if (!open) { + setOccluded(false); + return undefined; + } + const update = () => setOccluded(modalOccludesBrowser()); + update(); + const observer = new MutationObserver(update); + observer.observe(document.body, { + attributes: true, + attributeFilter: ['class', 'data-state', 'hidden', 'style'], + childList: true, + subtree: true, + }); + return () => observer.disconnect(); + }, [open]); + + useEffect(() => { + if (!projectId || !browserActive) return undefined; + if (!open || occluded) { + void presentAgentBrowser({ projectId, visible: false }).catch(() => undefined); + return undefined; + } + const viewport = viewportRef.current; + if (!viewport) return undefined; + let cancelled = false; + let timer: number | null = null; + let lastBounds = ''; + const schedule = () => { + if (timer !== null) window.clearTimeout(timer); + timer = window.setTimeout(() => { + timer = null; + const bounds = readBounds(viewport); + if (!bounds) return; + const key = `${bounds.x}:${bounds.y}:${bounds.width}:${bounds.height}`; + if (key === lastBounds) return; + lastBounds = key; + void presentAgentBrowser({ projectId, visible: true, bounds }) + .then((next) => { + if (!cancelled) applySnapshot(next); + }) + .catch((cause) => { + if (!cancelled) { + setError(cause instanceof Error ? cause.message : '无法显示开发浏览器'); + } + }); + }, 50); + }; + const observer = typeof ResizeObserver === 'undefined' + ? null + : new ResizeObserver(schedule); + observer?.observe(viewport); + window.addEventListener('resize', schedule); + schedule(); + return () => { + cancelled = true; + if (timer !== null) window.clearTimeout(timer); + observer?.disconnect(); + window.removeEventListener('resize', schedule); + void presentAgentBrowser({ projectId, visible: false }).catch(() => undefined); + }; + }, [applySnapshot, browserActive, occluded, open, projectId, snapshot?.generation]); + + useEffect(() => { + if (!open || !diagnosticsOpen || !projectId || snapshot?.state !== 'attached') { + return undefined; + } + let cancelled = false; + void setAgentBrowserDiagnostics({ projectId, enabled: true }) + .then((next) => { + if (!cancelled) applySnapshot(next); + }) + .catch(() => undefined); + return () => { + cancelled = true; + void setAgentBrowserDiagnostics({ projectId, enabled: false }).catch(() => undefined); + }; + }, [applySnapshot, diagnosticsOpen, open, projectId, snapshot?.browserId, snapshot?.generation, snapshot?.state]); + + useEffect(() => { + if (!open || !diagnosticsOpen || !projectId || snapshot?.state !== 'attached') { + return undefined; + } + let cancelled = false; + let inFlight = false; + let timer: number | null = null; + const poll = async () => { + if (inFlight) return; + inFlight = true; + let retryDelay = EVENT_EMPTY_DELAY_MS; + try { + const page = await readAgentBrowserEvents({ + projectId, + after: eventCursorRef.current, + methods: EVENT_METHODS, + limit: 200, + waitMs: EVENT_WAIT_MS, + }); + if (cancelled) return; + eventCursorRef.current = page.nextCursor; + setEvents((current) => { + const base = page.gap ? [] : current; + return [...base, ...page.events].slice(-MAX_RENDERED_EVENTS); + }); + retryDelay = page.events.length > 0 || page.hasMore + ? EVENT_DRAIN_DELAY_MS + : EVENT_EMPTY_DELAY_MS; + } catch { + // The state event or the next explicit open reports actionable failures. + } finally { + inFlight = false; + if (!cancelled) { + timer = window.setTimeout(() => { + timer = null; + void poll(); + }, retryDelay); + } + } + }; + void poll(); + return () => { + cancelled = true; + if (timer !== null) window.clearTimeout(timer); + }; + }, [diagnosticsOpen, open, projectId, snapshot?.generation, snapshot?.state]); + + const runBrowserAction = useCallback(async ( + action: () => Promise, + ) => { + setBusy(true); + setError(null); + try { + applySnapshot(await action()); + } catch (cause) { + setError(cause instanceof Error ? cause.message : '开发浏览器操作失败'); + } finally { + setBusy(false); + } + }, [applySnapshot]); + + const handleOpenAddress = useCallback((event?: FormEvent) => { + event?.preventDefault(); + if (!projectId) return; + const url = normalizeAddress(address); + if (!url) return; + setAddress(url); + void runBrowserAction(() => browserActive + ? navigateAgentBrowser({ projectId, action: 'url', url }) + : openAgentBrowser({ projectId, url, bounds: readBounds(viewportRef.current) })); + }, [address, browserActive, projectId, runBrowserAction]); + + const handleNavigate = useCallback((action: 'back' | 'forward' | 'reload') => { + if (!projectId || !browserActive) return; + void runBrowserAction(() => navigateAgentBrowser({ projectId, action })); + }, [browserActive, projectId, runBrowserAction]); + + if (!open) return null; + + return ( + + ); +} diff --git a/src/pages/Chat/CodingChatPanel.tsx b/src/pages/Chat/CodingChatPanel.tsx index 35bdd7b..52b1a01 100644 --- a/src/pages/Chat/CodingChatPanel.tsx +++ b/src/pages/Chat/CodingChatPanel.tsx @@ -35,6 +35,7 @@ import type { CodingConversationMetadata, } from '@/types/coding-project'; import { CodingComposer } from './CodingComposer'; +import { AgentBrowserPanel } from './AgentBrowserPanel'; import { CodingChangesSummary } from './CodingChangesSummary'; import { CodingConversationSidebar } from './CodingConversationSidebar'; import { CodingConversationHeader } from './CodingConversationHeader'; @@ -147,6 +148,7 @@ export function CodingChatPanel({ const [attachmentsByDraftKey, setAttachmentsByDraftKey] = useState< Record >({}); + const [agentBrowserOpen, setAgentBrowserOpen] = useState(false); const appliedNavigationDraftRef = useRef(null); const automaticCreationKeyRef = useRef(null); const selectedConversationContextRef = useRef(null); @@ -241,6 +243,10 @@ export function CodingChatPanel({ void loadWorkspace().catch(() => undefined); }, [loadWorkspace]); + useEffect(() => { + setAgentBrowserOpen(false); + }, [activeProject?.id]); + useEffect(() => () => disconnectEvents(), [disconnectEvents]); useEffect(() => subscribeHostEvent('lifecycle:sleep', () => { @@ -642,6 +648,9 @@ export function CodingChatPanel({ onRecover={async () => { if (targetConversationId) await recoverConversation(targetConversationId); }} + browserOpen={agentBrowserOpen} + browserAvailable={Boolean(activeProject)} + onToggleBrowser={() => setAgentBrowserOpen((current) => !current)} /> {(workspaceError || conversationMetadataError || connectionError) && ( @@ -761,6 +770,12 @@ export function CodingChatPanel({ }} /> + + ); } diff --git a/src/pages/Chat/CodingConversationHeader.tsx b/src/pages/Chat/CodingConversationHeader.tsx index 522d399..17af72d 100644 --- a/src/pages/Chat/CodingConversationHeader.tsx +++ b/src/pages/Chat/CodingConversationHeader.tsx @@ -3,6 +3,8 @@ import { createPortal } from 'react-dom'; import { LoaderCircle, Pencil, + PanelRightClose, + PanelRightOpen, RotateCcw, Square, } from 'lucide-react'; @@ -55,12 +57,18 @@ export function CodingConversationHeader({ onRename, onAbort, onRecover, + browserOpen = false, + browserAvailable = false, + onToggleBrowser, }: { conversation: CodingConversationMetadata | null; snapshot: ConversationSnapshot | null; onRename(title: string): Promise; onAbort(): Promise; onRecover(): Promise; + browserOpen?: boolean; + browserAvailable?: boolean; + onToggleBrowser?(): void; }) { const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed); const [busyAction, setBusyAction] = useState(null); @@ -121,6 +129,23 @@ export function CodingConversationHeader({ + {onToggleBrowser ? ( + + ) : null} + {running && runStatus !== 'aborting' && (