@different-ai/opencode-browser 4.0.7 → 4.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/README.md +13 -15
- package/dist/plugin.js +24 -39
- package/extension/background.js +489 -84
- package/extension/manifest.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -23,11 +23,11 @@ The installer will:
|
|
|
23
23
|
1. Copy the extension to `~/.opencode-browser/extension/`
|
|
24
24
|
2. Walk you through loading + pinning it in `chrome://extensions`
|
|
25
25
|
3. Ask for the extension ID and install a **Native Messaging Host manifest**
|
|
26
|
-
4. Update your
|
|
26
|
+
4. Update your `opencode.json` or `opencode.jsonc` to load the plugin
|
|
27
27
|
|
|
28
28
|
### Configure OpenCode
|
|
29
29
|
|
|
30
|
-
Your
|
|
30
|
+
Your `opencode.json` or `opencode.jsonc` should contain:
|
|
31
31
|
|
|
32
32
|
```json
|
|
33
33
|
{
|
|
@@ -50,26 +50,24 @@ OpenCode Plugin <-> Local Broker (unix socket) <-> Native Host <-> Chrome Extens
|
|
|
50
50
|
|
|
51
51
|
- First time a session touches a tab, the broker **auto-claims** it for that session.
|
|
52
52
|
- Other sessions attempting to use the same tab will get an error.
|
|
53
|
-
|
|
54
|
-
Tools:
|
|
55
|
-
|
|
56
|
-
- `browser_claim_tab({ tabId })`
|
|
57
|
-
- `browser_release_tab({ tabId })`
|
|
58
|
-
- `browser_list_claims()`
|
|
53
|
+
- Use `browser_status` to inspect claims if needed.
|
|
59
54
|
|
|
60
55
|
## Available tools
|
|
61
56
|
|
|
62
|
-
|
|
57
|
+
Core primitives:
|
|
63
58
|
- `browser_status`
|
|
64
59
|
- `browser_get_tabs`
|
|
65
60
|
- `browser_navigate`
|
|
61
|
+
- `browser_query` (modes: `text`, `value`, `list`, `exists`, `page_text`; optional `timeoutMs`/`pollMs`)
|
|
66
62
|
- `browser_click`
|
|
67
63
|
- `browser_type`
|
|
68
|
-
- `browser_screenshot`
|
|
69
|
-
- `browser_snapshot`
|
|
70
64
|
- `browser_scroll`
|
|
71
65
|
- `browser_wait`
|
|
72
|
-
|
|
66
|
+
|
|
67
|
+
Diagnostics:
|
|
68
|
+
- `browser_snapshot`
|
|
69
|
+
- `browser_screenshot`
|
|
70
|
+
- `browser_version`
|
|
73
71
|
|
|
74
72
|
## Troubleshooting
|
|
75
73
|
|
|
@@ -78,8 +76,8 @@ Tools:
|
|
|
78
76
|
- Confirm the extension ID you pasted matches the loaded extension in `chrome://extensions`
|
|
79
77
|
|
|
80
78
|
**Tab ownership errors**
|
|
81
|
-
- Use `
|
|
82
|
-
-
|
|
79
|
+
- Use `browser_status` to see current claims
|
|
80
|
+
- Close the other OpenCode session to release ownership
|
|
83
81
|
|
|
84
82
|
## Uninstall
|
|
85
83
|
|
|
@@ -87,4 +85,4 @@ Tools:
|
|
|
87
85
|
npx @different-ai/opencode-browser uninstall
|
|
88
86
|
```
|
|
89
87
|
|
|
90
|
-
Then remove the unpacked extension in `chrome://extensions` and remove the plugin from
|
|
88
|
+
Then remove the unpacked extension in `chrome://extensions` and remove the plugin from `opencode.json` or `opencode.jsonc`.
|
package/dist/plugin.js
CHANGED
|
@@ -12529,10 +12529,11 @@ var plugin = async (ctx) => {
|
|
|
12529
12529
|
description: "Click an element on the page using a CSS selector",
|
|
12530
12530
|
args: {
|
|
12531
12531
|
selector: schema.string(),
|
|
12532
|
+
index: schema.number().optional(),
|
|
12532
12533
|
tabId: schema.number().optional()
|
|
12533
12534
|
},
|
|
12534
|
-
async execute({ selector, tabId }, ctx2) {
|
|
12535
|
-
const data = await brokerRequest("tool", { tool: "click", args: { selector, tabId } });
|
|
12535
|
+
async execute({ selector, index, tabId }, ctx2) {
|
|
12536
|
+
const data = await brokerRequest("tool", { tool: "click", args: { selector, index, tabId } });
|
|
12536
12537
|
return toolResultText(data, `Clicked ${selector}`);
|
|
12537
12538
|
}
|
|
12538
12539
|
}),
|
|
@@ -12542,10 +12543,11 @@ var plugin = async (ctx) => {
|
|
|
12542
12543
|
selector: schema.string(),
|
|
12543
12544
|
text: schema.string(),
|
|
12544
12545
|
clear: schema.boolean().optional(),
|
|
12546
|
+
index: schema.number().optional(),
|
|
12545
12547
|
tabId: schema.number().optional()
|
|
12546
12548
|
},
|
|
12547
|
-
async execute({ selector, text, clear, tabId }, ctx2) {
|
|
12548
|
-
const data = await brokerRequest("tool", { tool: "type", args: { selector, text, clear, tabId } });
|
|
12549
|
+
async execute({ selector, text, clear, index, tabId }, ctx2) {
|
|
12550
|
+
const data = await brokerRequest("tool", { tool: "type", args: { selector, text, clear, index, tabId } });
|
|
12549
12551
|
return toolResultText(data, `Typed "${text}" into ${selector}`);
|
|
12550
12552
|
}
|
|
12551
12553
|
}),
|
|
@@ -12593,44 +12595,27 @@ var plugin = async (ctx) => {
|
|
|
12593
12595
|
return toolResultText(data, "Waited");
|
|
12594
12596
|
}
|
|
12595
12597
|
}),
|
|
12596
|
-
|
|
12597
|
-
description: "
|
|
12598
|
+
browser_query: tool({
|
|
12599
|
+
description: "Read data from the page using selectors, optional wait, or page_text extraction (shadow DOM + same-origin iframes).",
|
|
12598
12600
|
args: {
|
|
12599
|
-
|
|
12601
|
+
selector: schema.string().optional(),
|
|
12602
|
+
mode: schema.string().optional(),
|
|
12603
|
+
attribute: schema.string().optional(),
|
|
12604
|
+
property: schema.string().optional(),
|
|
12605
|
+
index: schema.number().optional(),
|
|
12606
|
+
limit: schema.number().optional(),
|
|
12607
|
+
timeoutMs: schema.number().optional(),
|
|
12608
|
+
pollMs: schema.number().optional(),
|
|
12609
|
+
pattern: schema.string().optional(),
|
|
12610
|
+
flags: schema.string().optional(),
|
|
12600
12611
|
tabId: schema.number().optional()
|
|
12601
12612
|
},
|
|
12602
|
-
async execute({
|
|
12603
|
-
const data = await brokerRequest("tool", {
|
|
12604
|
-
|
|
12605
|
-
|
|
12606
|
-
|
|
12607
|
-
|
|
12608
|
-
description: "Claim a tab for this OpenCode session (per-tab ownership).",
|
|
12609
|
-
args: {
|
|
12610
|
-
tabId: schema.number(),
|
|
12611
|
-
force: schema.boolean().optional()
|
|
12612
|
-
},
|
|
12613
|
-
async execute({ tabId, force }, ctx2) {
|
|
12614
|
-
const data = await brokerRequest("claim_tab", { tabId, force });
|
|
12615
|
-
return JSON.stringify(data);
|
|
12616
|
-
}
|
|
12617
|
-
}),
|
|
12618
|
-
browser_release_tab: tool({
|
|
12619
|
-
description: "Release a previously claimed tab.",
|
|
12620
|
-
args: {
|
|
12621
|
-
tabId: schema.number()
|
|
12622
|
-
},
|
|
12623
|
-
async execute({ tabId }, ctx2) {
|
|
12624
|
-
const data = await brokerRequest("release_tab", { tabId });
|
|
12625
|
-
return JSON.stringify(data);
|
|
12626
|
-
}
|
|
12627
|
-
}),
|
|
12628
|
-
browser_list_claims: tool({
|
|
12629
|
-
description: "List current tab ownership claims.",
|
|
12630
|
-
args: {},
|
|
12631
|
-
async execute(args, ctx2) {
|
|
12632
|
-
const data = await brokerRequest("list_claims", {});
|
|
12633
|
-
return JSON.stringify(data);
|
|
12613
|
+
async execute({ selector, mode, attribute, property, index, limit, timeoutMs, pollMs, pattern, flags, tabId }, ctx2) {
|
|
12614
|
+
const data = await brokerRequest("tool", {
|
|
12615
|
+
tool: "query",
|
|
12616
|
+
args: { selector, mode, attribute, property, index, limit, timeoutMs, pollMs, pattern, flags, tabId }
|
|
12617
|
+
});
|
|
12618
|
+
return toolResultText(data, "Query failed");
|
|
12634
12619
|
}
|
|
12635
12620
|
})
|
|
12636
12621
|
}
|
package/extension/background.js
CHANGED
|
@@ -15,7 +15,9 @@ chrome.alarms.onAlarm.addListener((alarm) => {
|
|
|
15
15
|
|
|
16
16
|
function connect() {
|
|
17
17
|
if (port) {
|
|
18
|
-
try {
|
|
18
|
+
try {
|
|
19
|
+
port.disconnect()
|
|
20
|
+
} catch {}
|
|
19
21
|
port = null
|
|
20
22
|
}
|
|
21
23
|
|
|
@@ -35,7 +37,6 @@ function connect() {
|
|
|
35
37
|
|
|
36
38
|
const err = chrome.runtime.lastError
|
|
37
39
|
if (err?.message) {
|
|
38
|
-
// Usually means native host not installed or crashed
|
|
39
40
|
connectionAttempts++
|
|
40
41
|
if (connectionAttempts === 1) {
|
|
41
42
|
console.log("[OpenCode] Native host not available. Run: npx @different-ai/opencode-browser install")
|
|
@@ -104,7 +105,7 @@ async function executeTool(toolName, args) {
|
|
|
104
105
|
type: toolType,
|
|
105
106
|
screenshot: toolScreenshot,
|
|
106
107
|
snapshot: toolSnapshot,
|
|
107
|
-
|
|
108
|
+
query: toolQuery,
|
|
108
109
|
scroll: toolScroll,
|
|
109
110
|
wait: toolWait,
|
|
110
111
|
}
|
|
@@ -124,6 +125,363 @@ async function getTabById(tabId) {
|
|
|
124
125
|
return tabId ? await chrome.tabs.get(tabId) : await getActiveTab()
|
|
125
126
|
}
|
|
126
127
|
|
|
128
|
+
async function runInPage(tabId, command, args) {
|
|
129
|
+
const result = await chrome.scripting.executeScript({
|
|
130
|
+
target: { tabId },
|
|
131
|
+
func: pageOps,
|
|
132
|
+
args: [command, args || {}],
|
|
133
|
+
world: "ISOLATED",
|
|
134
|
+
})
|
|
135
|
+
return result[0]?.result
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function pageOps(command, args) {
|
|
139
|
+
const options = args || {}
|
|
140
|
+
const MAX_DEPTH = 6
|
|
141
|
+
|
|
142
|
+
function safeString(value) {
|
|
143
|
+
return typeof value === "string" ? value : ""
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function normalizeSelectorList(selector) {
|
|
147
|
+
if (Array.isArray(selector)) {
|
|
148
|
+
return selector.map((s) => safeString(s).trim()).filter(Boolean)
|
|
149
|
+
}
|
|
150
|
+
if (typeof selector !== "string") return []
|
|
151
|
+
const parts = selector
|
|
152
|
+
.split(",")
|
|
153
|
+
.map((s) => s.trim())
|
|
154
|
+
.filter(Boolean)
|
|
155
|
+
return parts.length ? parts : [selector.trim()].filter(Boolean)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function isVisible(el) {
|
|
159
|
+
if (!el) return false
|
|
160
|
+
const rect = el.getBoundingClientRect()
|
|
161
|
+
if (rect.width <= 0 || rect.height <= 0) return false
|
|
162
|
+
const style = window.getComputedStyle(el)
|
|
163
|
+
if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") return false
|
|
164
|
+
return true
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function deepQuerySelectorAll(sel, rootDoc) {
|
|
168
|
+
const out = []
|
|
169
|
+
const seen = new Set()
|
|
170
|
+
|
|
171
|
+
function addAll(nodeList) {
|
|
172
|
+
for (const el of nodeList) {
|
|
173
|
+
if (!el || seen.has(el)) continue
|
|
174
|
+
seen.add(el)
|
|
175
|
+
out.push(el)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function walkRoot(root, depth) {
|
|
180
|
+
if (!root || depth > MAX_DEPTH) return
|
|
181
|
+
try {
|
|
182
|
+
addAll(root.querySelectorAll(sel))
|
|
183
|
+
} catch {
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const tree = root.querySelectorAll ? root.querySelectorAll("*") : []
|
|
188
|
+
for (const el of tree) {
|
|
189
|
+
if (el.shadowRoot) {
|
|
190
|
+
walkRoot(el.shadowRoot, depth + 1)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const frames = root.querySelectorAll ? root.querySelectorAll("iframe") : []
|
|
195
|
+
for (const frame of frames) {
|
|
196
|
+
try {
|
|
197
|
+
const doc = frame.contentDocument
|
|
198
|
+
if (doc) walkRoot(doc, depth + 1)
|
|
199
|
+
} catch {}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
walkRoot(rootDoc || document, 0)
|
|
204
|
+
return out
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function resolveMatches(selectors, index) {
|
|
208
|
+
for (const sel of selectors) {
|
|
209
|
+
const s = safeString(sel)
|
|
210
|
+
if (!s) continue
|
|
211
|
+
const matches = deepQuerySelectorAll(s, document)
|
|
212
|
+
if (!matches.length) continue
|
|
213
|
+
const visible = matches.filter(isVisible)
|
|
214
|
+
const chosen = visible[index] || matches[index]
|
|
215
|
+
return { selectorUsed: s, matches, chosen }
|
|
216
|
+
}
|
|
217
|
+
return { selectorUsed: selectors[0] || "", matches: [], chosen: null }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function clickElement(el) {
|
|
221
|
+
try {
|
|
222
|
+
el.scrollIntoView({ block: "center", inline: "center" })
|
|
223
|
+
} catch {}
|
|
224
|
+
|
|
225
|
+
const rect = el.getBoundingClientRect()
|
|
226
|
+
const x = Math.min(Math.max(rect.left + rect.width / 2, 0), window.innerWidth - 1)
|
|
227
|
+
const y = Math.min(Math.max(rect.top + rect.height / 2, 0), window.innerHeight - 1)
|
|
228
|
+
const opts = { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y }
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
el.dispatchEvent(new MouseEvent("mouseover", opts))
|
|
232
|
+
el.dispatchEvent(new MouseEvent("mousemove", opts))
|
|
233
|
+
el.dispatchEvent(new MouseEvent("mousedown", opts))
|
|
234
|
+
el.dispatchEvent(new MouseEvent("mouseup", opts))
|
|
235
|
+
el.dispatchEvent(new MouseEvent("click", opts))
|
|
236
|
+
} catch {}
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
el.click()
|
|
240
|
+
} catch {}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function setNativeValue(el, value) {
|
|
244
|
+
const tag = el.tagName
|
|
245
|
+
if (tag === "INPUT" || tag === "TEXTAREA") {
|
|
246
|
+
const proto = tag === "INPUT" ? window.HTMLInputElement.prototype : window.HTMLTextAreaElement.prototype
|
|
247
|
+
const setter = Object.getOwnPropertyDescriptor(proto, "value")?.set
|
|
248
|
+
if (setter) setter.call(el, value)
|
|
249
|
+
else el.value = value
|
|
250
|
+
return true
|
|
251
|
+
}
|
|
252
|
+
return false
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function getInputValues() {
|
|
256
|
+
const out = []
|
|
257
|
+
const nodes = document.querySelectorAll("input, textarea")
|
|
258
|
+
nodes.forEach((el) => {
|
|
259
|
+
try {
|
|
260
|
+
const name = el.getAttribute("aria-label") || el.getAttribute("name") || el.id || el.className || el.tagName
|
|
261
|
+
const value = el.value
|
|
262
|
+
if (value != null && String(value).trim()) out.push(`${name}: ${value}`)
|
|
263
|
+
} catch {}
|
|
264
|
+
})
|
|
265
|
+
return out.join("\n")
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function getPseudoText() {
|
|
269
|
+
const out = []
|
|
270
|
+
const elements = Array.from(document.querySelectorAll("*"))
|
|
271
|
+
for (let i = 0; i < elements.length && out.length < 2000; i++) {
|
|
272
|
+
const el = elements[i]
|
|
273
|
+
try {
|
|
274
|
+
const style = window.getComputedStyle(el)
|
|
275
|
+
if (style.display === "none" || style.visibility === "hidden") continue
|
|
276
|
+
const before = window.getComputedStyle(el, "::before").content
|
|
277
|
+
const after = window.getComputedStyle(el, "::after").content
|
|
278
|
+
const pushContent = (content) => {
|
|
279
|
+
if (!content) return
|
|
280
|
+
const c = String(content)
|
|
281
|
+
if (!c || c === "none" || c === "normal") return
|
|
282
|
+
const unquoted = c.replace(/^"|"$/g, "").replace(/^'|'$/g, "")
|
|
283
|
+
if (unquoted && unquoted !== "none" && unquoted !== "normal") out.push(unquoted)
|
|
284
|
+
}
|
|
285
|
+
pushContent(before)
|
|
286
|
+
pushContent(after)
|
|
287
|
+
} catch {}
|
|
288
|
+
}
|
|
289
|
+
return out.join("\n")
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function buildMatches(text, pattern, flags) {
|
|
293
|
+
if (!pattern) return []
|
|
294
|
+
try {
|
|
295
|
+
const re = new RegExp(pattern, flags || "")
|
|
296
|
+
const found = []
|
|
297
|
+
let m
|
|
298
|
+
while ((m = re.exec(text)) && found.length < 50) {
|
|
299
|
+
found.push(m[0])
|
|
300
|
+
if (!re.global) break
|
|
301
|
+
}
|
|
302
|
+
return found
|
|
303
|
+
} catch {
|
|
304
|
+
return []
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function getPageText(limit, pattern, flags) {
|
|
309
|
+
const parts = []
|
|
310
|
+
const bodyText = safeString(document.body?.innerText || "")
|
|
311
|
+
if (bodyText.trim()) parts.push(bodyText)
|
|
312
|
+
const inputValues = getInputValues()
|
|
313
|
+
if (inputValues) parts.push(inputValues)
|
|
314
|
+
const pseudo = getPseudoText()
|
|
315
|
+
if (pseudo) parts.push(pseudo)
|
|
316
|
+
const text = parts.filter(Boolean).join("\n\n").slice(0, Math.max(0, limit))
|
|
317
|
+
return {
|
|
318
|
+
url: location.href,
|
|
319
|
+
title: document.title,
|
|
320
|
+
text,
|
|
321
|
+
matches: buildMatches(text, pattern, flags),
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const mode = typeof options.mode === "string" && options.mode ? options.mode : "text"
|
|
326
|
+
const selectors = normalizeSelectorList(options.selector)
|
|
327
|
+
const index = Number.isFinite(options.index) ? options.index : 0
|
|
328
|
+
const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : 0
|
|
329
|
+
const pollMs = Number.isFinite(options.pollMs) ? options.pollMs : 200
|
|
330
|
+
const limit = Number.isFinite(options.limit) ? options.limit : mode === "page_text" ? 20000 : 50
|
|
331
|
+
const pattern = typeof options.pattern === "string" ? options.pattern : null
|
|
332
|
+
const flags = typeof options.flags === "string" ? options.flags : "i"
|
|
333
|
+
|
|
334
|
+
if (command === "click") {
|
|
335
|
+
const match = resolveMatches(selectors, index)
|
|
336
|
+
if (!match.chosen) {
|
|
337
|
+
return { ok: false, error: `Element not found for selectors: ${selectors.join(", ")}` }
|
|
338
|
+
}
|
|
339
|
+
clickElement(match.chosen)
|
|
340
|
+
return { ok: true, selectorUsed: match.selectorUsed }
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (command === "type") {
|
|
344
|
+
const text = options.text
|
|
345
|
+
const shouldClear = !!options.clear
|
|
346
|
+
const match = resolveMatches(selectors, index)
|
|
347
|
+
if (!match.chosen) {
|
|
348
|
+
return { ok: false, error: `Element not found for selectors: ${selectors.join(", ")}` }
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
try {
|
|
352
|
+
match.chosen.scrollIntoView({ block: "center", inline: "center" })
|
|
353
|
+
} catch {}
|
|
354
|
+
|
|
355
|
+
try {
|
|
356
|
+
match.chosen.focus()
|
|
357
|
+
} catch {}
|
|
358
|
+
|
|
359
|
+
const tag = match.chosen.tagName
|
|
360
|
+
const isTextInput = tag === "INPUT" || tag === "TEXTAREA"
|
|
361
|
+
|
|
362
|
+
if (isTextInput) {
|
|
363
|
+
if (shouldClear) setNativeValue(match.chosen, "")
|
|
364
|
+
setNativeValue(match.chosen, (match.chosen.value || "") + text)
|
|
365
|
+
match.chosen.dispatchEvent(new Event("input", { bubbles: true }))
|
|
366
|
+
match.chosen.dispatchEvent(new Event("change", { bubbles: true }))
|
|
367
|
+
return { ok: true, selectorUsed: match.selectorUsed }
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (match.chosen.isContentEditable) {
|
|
371
|
+
if (shouldClear) match.chosen.textContent = ""
|
|
372
|
+
try {
|
|
373
|
+
document.execCommand("insertText", false, text)
|
|
374
|
+
} catch {
|
|
375
|
+
match.chosen.textContent = (match.chosen.textContent || "") + text
|
|
376
|
+
}
|
|
377
|
+
match.chosen.dispatchEvent(new Event("input", { bubbles: true }))
|
|
378
|
+
return { ok: true, selectorUsed: match.selectorUsed }
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return { ok: false, error: `Element is not typable: ${match.selectorUsed} (${tag.toLowerCase()})` }
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (command === "scroll") {
|
|
385
|
+
const scrollX = Number.isFinite(options.x) ? options.x : 0
|
|
386
|
+
const scrollY = Number.isFinite(options.y) ? options.y : 0
|
|
387
|
+
if (selectors.length) {
|
|
388
|
+
const match = resolveMatches(selectors, index)
|
|
389
|
+
if (!match.chosen) {
|
|
390
|
+
return { ok: false, error: `Element not found for selectors: ${selectors.join(", ")}` }
|
|
391
|
+
}
|
|
392
|
+
try {
|
|
393
|
+
match.chosen.scrollIntoView({ behavior: "smooth", block: "center" })
|
|
394
|
+
} catch {}
|
|
395
|
+
return { ok: true, selectorUsed: match.selectorUsed }
|
|
396
|
+
}
|
|
397
|
+
window.scrollBy(scrollX, scrollY)
|
|
398
|
+
return { ok: true }
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (command === "query") {
|
|
402
|
+
if (mode === "page_text") {
|
|
403
|
+
if (selectors.length && timeoutMs > 0) {
|
|
404
|
+
const start = Date.now()
|
|
405
|
+
while (Date.now() - start < timeoutMs) {
|
|
406
|
+
const match = resolveMatches(selectors, index)
|
|
407
|
+
if (match.matches.length) break
|
|
408
|
+
await new Promise((r) => setTimeout(r, pollMs))
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return { ok: true, value: getPageText(limit, pattern, flags) }
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (!selectors.length) {
|
|
415
|
+
return { ok: false, error: "Selector is required" }
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
let match = resolveMatches(selectors, index)
|
|
419
|
+
if (timeoutMs > 0) {
|
|
420
|
+
const start = Date.now()
|
|
421
|
+
while (Date.now() - start < timeoutMs) {
|
|
422
|
+
match = resolveMatches(selectors, index)
|
|
423
|
+
if (mode === "exists" && match.matches.length) break
|
|
424
|
+
if (mode !== "exists" && match.chosen) break
|
|
425
|
+
await new Promise((r) => setTimeout(r, pollMs))
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (mode === "exists") {
|
|
430
|
+
return {
|
|
431
|
+
ok: true,
|
|
432
|
+
selectorUsed: match.selectorUsed,
|
|
433
|
+
value: { exists: match.matches.length > 0, count: match.matches.length },
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (!match.chosen) {
|
|
438
|
+
return { ok: false, error: `No matches for selectors: ${selectors.join(", ")}` }
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (mode === "text") {
|
|
442
|
+
const text = (match.chosen.innerText || match.chosen.textContent || "").trim()
|
|
443
|
+
return { ok: true, selectorUsed: match.selectorUsed, value: text }
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (mode === "value") {
|
|
447
|
+
const value = match.chosen.value
|
|
448
|
+
return { ok: true, selectorUsed: match.selectorUsed, value: typeof value === "string" ? value : String(value ?? "") }
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (mode === "attribute") {
|
|
452
|
+
const value = options.attribute ? match.chosen.getAttribute(options.attribute) : null
|
|
453
|
+
return { ok: true, selectorUsed: match.selectorUsed, value }
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (mode === "property") {
|
|
457
|
+
if (!options.property) return { ok: false, error: "property is required" }
|
|
458
|
+
return { ok: true, selectorUsed: match.selectorUsed, value: match.chosen[options.property] }
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (mode === "html") {
|
|
462
|
+
return { ok: true, selectorUsed: match.selectorUsed, value: match.chosen.outerHTML }
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (mode === "list") {
|
|
466
|
+
const maxItems = Math.min(Math.max(1, limit), 200)
|
|
467
|
+
const items = match.matches.slice(0, maxItems).map((el) => ({
|
|
468
|
+
text: (el.innerText || el.textContent || "").trim().slice(0, 200),
|
|
469
|
+
tag: (el.tagName || "").toLowerCase(),
|
|
470
|
+
ariaLabel: el.getAttribute ? el.getAttribute("aria-label") : null,
|
|
471
|
+
}))
|
|
472
|
+
return {
|
|
473
|
+
ok: true,
|
|
474
|
+
selectorUsed: match.selectorUsed,
|
|
475
|
+
value: { items, count: match.matches.length },
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
return { ok: false, error: `Unknown mode: ${mode}` }
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return { ok: false, error: `Unknown command: ${String(command)}` }
|
|
483
|
+
}
|
|
484
|
+
|
|
127
485
|
async function toolGetActiveTab() {
|
|
128
486
|
const tab = await getActiveTab()
|
|
129
487
|
return { tabId: tab.id, content: { tabId: tab.id, url: tab.url, title: tab.title } }
|
|
@@ -151,52 +509,25 @@ async function toolNavigate({ url, tabId }) {
|
|
|
151
509
|
return { tabId: tab.id, content: `Navigated to ${url}` }
|
|
152
510
|
}
|
|
153
511
|
|
|
154
|
-
async function toolClick({ selector, tabId }) {
|
|
512
|
+
async function toolClick({ selector, tabId, index = 0 }) {
|
|
155
513
|
if (!selector) throw new Error("Selector is required")
|
|
156
514
|
const tab = await getTabById(tabId)
|
|
157
515
|
|
|
158
|
-
const result = await
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
if (!el) return { success: false, error: `Element not found: ${sel}` }
|
|
163
|
-
el.click()
|
|
164
|
-
return { success: true }
|
|
165
|
-
},
|
|
166
|
-
args: [selector],
|
|
167
|
-
})
|
|
168
|
-
|
|
169
|
-
if (!result[0]?.result?.success) throw new Error(result[0]?.result?.error || "Click failed")
|
|
170
|
-
return { tabId: tab.id, content: `Clicked ${selector}` }
|
|
516
|
+
const result = await runInPage(tab.id, "click", { selector, index })
|
|
517
|
+
if (!result?.ok) throw new Error(result?.error || "Click failed")
|
|
518
|
+
const used = result.selectorUsed || selector
|
|
519
|
+
return { tabId: tab.id, content: `Clicked ${used}` }
|
|
171
520
|
}
|
|
172
521
|
|
|
173
|
-
async function toolType({ selector, text, tabId, clear = false }) {
|
|
522
|
+
async function toolType({ selector, text, tabId, clear = false, index = 0 }) {
|
|
174
523
|
if (!selector) throw new Error("Selector is required")
|
|
175
524
|
if (text === undefined) throw new Error("Text is required")
|
|
176
525
|
const tab = await getTabById(tabId)
|
|
177
526
|
|
|
178
|
-
const result = await
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
if (!el) return { success: false, error: `Element not found: ${sel}` }
|
|
183
|
-
el.focus()
|
|
184
|
-
if (shouldClear && (el.tagName === "INPUT" || el.tagName === "TEXTAREA")) el.value = ""
|
|
185
|
-
|
|
186
|
-
if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") {
|
|
187
|
-
el.value = el.value + txt
|
|
188
|
-
el.dispatchEvent(new Event("input", { bubbles: true }))
|
|
189
|
-
el.dispatchEvent(new Event("change", { bubbles: true }))
|
|
190
|
-
} else if (el.isContentEditable) {
|
|
191
|
-
document.execCommand("insertText", false, txt)
|
|
192
|
-
}
|
|
193
|
-
return { success: true }
|
|
194
|
-
},
|
|
195
|
-
args: [selector, text, clear],
|
|
196
|
-
})
|
|
197
|
-
|
|
198
|
-
if (!result[0]?.result?.success) throw new Error(result[0]?.result?.error || "Type failed")
|
|
199
|
-
return { tabId: tab.id, content: `Typed "${text}" into ${selector}` }
|
|
527
|
+
const result = await runInPage(tab.id, "type", { selector, text, clear, index })
|
|
528
|
+
if (!result?.ok) throw new Error(result?.error || "Type failed")
|
|
529
|
+
const used = result.selectorUsed || selector
|
|
530
|
+
return { tabId: tab.id, content: `Typed "${text}" into ${used}` }
|
|
200
531
|
}
|
|
201
532
|
|
|
202
533
|
async function toolScreenshot({ tabId }) {
|
|
@@ -211,56 +542,109 @@ async function toolSnapshot({ tabId }) {
|
|
|
211
542
|
const result = await chrome.scripting.executeScript({
|
|
212
543
|
target: { tabId: tab.id },
|
|
213
544
|
func: () => {
|
|
545
|
+
function safeText(s) {
|
|
546
|
+
return typeof s === "string" ? s : ""
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function isVisible(el) {
|
|
550
|
+
if (!el) return false
|
|
551
|
+
const rect = el.getBoundingClientRect()
|
|
552
|
+
if (rect.width <= 0 || rect.height <= 0) return false
|
|
553
|
+
const style = window.getComputedStyle(el)
|
|
554
|
+
if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") return false
|
|
555
|
+
return true
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function pseudoText(el) {
|
|
559
|
+
try {
|
|
560
|
+
const before = window.getComputedStyle(el, "::before").content
|
|
561
|
+
const after = window.getComputedStyle(el, "::after").content
|
|
562
|
+
const norm = (v) => {
|
|
563
|
+
const s = safeText(v)
|
|
564
|
+
if (!s || s === "none") return ""
|
|
565
|
+
return s.replace(/^"|"$/g, "")
|
|
566
|
+
}
|
|
567
|
+
return { before: norm(before), after: norm(after) }
|
|
568
|
+
} catch {
|
|
569
|
+
return { before: "", after: "" }
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
214
573
|
function getName(el) {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
)
|
|
574
|
+
const aria = el.getAttribute("aria-label")
|
|
575
|
+
if (aria) return aria
|
|
576
|
+
const alt = el.getAttribute("alt")
|
|
577
|
+
if (alt) return alt
|
|
578
|
+
const title = el.getAttribute("title")
|
|
579
|
+
if (title) return title
|
|
580
|
+
const placeholder = el.getAttribute("placeholder")
|
|
581
|
+
if (placeholder) return placeholder
|
|
582
|
+
const txt = safeText(el.innerText)
|
|
583
|
+
if (txt.trim()) return txt.slice(0, 200)
|
|
584
|
+
const pt = pseudoText(el)
|
|
585
|
+
const combo = `${pt.before} ${pt.after}`.trim()
|
|
586
|
+
if (combo) return combo.slice(0, 200)
|
|
587
|
+
return ""
|
|
223
588
|
}
|
|
224
589
|
|
|
225
590
|
function build(el, depth = 0, uid = 0) {
|
|
226
|
-
if (depth >
|
|
591
|
+
if (!el || depth > 12) return { nodes: [], nextUid: uid }
|
|
227
592
|
const nodes = []
|
|
228
|
-
|
|
229
|
-
if (
|
|
593
|
+
|
|
594
|
+
if (!isVisible(el)) return { nodes: [], nextUid: uid }
|
|
230
595
|
|
|
231
596
|
const isInteractive =
|
|
232
597
|
["A", "BUTTON", "INPUT", "TEXTAREA", "SELECT"].includes(el.tagName) ||
|
|
233
598
|
el.getAttribute("onclick") ||
|
|
234
599
|
el.getAttribute("role") === "button" ||
|
|
235
600
|
el.isContentEditable
|
|
236
|
-
const rect = el.getBoundingClientRect()
|
|
237
601
|
|
|
238
|
-
|
|
602
|
+
const name = getName(el)
|
|
603
|
+
const pt = pseudoText(el)
|
|
604
|
+
|
|
605
|
+
const shouldInclude = isInteractive || name.trim() || pt.before || pt.after
|
|
606
|
+
|
|
607
|
+
if (shouldInclude) {
|
|
239
608
|
const node = {
|
|
240
609
|
uid: `e${uid}`,
|
|
241
610
|
role: el.getAttribute("role") || el.tagName.toLowerCase(),
|
|
242
|
-
name:
|
|
611
|
+
name: name,
|
|
243
612
|
tag: el.tagName.toLowerCase(),
|
|
244
613
|
}
|
|
614
|
+
|
|
615
|
+
if (pt.before) node.before = pt.before
|
|
616
|
+
if (pt.after) node.after = pt.after
|
|
617
|
+
|
|
245
618
|
if (el.href) node.href = el.href
|
|
246
|
-
|
|
619
|
+
|
|
620
|
+
if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") {
|
|
247
621
|
node.type = el.type
|
|
248
622
|
node.value = el.value
|
|
623
|
+
if (el.readOnly) node.readOnly = true
|
|
624
|
+
if (el.disabled) node.disabled = true
|
|
249
625
|
}
|
|
626
|
+
|
|
250
627
|
if (el.id) node.selector = `#${el.id}`
|
|
251
628
|
else if (el.className && typeof el.className === "string") {
|
|
252
629
|
const cls = el.className.trim().split(/\s+/).slice(0, 2).join(".")
|
|
253
630
|
if (cls) node.selector = `${el.tagName.toLowerCase()}.${cls}`
|
|
254
631
|
}
|
|
632
|
+
|
|
255
633
|
nodes.push(node)
|
|
256
634
|
uid++
|
|
257
635
|
}
|
|
258
636
|
|
|
637
|
+
if (el.shadowRoot) {
|
|
638
|
+
const r = build(el.shadowRoot.host, depth + 1, uid)
|
|
639
|
+
uid = r.nextUid
|
|
640
|
+
}
|
|
641
|
+
|
|
259
642
|
for (const child of el.children) {
|
|
260
643
|
const r = build(child, depth + 1, uid)
|
|
261
644
|
nodes.push(...r.nodes)
|
|
262
645
|
uid = r.nextUid
|
|
263
646
|
}
|
|
647
|
+
|
|
264
648
|
return { nodes, nextUid: uid }
|
|
265
649
|
}
|
|
266
650
|
|
|
@@ -275,16 +659,25 @@ async function toolSnapshot({ tabId }) {
|
|
|
275
659
|
links.push({ href, text })
|
|
276
660
|
}
|
|
277
661
|
})
|
|
278
|
-
return links.slice(0,
|
|
662
|
+
return links.slice(0, 200)
|
|
279
663
|
}
|
|
280
664
|
|
|
665
|
+
let pageText = ""
|
|
666
|
+
try {
|
|
667
|
+
pageText = safeText(document.body?.innerText || "").slice(0, 20000)
|
|
668
|
+
} catch {}
|
|
669
|
+
|
|
670
|
+
const built = build(document.body).nodes.slice(0, 800)
|
|
671
|
+
|
|
281
672
|
return {
|
|
282
673
|
url: location.href,
|
|
283
674
|
title: document.title,
|
|
284
|
-
|
|
675
|
+
text: pageText,
|
|
676
|
+
nodes: built,
|
|
285
677
|
links: getAllLinks(),
|
|
286
678
|
}
|
|
287
679
|
},
|
|
680
|
+
world: "ISOLATED",
|
|
288
681
|
})
|
|
289
682
|
|
|
290
683
|
return { tabId: tab.id, content: JSON.stringify(result[0]?.result, null, 2) }
|
|
@@ -296,42 +689,54 @@ async function toolGetTabs() {
|
|
|
296
689
|
return { content: JSON.stringify(out, null, 2) }
|
|
297
690
|
}
|
|
298
691
|
|
|
299
|
-
async function
|
|
300
|
-
|
|
692
|
+
async function toolQuery({
|
|
693
|
+
tabId,
|
|
694
|
+
selector,
|
|
695
|
+
mode = "text",
|
|
696
|
+
attribute,
|
|
697
|
+
property,
|
|
698
|
+
limit,
|
|
699
|
+
index = 0,
|
|
700
|
+
timeoutMs,
|
|
701
|
+
pollMs,
|
|
702
|
+
pattern,
|
|
703
|
+
flags,
|
|
704
|
+
}) {
|
|
705
|
+
if (!selector && mode !== "page_text") throw new Error("selector is required")
|
|
301
706
|
const tab = await getTabById(tabId)
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
707
|
+
|
|
708
|
+
const result = await runInPage(tab.id, "query", {
|
|
709
|
+
selector,
|
|
710
|
+
mode,
|
|
711
|
+
attribute,
|
|
712
|
+
property,
|
|
713
|
+
limit,
|
|
714
|
+
index,
|
|
715
|
+
timeoutMs,
|
|
716
|
+
pollMs,
|
|
717
|
+
pattern,
|
|
718
|
+
flags,
|
|
305
719
|
})
|
|
306
|
-
|
|
720
|
+
|
|
721
|
+
if (!result?.ok) throw new Error(result?.error || "Query failed")
|
|
722
|
+
|
|
723
|
+
if (mode === "list" || mode === "property" || mode === "exists" || mode === "page_text") {
|
|
724
|
+
return { tabId: tab.id, content: JSON.stringify(result, null, 2) }
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
return { tabId: tab.id, content: typeof result.value === "string" ? result.value : JSON.stringify(result.value) }
|
|
307
728
|
}
|
|
308
729
|
|
|
309
730
|
async function toolScroll({ x = 0, y = 0, selector, tabId }) {
|
|
310
731
|
const tab = await getTabById(tabId)
|
|
311
|
-
const sel = selector || null
|
|
312
732
|
|
|
313
|
-
await
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
const el = document.querySelector(sel)
|
|
318
|
-
if (el) {
|
|
319
|
-
el.scrollIntoView({ behavior: "smooth", block: "center" })
|
|
320
|
-
return
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
window.scrollBy(scrollX, scrollY)
|
|
324
|
-
},
|
|
325
|
-
args: [x, y, sel],
|
|
326
|
-
})
|
|
327
|
-
|
|
328
|
-
return { tabId: tab.id, content: `Scrolled ${sel ? `to ${sel}` : `by (${x}, ${y})`}` }
|
|
733
|
+
const result = await runInPage(tab.id, "scroll", { x, y, selector })
|
|
734
|
+
if (!result?.ok) throw new Error(result?.error || "Scroll failed")
|
|
735
|
+
const target = result.selectorUsed ? `to ${result.selectorUsed}` : `by (${x}, ${y})`
|
|
736
|
+
return { tabId: tab.id, content: `Scrolled ${target}` }
|
|
329
737
|
}
|
|
330
738
|
|
|
331
739
|
async function toolWait({ ms = 1000, tabId }) {
|
|
332
|
-
if (typeof tabId === "number") {
|
|
333
|
-
// keep tabId in response for ownership purposes
|
|
334
|
-
}
|
|
335
740
|
await new Promise((resolve) => setTimeout(resolve, ms))
|
|
336
741
|
return { tabId, content: `Waited ${ms}ms` }
|
|
337
742
|
}
|
|
@@ -348,4 +753,4 @@ chrome.action.onClicked.addListener(() => {
|
|
|
348
753
|
})
|
|
349
754
|
})
|
|
350
755
|
|
|
351
|
-
connect()
|
|
756
|
+
connect()
|
package/extension/manifest.json
CHANGED