@thoughtflow/browser 0.2.0 → 0.3.1
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/package.json +4 -3
- package/src/tools/workspace/browser-session-pool.ts +376 -0
- package/src/tools/workspace/browser.tool.ts +362 -97
- package/src/tools/workspace/vision-browser.tool.ts +158 -4
- package/tsconfig.json +4 -1
- package/dist/index.d.ts +0 -3
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -8
- package/dist/index.js.map +0 -1
- package/dist/tools/workspace/browser.tool.d.ts +0 -23
- package/dist/tools/workspace/browser.tool.d.ts.map +0 -1
- package/dist/tools/workspace/browser.tool.js +0 -170
- package/dist/tools/workspace/browser.tool.js.map +0 -1
- package/dist/tools/workspace/vision-browser.tool.d.ts +0 -30
- package/dist/tools/workspace/vision-browser.tool.d.ts.map +0 -1
- package/dist/tools/workspace/vision-browser.tool.js +0 -269
- package/dist/tools/workspace/vision-browser.tool.js.map +0 -1
|
@@ -1,176 +1,375 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { ToolBase, ToolContext, ToolExecutionResult } from "@thoughtflow/core";
|
|
2
|
+
import { ToolBase, ToolContext, ToolExecutionResult, LlmAdapter } from "@thoughtflow/core";
|
|
3
|
+
import { browserPool } from "./browser-session-pool";
|
|
4
|
+
import { buildUserVisionMessage } from "@thoughtflow/core";
|
|
3
5
|
|
|
4
6
|
const BROWSER_ACTIONS = [
|
|
7
|
+
"open",
|
|
5
8
|
"navigate",
|
|
9
|
+
"snapshot",
|
|
6
10
|
"get_text",
|
|
7
|
-
"screenshot",
|
|
8
11
|
"get_dom",
|
|
9
|
-
"
|
|
12
|
+
"screenshot",
|
|
10
13
|
"click",
|
|
11
14
|
"type",
|
|
12
15
|
"select",
|
|
16
|
+
"scroll",
|
|
17
|
+
"console",
|
|
18
|
+
"network",
|
|
19
|
+
"vision",
|
|
20
|
+
"new_page",
|
|
21
|
+
"switch_page",
|
|
22
|
+
"list_pages",
|
|
23
|
+
"list",
|
|
24
|
+
"close_page",
|
|
13
25
|
"close",
|
|
26
|
+
"close_all",
|
|
14
27
|
] as const;
|
|
15
28
|
|
|
16
29
|
type BrowserAction = (typeof BROWSER_ACTIONS)[number];
|
|
17
30
|
|
|
18
|
-
const
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
31
|
+
const READ_ONLY_ACTIONS = new Set<BrowserAction>([
|
|
32
|
+
"snapshot", "get_text", "get_dom", "screenshot",
|
|
33
|
+
"console", "network", "list_pages", "list", "vision",
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
const DESTRUCTIVE_ACTIONS = new Set<BrowserAction>([
|
|
37
|
+
"close", "close_page", "close_all",
|
|
24
38
|
]);
|
|
25
39
|
|
|
26
40
|
/**
|
|
27
|
-
* BrowserTool —
|
|
41
|
+
* BrowserTool — stateful headless browser with session support.
|
|
28
42
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* missing, run() returns `success: false` with a helpful install message.
|
|
43
|
+
* Sessions persist across calls via sessionId. Each session can have
|
|
44
|
+
* multiple tabs. Console and network activity is auto-captured.
|
|
32
45
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
46
|
+
* Use `open` to start, then navigate/interact/screenshot, finally `close`.
|
|
47
|
+
* Omit sessionId to use a default single session.
|
|
35
48
|
*/
|
|
36
49
|
export class BrowserTool extends ToolBase {
|
|
37
50
|
readonly name = "browser";
|
|
51
|
+
/** Static vision adapter — set once before use. Used by the `vision` action. */
|
|
52
|
+
static visionAdapter: LlmAdapter | null = null
|
|
53
|
+
static visionModel: string = "gemma3:12b"
|
|
38
54
|
readonly description =
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
"
|
|
55
|
+
"Stateful headless browser (Playwright/Chromium). " +
|
|
56
|
+
"Sessions persist across calls — open a session, navigate pages, " +
|
|
57
|
+
"interact, inspect, then close. Each session has its own browser process " +
|
|
58
|
+
"(isolated cookies/storage). Console and network auto-captured. " +
|
|
59
|
+
"Actions: open, navigate, snapshot (accessibility tree), get_text, " +
|
|
60
|
+
"get_dom (raw HTML), screenshot, click, type, select, scroll, " +
|
|
61
|
+
"console (browser logs), network (requests/responses), " +
|
|
62
|
+
"vision (visual AI analysis of current page), " +
|
|
63
|
+
"new_page, switch_page, list_pages, list (all sessions), " +
|
|
64
|
+
"close_page, close, close_all. " +
|
|
65
|
+
"Use snapshot/click with [ref=eN] selectors for reliable interaction. " +
|
|
66
|
+
"Sessions auto-expire after 5 minutes of inactivity.\n\n" +
|
|
67
|
+
"CANONICAL FLOW — follow this exactly:\n" +
|
|
68
|
+
"1. browser({action:\"open\"}) → save sessionId\n" +
|
|
69
|
+
"2. browser({action:\"navigate\", sessionId, url:\"...\"})\n" +
|
|
70
|
+
"3. browser({action:\"snapshot\", sessionId}) → find [ref=eN]\n" +
|
|
71
|
+
"4. browser({action:\"click\", sessionId, selector:\"e4\"})\n" +
|
|
72
|
+
"5. browser({action:\"vision\", sessionId, prompt:\"Is the button visible?\"})\n" +
|
|
73
|
+
"6. browser({action:\"console\", sessionId}) to check for JS errors\n" +
|
|
74
|
+
"7. browser({action:\"close\", sessionId}) when done\n" +
|
|
75
|
+
"ALWAYS reuse sessionId — NEVER call open again for the same browsing task."
|
|
43
76
|
|
|
44
|
-
/** Read-only actions: navigate/get_text/screenshot/get_dom/extract */
|
|
45
77
|
override isReadOnly(input: Record<string, unknown>): boolean {
|
|
46
|
-
return
|
|
78
|
+
return READ_ONLY_ACTIONS.has((input["action"] as BrowserAction) ?? "navigate");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
override isDestructive(input: Record<string, unknown>): boolean {
|
|
82
|
+
return DESTRUCTIVE_ACTIONS.has((input["action"] as BrowserAction) ?? "navigate");
|
|
47
83
|
}
|
|
48
84
|
|
|
49
85
|
constructor() {
|
|
50
86
|
super(
|
|
51
87
|
z.object({
|
|
52
|
-
action: z
|
|
53
|
-
|
|
54
|
-
.describe("
|
|
55
|
-
url: z.string().optional().describe("URL to navigate to
|
|
56
|
-
selector: z.string().optional()
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
88
|
+
action: z.enum(BROWSER_ACTIONS).describe("Browser action to perform"),
|
|
89
|
+
sessionId: z.string().optional()
|
|
90
|
+
.describe("Session ID from `open`. Omit for default single session."),
|
|
91
|
+
url: z.string().optional().describe("URL to navigate to"),
|
|
92
|
+
selector: z.string().optional()
|
|
93
|
+
.describe("CSS selector or [ref=eN] from snapshot"),
|
|
94
|
+
text: z.string().optional().describe("Text to type"),
|
|
95
|
+
value: z.string().optional().describe("Value to select or scroll amount"),
|
|
96
|
+
pageId: z.string().optional()
|
|
97
|
+
.describe("Tab/page ID for switch_page/close_page"),
|
|
98
|
+
clear: z.boolean().optional().default(false)
|
|
99
|
+
.describe("Clear console/network logs after reading"),
|
|
100
|
+
fullPage: z.boolean().optional().default(false)
|
|
101
|
+
.describe("Screenshot full page (not just viewport)"),
|
|
102
|
+
isolated: z.boolean().optional().default(true)
|
|
103
|
+
.describe("Use separate browser process for cookie/storage isolation (default: true)"),
|
|
104
|
+
prompt: z.string().optional()
|
|
105
|
+
.describe("Vision analysis prompt — what to check visually on the page"),
|
|
106
|
+
model: z.string().optional()
|
|
107
|
+
.describe("Vision model override (default: gemma3:12b)"),
|
|
108
|
+
useCached: z.boolean().optional().default(false)
|
|
109
|
+
.describe("Re-analyze the cached screenshot instead of taking a new one"),
|
|
61
110
|
})
|
|
62
111
|
);
|
|
63
112
|
}
|
|
64
113
|
|
|
65
|
-
// Shared browser instance (reused across calls)
|
|
66
|
-
private browser: import("playwright").Browser | null = null;
|
|
67
|
-
private page: import("playwright").Page | null = null;
|
|
68
|
-
|
|
69
|
-
private async getPage(): Promise<import("playwright").Page> {
|
|
70
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
71
|
-
const { chromium } = require("playwright") as typeof import("playwright");
|
|
72
|
-
if (!this.browser || !this.browser.isConnected()) {
|
|
73
|
-
this.browser = await chromium.launch({ headless: true });
|
|
74
|
-
}
|
|
75
|
-
if (!this.page || this.page.isClosed()) {
|
|
76
|
-
this.page = await this.browser.newPage();
|
|
77
|
-
}
|
|
78
|
-
return this.page;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
114
|
async run(input: Record<string, unknown>, _ctx: ToolContext): Promise<ToolExecutionResult> {
|
|
82
|
-
// Lazy-load Playwright
|
|
115
|
+
// Lazy-load Playwright
|
|
83
116
|
try {
|
|
84
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
85
117
|
require("playwright");
|
|
86
118
|
} catch {
|
|
87
119
|
return {
|
|
88
120
|
success: false,
|
|
89
|
-
error:
|
|
90
|
-
"Playwright is not installed. Run: npm install playwright && npx playwright install chromium",
|
|
121
|
+
error: "Playwright is not installed. Run: npm install playwright && npx playwright install chromium",
|
|
91
122
|
};
|
|
92
123
|
}
|
|
93
124
|
|
|
94
125
|
const action = (input["action"] as BrowserAction) ?? "navigate";
|
|
95
|
-
const
|
|
126
|
+
const sessionId = input["sessionId"] as string | undefined;
|
|
127
|
+
const clear = (input["clear"] as boolean) ?? false;
|
|
96
128
|
|
|
97
129
|
try {
|
|
98
|
-
const page = await this.getPage();
|
|
99
|
-
|
|
100
130
|
switch (action) {
|
|
131
|
+
case "open": {
|
|
132
|
+
const { sessionId: sid, page } = await browserPool.getOrCreate(sessionId);
|
|
133
|
+
return {
|
|
134
|
+
success: true,
|
|
135
|
+
output: JSON.stringify({
|
|
136
|
+
sessionId: sid,
|
|
137
|
+
message: "Browser session created. Use this sessionId for all subsequent calls.",
|
|
138
|
+
}),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
101
142
|
case "navigate": {
|
|
143
|
+
const page = await this.requirePage(sessionId);
|
|
144
|
+
if (!page) return this.noSession();
|
|
102
145
|
const url = input["url"] as string;
|
|
103
|
-
if (!url) return { success: false, error: "url is required
|
|
104
|
-
await page.goto(url, { waitUntil: "domcontentloaded", timeout:
|
|
146
|
+
if (!url) return { success: false, error: "url is required" };
|
|
147
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 15000 });
|
|
105
148
|
const title = await page.title();
|
|
149
|
+
if (sessionId) browserPool.setInitUrl(sessionId, url);
|
|
106
150
|
return { success: true, output: JSON.stringify({ title, url: page.url() }) };
|
|
107
151
|
}
|
|
108
152
|
|
|
153
|
+
case "snapshot": {
|
|
154
|
+
const page = await this.requirePage(sessionId);
|
|
155
|
+
if (!page) return this.noSession();
|
|
156
|
+
const tree = await this.getAccessibilityTree(page);
|
|
157
|
+
return { success: true, output: tree };
|
|
158
|
+
}
|
|
159
|
+
|
|
109
160
|
case "get_text": {
|
|
110
|
-
const
|
|
111
|
-
|
|
161
|
+
const page = await this.requirePage(sessionId);
|
|
162
|
+
if (!page) return this.noSession();
|
|
163
|
+
const sel = (input["selector"] as string) ?? "body";
|
|
164
|
+
const text = await page.locator(sel).first().innerText({ timeout: 5000 });
|
|
112
165
|
return { success: true, output: text.trim() };
|
|
113
166
|
}
|
|
114
167
|
|
|
115
168
|
case "get_dom": {
|
|
116
|
-
const
|
|
117
|
-
|
|
169
|
+
const page = await this.requirePage(sessionId);
|
|
170
|
+
if (!page) return this.noSession();
|
|
171
|
+
const sel = (input["selector"] as string) ?? "body";
|
|
172
|
+
const html = await page.locator(sel).first().innerHTML({ timeout: 5000 });
|
|
118
173
|
return { success: true, output: html };
|
|
119
174
|
}
|
|
120
175
|
|
|
121
|
-
case "
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
for (const field of fields) {
|
|
133
|
-
const el = row.locator(`[class*="${field}"], [data-${field}], .${field}`).first();
|
|
134
|
-
obj[field] = (await el.innerText().catch(() => "")).trim();
|
|
135
|
-
}
|
|
136
|
-
results.push(obj);
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
return { success: true, output: JSON.stringify(results) };
|
|
176
|
+
case "screenshot": {
|
|
177
|
+
const page = await this.requirePage(sessionId);
|
|
178
|
+
if (!page) return this.noSession();
|
|
179
|
+
const buf = await page.screenshot({
|
|
180
|
+
type: "png",
|
|
181
|
+
fullPage: (input["fullPage"] as boolean) ?? false,
|
|
182
|
+
});
|
|
183
|
+
const b64 = buf.toString("base64");
|
|
184
|
+
// Cache for vision action
|
|
185
|
+
if (sessionId) browserPool.cacheScreenshot(sessionId, b64);
|
|
186
|
+
return { success: true, output: `screenshot:base64:${b64}` };
|
|
140
187
|
}
|
|
141
188
|
|
|
142
189
|
case "click": {
|
|
143
|
-
const
|
|
144
|
-
if (!
|
|
145
|
-
|
|
146
|
-
return { success:
|
|
190
|
+
const page = await this.requirePage(sessionId);
|
|
191
|
+
if (!page) return this.noSession();
|
|
192
|
+
const sel = input["selector"] as string;
|
|
193
|
+
if (!sel) return { success: false, error: "selector is required" };
|
|
194
|
+
const locator = this.resolveSelector(page, sel);
|
|
195
|
+
await locator.first().click({ timeout: 5000 });
|
|
196
|
+
return { success: true, output: `Clicked: ${sel}` };
|
|
147
197
|
}
|
|
148
198
|
|
|
149
199
|
case "type": {
|
|
150
|
-
const
|
|
200
|
+
const page = await this.requirePage(sessionId);
|
|
201
|
+
if (!page) return this.noSession();
|
|
202
|
+
const sel = input["selector"] as string;
|
|
151
203
|
const text = input["text"] as string;
|
|
152
|
-
if (!
|
|
153
|
-
|
|
154
|
-
|
|
204
|
+
if (!sel || text === undefined)
|
|
205
|
+
return { success: false, error: "selector and text required" };
|
|
206
|
+
const locator = this.resolveSelector(page, sel);
|
|
207
|
+
await locator.first().fill(text, { timeout: 5000 });
|
|
208
|
+
return { success: true, output: `Typed into ${sel}` };
|
|
155
209
|
}
|
|
156
210
|
|
|
157
211
|
case "select": {
|
|
158
|
-
const
|
|
212
|
+
const page = await this.requirePage(sessionId);
|
|
213
|
+
if (!page) return this.noSession();
|
|
214
|
+
const sel = input["selector"] as string;
|
|
159
215
|
const value = input["value"] as string;
|
|
160
|
-
if (!
|
|
161
|
-
|
|
162
|
-
|
|
216
|
+
if (!sel || !value)
|
|
217
|
+
return { success: false, error: "selector and value required" };
|
|
218
|
+
const locator = this.resolveSelector(page, sel);
|
|
219
|
+
await locator.first().selectOption(value, { timeout: 5000 });
|
|
220
|
+
return { success: true, output: `Selected ${value} in ${sel}` };
|
|
163
221
|
}
|
|
164
222
|
|
|
165
|
-
case "
|
|
166
|
-
const
|
|
167
|
-
|
|
223
|
+
case "scroll": {
|
|
224
|
+
const page = await this.requirePage(sessionId);
|
|
225
|
+
if (!page) return this.noSession();
|
|
226
|
+
const amount = parseInt(input["value"] as string ?? "") || 500;
|
|
227
|
+
await page.evaluate((px) => window.scrollBy(0, px), amount);
|
|
228
|
+
return { success: true, output: `Scrolled ${amount}px` };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
case "vision": {
|
|
232
|
+
if (!BrowserTool.visionAdapter) {
|
|
233
|
+
return { success: false, error: "Vision adapter not configured. Set BrowserTool.visionAdapter before use." };
|
|
234
|
+
}
|
|
235
|
+
const prompt = input["prompt"] as string;
|
|
236
|
+
if (!prompt) return { success: false, error: "prompt is required for vision analysis" };
|
|
237
|
+
const useCached = (input["useCached"] as boolean) ?? false;
|
|
238
|
+
const model = input["model"] as string | undefined;
|
|
239
|
+
|
|
240
|
+
let base64Data: string | null = null;
|
|
241
|
+
if (useCached && sessionId) {
|
|
242
|
+
base64Data = browserPool.getCachedScreenshot(sessionId);
|
|
243
|
+
}
|
|
244
|
+
if (!base64Data) {
|
|
245
|
+
const page = await this.requirePage(sessionId);
|
|
246
|
+
if (!page) return this.noSession();
|
|
247
|
+
const buf = await page.screenshot({
|
|
248
|
+
type: "png",
|
|
249
|
+
fullPage: (input["fullPage"] as boolean) ?? false,
|
|
250
|
+
});
|
|
251
|
+
base64Data = buf.toString("base64");
|
|
252
|
+
if (sessionId) browserPool.cacheScreenshot(sessionId, base64Data);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const visionMessage = buildUserVisionMessage(
|
|
256
|
+
`You are a senior UI/UX designer and graphic design specialist. ` +
|
|
257
|
+
`Analyze this screenshot with extreme attention to visual detail:\n` +
|
|
258
|
+
`- Colors: exact hex/rgb values of backgrounds, text, borders, buttons\n` +
|
|
259
|
+
`- Borders: presence, color, width (px), radius, style (solid/dashed)\n` +
|
|
260
|
+
`- Spacing: padding, margins, gaps between elements (estimate px)\n` +
|
|
261
|
+
`- Typography: font sizes, weights, alignment, line heights\n` +
|
|
262
|
+
`- Layout: element positions, centering, whitespace balance\n` +
|
|
263
|
+
`- Visual bugs: overflow, misalignment, overlapping, cut-off text\n` +
|
|
264
|
+
`\n` +
|
|
265
|
+
`User request: ${prompt}\n` +
|
|
266
|
+
`\n` +
|
|
267
|
+
`Prioritize the user's request above these defaults. ` +
|
|
268
|
+
`If the user asks about something specific, focus on that ` +
|
|
269
|
+
`while still noting any obvious visual issues.`,
|
|
270
|
+
base64Data
|
|
271
|
+
);
|
|
272
|
+
const response = await BrowserTool.visionAdapter.chat({
|
|
273
|
+
model: model || BrowserTool.visionModel,
|
|
274
|
+
messages: [visionMessage],
|
|
275
|
+
tools: [],
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
return { success: true, output: response.message.content ?? "(no vision analysis)" };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
case "console": {
|
|
282
|
+
if (!sessionId) return this.noSession();
|
|
283
|
+
const entries = browserPool.getConsole(sessionId);
|
|
284
|
+
if (clear) browserPool.clearConsole(sessionId);
|
|
285
|
+
if (entries.length === 0) return { success: true, output: "(no console output)" };
|
|
286
|
+
const lines = entries.map((e) => {
|
|
287
|
+
const tag = e.type === "error" ? "error" : e.type === "warning" ? "warn " : e.type;
|
|
288
|
+
const loc = e.location ? ` — ${e.location}` : "";
|
|
289
|
+
return `[${tag}] ${e.text.slice(0, 500)}${loc}`;
|
|
290
|
+
});
|
|
291
|
+
return { success: true, output: `Console (${entries.length} entries):\n${lines.join("\n")}` };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
case "network": {
|
|
295
|
+
if (!sessionId) return this.noSession();
|
|
296
|
+
const entries = browserPool.getNetwork(sessionId);
|
|
297
|
+
if (clear) browserPool.clearNetwork(sessionId);
|
|
298
|
+
if (entries.length === 0) return { success: true, output: "(no network activity)" };
|
|
299
|
+
const sorted = [...entries].sort((a, b) => a.idx - b.idx);
|
|
300
|
+
const lines = sorted.map((e) => {
|
|
301
|
+
const status = e.error ? "FAILED" : `${e.status} ${e.statusText ?? ""}`;
|
|
302
|
+
const dur = e.duration != null ? ` (${e.duration}ms)` : "";
|
|
303
|
+
return `${e.error ? "✗" : "✓"} ${e.method} ${e.url.slice(0, 120)} → ${status}${dur}`;
|
|
304
|
+
});
|
|
305
|
+
return {
|
|
306
|
+
success: true,
|
|
307
|
+
output: `Network (${entries.length} requests):\n${lines.join("\n")}`,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
case "new_page": {
|
|
312
|
+
if (!sessionId) return { success: false, error: "sessionId required — use `open` first" };
|
|
313
|
+
const { page } = await browserPool.newPage(sessionId);
|
|
314
|
+
return {
|
|
315
|
+
success: true,
|
|
316
|
+
output: JSON.stringify({ pageId: `pg_active`, url: page.url(), message: "New tab created" }),
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
case "switch_page": {
|
|
321
|
+
if (!sessionId) return this.noSession();
|
|
322
|
+
const pageId = input["pageId"] as string;
|
|
323
|
+
if (!pageId) return { success: false, error: "pageId required" };
|
|
324
|
+
const ok = browserPool.switchPage(sessionId, pageId);
|
|
325
|
+
return { success: ok, output: ok ? `Switched to ${pageId}` : `Page ${pageId} not found` };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
case "list_pages": {
|
|
329
|
+
if (!sessionId) return this.noSession();
|
|
330
|
+
const pages = browserPool.listPages(sessionId);
|
|
331
|
+
const session = (browserPool as any).sessions?.get(sessionId);
|
|
332
|
+
if (session) {
|
|
333
|
+
for (const p of pages) {
|
|
334
|
+
const bp = session.pages?.find((bp2: any) => bp2.id === p.id && !bp2.page.isClosed());
|
|
335
|
+
if (bp) {
|
|
336
|
+
try { p.title = await bp.page.title(); } catch {}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return {
|
|
341
|
+
success: true,
|
|
342
|
+
output: JSON.stringify(pages.map(p => ({
|
|
343
|
+
id: p.id,
|
|
344
|
+
url: p.url,
|
|
345
|
+
title: p.title,
|
|
346
|
+
active: (p as any).active,
|
|
347
|
+
}))),
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
case "list": {
|
|
352
|
+
const sessions = browserPool.list();
|
|
353
|
+
if (sessions.length === 0) return { success: true, output: "(no active browser sessions)" };
|
|
354
|
+
return { success: true, output: JSON.stringify(sessions, null, 2) };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
case "close_page": {
|
|
358
|
+
if (!sessionId) return this.noSession();
|
|
359
|
+
const pageId = input["pageId"] as string | undefined;
|
|
360
|
+
await browserPool.closePage(sessionId, pageId);
|
|
361
|
+
return { success: true, output: "Page closed" };
|
|
168
362
|
}
|
|
169
363
|
|
|
170
364
|
case "close": {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
return { success: true, output:
|
|
365
|
+
if (!sessionId) return { success: false, error: "sessionId required" };
|
|
366
|
+
await browserPool.destroy(sessionId);
|
|
367
|
+
return { success: true, output: `Session ${sessionId} closed` };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
case "close_all": {
|
|
371
|
+
const count = await browserPool.destroyAll();
|
|
372
|
+
return { success: true, output: `Closed ${count} browser session(s)` };
|
|
174
373
|
}
|
|
175
374
|
|
|
176
375
|
default:
|
|
@@ -180,4 +379,70 @@ export class BrowserTool extends ToolBase {
|
|
|
180
379
|
return { success: false, error: String(err instanceof Error ? err.message : err) };
|
|
181
380
|
}
|
|
182
381
|
}
|
|
183
|
-
|
|
382
|
+
|
|
383
|
+
// ── Private helpers ────────────────────────────────────────────────────────
|
|
384
|
+
|
|
385
|
+
private async requirePage(sessionId?: string): Promise<any> {
|
|
386
|
+
const { page } = await browserPool.getOrCreate(sessionId);
|
|
387
|
+
return page;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
private noSession(): ToolExecutionResult {
|
|
391
|
+
return { success: false, error: "No active session. Use action: 'open' first." };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Resolve [ref=eN] selectors from snapshot, or pass through CSS selectors. */
|
|
395
|
+
private resolveSelector(page: any, sel: string) {
|
|
396
|
+
const refMatch = sel.match(/^e(\d+)$/);
|
|
397
|
+
if (refMatch) {
|
|
398
|
+
return page.locator(`[data-ref="e${refMatch[1]}"]`);
|
|
399
|
+
}
|
|
400
|
+
const bracketMatch = sel.match(/^\[ref=e(\d+)\]$/);
|
|
401
|
+
if (bracketMatch) {
|
|
402
|
+
return page.locator(`[data-ref="e${bracketMatch[1]}"]`);
|
|
403
|
+
}
|
|
404
|
+
return page.locator(sel);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Build a simplified accessibility tree with [ref=eN] markers. */
|
|
408
|
+
private async getAccessibilityTree(page: any): Promise<string> {
|
|
409
|
+
const lines: string[] = [];
|
|
410
|
+
|
|
411
|
+
// Inject data-ref attributes into interactive elements
|
|
412
|
+
await page.evaluate(() => {
|
|
413
|
+
let counter = 0;
|
|
414
|
+
const interactive = document.querySelectorAll(
|
|
415
|
+
'a, button, input, select, textarea, [role="button"], [role="link"], [role="textbox"], [role="combobox"], [role="checkbox"], [role="radio"], [role="tab"], [role="menuitem"], [role="option"], [role="switch"], [onclick], summary, details'
|
|
416
|
+
);
|
|
417
|
+
interactive.forEach((el) => {
|
|
418
|
+
(el as HTMLElement).setAttribute("data-ref", `e${++counter}`);
|
|
419
|
+
});
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
// Use Playwright's accessibility snapshot
|
|
423
|
+
try {
|
|
424
|
+
const snapshot = await (page as any).accessibility.snapshot({ interestingOnly: false });
|
|
425
|
+
if (snapshot) {
|
|
426
|
+
this.flattenA11yTree(snapshot, lines, 0);
|
|
427
|
+
}
|
|
428
|
+
} catch {
|
|
429
|
+
const text = await page.locator("body").innerText();
|
|
430
|
+
lines.push(text.slice(0, 5000));
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return lines.join("\n") || "(empty page)";
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
private flattenA11yTree(node: any, lines: string[], depth: number): void {
|
|
437
|
+
const indent = " ".repeat(Math.min(depth, 6));
|
|
438
|
+
const role = node.role || "unknown";
|
|
439
|
+
const name = node.name ? ` "${node.name.slice(0, 80)}"` : "";
|
|
440
|
+
lines.push(`${indent}- ${role}${name}`);
|
|
441
|
+
|
|
442
|
+
if (node.children) {
|
|
443
|
+
for (const child of node.children) {
|
|
444
|
+
this.flattenA11yTree(child, lines, depth + 1);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|