Files
userscripts/page-agent.user.js
T

517 lines
22 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.
// ==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">🤖 Page Agent</span>' +
'<button class="pa-iconbtn" data-act="settings" title="Settings">⚙</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)">✕</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 ⚙ to configure.'); return; }
append(' ' + request);
setBusy(true, 'thinking…');
try {
const { answer, messages } = await agent(request, append, conversation);
conversation = messages; // persist for later turns
append('🤖 ' + 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('✅ 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('⬇ 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('⚙ 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 ⚙ first. ') + 'Ask me to inspect or act on this page.');
showToast('🤖 Page Agent loaded — ' + HOTKEY.label + ' (desktop) or three-finger tap (mobile) to open.');
})();