WebAider

Double-click any element to give an LLM access to it with tool calling

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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);
  }

})();