Files
aiturk-hermes-ide/apps/chrome/extension/page-agent.js
T
yilsemandClaude Opus 5.5 86a6621936
Server CI and Windows package / verify (push) Successful in 3m33s
Server CI and Windows package / windows-package (push) Successful in 7m36s
feat(chrome): let the IDE agent use the user's Chrome and beta.42
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>
2026-10-15 08:38:52 +03:00

342 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 }
}