@restregular/dsh-visual-browser 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/cdp.js CHANGED
@@ -1,192 +1,208 @@
1
- /**
2
- * Minimal Chrome DevTools Protocol client.
3
- *
4
- * The plugin ships without dependencies: Node.js 22+ provides a WHATWG
5
- * `WebSocket`, which is all a flattened CDP session needs. Every command is a
6
- * JSON message with an `id`; flattened target sessions add `sessionId` to the
7
- * command and echo it on the matching response.
8
- */
9
-
10
- /** A CDP failure with a stable code so tools and routes can report a reason. */
11
- export class CdpError extends Error {
12
- constructor(code, message) {
13
- super(message);
14
- this.name = 'CdpError';
15
- this.code = code;
16
- }
17
- }
18
-
19
- const OPEN_TIMEOUT_MS = 15_000;
20
- const COMMAND_TIMEOUT_MS = 30_000;
21
-
22
- export class CdpConnection {
23
- #socket;
24
- #nextId = 1;
25
- #pending = new Map();
26
- #listeners = new Map();
27
- #closed = false;
28
-
29
- constructor(socket) {
30
- this.#socket = socket;
31
- socket.addEventListener('message', (event) => this.#onMessage(event.data));
32
- socket.addEventListener('close', () => this.#failAll(new CdpError('CDP_CLOSED', 'the DevTools connection closed')));
33
- socket.addEventListener('error', () => {
34
- /* The close event carries the outcome; keep this listener quiet. */
35
- });
36
- }
37
-
38
- /** Connect to a browser-level or page-level WebSocket endpoint. */
39
- static async open(url, { timeoutMs = OPEN_TIMEOUT_MS } = {}) {
40
- const Socket = globalThis.WebSocket;
41
- if (typeof Socket !== 'function') {
42
- throw new CdpError('CDP_UNAVAILABLE', 'this Node.js runtime has no built-in WebSocket (Node 22 or newer is required)');
43
- }
44
- const socket = new Socket(url);
45
- await new Promise((resolve, reject) => {
46
- const timer = setTimeout(() => {
47
- cleanup();
48
- try {
49
- socket.close();
50
- } catch {
51
- /* already unusable */
52
- }
53
- reject(new CdpError('CDP_TIMEOUT', `timed out connecting to ${url}`));
54
- }, timeoutMs);
55
- const cleanup = () => {
56
- clearTimeout(timer);
57
- socket.removeEventListener('open', onOpen);
58
- socket.removeEventListener('error', onError);
59
- };
60
- const onOpen = () => {
61
- cleanup();
62
- resolve();
63
- };
64
- const onError = () => {
65
- cleanup();
66
- reject(new CdpError('CDP_CONNECT_FAILED', `could not connect to ${url}`));
67
- };
68
- socket.addEventListener('open', onOpen);
69
- socket.addEventListener('error', onError);
70
- });
71
- return new CdpConnection(socket);
72
- }
73
-
74
- get closed() {
75
- return this.#closed;
76
- }
77
-
78
- /** Send one protocol command; `sessionId` targets a flattened session. */
79
- send(method, params = {}, sessionId) {
80
- if (this.#closed) return Promise.reject(new CdpError('CDP_CLOSED', 'the DevTools connection closed'));
81
- const id = this.#nextId++;
82
- const payload = { id, method, params };
83
- if (sessionId !== undefined) payload.sessionId = sessionId;
84
- return new Promise((resolve, reject) => {
85
- const timer = setTimeout(() => {
86
- this.#pending.delete(id);
87
- reject(new CdpError('CDP_COMMAND_TIMEOUT', `${method} did not answer within ${COMMAND_TIMEOUT_MS}ms`));
88
- }, COMMAND_TIMEOUT_MS);
89
- this.#pending.set(id, { resolve, reject, method, timer });
90
- try {
91
- this.#socket.send(JSON.stringify(payload));
92
- } catch (error) {
93
- clearTimeout(timer);
94
- this.#pending.delete(id);
95
- reject(new CdpError('CDP_SEND_FAILED', messageOf(error)));
96
- }
97
- });
98
- }
99
-
100
- /** Subscribe to one protocol event; the returned function unsubscribes. */
101
- on(method, listener) {
102
- const bucket = this.#listeners.get(method) ?? new Set();
103
- bucket.add(listener);
104
- this.#listeners.set(method, bucket);
105
- return () => {
106
- bucket.delete(listener);
107
- if (bucket.size === 0) this.#listeners.delete(method);
108
- };
109
- }
110
-
111
- /** Resolve on the next `method` event, or reject on timeout. */
112
- once(method, timeoutMs) {
113
- return new Promise((resolve, reject) => {
114
- const timer = setTimeout(() => {
115
- off();
116
- reject(new CdpError('CDP_EVENT_TIMEOUT', `waited ${timeoutMs}ms for ${method}`));
117
- }, timeoutMs);
118
- const off = this.on(method, (params, sessionId) => {
119
- clearTimeout(timer);
120
- off();
121
- resolve({ params, sessionId });
122
- });
123
- });
124
- }
125
-
126
- close() {
127
- if (this.#closed) return;
128
- this.#failAll(new CdpError('CDP_CLOSED', 'the DevTools connection closed'));
129
- try {
130
- this.#socket.close();
131
- } catch {
132
- /* already closed */
133
- }
134
- }
135
-
136
- #onMessage(raw) {
137
- let message;
138
- try {
139
- message = JSON.parse(typeof raw === 'string' ? raw : String(raw));
140
- } catch {
141
- return;
142
- }
143
- if (message && typeof message.id === 'number') {
144
- const pending = this.#pending.get(message.id);
145
- if (pending === undefined) return;
146
- this.#pending.delete(message.id);
147
- clearTimeout(pending.timer);
148
- if (message.error) {
149
- pending.reject(
150
- new CdpError(
151
- `CDP_${String(message.error.code ?? 'ERROR')}`,
152
- `${pending.method}: ${message.error.message ?? 'protocol error'}`,
153
- ),
154
- );
155
- } else {
156
- pending.resolve(message.result);
157
- }
158
- return;
159
- }
160
- if (!message || typeof message.method !== 'string') return;
161
- const bucket = this.#listeners.get(message.method);
162
- if (bucket === undefined) return;
163
- for (const listener of [...bucket]) {
164
- try {
165
- listener(message.params, message.sessionId);
166
- } catch {
167
- /* a failing listener must not break the socket */
168
- }
169
- }
170
- }
171
-
172
- #failAll(error) {
173
- if (this.#closed) return;
174
- this.#closed = true;
175
- for (const [id, pending] of this.#pending) {
176
- clearTimeout(pending.timer);
177
- this.#pending.delete(id);
178
- pending.reject(error);
179
- }
180
- this.#listeners.clear();
181
- }
182
- }
183
-
184
- export function messageOf(error) {
185
- if (error instanceof Error) return error.message;
186
- if (typeof error === 'string') return error;
187
- try {
188
- return JSON.stringify(error);
189
- } catch {
190
- return String(error);
191
- }
192
- }
1
+ /**
2
+ * Minimal Chrome DevTools Protocol client.
3
+ *
4
+ * The plugin ships without dependencies: Node.js 22+ provides a WHATWG
5
+ * `WebSocket`, which is all a flattened CDP session needs. Every command is a
6
+ * JSON message with an `id`; flattened target sessions add `sessionId` to the
7
+ * command and echo it on the matching response.
8
+ */
9
+
10
+ /** A CDP failure with a stable code so tools and routes can report a reason. */
11
+ export class CdpError extends Error {
12
+ constructor(code, message) {
13
+ super(message);
14
+ this.name = 'CdpError';
15
+ this.code = code;
16
+ }
17
+ }
18
+
19
+ const OPEN_TIMEOUT_MS = 15_000;
20
+ const COMMAND_TIMEOUT_MS = 30_000;
21
+
22
+ export class CdpConnection {
23
+ #socket;
24
+ #nextId = 1;
25
+ #pending = new Map();
26
+ #listeners = new Map();
27
+ #closeListeners = new Set();
28
+ #closed = false;
29
+
30
+ constructor(socket) {
31
+ this.#socket = socket;
32
+ socket.addEventListener('message', (event) => this.#onMessage(event.data));
33
+ socket.addEventListener('close', () => this.#failAll(new CdpError('CDP_CLOSED', 'the DevTools connection closed')));
34
+ socket.addEventListener('error', () => {
35
+ /* The close event carries the outcome; keep this listener quiet. */
36
+ });
37
+ }
38
+
39
+ /** Connect to a browser-level or page-level WebSocket endpoint. */
40
+ static async open(url, { timeoutMs = OPEN_TIMEOUT_MS } = {}) {
41
+ const Socket = globalThis.WebSocket;
42
+ if (typeof Socket !== 'function') {
43
+ throw new CdpError('CDP_UNAVAILABLE', 'this Node.js runtime has no built-in WebSocket (Node 22 or newer is required)');
44
+ }
45
+ const socket = new Socket(url);
46
+ await new Promise((resolve, reject) => {
47
+ const timer = setTimeout(() => {
48
+ cleanup();
49
+ try {
50
+ socket.close();
51
+ } catch {
52
+ /* already unusable */
53
+ }
54
+ reject(new CdpError('CDP_TIMEOUT', `timed out connecting to ${url}`));
55
+ }, timeoutMs);
56
+ const cleanup = () => {
57
+ clearTimeout(timer);
58
+ socket.removeEventListener('open', onOpen);
59
+ socket.removeEventListener('error', onError);
60
+ };
61
+ const onOpen = () => {
62
+ cleanup();
63
+ resolve();
64
+ };
65
+ const onError = () => {
66
+ cleanup();
67
+ reject(new CdpError('CDP_CONNECT_FAILED', `could not connect to ${url}`));
68
+ };
69
+ socket.addEventListener('open', onOpen);
70
+ socket.addEventListener('error', onError);
71
+ });
72
+ return new CdpConnection(socket);
73
+ }
74
+
75
+ get closed() {
76
+ return this.#closed;
77
+ }
78
+
79
+ /** Send one protocol command; `sessionId` targets a flattened session. */
80
+ send(method, params = {}, sessionId) {
81
+ if (this.#closed) return Promise.reject(new CdpError('CDP_CLOSED', 'the DevTools connection closed'));
82
+ const id = this.#nextId++;
83
+ const payload = { id, method, params };
84
+ if (sessionId !== undefined) payload.sessionId = sessionId;
85
+ return new Promise((resolve, reject) => {
86
+ const timer = setTimeout(() => {
87
+ this.#pending.delete(id);
88
+ reject(new CdpError('CDP_COMMAND_TIMEOUT', `${method} did not answer within ${COMMAND_TIMEOUT_MS}ms`));
89
+ }, COMMAND_TIMEOUT_MS);
90
+ this.#pending.set(id, { resolve, reject, method, timer });
91
+ try {
92
+ this.#socket.send(JSON.stringify(payload));
93
+ } catch (error) {
94
+ clearTimeout(timer);
95
+ this.#pending.delete(id);
96
+ reject(new CdpError('CDP_SEND_FAILED', messageOf(error)));
97
+ }
98
+ });
99
+ }
100
+
101
+ /** Subscribe to one protocol event; the returned function unsubscribes. */
102
+ on(method, listener) {
103
+ const bucket = this.#listeners.get(method) ?? new Set();
104
+ bucket.add(listener);
105
+ this.#listeners.set(method, bucket);
106
+ return () => {
107
+ bucket.delete(listener);
108
+ if (bucket.size === 0) this.#listeners.delete(method);
109
+ };
110
+ }
111
+
112
+ /** Resolve on the next `method` event, or reject on timeout. */
113
+ once(method, timeoutMs) {
114
+ return new Promise((resolve, reject) => {
115
+ const timer = setTimeout(() => {
116
+ off();
117
+ reject(new CdpError('CDP_EVENT_TIMEOUT', `waited ${timeoutMs}ms for ${method}`));
118
+ }, timeoutMs);
119
+ const off = this.on(method, (params, sessionId) => {
120
+ clearTimeout(timer);
121
+ off();
122
+ resolve({ params, sessionId });
123
+ });
124
+ });
125
+ }
126
+
127
+ /** Notified once with the failure that closed the socket. */
128
+ onClose(listener) {
129
+ this.#closeListeners.add(listener);
130
+ return () => this.#closeListeners.delete(listener);
131
+ }
132
+
133
+ close() {
134
+ if (this.#closed) return;
135
+ this.#failAll(new CdpError('CDP_CLOSED', 'the DevTools connection closed'));
136
+ try {
137
+ this.#socket.close();
138
+ } catch {
139
+ /* already closed */
140
+ }
141
+ }
142
+
143
+ #onMessage(raw) {
144
+ let message;
145
+ try {
146
+ message = JSON.parse(typeof raw === 'string' ? raw : String(raw));
147
+ } catch {
148
+ return;
149
+ }
150
+ if (message && typeof message.id === 'number') {
151
+ const pending = this.#pending.get(message.id);
152
+ if (pending === undefined) return;
153
+ this.#pending.delete(message.id);
154
+ clearTimeout(pending.timer);
155
+ if (message.error) {
156
+ pending.reject(
157
+ new CdpError(
158
+ `CDP_${String(message.error.code ?? 'ERROR')}`,
159
+ `${pending.method}: ${message.error.message ?? 'protocol error'}`,
160
+ ),
161
+ );
162
+ } else {
163
+ pending.resolve(message.result);
164
+ }
165
+ return;
166
+ }
167
+ if (!message || typeof message.method !== 'string') return;
168
+ const bucket = this.#listeners.get(message.method);
169
+ if (bucket === undefined) return;
170
+ for (const listener of [...bucket]) {
171
+ try {
172
+ listener(message.params, message.sessionId);
173
+ } catch {
174
+ /* a failing listener must not break the socket */
175
+ }
176
+ }
177
+ }
178
+
179
+ #failAll(error) {
180
+ if (this.#closed) return;
181
+ this.#closed = true;
182
+ for (const [id, pending] of this.#pending) {
183
+ clearTimeout(pending.timer);
184
+ this.#pending.delete(id);
185
+ pending.reject(error);
186
+ }
187
+ this.#listeners.clear();
188
+ const closeListeners = [...this.#closeListeners];
189
+ this.#closeListeners.clear();
190
+ for (const listener of closeListeners) {
191
+ try {
192
+ listener(error);
193
+ } catch {
194
+ /* a close listener must not block teardown */
195
+ }
196
+ }
197
+ }
198
+ }
199
+
200
+ export function messageOf(error) {
201
+ if (error instanceof Error) return error.message;
202
+ if (typeof error === 'string') return error;
203
+ try {
204
+ return JSON.stringify(error);
205
+ } catch {
206
+ return String(error);
207
+ }
208
+ }
package/lib/routes.js CHANGED
@@ -1,15 +1,15 @@
1
1
  /**
2
2
  * Same-origin HTTP API behind the Visual Browser panel.
3
3
  *
4
- * The panel polls `/state` for the timeline and pulls `/frame?seq=` for the
5
- * picture; user clicks, wheel events and keys go back through the same
6
- * BrowserManager methods the agent tools use. Frames stay on the host, so a
7
- * 1 Hz picture never costs a session token.
4
+ * Three jobs: a tiny `/state` JSON poll for the operation log, one long-lived
5
+ * `/stream` response that carries Chrome's own screencast frames as a binary
6
+ * frame stream, and `/input` batches that go straight to the DevTools input
7
+ * domain. No screenshot per click, no per-frame request, no base64 hop.
8
8
  */
9
9
 
10
10
  import { BrowserError } from './browser.js';
11
11
 
12
- const BODY_LIMIT = 64 * 1024;
12
+ const BODY_LIMIT = 128 * 1024;
13
13
 
14
14
  class HttpError extends Error {
15
15
  constructor(status, code, message) {
@@ -28,17 +28,10 @@ function sendJson(res, status, payload) {
28
28
  res.end(body);
29
29
  }
30
30
 
31
- function sendFrame(res, frame) {
32
- if (frame === null || frame === undefined) {
33
- res.statusCode = 204;
34
- res.end();
35
- return;
36
- }
37
- res.statusCode = 200;
38
- res.setHeader('content-type', 'image/jpeg');
31
+ function sendEmpty(res, status) {
32
+ res.statusCode = status;
39
33
  res.setHeader('cache-control', 'no-store');
40
- res.setHeader('content-length', String(frame.jpeg.length));
41
- res.end(frame.jpeg);
34
+ res.end();
42
35
  }
43
36
 
44
37
  async function readJsonBody(req) {
@@ -75,6 +68,78 @@ function failure(res, error) {
75
68
  sendJson(res, status, { ok: false, error: { code, message } });
76
69
  }
77
70
 
71
+ /**
72
+ * Hold the response open as one binary frame stream:
73
+ *
74
+ * [uint32 LE payload length][uint32 LE sequence][JPEG bytes]...
75
+ *
76
+ * The panel reads it with `fetch` streaming and decodes each JPEG with
77
+ * `createImageBitmap` onto a canvas. Parsing the framing ourselves keeps the
78
+ * picture deterministic across browser builds and skips the base64 hop an
79
+ * event stream would need.
80
+ */
81
+ function openStream(manager, res) {
82
+ if (typeof manager.subscribeFrames !== 'function') {
83
+ sendEmpty(res, 503);
84
+ return;
85
+ }
86
+ if (!manager.running) {
87
+ sendEmpty(res, 204);
88
+ return;
89
+ }
90
+ res.statusCode = 200;
91
+ // A custom type stays out of every compressible mime table on purpose: the
92
+ // host's gzip middleware must never buffer a live picture.
93
+ res.setHeader('content-type', 'application/x-dsh-visual-browser-frames');
94
+ // no-transform keeps the host's gzip middleware out of a live picture.
95
+ res.setHeader('cache-control', 'no-store, no-transform');
96
+ res.setHeader('pragma', 'no-cache');
97
+ res.setHeader('x-accel-buffering', 'no');
98
+ res.setHeader('connection', 'keep-alive');
99
+ res.flushHeaders?.();
100
+
101
+ let closed = false;
102
+ let paused = false;
103
+ let unsubscribe = null;
104
+ let sequence = 0;
105
+
106
+ const cleanup = () => {
107
+ if (closed) return;
108
+ closed = true;
109
+ try {
110
+ unsubscribe?.();
111
+ } catch {
112
+ /* the browser may be gone */
113
+ }
114
+ unsubscribe = null;
115
+ };
116
+
117
+ const writeFrame = (jpeg) => {
118
+ if (closed || paused) return;
119
+ try {
120
+ const header = Buffer.allocUnsafe(8);
121
+ header.writeUInt32LE(jpeg.length, 0);
122
+ header.writeUInt32LE(++sequence, 4);
123
+ const headOk = res.write(header);
124
+ const bodyOk = res.write(jpeg);
125
+ if (!headOk || !bodyOk) {
126
+ // Slow consumer: drop frames until the socket drains instead of growing
127
+ // an unbounded queue of stale pictures.
128
+ paused = true;
129
+ res.once('drain', () => {
130
+ paused = false;
131
+ });
132
+ }
133
+ } catch {
134
+ cleanup();
135
+ }
136
+ };
137
+
138
+ unsubscribe = manager.subscribeFrames(writeFrame);
139
+ res.on('close', cleanup);
140
+ res.on('error', cleanup);
141
+ }
142
+
78
143
  /**
79
144
  * Handle one request under `prefix`. Exported so a smoke test can drive it with
80
145
  * a plain request/response pair and a stub manager.
@@ -90,7 +155,7 @@ export async function handle(manager, prefix, req, res) {
90
155
  }
91
156
  let route = url.pathname;
92
157
  if (route.startsWith(prefix)) route = route.slice(prefix.length);
93
- if (route === '' || route === '/') route = '/';
158
+ if (route === '') route = '/';
94
159
 
95
160
  try {
96
161
  if (method === 'GET' && route === '/') {
@@ -101,15 +166,15 @@ export async function handle(manager, prefix, req, res) {
101
166
  sendJson(res, 200, { ok: true, state: manager.state() });
102
167
  return;
103
168
  }
104
- if (method === 'GET' && route === '/frame') {
105
- const seq = Number(url.searchParams.get('seq') ?? '0');
106
- sendFrame(res, manager.frameFor(seq));
169
+ if (method === 'GET' && route === '/stream') {
170
+ openStream(manager, res);
107
171
  return;
108
172
  }
109
- if (method === 'GET' && route === '/live') {
110
- const state = await manager.refreshFrame();
111
- const frame = manager.frameFor(state.frameSeq);
112
- sendFrame(res, frame);
173
+ if (method === 'POST' && route === '/input') {
174
+ const body = await readJsonBody(req);
175
+ const events = Array.isArray(body.events) ? body.events : [];
176
+ const result = await manager.dispatchInput(events);
177
+ sendJson(res, 200, { ok: true, applied: result?.applied ?? 0, state: manager.state() });
113
178
  return;
114
179
  }
115
180
  if (method === 'POST' && route === '/start') {
@@ -117,6 +182,7 @@ export async function handle(manager, prefix, req, res) {
117
182
  const target = typeof body.url === 'string' ? body.url.trim() : '';
118
183
  if (target !== '') await manager.open(target);
119
184
  else await manager.ensureStarted();
185
+ manager.refreshLiveView?.();
120
186
  sendJson(res, 200, { ok: true, state: manager.state() });
121
187
  return;
122
188
  }
@@ -125,33 +191,12 @@ export async function handle(manager, prefix, req, res) {
125
191
  sendJson(res, 200, { ok: true, state: manager.state() });
126
192
  return;
127
193
  }
128
- if (method === 'POST' && route === '/capture') {
129
- const state = await manager.refreshFrame();
130
- sendJson(res, 200, { ok: true, state });
131
- return;
132
- }
133
- if (method === 'POST' && route === '/click') {
134
- const body = await readJsonBody(req);
135
- await manager.clickAt({ x: body.x, y: body.y });
136
- sendJson(res, 200, { ok: true, state: manager.state() });
137
- return;
138
- }
139
- if (method === 'POST' && route === '/wheel') {
140
- const body = await readJsonBody(req);
141
- await manager.wheelAt({ deltaY: body.deltaY, deltaX: body.deltaX });
142
- sendJson(res, 200, { ok: true, state: manager.state() });
143
- return;
144
- }
145
- if (method === 'POST' && route === '/key') {
146
- const body = await readJsonBody(req);
147
- await manager.keyFromUser(String(body.key ?? 'Enter'));
148
- sendJson(res, 200, { ok: true, state: manager.state() });
149
- return;
150
- }
151
194
  if (method === 'POST' && route === '/nav') {
152
195
  const body = await readJsonBody(req);
153
196
  const action = String(body.action ?? '');
154
- if (!['back', 'forward', 'reload'].includes(action)) throw new HttpError(400, 'BAD_ACTION', 'action must be back, forward, or reload');
197
+ if (!['back', 'forward', 'reload'].includes(action)) {
198
+ throw new HttpError(400, 'BAD_ACTION', 'action must be back, forward, or reload');
199
+ }
155
200
  await manager.act({ action });
156
201
  sendJson(res, 200, { ok: true, state: manager.state() });
157
202
  return;
@@ -159,6 +204,15 @@ export async function handle(manager, prefix, req, res) {
159
204
  sendJson(res, 404, { ok: false, error: { code: 'NOT_FOUND', message: `No route for ${method} ${route}` } });
160
205
  } catch (error) {
161
206
  manager.noteError?.(error);
207
+ if (res.headersSent) {
208
+ // A stream that failed after its headers went out can only be torn down.
209
+ try {
210
+ res.end();
211
+ } catch {
212
+ /* already closed */
213
+ }
214
+ return;
215
+ }
162
216
  failure(res, error);
163
217
  }
164
218
  }
package/locale/en.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Visual Browser",
4
- "description": "A real Chrome for the Agent plus a live view for you: the Agent reads a numbered, token-light list of interactive elements instead of the raw DOM, and every action lands in a panel with the page frame, a replayable timeline, and click-through control."
4
+ "description": "One extra door under the right Sidebar's built-in Browser: a live picture of the Chrome the Agent drives, with real click, drag, wheel and keyboard control, plus a collapsible, scrollable action log — no screenshot polling, no frame replay."
5
5
  }
6
6
  }
package/locale/zh.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "可视化浏览器",
4
- "description": "给 Agent 一个真实的 Chrome,也给你一块实时画面:Agent 只读「可交互元素编号列表」这种精简视图而不是整页 DOM,每次操作都会在侧边栏留下画面、可回放的操作时间线,你也可以直接点画面接管浏览器。"
4
+ "description": "在右侧边栏「浏览器」下面多一个入口:实时映射 Agent 正在操作的 Chrome 画面,点击、拖动、滚轮、键盘都能直接操作,操作记录可折叠、可滚动,不再需要逐帧截图或回放。"
5
5
  }
6
6
  }