The AITURK Chrome extension now registers the user's Chrome as the desktop backend's browser controller, the way the ChatGPT extension lets its agent work in the user's browser. The agent's existing browser tools (navigate, snapshot, click, type, scroll, back, press, get_images, vision) run in its own background tabs grouped as "AITURK", or a tab the user hands over. Backend: a local desktop lane in the browser-control broker. One controller registered from the loopback desktop transport is offered to every desktop session as a lane template; each session still gets its own exact scope on first use. The lane has its own switch (browser.extension_control.local_desktop, on by default); with no extension registered, or after it disconnects, the normal browser backend runs unchanged. browser_vision turns the extension's capture into the usual native/auxiliary vision result. Bridge: forwards controller commands only to a connection that registered, refuses unknown or oversized commands, relays results. Extension: service-worker controller with its own native connection, trusted chrome.debugger input (press/release only: a hover move waits 5 s on background tabs), refusal of password and payment-card fields and of browser, extension, file and Web Store pages, panel switch, Stop, tab hand-over, recent actions, Alt+Shift+A and right-click "send to AITURK". Cancelling Chrome's debugging bar stops the agent. Existing panel flows and privacy rules are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
342 lines
18 KiB
JavaScript
342 lines
18 KiB
JavaScript
// Functions injected into a page the IDE agent controls. Each one runs through
|
||
// chrome.scripting.executeScript in Chrome's isolated world, so every function
|
||
// must be self-contained (no imports, no outer variables). They share one
|
||
// global per document: the element map of the latest snapshot, which turns the
|
||
// model's "@e5" back into the element it saw.
|
||
|
||
// Accessibility-style outline of the page with refs for interactive elements.
|
||
// full=false lists interactive elements and headings; full=true adds visible
|
||
// text in reading order. Same-origin iframes and open shadow roots are walked.
|
||
export function snapshotPage(options) {
|
||
const full = Boolean(options && options.full)
|
||
const limit = Math.max(2000, Math.min(Number(options && options.limit) || 15000, 40000))
|
||
const refs = new Map()
|
||
const lines = []
|
||
let counter = 0, length = 0, omitted = 0
|
||
const ROLE_BY_TAG = { a: 'link', button: 'button', select: 'combobox', textarea: 'textbox', summary: 'button',
|
||
h1: 'heading', h2: 'heading', h3: 'heading', h4: 'heading', h5: 'heading', h6: 'heading', img: 'img', iframe: 'iframe' }
|
||
const INTERACTIVE = new Set(['button', 'link', 'textbox', 'searchbox', 'combobox', 'checkbox', 'radio', 'switch', 'tab',
|
||
'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'slider', 'spinbutton', 'treeitem', 'listbox'])
|
||
const SKIP = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'SVG', 'HEAD', 'META', 'LINK'])
|
||
const clean = (value, max = 100) => String(value || '').replace(/\s+/g, ' ').trim().slice(0, max)
|
||
const quote = value => JSON.stringify(value)
|
||
|
||
function visible(element) {
|
||
const rect = element.getBoundingClientRect()
|
||
if (rect.width < 1 || rect.height < 1) return false
|
||
const style = element.ownerDocument.defaultView.getComputedStyle(element)
|
||
return style.visibility !== 'hidden' && style.display !== 'none' && style.opacity !== '0'
|
||
}
|
||
|
||
function inputRole(element) {
|
||
const type = (element.getAttribute('type') || 'text').toLowerCase()
|
||
if (['button', 'submit', 'reset', 'image'].includes(type)) return 'button'
|
||
if (type === 'checkbox') return 'checkbox'
|
||
if (type === 'radio') return 'radio'
|
||
if (type === 'range') return 'slider'
|
||
if (type === 'number') return 'spinbutton'
|
||
if (type === 'search') return 'searchbox'
|
||
if (type === 'hidden') return ''
|
||
return 'textbox'
|
||
}
|
||
|
||
function roleOf(element) {
|
||
const explicit = clean(element.getAttribute('role'), 40).split(' ')[0]
|
||
if (explicit) return explicit
|
||
const tag = element.tagName.toLowerCase()
|
||
if (tag === 'input') return inputRole(element)
|
||
if (tag === 'a') return element.hasAttribute('href') ? 'link' : ''
|
||
if (element.isContentEditable && element.getAttribute('contenteditable') !== null) return 'textbox'
|
||
return ROLE_BY_TAG[tag] || ''
|
||
}
|
||
|
||
function nameOf(element, role) {
|
||
const doc = element.ownerDocument
|
||
const labelled = element.getAttribute('aria-labelledby')
|
||
if (labelled) {
|
||
const text = labelled.split(/\s+/).map(id => doc.getElementById(id)?.textContent || '').join(' ')
|
||
if (clean(text)) return clean(text)
|
||
}
|
||
const aria = element.getAttribute('aria-label')
|
||
if (clean(aria)) return clean(aria)
|
||
if (element.id && ['textbox', 'searchbox', 'combobox', 'checkbox', 'radio', 'slider', 'spinbutton', 'switch'].includes(role)) {
|
||
const label = Array.from(doc.getElementsByTagName('label')).find(candidate => candidate.htmlFor === element.id)
|
||
if (label && clean(label.textContent)) return clean(label.textContent)
|
||
}
|
||
const wrapping = element.closest('label')
|
||
if (wrapping && wrapping !== element && clean(wrapping.textContent)) return clean(wrapping.textContent)
|
||
if (element.tagName === 'IMG') return clean(element.getAttribute('alt'))
|
||
if (element.tagName === 'INPUT' && ['button', 'submit', 'reset'].includes((element.type || '').toLowerCase())) {
|
||
return clean(element.value || element.type)
|
||
}
|
||
const text = role === 'textbox' || role === 'searchbox' || role === 'combobox' ? '' : clean(element.innerText || element.textContent)
|
||
return text || clean(element.getAttribute('title')) || clean(element.getAttribute('placeholder'))
|
||
}
|
||
|
||
function states(element, role) {
|
||
const out = []
|
||
if (role === 'heading') out.push(`level=${element.tagName.match(/\d/)?.[0] || element.getAttribute('aria-level') || 2}`)
|
||
if (element.disabled || element.getAttribute('aria-disabled') === 'true') out.push('disabled')
|
||
if (element.checked || element.getAttribute('aria-checked') === 'true') out.push('checked')
|
||
if (element.getAttribute('aria-expanded') === 'true') out.push('expanded')
|
||
if (element.getAttribute('aria-selected') === 'true') out.push('selected')
|
||
if (element.required) out.push('required')
|
||
return out
|
||
}
|
||
|
||
function valueOf(element, role) {
|
||
if (!['textbox', 'searchbox', 'combobox', 'spinbutton', 'slider'].includes(role)) return ''
|
||
const type = (element.getAttribute('type') || '').toLowerCase()
|
||
if (type === 'password') return element.value ? '••••' : ''
|
||
if (element.tagName === 'SELECT') return clean(element.selectedOptions?.[0]?.textContent, 80)
|
||
if (element.isContentEditable) return clean(element.innerText, 120)
|
||
return clean(element.value, 120)
|
||
}
|
||
|
||
function push(line) {
|
||
if (length + line.length + 1 > limit) { omitted++; return }
|
||
lines.push(line)
|
||
length += line.length + 1
|
||
}
|
||
|
||
function describe(element, depth) {
|
||
const role = roleOf(element)
|
||
if (!role) return false
|
||
const interactive = INTERACTIVE.has(role) || (element.tagName === 'INPUT' && role)
|
||
if (!interactive && role !== 'heading' && !(full && (role === 'img' || role === 'iframe'))) return false
|
||
if (!visible(element)) return false
|
||
const name = nameOf(element, role)
|
||
const parts = [`${' '.repeat(depth)}- ${role}`]
|
||
if (name) parts.push(quote(name))
|
||
const flags = states(element, role)
|
||
let ref = ''
|
||
if (interactive) {
|
||
ref = `e${++counter}`
|
||
refs.set(ref, new WeakRef(element))
|
||
flags.push(`ref=${ref}`)
|
||
}
|
||
if (flags.length) parts.push(`[${flags.join('] [')}]`)
|
||
const value = valueOf(element, role)
|
||
if (value) parts.push(`(değer: ${quote(value)})`)
|
||
if ((element.getAttribute('type') || '').toLowerCase() === 'password') parts.push('(şifre alanı: ajan yazamaz)')
|
||
if (role === 'link' && full) {
|
||
try { parts.push(`→ ${new URL(element.getAttribute('href'), element.baseURI).href.slice(0, 160)}`) } catch {}
|
||
}
|
||
push(parts.join(' '))
|
||
if (full && element.tagName === 'SELECT') {
|
||
Array.from(element.options).slice(0, 20).forEach(option => push(`${' '.repeat(depth + 1)}- option ${quote(clean(option.textContent))}${option.selected ? ' [selected]' : ''}`))
|
||
}
|
||
return interactive
|
||
}
|
||
|
||
function directText(element) {
|
||
let text = ''
|
||
for (const node of element.childNodes) if (node.nodeType === 3) text += node.textContent
|
||
return clean(text, 300)
|
||
}
|
||
|
||
function walk(root, depth) {
|
||
for (const element of root.children || []) {
|
||
if (SKIP.has(element.tagName.toUpperCase())) continue
|
||
if (element.getAttribute('aria-hidden') === 'true' || element.hidden) continue
|
||
if (element.tagName === 'IFRAME') {
|
||
let inner = null
|
||
try { inner = element.contentDocument } catch {}
|
||
if (inner?.body && visible(element)) { push(`${' '.repeat(depth)}- iframe ${quote(clean(element.title || element.name || ''))}`); walk(inner.body, depth + 1) }
|
||
else if (visible(element)) push(`${' '.repeat(depth)}- iframe ${quote(clean(element.title || element.src, 80))} (farklı alan adı: içerik okunamaz)`)
|
||
continue
|
||
}
|
||
const interactive = describe(element, depth)
|
||
if (!interactive && full && visible(element)) {
|
||
const text = directText(element)
|
||
if (text && !roleOf(element)) push(`${' '.repeat(depth)}- text: ${text}`)
|
||
}
|
||
if (element.shadowRoot) walk(element.shadowRoot, depth)
|
||
// A described control's own children rarely add meaning (button labels).
|
||
if (!interactive || element.tagName === 'SELECT' || element.getAttribute('role') === 'listbox') walk(element, depth)
|
||
}
|
||
}
|
||
|
||
walk(document.body || document.documentElement, 0)
|
||
globalThis.__aiturkRefs = refs
|
||
const header = [`Sayfa: ${clean(document.title, 200) || '(başlıksız)'}`, `Adres: ${location.href.slice(0, 300)}`]
|
||
if (!lines.length) lines.push('(Görünür etkileşimli öğe yok. Sayfa yükleniyor olabilir; browser_snapshot ile tekrar bakın ya da full=true deneyin.)')
|
||
if (omitted) lines.push(`… (${omitted} satır daha var; kısaltıldı. Sayfayı kaydırıp tekrar bakın.)`)
|
||
return { snapshot: header.concat(lines).join('\n'), element_count: counter, url: location.href, title: document.title, truncated: omitted > 0 }
|
||
}
|
||
|
||
// Where to click/type for a ref, after scrolling it into view. Coordinates are
|
||
// top-level viewport CSS pixels (same-origin iframe offsets added).
|
||
export function locateRef(ref) {
|
||
const key = String(ref || '').trim().replace(/^@/, '').replace(/^ref=/, '').replace(/^\[|\]$/g, '')
|
||
const element = globalThis.__aiturkRefs?.get(key)?.deref()
|
||
if (!element || !element.isConnected) {
|
||
return { error: `Öğe bulunamadı (@${key}). Sayfa değişmiş olabilir; browser_snapshot ile yeni numaraları alın.` }
|
||
}
|
||
element.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' })
|
||
const rect = element.getBoundingClientRect()
|
||
let x = rect.left + rect.width / 2, y = rect.top + rect.height / 2
|
||
let view = element.ownerDocument.defaultView
|
||
while (view && view !== window && view.frameElement) {
|
||
const frame = view.frameElement.getBoundingClientRect()
|
||
x += frame.left; y += frame.top
|
||
view = view.parent
|
||
}
|
||
const type = (element.getAttribute('type') || '').toLowerCase()
|
||
const autocomplete = (element.getAttribute('autocomplete') || '').toLowerCase()
|
||
let covered = ''
|
||
if (element.ownerDocument === document) {
|
||
const top = document.elementFromPoint(x, y)
|
||
if (top && top !== element && !element.contains(top) && !top.contains(element)) {
|
||
covered = String(top.innerText || top.getAttribute('aria-label') || top.tagName).replace(/\s+/g, ' ').trim().slice(0, 80)
|
||
}
|
||
}
|
||
return {
|
||
x: Math.round(x), y: Math.round(y),
|
||
tag: element.tagName.toLowerCase(),
|
||
name: String(element.getAttribute('aria-label') || element.innerText || element.value || element.getAttribute('title') || '').replace(/\s+/g, ' ').trim().slice(0, 80),
|
||
editable: element.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(element.tagName),
|
||
password: type === 'password',
|
||
payment: /^cc-|^card|card-?number|cvc|cvv/.test(autocomplete) || /cvc|cvv|card.?number/i.test(element.name || ''),
|
||
disabled: Boolean(element.disabled || element.getAttribute('aria-disabled') === 'true'),
|
||
covered,
|
||
}
|
||
}
|
||
|
||
// Focus a ref and select its current content so typed text replaces it.
|
||
// Refuses password and payment-card fields: those stay with the user.
|
||
export function prepareTyping(ref) {
|
||
const key = String(ref || '').trim().replace(/^@/, '').replace(/^ref=/, '').replace(/^[|]$/g, '')
|
||
const element = globalThis.__aiturkRefs?.get(key)?.deref()
|
||
if (!element || !element.isConnected) {
|
||
return { error: `Öğe bulunamadı (@${key}). Sayfa değişmiş olabilir; browser_snapshot ile yeni numaraları alın.` }
|
||
}
|
||
const type = (element.getAttribute('type') || '').toLowerCase()
|
||
const autocomplete = (element.getAttribute('autocomplete') || '').toLowerCase()
|
||
if (type === 'password' || autocomplete.includes('password')) {
|
||
return { error: 'Şifre alanına ajan yazamaz. Bu adımı kullanıcıya bırakın.', handoff: true }
|
||
}
|
||
if (/^cc-|card-?number|cvc|cvv/.test(autocomplete) || /cvc|cvv|card.?number/i.test(element.name || '')) {
|
||
return { error: 'Kart bilgisi alanına ajan yazamaz. Bu adımı kullanıcıya bırakın.', handoff: true }
|
||
}
|
||
const editable = element.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(element.tagName)
|
||
if (!editable) return { error: `@${key} bir yazı alanı değil (${element.tagName.toLowerCase()}).` }
|
||
if (element.disabled || element.readOnly || element.getAttribute('aria-disabled') === 'true') {
|
||
return { error: `@${key} şu an devre dışı ya da salt okunur.` }
|
||
}
|
||
element.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' })
|
||
if (element.tagName === 'SELECT') return { select: true }
|
||
element.focus()
|
||
if (element.isContentEditable) {
|
||
const range = element.ownerDocument.createRange()
|
||
range.selectNodeContents(element)
|
||
const selection = element.ownerDocument.getSelection()
|
||
selection.removeAllRanges(); selection.addRange(range)
|
||
} else if (typeof element.select === 'function' && element.tagName !== 'SELECT') {
|
||
element.select()
|
||
}
|
||
return { focused: element.ownerDocument.activeElement === element }
|
||
}
|
||
|
||
// Script fallback when trusted input is unavailable: replace the value and fire
|
||
// the events frameworks listen to.
|
||
export function typeWithEvents(ref, text) {
|
||
const key = String(ref || '').trim().replace(/^@/, '').replace(/^ref=/, '')
|
||
const element = globalThis.__aiturkRefs?.get(key)?.deref()
|
||
if (!element) return { error: `Öğe bulunamadı (@${key}).` }
|
||
if (element.tagName === 'SELECT') {
|
||
const wanted = String(text).trim().toLocaleLowerCase()
|
||
const option = Array.from(element.options).find(item => item.textContent.trim().toLocaleLowerCase() === wanted ||
|
||
item.value.toLocaleLowerCase() === wanted) || Array.from(element.options).find(item => item.textContent.trim().toLocaleLowerCase().includes(wanted))
|
||
if (!option) return { error: `Listede "${String(text).slice(0, 80)}" seçeneği yok.` }
|
||
element.value = option.value
|
||
} else if (element.isContentEditable) {
|
||
element.textContent = text
|
||
} else {
|
||
const proto = element.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype
|
||
const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set
|
||
setter ? setter.call(element, text) : (element.value = text)
|
||
}
|
||
element.dispatchEvent(new Event('input', { bubbles: true }))
|
||
element.dispatchEvent(new Event('change', { bubbles: true }))
|
||
return { value: element.isContentEditable ? element.textContent : element.value }
|
||
}
|
||
|
||
// Read back what a field holds after typing (password fields never echoed).
|
||
export function readValue(ref) {
|
||
const key = String(ref || '').trim().replace(/^@/, '').replace(/^ref=/, '')
|
||
const element = globalThis.__aiturkRefs?.get(key)?.deref()
|
||
if (!element) return { error: `Öğe bulunamadı (@${key}).` }
|
||
return { value: String(element.isContentEditable ? element.innerText : element.value ?? '').slice(0, 2000) }
|
||
}
|
||
|
||
export function clickWithEvents(ref) {
|
||
const key = String(ref || '').trim().replace(/^@/, '').replace(/^ref=/, '')
|
||
const element = globalThis.__aiturkRefs?.get(key)?.deref()
|
||
if (!element) return { error: `Öğe bulunamadı (@${key}).` }
|
||
element.click()
|
||
return { clicked: true }
|
||
}
|
||
|
||
// Scroll the page, or the scrollable area under the viewport centre when the
|
||
// window itself does not move (single-page apps).
|
||
export function scrollPage(direction) {
|
||
const sign = direction === 'up' ? -1 : 1
|
||
const amount = Math.round(window.innerHeight * 0.8) * sign
|
||
const before = window.scrollY
|
||
window.scrollBy({ top: amount, behavior: 'instant' })
|
||
if (window.scrollY !== before) {
|
||
return { scrolled: true, atEnd: sign > 0 ? window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 2 : window.scrollY <= 0 }
|
||
}
|
||
let element = document.elementFromPoint(window.innerWidth / 2, window.innerHeight / 2)
|
||
while (element && element !== document.body) {
|
||
const style = getComputedStyle(element)
|
||
if (/(auto|scroll)/.test(style.overflowY) && element.scrollHeight > element.clientHeight) {
|
||
const previous = element.scrollTop
|
||
element.scrollBy({ top: amount, behavior: 'instant' })
|
||
if (element.scrollTop !== previous) {
|
||
return { scrolled: true, atEnd: sign > 0 ? element.scrollTop + element.clientHeight >= element.scrollHeight - 2 : element.scrollTop <= 0 }
|
||
}
|
||
}
|
||
element = element.parentElement
|
||
}
|
||
return { scrolled: false, atEnd: true }
|
||
}
|
||
|
||
export function listImages() {
|
||
const images = []
|
||
for (const image of document.images) {
|
||
if (images.length >= 100) break
|
||
const rect = image.getBoundingClientRect()
|
||
if (rect.width < 8 || rect.height < 8) continue
|
||
const src = image.currentSrc || image.src || ''
|
||
images.push({ src: src.startsWith('data:') ? `${src.slice(0, 60)}…` : src.slice(0, 500),
|
||
alt: String(image.alt || '').slice(0, 200), width: Math.round(rect.width), height: Math.round(rect.height) })
|
||
}
|
||
return { images, count: images.length, url: location.href, title: document.title }
|
||
}
|
||
|
||
// Numbered labels over the refs of the latest snapshot, for annotated
|
||
// screenshots; call again with show=false to remove them.
|
||
export function annotateRefs(show) {
|
||
document.getElementById('__aiturk_labels')?.remove()
|
||
if (!show) return { removed: true }
|
||
const layer = document.createElement('div')
|
||
layer.id = '__aiturk_labels'
|
||
layer.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:2147483647'
|
||
const annotations = []
|
||
for (const [ref, weak] of globalThis.__aiturkRefs || []) {
|
||
const element = weak.deref()
|
||
if (!element || element.ownerDocument !== document) continue
|
||
const rect = element.getBoundingClientRect()
|
||
if (rect.bottom < 0 || rect.top > innerHeight || rect.right < 0 || rect.left > innerWidth) continue
|
||
const label = document.createElement('span')
|
||
label.textContent = ref.slice(1)
|
||
label.style.cssText = `position:fixed;left:${Math.max(0, rect.left)}px;top:${Math.max(0, rect.top)}px;background:#007f79;color:#fff;font:600 11px/14px system-ui;padding:0 3px;border-radius:3px`
|
||
layer.append(label)
|
||
annotations.push({ ref: `@${ref}`, label: `[${ref.slice(1)}]` })
|
||
}
|
||
document.documentElement.append(layer)
|
||
return { annotations }
|
||
}
|