Initial commit: userscripts collection with oxlint tooling
This commit is contained in:
@@ -0,0 +1,516 @@
|
||||
// ==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.');
|
||||
})();
|
||||
Reference in New Issue
Block a user