517 lines
21 KiB
JavaScript
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.');
|
|
})();
|