Files
userscripts/scripts/page-agent.user.js
T

517 lines
21 KiB
JavaScript

// ==UserScript==
// @name Page Agent - LLM in the page
// @namespace https://github.com/example/page-agent
// @version 0.1.0
// @description Turn any web page into an LLM agent that can run JS in the page to inspect, extract, fetch and act - then bake what it learned into a standalone, LLM-free userscript.
// @match *://*/*
// @run-at document-idle
// @grant GM.xmlHttpRequest
// @grant GM.setValue
// @grant GM.getValue
// @connect api.openai.com
// @connect *
// ==/UserScript==
// SECURITY NOTE: `@connect *` allows GM.xmlHttpRequest to POST to ANY host. That is
// handy while you experiment with self-hosted / proxied endpoints, but it is broad.
// Once you know your endpoint's host, DELETE the `@connect *` line above and keep
// only the specific host(s) you use (e.g. just `@connect api.openai.com`).
(async () => {
'use strict';
// Built at runtime so this file contains no literal triple-backticks and stays paste-safe.
const BT3 = '`'.repeat(3);
// ============================================================
// Configuration
// ============================================================
const DEFAULTS = {
endpoint: 'https://api.openai.com/v1/chat/completions',
apiKey: '',
model: 'gpt-4o-mini',
};
const CONFIG = {
endpoint: await GM.getValue('endpoint', DEFAULTS.endpoint),
apiKey: await GM.getValue('apiKey', DEFAULTS.apiKey),
model: await GM.getValue('model', DEFAULTS.model),
};
const maxSteps = 8; // upper bound on the agent's tool-use loop
// The panel starts fully hidden. Reveal it with:
// Desktop - Ctrl+Shift+Space (see HOTKEY)
// Mobile - three-finger tap anywhere on the page
const HOTKEY = { ctrl: true, shift: true, alt: false, code: 'Space', label: 'Ctrl+Shift+Space' };
// ============================================================
// safeStringify - serialize arbitrary values without blowing up
// ============================================================
function safeStringify(value, maxLen = 20000) {
const seen = new WeakSet();
let out;
try {
out = JSON.stringify(value, function (key, val) {
// DOM nodes -> "[NODENAME] <first 200 chars of textContent>"
if (typeof Node !== 'undefined' && val instanceof Node) {
return '[' + val.nodeName + '] ' + String(val.textContent || '').slice(0, 200);
}
if (typeof val === 'function') return '[Function]';
if (val !== null && typeof val === 'object') {
if (seen.has(val)) return String(val); // circular / repeated ref
seen.add(val);
}
return val;
});
} catch (e) {
out = String(value);
}
if (out === undefined) out = String(value); // undefined coercion
if (out.length > maxLen) {
const total = out.length;
out = out.slice(0, maxLen) + '...[truncated, ' + total + ' chars]';
}
return out;
}
// ============================================================
// The single tool: run_js
// ============================================================
const TOOLS = [{
type: 'function',
function: {
name: 'run_js',
description:
'Execute JavaScript in the context of the current web page. Your code runs ' +
'as the BODY of an async function, so you can use `await` and you MUST `return` ' +
'a value to see any result. The returned value is JSON-stringified and sent back ' +
'to you, so keep it SMALL and RELEVANT - return only the specific fields/text you ' +
'need, never whole DOM trees or huge arrays. Use it to query selectors, extract ' +
'content, call fetch(), read/modify the page, click, fill and navigate. Take ' +
'several small targeted steps rather than one giant snippet.',
parameters: {
type: 'object',
properties: {
code: {
type: 'string',
description: 'JavaScript run as the body of an async function. `return` the result you want back.',
},
},
required: ['code'],
},
},
}];
async function runJs(code) {
try {
// Wrapped so the model can `await` and `return` naturally.
const fn = new Function('return (async () => { ' + code + ' })()');
const result = await fn();
return safeStringify(result);
} catch (e) {
return 'ERROR: ' + (e && e.stack ? e.stack : String(e));
}
}
// ============================================================
// OpenAI-compatible request via GM.xmlHttpRequest
// ============================================================
function chat(messages) {
return new Promise((resolve, reject) => {
GM.xmlHttpRequest({
method: 'POST',
url: CONFIG.endpoint,
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + CONFIG.apiKey,
},
data: JSON.stringify({
model: CONFIG.model,
messages: messages,
tools: TOOLS,
tool_choice: 'auto',
temperature: 0.2,
}),
timeout: 60000,
onload: (res) => {
if (res.status < 200 || res.status >= 300) {
reject(new Error('HTTP ' + res.status + ': ' + res.responseText));
return;
}
try {
resolve(JSON.parse(res.responseText));
} catch (e) {
reject(new Error('Bad JSON from endpoint: ' + res.responseText));
}
},
onerror: () => reject(new Error('Network error contacting ' + CONFIG.endpoint)),
ontimeout: () => reject(new Error('Request timed out after 60s')),
});
});
}
// ============================================================
// Agentic loop
// ============================================================
const SYSTEM_PROMPT =
'You are an agent embedded directly in a live web page in the user\'s browser. ' +
'You have exactly one tool, run_js, which executes JavaScript in this page and ' +
'returns its JSON-stringified return value. Use it to inspect the DOM, query ' +
'selectors, extract content, call fetch(), and act on the page (click, fill, ' +
'navigate) to satisfy the request. Prefer small, targeted snippets that return ' +
'only the specific data you need; do not dump whole DOM trees or large arrays. ' +
'Take several small steps rather than one giant one. When the request is complete, ' +
'stop calling tools and reply to the user in plain text.';
// Returns { answer, messages } so the conversation can be reused for later turns.
async function agent(userRequest, log = console.log, history = null) {
const messages = history ? history : [{ role: 'system', content: SYSTEM_PROMPT }];
if (userRequest) messages.push({ role: 'user', content: userRequest });
for (let step = 0; step < maxSteps; step++) {
const data = await chat(messages);
const msg = data && data.choices && data.choices[0] && data.choices[0].message;
if (!msg) {
return { answer: 'ERROR: no message in response: ' + safeStringify(data), messages };
}
messages.push(msg);
const toolCalls = msg.tool_calls;
if (!toolCalls || toolCalls.length === 0) {
return { answer: msg.content || '', messages }; // final plain-text answer
}
for (const tc of toolCalls) {
let args = {};
try {
args = JSON.parse(tc.function.arguments || '{}');
} catch (e) {
args = { code: '' };
}
const code = args.code || '';
log('>> run_js:\n' + code);
const result = await runJs(code);
log('<< result: ' + result.slice(0, 500));
messages.push({ role: 'tool', tool_call_id: tc.id, content: result });
}
}
return { answer: '(Stopped after ' + maxSteps + ' steps without a final answer.)', messages };
}
// ============================================================
// UI - floating panel (bottom-right, dark), hidden by default
// ============================================================
const PANEL_ID = 'llm-page-agent-panel';
if (document.getElementById(PANEL_ID)) return; // avoid double-inject
const style = document.createElement('style');
style.textContent = `
#${PANEL_ID}{position:fixed;bottom:16px;right:16px;z-index:2147483647;width:340px;
font-family:-apple-system,system-ui,Segoe UI,Roboto,sans-serif;font-size:13px;
background:#1e1e22;color:#e6e6e6;border:1px solid #3a3a40;border-radius:10px;
box-shadow:0 8px 30px rgba(0,0,0,.5);overflow:hidden;
transition:opacity .12s ease, transform .12s ease;}
#${PANEL_ID}.pa-hidden{opacity:0;transform:translateY(8px) scale(.98);pointer-events:none;
visibility:hidden;}
#${PANEL_ID} .pa-header{display:flex;align-items:center;gap:8px;padding:8px 10px;
background:#27272c;user-select:none;}
#${PANEL_ID} .pa-title{font-weight:600;flex:1;}
#${PANEL_ID} .pa-iconbtn{background:transparent;border:none;color:#bdbdbd;cursor:pointer;
font-size:14px;line-height:1;padding:3px 7px;border-radius:6px;}
#${PANEL_ID} .pa-iconbtn:hover{background:#3a3a40;color:#fff;}
#${PANEL_ID} .pa-body{padding:8px 10px;}
#${PANEL_ID} .pa-log{max-height:240px;overflow-y:auto;white-space:pre-wrap;
word-break:break-word;background:#131316;border:1px solid #2c2c31;border-radius:6px;
padding:8px;line-height:1.35;margin-bottom:8px;font-family:ui-monospace,Menlo,Consolas,monospace;
font-size:12px;}
#${PANEL_ID} .pa-row{display:flex;gap:6px;margin-bottom:6px;}
#${PANEL_ID} input.pa-input{flex:1;min-width:0;background:#131316;color:#e6e6e6;
border:1px solid #3a3a40;border-radius:6px;padding:6px 8px;outline:none;}
#${PANEL_ID} input.pa-input:disabled{opacity:.5;}
#${PANEL_ID} button.pa-btn{background:#3b82f6;color:#fff;border:none;border-radius:6px;
padding:6px 10px;cursor:pointer;font-size:12px;white-space:nowrap;}
#${PANEL_ID} button.pa-btn.pa-secondary{background:#3a3a40;}
#${PANEL_ID} button.pa-btn:disabled{opacity:.5;cursor:not-allowed;}
#${PANEL_ID} .pa-spinner{display:inline-block;width:12px;height:12px;
border:2px solid rgba(255,255,255,.35);border-top-color:#fff;border-radius:50%;
animation:pa-rotation .7s linear infinite;vertical-align:middle;}
#${PANEL_ID}.pa-collapsed .pa-body{display:none;}
@keyframes pa-rotation{from{transform:rotate(0deg);}to{transform:rotate(360deg);}}
#pa-agent-toast{position:fixed;bottom:16px;right:16px;z-index:2147483647;
font-family:-apple-system,system-ui,Segoe UI,Roboto,sans-serif;font-size:12px;
background:#1e1e22;color:#e6e6e6;border:1px solid #3a3a40;border-radius:8px;
padding:8px 12px;box-shadow:0 6px 20px rgba(0,0,0,.45);max-width:280px;
transition:opacity .3s ease;pointer-events:none;}
#pa-agent-toast.pa-fade{opacity:0;}
`;
document.head.appendChild(style);
const panel = document.createElement('div');
panel.id = PANEL_ID;
panel.className = 'pa-hidden'; // hidden until revealed
panel.innerHTML =
'<div class="pa-header">' +
'<span class="pa-title">[AI] Page Agent</span>' +
'<button class="pa-iconbtn" data-act="settings" title="Settings">[cfg]</button>' +
'<button class="pa-iconbtn" data-act="collapse" title="Collapse">-</button>' +
'<button class="pa-iconbtn" data-act="hide" title="Hide (reopen with ' + HOTKEY.label + ' or a three-finger tap)">x</button>' +
'</div>' +
'<div class="pa-body">' +
'<div class="pa-log"></div>' +
'<div class="pa-row">' +
'<input class="pa-input" type="text" placeholder="Ask the agent to do something..." />' +
'<button class="pa-btn" data-act="send">Send</button>' +
'</div>' +
'<div class="pa-row">' +
'<button class="pa-btn pa-secondary" data-act="convert">Convert to userscript</button>' +
'<button class="pa-btn pa-secondary" data-act="download">Download .user.js</button>' +
'</div>' +
'</div>';
document.body.appendChild(panel);
const logEl = panel.querySelector('.pa-log');
const inputEl = panel.querySelector('.pa-input');
const sendBtn = panel.querySelector('[data-act="send"]');
const convertBtn = panel.querySelector('[data-act="convert"]');
const downloadBtn = panel.querySelector('[data-act="download"]');
const settingsBtn = panel.querySelector('[data-act="settings"]');
const collapseBtn = panel.querySelector('[data-act="collapse"]');
const hideBtn = panel.querySelector('[data-act="hide"]');
const allButtons = [sendBtn, convertBtn, downloadBtn];
function append(text) {
const line = document.createElement('div');
line.textContent = text;
logEl.appendChild(line);
logEl.scrollTop = logEl.scrollHeight; // autoscroll
}
// ============================================================
// Visibility - hidden by default; toggle via hotkey / gesture
// ============================================================
let hintShown = false;
function isHidden() { return panel.classList.contains('pa-hidden'); }
function showPanel() {
panel.classList.remove('pa-hidden');
if (!hintShown) {
append('Reveal/hide me anytime: ' + HOTKEY.label + ' (desktop) or a three-finger tap (mobile).');
hintShown = true;
}
setTimeout(() => inputEl.focus(), 0);
}
function hidePanel() { panel.classList.add('pa-hidden'); }
function togglePanel() { if (isHidden()) showPanel(); else hidePanel(); }
// Transient toast so the reveal shortcut is discoverable on page load.
function showToast(text, ms = 4000) {
const existing = document.getElementById('pa-agent-toast');
if (existing) existing.remove();
const toast = document.createElement('div');
toast.id = 'pa-agent-toast';
toast.textContent = text;
document.body.appendChild(toast);
setTimeout(() => {
toast.classList.add('pa-fade');
setTimeout(() => toast.remove(), 320);
}, ms);
}
// ============================================================
// Busy / loading state
// ============================================================
let busy = false;
let statusEl = null;
function setBusy(on, label) {
busy = on;
inputEl.disabled = on;
allButtons.forEach((b) => { b.disabled = on; });
if (on) {
sendBtn.dataset.label = sendBtn.textContent;
sendBtn.innerHTML = '<span class="pa-spinner"></span>';
statusEl = document.createElement('div');
statusEl.innerHTML = '<span class="pa-spinner"></span> ' + (label || 'thinking...');
logEl.appendChild(statusEl);
logEl.scrollTop = logEl.scrollHeight;
} else {
if (sendBtn.dataset.label) sendBtn.textContent = sendBtn.dataset.label;
if (statusEl && statusEl.parentNode) statusEl.parentNode.removeChild(statusEl);
statusEl = null;
}
}
// ============================================================
// Conversation state + runners
// ============================================================
let conversation = null; // running messages array, reused across turns
let lastCode = ''; // last generated userscript
async function run(request) {
if (busy) return;
if (!CONFIG.apiKey) { append('! No API key set. Click [cfg] to configure.'); return; }
append('> ' + request);
setBusy(true, 'thinking...');
try {
const { answer, messages } = await agent(request, append, conversation);
conversation = messages; // persist for later turns
append('[AI] ' + answer);
} catch (e) {
append('ERROR: ' + (e && e.message ? e.message : String(e)));
} finally {
setBusy(false); // always reset
}
}
function send() {
const v = inputEl.value.trim();
if (!v) return;
inputEl.value = '';
run(v);
}
// ============================================================
// Convert / Download - bake the session into a standalone userscript
// ============================================================
const CONVERT_PROMPT =
'Now convert EVERYTHING you learned and did in this session into a single, ' +
'standalone, repeatable Tampermonkey/Violentmonkey userscript that performs the ' +
'same task automatically with NO LLM calls and no dependence on this agent. Bake ' +
'in the concrete selectors, fetch() URLs, and data transforms you discovered. ' +
'Include a complete ==UserScript== metadata header (@name, @namespace, @version, ' +
'@description, an appropriate @match, @run-at and any @grant/@connect you need). ' +
'The script must be self-contained and idempotent. Output ONLY the finished ' +
'userscript inside a single ' + BT3 + 'javascript code block - no explanation ' +
'before or after.';
function extractCode(text) {
if (!text) return '';
const re = new RegExp(BT3 + 'javascript\\s*([\\s\\S]*?)' + BT3, 'i');
const m = text.match(re);
if (m) return m[1].trim();
return text.trim(); // fallback: raw text
}
async function copyToClipboard(text) {
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
return;
}
} catch (e) { /* fall through to fallback */ }
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.focus();
ta.select();
try { document.execCommand('copy'); } catch (e) { /* ignore */ }
document.body.removeChild(ta);
}
function saveFile(text, filename) {
const blob = new Blob([text], { type: 'text/javascript' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// Runs the convert prompt against a COPY of the conversation so the live one stays clean.
async function generateUserscript() {
const copy = JSON.parse(JSON.stringify(conversation));
const { answer } = await agent(CONVERT_PROMPT, append, copy);
lastCode = extractCode(answer);
return lastCode;
}
async function convert() {
if (busy) return;
if (!conversation) { append('! Nothing to convert yet - run the agent first.'); return; }
setBusy(true, 'converting...');
try {
const code = await generateUserscript();
await copyToClipboard(code);
append('OK Userscript generated & copied to clipboard (' + code.length + ' chars). Use "Download .user.js" to save it.');
} catch (e) {
append('ERROR: ' + (e && e.message ? e.message : String(e)));
} finally {
setBusy(false);
}
}
async function download() {
if (busy) return;
if (!conversation) { append('! Nothing to convert yet - run the agent first.'); return; }
setBusy(true, 'preparing download...');
try {
const code = lastCode || await generateUserscript();
saveFile(code, 'page-agent-generated.user.js');
append('v Saved page-agent-generated.user.js (' + code.length + ' chars).');
} catch (e) {
append('ERROR: ' + (e && e.message ? e.message : String(e)));
} finally {
setBusy(false);
}
}
// ============================================================
// Settings
// ============================================================
async function openSettings() {
const ep = prompt('Endpoint (chat-completions URL):', CONFIG.endpoint);
if (ep !== null) { CONFIG.endpoint = ep.trim() || DEFAULTS.endpoint; await GM.setValue('endpoint', CONFIG.endpoint); }
const key = prompt('API key (Bearer token):', CONFIG.apiKey);
if (key !== null) { CONFIG.apiKey = key.trim(); await GM.setValue('apiKey', CONFIG.apiKey); }
const mdl = prompt('Model:', CONFIG.model);
if (mdl !== null) { CONFIG.model = mdl.trim() || DEFAULTS.model; await GM.setValue('model', CONFIG.model); }
append('[cfg] Settings saved - endpoint=' + CONFIG.endpoint + ' model=' + CONFIG.model + ' key=' + (CONFIG.apiKey ? '(set)' : '(empty)'));
}
// ============================================================
// Wiring
// ============================================================
sendBtn.addEventListener('click', send);
inputEl.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); send(); } });
convertBtn.addEventListener('click', convert);
downloadBtn.addEventListener('click', download);
settingsBtn.addEventListener('click', openSettings);
hideBtn.addEventListener('click', hidePanel);
collapseBtn.addEventListener('click', () => {
const collapsed = panel.classList.toggle('pa-collapsed');
collapseBtn.textContent = collapsed ? '[ ]' : '-';
collapseBtn.title = collapsed ? 'Expand' : 'Collapse';
});
// Desktop reveal/hide - global hotkey (Ctrl+Shift+Space by default).
window.addEventListener('keydown', (e) => {
if (!!e.ctrlKey === HOTKEY.ctrl && !!e.shiftKey === HOTKEY.shift &&
!!e.altKey === HOTKEY.alt && e.code === HOTKEY.code) {
e.preventDefault();
togglePanel();
} else if (e.key === 'Escape' && !isHidden() && document.activeElement !== inputEl) {
hidePanel();
}
}, true);
// Mobile reveal/hide - three-finger tap anywhere on the page.
window.addEventListener('touchstart', (e) => {
if (e.touches && e.touches.length === 3) {
e.preventDefault();
togglePanel();
}
}, { capture: true, passive: false });
append('Ready. ' + (CONFIG.apiKey ? '' : 'Set your API key via [cfg] first. ') + 'Ask me to inspect or act on this page.');
showToast('[AI] Page Agent loaded - ' + HOTKEY.label + ' (desktop) or three-finger tap (mobile) to open.');
})();