Double-click any element to give an LLM access to it with tool calling
// ==UserScript==
// @name WebAider
// @namespace https://theabbie.github.io
// @version 1.0.1
// @description Double-click any element to give an LLM access to it with tool calling
// @author Abhishek Choudhary (theabbie)
// @match *://*/*
// @grant none
// @license MIT
// @homepageURL https://theabbie.github.io
// @supportURL https://theabbie.github.io
// ==/UserScript==
(function () {
'use strict';
const STORAGE_KEY = 'webaider_config';
function loadConfig() {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null'); } catch { return null; }
}
function saveConfig(cfg) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(cfg));
}
function elToDescriptor(el) {
const tag = el.tagName.toLowerCase();
const id = el.id ? `#${el.id}` : '';
const cls = el.className && typeof el.className === 'string'
? '.' + el.className.trim().split(/\s+/).join('.') : '';
const attrs = Array.from(el.attributes)
.filter(a => !['id','class','style'].includes(a.name))
.map(a => `${a.name}="${a.value}"`)
.join(' ');
const value = el.value !== undefined ? el.value : null;
const text = el.innerText ? el.innerText.slice(0, 300) : null;
return { tag, selector: `${tag}${id}${cls}`, attrs, value, text, isInput: ['input','textarea','select'].includes(tag) };
}
function resolveEl(selector) {
try { return document.querySelector(selector); } catch { return null; }
}
const TOOLS = [
{
type: 'function',
function: {
name: 'read_element',
description: 'Read current value or text content of the target element',
parameters: {
type: 'object',
properties: {},
required: []
}
}
},
{
type: 'function',
function: {
name: 'write_element',
description: 'Set value or text content of the target element and dispatch input/change events',
parameters: {
type: 'object',
properties: {
content: { type: 'string', description: 'Content to write' }
},
required: ['content']
}
}
},
{
type: 'function',
function: {
name: 'click_element',
description: 'Click the target element',
parameters: { type: 'object', properties: {}, required: [] }
}
},
{
type: 'function',
function: {
name: 'query_selector',
description: 'Read value or text of any element on the page by CSS selector',
parameters: {
type: 'object',
properties: {
selector: { type: 'string', description: 'CSS selector' }
},
required: ['selector']
}
}
},
{
type: 'function',
function: {
name: 'set_selector',
description: 'Write value or text to any element on the page by CSS selector',
parameters: {
type: 'object',
properties: {
selector: { type: 'string', description: 'CSS selector' },
content: { type: 'string', description: 'Content to write' }
},
required: ['selector', 'content']
}
}
},
{
type: 'function',
function: {
name: 'click_selector',
description: 'Click any element on the page by CSS selector',
parameters: {
type: 'object',
properties: {
selector: { type: 'string', description: 'CSS selector' }
},
required: ['selector']
}
}
},
{
type: 'function',
function: {
name: 'get_page_html',
description: 'Get the full page HTML (truncated to 8000 chars)',
parameters: { type: 'object', properties: {}, required: [] }
}
},
{
type: 'function',
function: {
name: 'evaluate_js',
description: 'Evaluate a JavaScript expression on the page and return result as string',
parameters: {
type: 'object',
properties: {
code: { type: 'string', description: 'JavaScript expression to evaluate' }
},
required: ['code']
}
}
}
];
function executeTool(name, args, targetEl) {
if (name === 'read_element') {
if (!targetEl) return 'No target element';
return JSON.stringify({ value: targetEl.value ?? null, text: targetEl.innerText?.slice(0, 2000) ?? null });
}
if (name === 'write_element') {
if (!targetEl) return 'No target element';
const tag = targetEl.tagName.toLowerCase();
if (['input','textarea'].includes(tag)) {
const nativeInput = Object.getOwnPropertyDescriptor(window[tag === 'textarea' ? 'HTMLTextAreaElement' : 'HTMLInputElement'].prototype, 'value');
nativeInput.set.call(targetEl, args.content);
targetEl.dispatchEvent(new Event('input', { bubbles: true }));
targetEl.dispatchEvent(new Event('change', { bubbles: true }));
} else {
targetEl.innerText = args.content;
}
return 'done';
}
if (name === 'click_element') {
if (!targetEl) return 'No target element';
targetEl.click();
return 'done';
}
if (name === 'query_selector') {
const el = resolveEl(args.selector);
if (!el) return 'Element not found';
return JSON.stringify({ value: el.value ?? null, text: el.innerText?.slice(0, 2000) ?? null });
}
if (name === 'set_selector') {
const el = resolveEl(args.selector);
if (!el) return 'Element not found';
const tag = el.tagName.toLowerCase();
if (['input','textarea'].includes(tag)) {
const proto = tag === 'textarea' ? HTMLTextAreaElement : HTMLInputElement;
const nativeInput = Object.getOwnPropertyDescriptor(proto.prototype, 'value');
nativeInput.set.call(el, args.content);
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
} else {
el.innerText = args.content;
}
return 'done';
}
if (name === 'click_selector') {
const el = resolveEl(args.selector);
if (!el) return 'Element not found';
el.click();
return 'done';
}
if (name === 'get_page_html') {
return document.documentElement.outerHTML.slice(0, 8000);
}
if (name === 'evaluate_js') {
try {
const result = eval(args.code);
return String(result);
} catch (e) {
return `Error: ${e.message}`;
}
}
return 'Unknown tool';
}
async function callOpenAICompat(cfg, messages) {
const headers = { 'Content-Type': 'application/json' };
if (cfg.provider === 'anthropic') {
headers['x-api-key'] = cfg.apiKey;
headers['anthropic-version'] = '2023-06-01';
} else {
headers['Authorization'] = `Bearer ${cfg.apiKey}`;
if (cfg.extraHeaders) Object.assign(headers, cfg.extraHeaders);
}
if (cfg.provider === 'anthropic') {
const system = messages.find(m => m.role === 'system');
const rest = messages.filter(m => m.role !== 'system');
const body = {
model: cfg.model,
max_tokens: 4096,
messages: rest,
tools: TOOLS.map(t => ({
name: t.function.name,
description: t.function.description,
input_schema: t.function.parameters
}))
};
if (system) body.system = system.content;
const r = await fetch(`${cfg.endpoint}/v1/messages`, { method: 'POST', headers, body: JSON.stringify(body) });
const data = await r.json();
if (!r.ok) throw new Error(data.error?.message || JSON.stringify(data));
const toolUse = data.content.find(b => b.type === 'tool_use');
const textBlock = data.content.find(b => b.type === 'text');
if (toolUse) {
return { tool_call: { name: toolUse.name, arguments: toolUse.input }, stop: false };
}
return { content: textBlock?.text ?? '', stop: data.stop_reason === 'end_turn' };
}
if (cfg.provider === 'gemini') {
const geminiTools = [{
functionDeclarations: TOOLS.map(t => ({
name: t.function.name,
description: t.function.description,
parameters: t.function.parameters
}))
}];
const geminiMessages = messages
.filter(m => m.role !== 'system')
.map(m => ({ role: m.role === 'assistant' ? 'model' : 'user', parts: [{ text: m.content }] }));
const systemMsg = messages.find(m => m.role === 'system');
const body = {
contents: geminiMessages,
tools: geminiTools
};
if (systemMsg) body.systemInstruction = { parts: [{ text: systemMsg.content }] };
const url = `${cfg.endpoint}/v1beta/models/${cfg.model}:generateContent?key=${cfg.apiKey}`;
const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const data = await r.json();
if (!r.ok) throw new Error(data.error?.message || JSON.stringify(data));
const part = data.candidates?.[0]?.content?.parts?.[0];
if (part?.functionCall) {
return { tool_call: { name: part.functionCall.name, arguments: part.functionCall.args }, stop: false };
}
return { content: part?.text ?? '', stop: true };
}
const body = {
model: cfg.model,
messages,
tools: TOOLS,
tool_choice: 'auto'
};
const r = await fetch(`${cfg.endpoint}/chat/completions`, { method: 'POST', headers, body: JSON.stringify(body) });
const data = await r.json();
if (!r.ok) throw new Error(data.error?.message || JSON.stringify(data));
const msg = data.choices[0].message;
if (msg.tool_calls?.length) {
const tc = msg.tool_calls[0];
return { tool_call: { name: tc.function.name, arguments: JSON.parse(tc.function.arguments) }, stop: false };
}
return { content: msg.content ?? '', stop: data.choices[0].finish_reason === 'stop' };
}
async function runAgent(cfg, userPrompt, targetEl, onLog) {
const descriptor = elToDescriptor(targetEl);
const system = `You are WebAider, an AI assistant embedded in a browser page via a userscript.
The user double-clicked an element. Here is its descriptor:
${JSON.stringify(descriptor, null, 2)}
Page URL: ${location.href}
Page title: ${document.title}
You have tools to read and manipulate elements on the page.
IMPORTANT: Always use your tools to directly fulfill the user's request. Never ask for confirmation or permission. Never describe what you could do — just do it immediately using the available tools. If something is ambiguous, make a reasonable assumption and act.
When done, respond with a single short sentence summarizing what you did.`;
const messages = [
{ role: 'system', content: system },
{ role: 'user', content: userPrompt }
];
let iterations = 0;
while (iterations < 20) {
iterations++;
onLog('thinking...');
let resp;
try {
resp = await callOpenAICompat(cfg, messages);
} catch (e) {
onLog(`Error: ${e.message}`);
return;
}
if (resp.tool_call) {
const { name, arguments: args } = resp.tool_call;
onLog(`tool: ${name}(${JSON.stringify(args).slice(0, 120)})`);
const result = executeTool(name, args, targetEl);
onLog(`→ ${String(result).slice(0, 200)}`);
messages.push({ role: 'assistant', content: null, tool_calls: [{ id: `tc_${iterations}`, type: 'function', function: { name, arguments: JSON.stringify(args) } }] });
messages.push({ role: 'tool', tool_call_id: `tc_${iterations}`, content: String(result) });
} else {
onLog(`done: ${resp.content}`);
return;
}
}
onLog('Max iterations reached.');
}
const PROVIDER_PRESETS = {
openai: { label: 'OpenAI', endpoint: 'https://api.openai.com/v1', model: 'gpt-4o', provider: 'openai' },
anthropic: { label: 'Anthropic', endpoint: 'https://api.anthropic.com', model: 'claude-opus-4-8', provider: 'anthropic' },
gemini: { label: 'Google Gemini', endpoint: 'https://generativelanguage.googleapis.com', model: 'gemini-2.5-pro', provider: 'gemini' },
copilot: { label: 'GitHub Copilot', endpoint: 'https://api.individual.githubcopilot.com', model: 'gpt-4o', provider: 'openai', extraHeaders: { 'Editor-Version': 'vscode/1.85.0', 'Copilot-Integration-Id': 'vscode-chat', 'Openai-Intent': 'conversation-completions' } },
openrouter: { label: 'OpenRouter', endpoint: 'https://openrouter.ai/api/v1', model: 'openai/gpt-4o', provider: 'openai' },
custom: { label: 'Custom', endpoint: '', model: '', provider: 'openai' }
};
function injectStyles() {
if (document.getElementById('webaider-styles')) return;
const s = document.createElement('style');
s.id = 'webaider-styles';
s.textContent = `
#webaider-dialog {
border: none; border-radius: 12px; padding: 0;
box-shadow: 0 8px 40px rgba(0,0,0,0.25); max-width: 480px; width: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 14px; background: #fff; color: #111;
}
#webaider-dialog::backdrop { background: rgba(0,0,0,0.4); }
.wa-header { padding: 16px 20px; border-bottom: 1px solid #eee; display: flex; align-items: center; gap: 8px; }
.wa-header h2 { margin: 0; font-size: 16px; font-weight: 600; }
.wa-badge { font-size: 11px; background: #0070f3; color: #fff; border-radius: 4px; padding: 2px 6px; }
.wa-body { padding: 16px 20px; display: flex; flex-direction: column; gap: 12px; }
.wa-body label { font-size: 12px; font-weight: 500; color: #555; display: block; margin-bottom: 4px; }
.wa-body select, .wa-body input, .wa-body textarea {
width: 100%; box-sizing: border-box; border: 1px solid #ddd; border-radius: 6px;
padding: 7px 10px; font-size: 13px; outline: none; background: #fafafa;
font-family: inherit;
}
.wa-body select:focus, .wa-body input:focus, .wa-body textarea:focus { border-color: #0070f3; background: #fff; }
.wa-body textarea { resize: vertical; min-height: 70px; }
.wa-row { display: flex; gap: 8px; }
.wa-row > * { flex: 1; }
.wa-footer { padding: 12px 20px; border-top: 1px solid #eee; display: flex; gap: 8px; justify-content: flex-end; }
.wa-btn { padding: 7px 16px; border-radius: 6px; border: 1px solid #ddd; cursor: pointer; font-size: 13px; font-weight: 500; background: #fff; }
.wa-btn.primary { background: #0070f3; color: #fff; border-color: #0070f3; }
.wa-btn.primary:hover { background: #005ed4; }
.wa-btn.danger { background: #fff; color: #d00; border-color: #d00; }
.wa-log { background: #111; color: #0f0; border-radius: 6px; padding: 10px; font-family: monospace; font-size: 12px; max-height: 180px; overflow-y: auto; white-space: pre-wrap; }
.wa-el-badge { font-size: 11px; background: #f0f0f0; border-radius: 4px; padding: 3px 8px; font-family: monospace; color: #333; }
.wa-spinner { display: inline-block; width: 12px; height: 12px; border: 2px solid #0070f3; border-top-color: transparent; border-radius: 50%; animation: wa-spin 0.6s linear infinite; }
@keyframes wa-spin { to { transform: rotate(360deg); } }
`;
document.head.appendChild(s);
}
function buildDialog() {
if (document.getElementById('webaider-dialog')) document.getElementById('webaider-dialog').remove();
const d = document.createElement('dialog');
d.id = 'webaider-dialog';
d.innerHTML = `
<div class="wa-header">
<h2>WebAider</h2>
<span class="wa-badge">AI</span>
</div>
<div class="wa-body">
<div id="wa-el-section">
<label>Target element</label>
<span class="wa-el-badge" id="wa-el-label"></span>
</div>
<div id="wa-config-section" style="display:none; flex-direction:column; gap:12px;">
<div>
<label>Provider</label>
<select id="wa-preset">
${Object.entries(PROVIDER_PRESETS).map(([k,v]) => `<option value="${k}">${v.label}</option>`).join('')}
</select>
</div>
<div class="wa-row">
<div>
<label>Endpoint</label>
<input id="wa-endpoint" type="text" placeholder="https://..." />
</div>
<div>
<label>Model</label>
<input id="wa-model" type="text" placeholder="gpt-4o" />
</div>
</div>
<div>
<label>API Key</label>
<input id="wa-apikey" type="password" placeholder="sk-..." />
</div>
<div id="wa-save-row" style="display:flex; align-items:center; gap:8px;">
<input type="checkbox" id="wa-save" checked />
<label for="wa-save" style="margin:0; font-weight:400;">Save to localStorage</label>
</div>
</div>
<div id="wa-prompt-section">
<label>Prompt</label>
<textarea id="wa-prompt" placeholder="What should the AI do with this element?"></textarea>
</div>
<div id="wa-log-section" style="display:none;">
<label>Log</label>
<div class="wa-log" id="wa-log"></div>
</div>
</div>
<div class="wa-footer">
<button class="wa-btn danger" id="wa-clear-btn">Clear config</button>
<button class="wa-btn" id="wa-cancel-btn">Cancel</button>
<button class="wa-btn primary" id="wa-run-btn">Run</button>
</div>
`;
document.body.appendChild(d);
return d;
}
function openDialog(targetEl) {
injectStyles();
const d = buildDialog();
const cfg = loadConfig();
const elLabel = document.getElementById('wa-el-label');
const configSection = document.getElementById('wa-config-section');
const promptInput = document.getElementById('wa-prompt');
const logSection = document.getElementById('wa-log-section');
const logBox = document.getElementById('wa-log');
const runBtn = document.getElementById('wa-run-btn');
const cancelBtn = document.getElementById('wa-cancel-btn');
const clearBtn = document.getElementById('wa-clear-btn');
const presetSel = document.getElementById('wa-preset');
const endpointIn = document.getElementById('wa-endpoint');
const modelIn = document.getElementById('wa-model');
const apikeyIn = document.getElementById('wa-apikey');
const saveChk = document.getElementById('wa-save');
const desc = elToDescriptor(targetEl);
elLabel.textContent = `<${desc.tag}> ${desc.selector}`;
if (!cfg) {
configSection.style.display = 'flex';
}
presetSel.addEventListener('change', () => {
const p = PROVIDER_PRESETS[presetSel.value];
endpointIn.value = p.endpoint;
modelIn.value = p.model;
});
if (cfg) {
const matchedPreset = Object.entries(PROVIDER_PRESETS).find(([,v]) => v.endpoint === cfg.endpoint && v.provider === cfg.provider);
presetSel.value = matchedPreset ? matchedPreset[0] : 'custom';
endpointIn.value = cfg.endpoint || '';
modelIn.value = cfg.model || '';
apikeyIn.value = cfg.apiKey || '';
} else {
const p = PROVIDER_PRESETS['openai'];
endpointIn.value = p.endpoint;
modelIn.value = p.model;
}
function appendLog(msg) {
logBox.textContent += msg + '\n';
logBox.scrollTop = logBox.scrollHeight;
}
let configExpanded = !cfg;
document.getElementById('wa-el-section').style.cursor = 'pointer';
document.getElementById('wa-el-section').addEventListener('click', () => {
configExpanded = !configExpanded;
configSection.style.display = configExpanded ? 'flex' : 'none';
});
clearBtn.addEventListener('click', () => {
localStorage.removeItem(STORAGE_KEY);
configSection.style.display = 'flex';
configExpanded = true;
apikeyIn.value = '';
});
cancelBtn.addEventListener('click', () => { d.close(); d.remove(); });
runBtn.addEventListener('click', async () => {
const prompt = promptInput.value.trim();
if (!prompt) { promptInput.focus(); return; }
const presetKey = presetSel.value;
const preset = PROVIDER_PRESETS[presetKey];
const activeCfg = cfg && !configExpanded ? cfg : {
provider: preset.provider,
endpoint: endpointIn.value.trim().replace(/\/$/, ''),
model: modelIn.value.trim(),
apiKey: apikeyIn.value.trim(),
extraHeaders: preset.extraHeaders || undefined
};
if (!activeCfg.apiKey) { apikeyIn.focus(); return; }
if (saveChk.checked) saveConfig(activeCfg);
logSection.style.display = 'block';
logBox.textContent = '';
runBtn.disabled = true;
runBtn.innerHTML = '<span class="wa-spinner"></span>';
await runAgent(activeCfg, prompt, targetEl, appendLog);
runBtn.disabled = false;
runBtn.textContent = 'Run';
});
promptInput.addEventListener('keydown', e => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) runBtn.click();
});
d.showModal();
promptInput.focus();
}
function attachToDocument(doc) {
doc.addEventListener('dblclick', e => {
if (!e.target.closest('#webaider-dialog')) {
e.preventDefault();
e.stopPropagation();
openDialog(e.target);
}
}, true);
}
function injectIntoIframe(iframe) {
let iframeDoc;
try {
iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
} catch {
return;
}
if (!iframeDoc || iframeDoc._webaiderAttached) return;
iframeDoc._webaiderAttached = true;
attachToDocument(iframeDoc);
observeIframes(iframeDoc);
}
function observeIframes(doc) {
doc.querySelectorAll('iframe').forEach(injectIntoIframe);
new MutationObserver(mutations => {
for (const m of mutations) {
for (const node of m.addedNodes) {
if (node.tagName === 'IFRAME') injectIntoIframe(node);
if (node.querySelectorAll) node.querySelectorAll('iframe').forEach(injectIntoIframe);
}
}
}).observe(doc.body || doc.documentElement, { childList: true, subtree: true });
}
attachToDocument(document);
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => observeIframes(document));
} else {
observeIframes(document);
}
})();