Initial commit: Browser Pilot - Agentic Browser Control Extension

- Side Panel with Chat UI, Quick Actions, Dev Tools
- 22 browser automation tools (DOM, JS, screenshots, navigation)
- MCP server (zero-dep, Node 18+) for external AI clients
- Supports OpenRouter, Ollama, Anthropic, OpenAI, custom endpoints
- Chrome/Brave MV3 extension with content script console capture
This commit is contained in:
2026-07-28 11:04:37 +02:00
commit d708c8b58c
13 changed files with 2258 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
.env
*.log
dist/
+207
View File
@@ -0,0 +1,207 @@
# 🚀 Browser Pilot
**Agentic Browser Control wie im Screenshot: Side Panel Chat + MCP Server + eigenständige Browser-KI.**
Eine Chrome/Brave Extension die drei Dinge gleichzeitig kann:
1. **🗨️ In-Browser Agent (Side Panel)** Chat-UI direkt im Browser, die KI steuert die Seite
2. **🔌 MCP Server** externe KI-Clients (Claude Desktop, Cursor, du als Agent) können den Browser fernsteuern
3. **🛠️ Dev Tools** JS Console, CSS Selector Tester, direkte Tool-Ausführung
![Architecture](https://img.shields.io/badge/MV3-Chrome%20Extension-blue) ![Zero Deps](https://img.shields.io/badge/Server-Zero%20Dependencies-green)
---
## 🏗️ Architektur
```
┌──────────────────────────────────────────────────────────────────────┐
│ BROWSER (Chrome/Brave) │
│ │
│ ┌─────────────────────┐ ┌──────────────────────────────────────┐ │
│ │ Side Panel (UI) │ │ Background Service Worker │ │
│ │ │ │ │ │
│ │ • Chat mit LLM │◄──►│ • 22 Browser-Tools │ │
│ │ • Quick Actions │ │ • WebSocket → MCP Server │ │
│ │ • JS Console │ │ • Message Router │ │
│ │ • Selector Test │ │ │ │
│ └─────────────────────┘ └────────────────┬───────────────────────┘ │
│ │ │
│ ┌─────────────────────┐ │ │
│ │ Content Script │◄────────────────────┘ │
│ │ (jede Seite) │ │
│ │ • Console Capture │ │
│ │ • Error Tracking │ │
│ └─────────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
▲ ▲
│ LLM API │ WebSocket
│ (OpenRouter/Ollama/etc) │
▼ ▼
┌─────────────────────┐ ┌─────────────────────────┐
│ LLM Provider │ │ MCP Server (Node.js) │
│ (dein Endpoint) │ │ Port 9224 │
│ │ │ │
│ • OpenRouter │ │ • HTTP MCP Protocol │
│ • Ollama (lokal) │ │ • WS Bridge │
│ • Anthropic │ │ • Zero Dependencies │
│ • OpenAI │ │ │
│ • Custom │ │ Clients: │
└─────────────────────┘ │ • Claude Desktop │
│ • Cursor │
│ • Dieser Agent (du) │
│ • Beliebiger MCP-Client│
└─────────────────────────┘
```
---
## ⚡ Quick Start
### 1. Extension installieren
```bash
# In Chrome/Brave:
# 1. chrome://extensions/ öffnen
# 2. Developer Mode aktivieren
# 3. "Load unpacked" → browser-pilot/ Ordner wählen
```
### 2. Side Panel nutzen (eigenständig im Browser)
1. Extension-Icon klicken → Side Panel öffnet sich
2. ⚙️ Settings → API Key + Endpoint konfigurieren
3. Lostippen: "Was steht auf dieser Seite?" oder "Klicke auf den Login-Button"
### 3. MCP Server starten (für externe KI-Anbindung)
```bash
cd browser-pilot/server
node index.js
```
### 4. In Claude Desktop / Cursor / etc. einbinden
```json
{
"mcpServers": {
"browser-pilot": {
"type": "streamableHttp",
"url": "http://127.0.0.1:9224/mcp"
}
}
}
```
---
## 🗨️ Side Panel Features
| Tab | Funktion |
|-----|----------|
| **Fragen** | Chat mit KI stellt Fragen über die Seite, lässt Aufgaben ausführen |
| **Handeln** | Quick Actions: Screenshot, Seitentext, Links, Formulare, Zusammenfassung |
| **Dev** | JS Console (Ctrl+Enter), CSS Selector Tester mit Highlighting |
### Unterstützte LLM Provider
| Provider | Base URL | Modelle |
|----------|----------|---------|
| OpenRouter | `https://openrouter.ai/api/v1` | Alle (Claude, GPT, Gemini, ...) |
| Ollama | `http://localhost:11434/v1` | qwen2.5, llama3, mistral, ... |
| Anthropic | `https://api.anthropic.com/v1` | Claude Sonnet/Haiku/Opus |
| OpenAI | `https://api.openai.com/v1` | GPT-4o, o1, ... |
| Custom | Beliebig | Jeder OpenAI-kompatible Endpoint |
---
## 🔧 22 Browser-Tools
| Tool | Beschreibung |
|------|-------------|
| `browser_navigate` | Zu URL navigieren |
| `browser_go_back/forward` | History-Navigation |
| `browser_reload` | Seite neu laden |
| `browser_get_tabs` | Alle Tabs auflisten |
| `browser_switch_tab` | Tab wechseln |
| `browser_close_tab` | Tab schließen |
| `browser_screenshot` | Screenshot (base64 PNG) |
| `browser_get_page_info` | URL, Titel, Viewport, Scroll |
| `browser_get_text` | Textinhalt der Seite/Element |
| `browser_get_html` | HTML der Seite/Element |
| `browser_query_selector` | Elemente per CSS-Selektor |
| `browser_get_interactive_elements` | Alle klickbaren Elemente |
| `browser_click` | Element anklicken |
| `browser_type` | Text in Input eingeben |
| `browser_select` | Option in Select wählen |
| `browser_scroll` | Seite/Element scrollen |
| `browser_execute_js` | Beliebiges JavaScript ausführen |
| `browser_press_key` | Taste drücken (mit Modifiern) |
| `browser_wait` | Auf Element/Timeout warten |
| `browser_get_console_logs` | Console-Logs auslesen |
| `browser_highlight` | Element visuell hervorheben |
---
## 🔒 Compliance & Sicherheit
- **Eigene Endpoints** du bestimmst welches LLM, welcher Server
- **Kein Cloud-Zwang** funktioniert komplett offline mit Ollama
- **Kein Tracking** null Telemetrie, null externe Calls (außer dein LLM)
- **Lokaler MCP Server** bindet nur auf 127.0.0.1
- **Optional Auth Token** `AUTH_TOKEN=xxx` Environment Variable
- **Zero Dependencies** Server braucht kein `npm install`
---
## 📁 Projektstruktur
```
browser-pilot/
├── manifest.json # Chrome MV3 Manifest
├── background.js # Service Worker (Tools + WS Bridge)
├── content.js # Content Script (Console Capture)
├── sidepanel.html # Side Panel UI
├── sidepanel.css # Styles (Dark Theme)
├── sidepanel.js # Agent Loop + Chat Logic
├── icons/ # Extension Icons
└── server/
├── index.js # MCP Server (zero-dep, Node 18+)
└── package.json
```
---
## 💡 Beispiele
### Im Side Panel:
```
"Fasse diese Seite zusammen"
"Fülle das Kontaktformular mit Testdaten aus"
"Finde alle Links die auf externe Seiten zeigen"
"Klicke auf 'Weiter' und warte bis die nächste Seite geladen ist"
"Sperre die Beobachtungsliste" (wie im Screenshot)
```
### Via MCP (Claude Desktop / dieser Agent):
```
Tool: browser_navigate → {"url": "https://servicenow.mercedes-benz.com"}
Tool: browser_get_interactive_elements → {}
Tool: browser_click → {"selector": "#resolve-btn"}
Tool: browser_screenshot → {}
```
---
## 🚀 Entwicklung
```bash
# Server im Watch-Mode:
cd server && node --watch index.js
# Extension: Dateien editieren → chrome://extensions/ → Reload
```
---
MIT License. Keine Abhängigkeiten. Dein Browser, dein LLM, deine Regeln. 🔥
+428
View File
@@ -0,0 +1,428 @@
/**
* Browser Pilot Background Service Worker
*
* Three roles:
* 1. Tool execution engine (DOM, screenshots, JS, navigation)
* 2. WebSocket bridge for external MCP server
* 3. Message router between side panel, content scripts, and external clients
*/
// ============================================================
// Tool Registry
// ============================================================
const TOOLS = {
browser_navigate: {
description: 'Navigate to a URL',
params: { url: 'string', newTab: 'boolean?' },
handler: async ({ url, newTab }) => {
if (newTab) {
const tab = await chrome.tabs.create({ url });
return { tabId: tab.id, url };
}
const tab = await getActiveTab();
await chrome.tabs.update(tab.id, { url });
return { tabId: tab.id, url };
}
},
browser_go_back: {
description: 'Go back in history',
params: {},
handler: async () => { const t = await getActiveTab(); await chrome.tabs.goBack(t.id); return { ok: true }; }
},
browser_go_forward: {
description: 'Go forward in history',
params: {},
handler: async () => { const t = await getActiveTab(); await chrome.tabs.goForward(t.id); return { ok: true }; }
},
browser_reload: {
description: 'Reload current page',
params: {},
handler: async () => { const t = await getActiveTab(); await chrome.tabs.reload(t.id); return { ok: true }; }
},
browser_get_tabs: {
description: 'List all open tabs',
params: {},
handler: async () => {
const tabs = await chrome.tabs.query({});
return tabs.map(t => ({ id: t.id, title: t.title, url: t.url, active: t.active }));
}
},
browser_switch_tab: {
description: 'Switch to tab by ID',
params: { tabId: 'number' },
handler: async ({ tabId }) => {
await chrome.tabs.update(tabId, { active: true });
const t = await chrome.tabs.get(tabId);
await chrome.windows.update(t.windowId, { focused: true });
return { ok: true, tabId };
}
},
browser_close_tab: {
description: 'Close a tab',
params: { tabId: 'number' },
handler: async ({ tabId }) => { await chrome.tabs.remove(tabId); return { ok: true }; }
},
browser_screenshot: {
description: 'Screenshot visible viewport (base64 PNG)',
params: {},
handler: async () => {
const tab = await getActiveTab();
const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, { format: 'png' });
return { image: dataUrl };
}
},
browser_get_page_info: {
description: 'Get page URL, title, viewport, scroll position',
params: {},
handler: async () => {
const tab = await getActiveTab();
const info = await execInTab(tab.id, () => ({
title: document.title,
url: location.href,
viewport: { w: window.innerWidth, h: window.innerHeight },
scroll: { x: window.scrollX, y: window.scrollY },
docHeight: document.documentElement.scrollHeight
}));
return { ...info, tabId: tab.id };
}
},
browser_get_text: {
description: 'Get text content of page or element',
params: { selector: 'string?' },
handler: async ({ selector }) => {
const tab = await getActiveTab();
return await execInTab(tab.id, (sel) => {
const el = sel ? document.querySelector(sel) : document.body;
if (!el) return { error: 'Element not found: ' + sel };
return el.innerText;
}, [selector || null]);
}
},
browser_get_html: {
description: 'Get HTML of page or element',
params: { selector: 'string?', outer: 'boolean?' },
handler: async ({ selector, outer = true }) => {
const tab = await getActiveTab();
return await execInTab(tab.id, (sel, isOuter) => {
const el = sel ? document.querySelector(sel) : document.documentElement;
if (!el) return { error: 'Element not found: ' + sel };
return isOuter ? el.outerHTML : el.innerHTML;
}, [selector || null, outer]);
}
},
browser_query_selector: {
description: 'Query elements returns tag, text, attributes, bounding box',
params: { selector: 'string', limit: 'number?' },
handler: async ({ selector, limit = 20 }) => {
const tab = await getActiveTab();
return await execInTab(tab.id, (sel, lim) => {
return Array.from(document.querySelectorAll(sel)).slice(0, lim).map(el => {
const r = el.getBoundingClientRect();
const attrs = {};
for (const a of el.attributes) attrs[a.name] = a.value;
return { tag: el.tagName.toLowerCase(), text: (el.innerText||'').slice(0,200), attrs, box: {x:r.x,y:r.y,w:r.width,h:r.height} };
});
}, [selector, limit]);
}
},
browser_get_interactive_elements: {
description: 'Find all interactive elements (buttons, links, inputs) with selectors',
params: { viewportOnly: 'boolean?' },
handler: async ({ viewportOnly = true }) => {
const tab = await getActiveTab();
return await execInTab(tab.id, (vpOnly) => {
const sels = 'a[href], button, input, select, textarea, [role="button"], [onclick], [tabindex]:not([tabindex="-1"])';
const els = Array.from(document.querySelectorAll(sels));
const results = [];
for (const el of els) {
const r = el.getBoundingClientRect();
if (r.width === 0 && r.height === 0) continue;
if (vpOnly && (r.bottom < 0 || r.top > window.innerHeight || r.right < 0 || r.left > window.innerWidth)) continue;
const attrs = {};
for (const a of ['id','class','name','type','href','value','placeholder','aria-label','role','title'])
if (el.getAttribute(a)) attrs[a] = el.getAttribute(a);
let selector = '';
if (el.id) selector = '#' + el.id;
else if (el.name) selector = el.tagName.toLowerCase() + '[name="' + el.name + '"]';
else {
const parts = []; let cur = el;
while (cur && cur !== document.body && parts.length < 4) {
let s = cur.tagName.toLowerCase();
if (cur.id) { parts.unshift('#' + cur.id); break; }
const parent = cur.parentElement;
if (parent) {
const sibs = Array.from(parent.children).filter(c => c.tagName === cur.tagName);
if (sibs.length > 1) s += ':nth-of-type(' + (sibs.indexOf(cur)+1) + ')';
}
parts.unshift(s); cur = cur.parentElement;
}
selector = parts.join(' > ');
}
results.push({ tag: el.tagName.toLowerCase(), text: (el.innerText||el.value||'').slice(0,80), attrs, selector, box:{x:Math.round(r.x),y:Math.round(r.y),w:Math.round(r.width),h:Math.round(r.height)} });
if (results.length >= 50) break;
}
return results;
}, [viewportOnly]);
}
},
browser_click: {
description: 'Click element by CSS selector',
params: { selector: 'string' },
handler: async ({ selector }) => {
const tab = await getActiveTab();
return await execInTab(tab.id, (sel) => {
const el = document.querySelector(sel);
if (!el) return { error: 'Not found: ' + sel };
el.scrollIntoView({ block: 'center', behavior: 'instant' });
el.click();
return { clicked: sel, tag: el.tagName.toLowerCase(), text: (el.innerText||'').slice(0,80) };
}, [selector]);
}
},
browser_type: {
description: 'Type text into input/textarea',
params: { selector: 'string', text: 'string', clear: 'boolean?' },
handler: async ({ selector, text, clear = true }) => {
const tab = await getActiveTab();
return await execInTab(tab.id, (sel, txt, clr) => {
const el = document.querySelector(sel);
if (!el) return { error: 'Not found: ' + sel };
el.focus();
if (clr) { el.value = ''; el.dispatchEvent(new Event('input', {bubbles:true})); }
// Simulate character-by-character for React/Angular
for (const ch of txt) {
el.value += ch;
el.dispatchEvent(new Event('input', {bubbles:true}));
}
el.dispatchEvent(new Event('change', {bubbles:true}));
return { typed: txt, selector: sel };
}, [selector, text, clear]);
}
},
browser_select: {
description: 'Select option in <select>',
params: { selector: 'string', value: 'string' },
handler: async ({ selector, value }) => {
const tab = await getActiveTab();
return await execInTab(tab.id, (sel, val) => {
const el = document.querySelector(sel);
if (!el) return { error: 'Not found: ' + sel };
let opt = Array.from(el.options).find(o => o.value === val || o.text === val);
if (!opt) return { error: 'Option not found: ' + val };
el.value = opt.value;
el.dispatchEvent(new Event('change', {bubbles:true}));
return { selected: opt.text, value: opt.value };
}, [selector, value]);
}
},
browser_scroll: {
description: 'Scroll page or element',
params: { direction: 'string', amount: 'number?', selector: 'string?' },
handler: async ({ direction, amount = 500, selector }) => {
const tab = await getActiveTab();
return await execInTab(tab.id, (dir, amt, sel) => {
const target = sel ? document.querySelector(sel) : window;
const opts = { behavior: 'smooth' };
if (dir === 'down') (target === window ? window : target).scrollBy({top: amt, ...opts});
else if (dir === 'up') (target === window ? window : target).scrollBy({top: -amt, ...opts});
else if (dir === 'right') (target === window ? window : target).scrollBy({left: amt, ...opts});
else if (dir === 'left') (target === window ? window : target).scrollBy({left: -amt, ...opts});
return { scrolled: dir, amount: amt };
}, [direction, amount, selector || null]);
}
},
browser_execute_js: {
description: 'Execute JavaScript in page context, return result',
params: { code: 'string' },
handler: async ({ code }) => {
const tab = await getActiveTab();
const results = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: (js) => { try { const r = eval(js); return { value: JSON.parse(JSON.stringify(r ?? null)) }; } catch(e) { return { error: e.message }; } },
args: [code]
});
if (results?.[0]?.result?.error) throw new Error(results[0].result.error);
return results?.[0]?.result?.value;
}
},
browser_press_key: {
description: 'Press keyboard key (Enter, Escape, Tab, etc.)',
params: { key: 'string', modifiers: 'array?' },
handler: async ({ key, modifiers = [] }) => {
const tab = await getActiveTab();
return await execInTab(tab.id, (k, mods) => {
const opts = { key: k, bubbles: true, cancelable: true, ctrlKey: mods.includes('ctrl'), altKey: mods.includes('alt'), shiftKey: mods.includes('shift'), metaKey: mods.includes('meta') };
const t = document.activeElement || document.body;
t.dispatchEvent(new KeyboardEvent('keydown', opts));
t.dispatchEvent(new KeyboardEvent('keyup', opts));
return { pressed: k, modifiers: mods };
}, [key, modifiers]);
}
},
browser_wait: {
description: 'Wait for element or timeout',
params: { selector: 'string?', timeout: 'number?' },
handler: async ({ selector, timeout = 5000 }) => {
if (!selector) { await new Promise(r => setTimeout(r, timeout)); return { waited: timeout }; }
const tab = await getActiveTab();
return await execInTab(tab.id, (sel, ms) => {
return new Promise(resolve => {
const start = Date.now();
(function check() {
if (document.querySelector(sel)) return resolve({ found: true, elapsed: Date.now()-start });
if (Date.now()-start > ms) return resolve({ found: false, timeout: true });
requestAnimationFrame(check);
})();
});
}, [selector, timeout]);
}
},
browser_get_console_logs: {
description: 'Get captured console logs',
params: { clear: 'boolean?' },
handler: async ({ clear = false }) => {
const logs = [...consoleLogs];
if (clear) consoleLogs.length = 0;
return logs;
}
},
browser_highlight: {
description: 'Highlight an element visually (for debugging)',
params: { selector: 'string', color: 'string?' },
handler: async ({ selector, color = '#ff6b35' }) => {
const tab = await getActiveTab();
return await execInTab(tab.id, (sel, col) => {
const el = document.querySelector(sel);
if (!el) return { error: 'Not found: ' + sel };
el.style.outline = '3px solid ' + col;
el.style.outlineOffset = '2px';
setTimeout(() => { el.style.outline = ''; el.style.outlineOffset = ''; }, 3000);
return { highlighted: sel };
}, [selector, color]);
}
}
};
// ============================================================
// State
// ============================================================
let consoleLogs = [];
// ============================================================
// Helpers
// ============================================================
async function getActiveTab() {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab) throw new Error('No active tab');
return tab;
}
async function execInTab(tabId, func, args = []) {
const results = await chrome.scripting.executeScript({ target: { tabId }, func, args });
if (results?.[0]?.error) throw new Error(results[0].error.message || 'Script error');
return results?.[0]?.result;
}
async function handleToolCall(name, params = {}) {
const tool = TOOLS[name];
if (!tool) throw new Error('Unknown tool: ' + name);
return await tool.handler(params);
}
// ============================================================
// Message handling (from side panel, content scripts, external)
// ============================================================
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === 'TOOL_CALL') {
handleToolCall(msg.tool, msg.params || {})
.then(result => sendResponse({ success: true, result }))
.catch(err => sendResponse({ success: false, error: err.message }));
return true;
}
if (msg.type === 'GET_TOOLS') {
const tools = Object.entries(TOOLS).map(([name, t]) => ({ name, description: t.description, params: t.params }));
sendResponse({ tools });
return true;
}
if (msg.type === 'CONSOLE_LOG') {
consoleLogs.push({ level: msg.level, text: msg.text, ts: Date.now(), url: sender.tab?.url });
if (consoleLogs.length > 200) consoleLogs.shift();
}
if (msg.type === 'PING') {
sendResponse({ pong: true, version: '1.0.0', tools: Object.keys(TOOLS).length });
return true;
}
});
// External messages (from MCP server page or other extensions)
chrome.runtime.onMessageExternal.addListener((msg, sender, sendResponse) => {
if (msg.type === 'TOOL_CALL') {
handleToolCall(msg.tool, msg.params || {})
.then(result => sendResponse({ success: true, result }))
.catch(err => sendResponse({ success: false, error: err.message }));
return true;
}
if (msg.type === 'GET_TOOLS') {
const tools = Object.entries(TOOLS).map(([name, t]) => ({ name, description: t.description, params: t.params }));
sendResponse({ tools });
return true;
}
});
// ============================================================
// Side Panel activation on icon click
// ============================================================
chrome.action.onClicked.addListener(async (tab) => {
await chrome.sidePanel.open({ windowId: tab.windowId });
});
// Ensure side panel is available
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(() => {});
// ============================================================
// Keep alive
// ============================================================
chrome.alarms.create('keepAlive', { periodInMinutes: 0.4 });
chrome.alarms.onAlarm.addListener(() => {});
// ============================================================
// WebSocket bridge to MCP server (connects when server is running)
// ============================================================
let ws = null;
let wsRetry = null;
function connectWs() {
if (ws && ws.readyState === WebSocket.OPEN) return;
try {
ws = new WebSocket('ws://127.0.0.1:9224');
ws.onopen = () => {
console.log('[BrowserPilot] Connected to MCP bridge');
if (wsRetry) { clearInterval(wsRetry); wsRetry = null; }
ws.send(JSON.stringify({ type: 'hello', version: '1.0.0', tools: Object.keys(TOOLS).length }));
};
ws.onmessage = async (e) => {
try {
const msg = JSON.parse(e.data);
if (msg.type === 'TOOL_CALL' && msg.id) {
try {
const result = await handleToolCall(msg.tool, msg.params || {});
ws.send(JSON.stringify({ id: msg.id, success: true, result }));
} catch (err) {
ws.send(JSON.stringify({ id: msg.id, success: false, error: err.message }));
}
}
} catch (err) { console.error('[BrowserPilot] WS msg error:', err); }
};
ws.onclose = () => { ws = null; scheduleWsRetry(); };
ws.onerror = () => { ws = null; scheduleWsRetry(); };
} catch (e) { scheduleWsRetry(); }
}
function scheduleWsRetry() {
if (wsRetry) return;
wsRetry = setInterval(connectWs, 5000);
}
connectWs();
console.log('[BrowserPilot] Service worker loaded ', Object.keys(TOOLS).length, 'tools');
+34
View File
@@ -0,0 +1,34 @@
/**
* Browser Pilot Content Script
* Captures console logs and provides DOM utilities.
*/
(function() {
'use strict';
if (window.__bpInjected) return;
window.__bpInjected = true;
// Console capture
const orig = { log: console.log, warn: console.warn, error: console.error, info: console.info };
function hook(level) {
return function(...args) {
try {
chrome.runtime.sendMessage({
type: 'CONSOLE_LOG', level,
text: args.map(a => { try { return typeof a === 'object' ? JSON.stringify(a) : String(a); } catch { return String(a); } }).join(' ')
});
} catch {}
orig[level].apply(console, args);
};
}
console.log = hook('log');
console.warn = hook('warn');
console.error = hook('error');
console.info = hook('info');
window.addEventListener('error', e => {
try { chrome.runtime.sendMessage({ type: 'CONSOLE_LOG', level: 'error', text: `${e.message} @ ${e.filename}:${e.lineno}` }); } catch {}
});
window.addEventListener('unhandledrejection', e => {
try { chrome.runtime.sendMessage({ type: 'CONSOLE_LOG', level: 'error', text: 'Unhandled: ' + e.reason }); } catch {}
});
})();
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 797 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 156 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 315 B

+44
View File
@@ -0,0 +1,44 @@
{
"manifest_version": 3,
"name": "Browser Pilot",
"version": "1.0.0",
"description": "Agentic browser control Chat UI, MCP server, and direct AI integration. Let any LLM read pages, click buttons, fill forms, execute JS, take screenshots.",
"permissions": [
"activeTab",
"tabs",
"scripting",
"storage",
"sidePanel",
"alarms",
"webNavigation",
"debugger"
],
"host_permissions": ["<all_urls>"],
"background": {
"service_worker": "background.js"
},
"side_panel": {
"default_path": "sidepanel.html"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"],
"run_at": "document_idle",
"all_frames": false
}
],
"action": {
"default_title": "Browser Pilot",
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
}
+343
View File
@@ -0,0 +1,343 @@
/**
* Browser Pilot MCP Server + WebSocket Bridge
*
* Zero dependencies. Runs on Node.js 18+.
*
* This server does TWO things:
* 1. HTTP MCP endpoint (port 9224) for LLM clients (Claude Desktop, Cursor, etc.)
* 2. WebSocket server (same port, upgrade) for the Chrome extension to connect
*
* Flow:
* Claude/Cursor --MCP HTTP--> This Server --WebSocket--> Chrome Extension --Chrome APIs--> Browser
*/
import http from 'node:http';
import crypto from 'node:crypto';
// ============================================================
// Config
// ============================================================
const PORT = parseInt(process.env.PORT || '9224');
const AUTH_TOKEN = process.env.AUTH_TOKEN || '';
// ============================================================
// WebSocket (RFC 6455 minimal implementation)
// ============================================================
const WS_GUID = '258EAFA5-E914-47DA-95CA-5AB5DC85B7F3';
class WsConnection {
constructor(socket) {
this.socket = socket;
this.alive = true;
this.buffer = Buffer.alloc(0);
this.onMessage = null;
this.onClose = null;
socket.on('data', d => this._onData(d));
socket.on('close', () => { this.alive = false; this.onClose?.(); });
socket.on('error', () => { this.alive = false; });
}
send(data) {
if (!this.alive) return;
const buf = Buffer.from(typeof data === 'string' ? data : JSON.stringify(data));
const frame = this._frame(buf, 0x01);
this.socket.write(frame);
}
close() {
this.socket.write(this._frame(Buffer.alloc(0), 0x08));
this.alive = false;
this.socket.end();
}
_frame(payload, opcode) {
const len = payload.length;
let header;
if (len < 126) {
header = Buffer.alloc(2);
header[0] = 0x80 | opcode;
header[1] = len;
} else if (len < 65536) {
header = Buffer.alloc(4);
header[0] = 0x80 | opcode;
header[1] = 126;
header.writeUInt16BE(len, 2);
} else {
header = Buffer.alloc(10);
header[0] = 0x80 | opcode;
header[1] = 127;
header.writeBigUInt64BE(BigInt(len), 2);
}
return Buffer.concat([header, payload]);
}
_onData(data) {
this.buffer = Buffer.concat([this.buffer, data]);
while (this.buffer.length >= 2) {
const frame = this._parseFrame();
if (!frame) break;
if (frame.opcode === 0x08) { this.alive = false; this.socket.end(); this.onClose?.(); return; }
if (frame.opcode === 0x09) { this.socket.write(this._frame(frame.payload, 0x0A)); continue; }
if (frame.opcode === 0x01 || frame.opcode === 0x02) {
this.onMessage?.(frame.payload.toString('utf-8'));
}
}
}
_parseFrame() {
if (this.buffer.length < 2) return null;
const masked = (this.buffer[1] & 0x80) !== 0;
let payloadLen = this.buffer[1] & 0x7F;
let offset = 2;
if (payloadLen === 126) { if (this.buffer.length < 4) return null; payloadLen = this.buffer.readUInt16BE(2); offset = 4; }
else if (payloadLen === 127) { if (this.buffer.length < 10) return null; payloadLen = Number(this.buffer.readBigUInt64BE(2)); offset = 10; }
const maskSize = masked ? 4 : 0;
const total = offset + maskSize + payloadLen;
if (this.buffer.length < total) return null;
let mask = masked ? this.buffer.slice(offset, offset + 4) : null;
if (masked) offset += 4;
let payload = this.buffer.slice(offset, offset + payloadLen);
if (mask) for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i % 4];
this.buffer = this.buffer.slice(total);
return { opcode: this.buffer.length >= 0 ? (this.buffer[-total] ?? this.buffer[0]) & 0x0F : 0, payload };
}
}
// Fix: the opcode parsing above is wrong after slicing. Let me redo properly:
WsConnection.prototype._parseFrame = function() {
if (this.buffer.length < 2) return null;
const firstByte = this.buffer[0];
const secondByte = this.buffer[1];
const opcode = firstByte & 0x0F;
const masked = (secondByte & 0x80) !== 0;
let payloadLen = secondByte & 0x7F;
let offset = 2;
if (payloadLen === 126) { if (this.buffer.length < 4) return null; payloadLen = this.buffer.readUInt16BE(2); offset = 4; }
else if (payloadLen === 127) { if (this.buffer.length < 10) return null; payloadLen = Number(this.buffer.readBigUInt64BE(2)); offset = 10; }
const maskSize = masked ? 4 : 0;
const total = offset + maskSize + payloadLen;
if (this.buffer.length < total) return null;
let mask = masked ? this.buffer.slice(offset, offset + 4) : null;
if (masked) offset += 4;
let payload = Buffer.from(this.buffer.slice(offset, offset + payloadLen));
if (mask) for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i % 4];
this.buffer = this.buffer.slice(total);
return { opcode, payload };
};
// ============================================================
// Extension connection state
// ============================================================
let extensionWs = null;
let pendingCalls = new Map();
let callId = 0;
function callExtension(tool, params, timeout = 30000) {
return new Promise((resolve, reject) => {
if (!extensionWs || !extensionWs.alive) {
return reject(new Error('Browser extension not connected. Open Browser Pilot in Chrome/Brave.'));
}
const id = ++callId;
const timer = setTimeout(() => { pendingCalls.delete(id); reject(new Error('Timeout')); }, timeout);
pendingCalls.set(id, { resolve: (r) => { clearTimeout(timer); resolve(r); }, reject: (e) => { clearTimeout(timer); reject(e); } });
extensionWs.send(JSON.stringify({ id, type: 'TOOL_CALL', tool, params }));
});
}
// ============================================================
// MCP Tools Schema
// ============================================================
const TOOLS = [
{ name: 'browser_navigate', description: 'Navigate to a URL', inputSchema: { type: 'object', properties: { url: { type: 'string' }, newTab: { type: 'boolean' } }, required: ['url'] } },
{ name: 'browser_go_back', description: 'Go back in history', inputSchema: { type: 'object', properties: {} } },
{ name: 'browser_go_forward', description: 'Go forward in history', inputSchema: { type: 'object', properties: {} } },
{ name: 'browser_reload', description: 'Reload page', inputSchema: { type: 'object', properties: {} } },
{ name: 'browser_get_tabs', description: 'List all open tabs', inputSchema: { type: 'object', properties: {} } },
{ name: 'browser_switch_tab', description: 'Switch to tab by ID', inputSchema: { type: 'object', properties: { tabId: { type: 'number' } }, required: ['tabId'] } },
{ name: 'browser_close_tab', description: 'Close tab', inputSchema: { type: 'object', properties: { tabId: { type: 'number' } }, required: ['tabId'] } },
{ name: 'browser_screenshot', description: 'Screenshot visible viewport (base64 PNG)', inputSchema: { type: 'object', properties: {} } },
{ name: 'browser_get_page_info', description: 'Get page URL, title, viewport, scroll', inputSchema: { type: 'object', properties: {} } },
{ name: 'browser_get_text', description: 'Get text content of page or element', inputSchema: { type: 'object', properties: { selector: { type: 'string' } } } },
{ name: 'browser_get_html', description: 'Get HTML of page or element', inputSchema: { type: 'object', properties: { selector: { type: 'string' }, outer: { type: 'boolean' } } } },
{ name: 'browser_query_selector', description: 'Query elements by CSS selector', inputSchema: { type: 'object', properties: { selector: { type: 'string' }, limit: { type: 'number' } }, required: ['selector'] } },
{ name: 'browser_get_interactive_elements', description: 'Find all interactive elements', inputSchema: { type: 'object', properties: { viewportOnly: { type: 'boolean' } } } },
{ name: 'browser_click', description: 'Click element by selector', inputSchema: { type: 'object', properties: { selector: { type: 'string' } }, required: ['selector'] } },
{ name: 'browser_type', description: 'Type text into input', inputSchema: { type: 'object', properties: { selector: { type: 'string' }, text: { type: 'string' }, clear: { type: 'boolean' } }, required: ['selector', 'text'] } },
{ name: 'browser_select', description: 'Select option in <select>', inputSchema: { type: 'object', properties: { selector: { type: 'string' }, value: { type: 'string' } }, required: ['selector', 'value'] } },
{ name: 'browser_scroll', description: 'Scroll page or element', inputSchema: { type: 'object', properties: { direction: { type: 'string', enum: ['up','down','left','right'] }, amount: { type: 'number' }, selector: { type: 'string' } }, required: ['direction'] } },
{ name: 'browser_execute_js', description: 'Execute JavaScript in page context', inputSchema: { type: 'object', properties: { code: { type: 'string' } }, required: ['code'] } },
{ name: 'browser_press_key', description: 'Press keyboard key', inputSchema: { type: 'object', properties: { key: { type: 'string' }, modifiers: { type: 'array', items: { type: 'string' } } }, required: ['key'] } },
{ name: 'browser_wait', description: 'Wait for element or timeout', inputSchema: { type: 'object', properties: { selector: { type: 'string' }, timeout: { type: 'number' } } } },
{ name: 'browser_get_console_logs', description: 'Get console logs', inputSchema: { type: 'object', properties: { clear: { type: 'boolean' } } } },
{ name: 'browser_highlight', description: 'Highlight element visually', inputSchema: { type: 'object', properties: { selector: { type: 'string' }, color: { type: 'string' } }, required: ['selector'] } },
];
// ============================================================
// MCP JSON-RPC Handler
// ============================================================
async function handleMcp(body) {
const { method, params, id } = body;
if (method === 'initialize') {
return { jsonrpc: '2.0', id, result: { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'browser-pilot', version: '1.0.0' } } };
}
if (method === 'notifications/initialized') return null;
if (method === 'tools/list') {
return { jsonrpc: '2.0', id, result: { tools: TOOLS } };
}
if (method === 'tools/call') {
const { name, arguments: args } = params;
try {
const resp = await callExtension(name, args || {});
const content = [];
if (resp.success) {
if (resp.result?.image) {
const b64 = resp.result.image.replace(/^data:image\/png;base64,/, '');
content.push({ type: 'image', data: b64, mimeType: 'image/png' });
} else {
content.push({ type: 'text', text: JSON.stringify(resp.result, null, 2) });
}
} else {
content.push({ type: 'text', text: 'Error: ' + resp.error });
}
return { jsonrpc: '2.0', id, result: { content, isError: !resp.success } };
} catch (e) {
return { jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: 'Error: ' + e.message }], isError: true } };
}
}
return { jsonrpc: '2.0', id, error: { code: -32601, message: 'Method not found: ' + method } };
}
// ============================================================
// HTTP Server
// ============================================================
const server = http.createServer(async (req, res) => {
const cors = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, Mcp-Session-Id',
'Access-Control-Expose-Headers': 'Mcp-Session-Id'
};
if (req.method === 'OPTIONS') { res.writeHead(204, cors); res.end(); return; }
// Auth
if (AUTH_TOKEN && req.headers.authorization !== `Bearer ${AUTH_TOKEN}`) {
res.writeHead(401, { ...cors, 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
}
const url = new URL(req.url, `http://localhost:${PORT}`);
// Health
if (url.pathname === '/health') {
res.writeHead(200, { ...cors, 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok', extensionConnected: !!extensionWs?.alive, tools: TOOLS.length }));
return;
}
// Tools list (convenience)
if (url.pathname === '/tools' && req.method === 'GET') {
res.writeHead(200, { ...cors, 'Content-Type': 'application/json' });
res.end(JSON.stringify({ tools: TOOLS }));
return;
}
// Direct execute (convenience)
if (url.pathname === '/execute' && req.method === 'POST') {
let body = ''; for await (const c of req) body += c;
try {
const { tool, params } = JSON.parse(body);
const result = await callExtension(tool, params || {});
res.writeHead(200, { ...cors, 'Content-Type': 'application/json' });
res.end(JSON.stringify(result));
} catch (e) {
res.writeHead(500, { ...cors, 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: e.message }));
}
return;
}
// MCP endpoint
if (url.pathname === '/mcp') {
if (req.method === 'GET') {
// SSE
res.writeHead(200, { ...cors, 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
res.write('event: open\ndata: {"status":"connected"}\n\n');
const ka = setInterval(() => res.write(': ping\n\n'), 15000);
req.on('close', () => clearInterval(ka));
return;
}
if (req.method === 'POST') {
let body = ''; for await (const c of req) body += c;
try {
const parsed = JSON.parse(body);
const result = await handleMcp(parsed);
if (result) {
res.writeHead(200, { ...cors, 'Content-Type': 'application/json' });
res.end(JSON.stringify(result));
} else {
res.writeHead(204, cors); res.end();
}
} catch (e) {
res.writeHead(400, { ...cors, 'Content-Type': 'application/json' });
res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32700, message: e.message } }));
}
return;
}
}
res.writeHead(404, cors); res.end('Not found');
});
// WebSocket upgrade
server.on('upgrade', (req, socket, head) => {
const key = req.headers['sec-websocket-key'];
if (!key) { socket.destroy(); return; }
const accept = crypto.createHash('sha1').update(key + WS_GUID).digest('base64');
socket.write([
'HTTP/1.1 101 Switching Protocols',
'Upgrade: websocket',
'Connection: Upgrade',
`Sec-WebSocket-Accept: ${accept}`,
'', ''
].join('\r\n'));
const ws = new WsConnection(socket);
extensionWs = ws;
console.log('✓ Extension connected');
ws.onMessage = (data) => {
try {
const msg = JSON.parse(data);
if (msg.id && pendingCalls.has(msg.id)) {
pendingCalls.get(msg.id).resolve(msg);
pendingCalls.delete(msg.id);
}
} catch {}
};
ws.onClose = () => {
console.log('✗ Extension disconnected');
if (extensionWs === ws) extensionWs = null;
for (const [id, { reject }] of pendingCalls) reject(new Error('Extension disconnected'));
pendingCalls.clear();
};
});
server.listen(PORT, '127.0.0.1', () => {
console.log(`
┌─────────────────────────────────────────────────┐
│ 🚀 Browser Pilot MCP Server │
│ │
│ MCP: http://127.0.0.1:${PORT}/mcp │
│ Health: http://127.0.0.1:${PORT}/health │
│ WS: ws://127.0.0.1:${PORT} (extension bridge) │
│ │
│ Waiting for extension... │
└─────────────────────────────────────────────────┘
`);
});
+13
View File
@@ -0,0 +1,13 @@
{
"name": "browser-pilot-server",
"version": "1.0.0",
"description": "MCP server + WebSocket bridge for Browser Pilot extension",
"type": "module",
"main": "index.js",
"scripts": {
"start": "node index.js",
"dev": "node --watch index.js"
},
"dependencies": {},
"license": "MIT"
}
+415
View File
@@ -0,0 +1,415 @@
/* Browser Pilot Side Panel Styles */
:root {
--bg: #0d1117;
--bg2: #161b22;
--bg3: #21262d;
--border: #30363d;
--text: #e6edf3;
--text2: #8b949e;
--accent: #ff6b35;
--accent2: #ff8c5a;
--green: #3fb950;
--red: #f85149;
--blue: #58a6ff;
--radius: 8px;
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: var(--font);
background: var(--bg);
color: var(--text);
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
font-size: 13px;
}
/* Header */
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: var(--bg2);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.header-left { display: flex; align-items: center; gap: 8px; }
.logo { font-size: 18px; }
.title { font-weight: 600; font-size: 14px; }
.header-right { display: flex; align-items: center; gap: 8px; }
.provider-select {
background: var(--bg3);
border: 1px solid var(--border);
color: var(--text);
border-radius: 4px;
padding: 3px 6px;
font-size: 11px;
}
.status-dot {
width: 8px; height: 8px;
border-radius: 50%;
background: var(--red);
transition: background 0.3s;
}
.status-dot.connected { background: var(--green); }
.icon-btn {
background: none;
border: none;
cursor: pointer;
font-size: 16px;
padding: 2px;
}
/* Tabs */
.tabs {
display: flex;
background: var(--bg2);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.tab {
flex: 1;
padding: 8px;
background: none;
border: none;
color: var(--text2);
font-size: 12px;
font-weight: 500;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.2s;
}
.tab:hover { color: var(--text); background: var(--bg3); }
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
/* Tab Content */
.tab-content {
display: none;
flex: 1;
overflow: hidden;
flex-direction: column;
}
.tab-content.active { display: flex; }
/* Messages */
.messages {
flex: 1;
overflow-y: auto;
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.message {
max-width: 95%;
padding: 10px 12px;
border-radius: var(--radius);
font-size: 13px;
line-height: 1.5;
word-wrap: break-word;
}
.message.assistant {
background: var(--bg2);
border: 1px solid var(--border);
align-self: flex-start;
}
.message.user {
background: var(--accent);
color: white;
align-self: flex-end;
border-radius: var(--radius) var(--radius) 4px var(--radius);
}
.message.tool {
background: var(--bg3);
border: 1px solid var(--border);
font-family: var(--mono);
font-size: 11px;
color: var(--text2);
align-self: flex-start;
max-width: 100%;
}
.message.error {
background: #2d1b1b;
border: 1px solid var(--red);
color: var(--red);
}
.message-content { white-space: pre-wrap; }
.message-content strong { color: var(--accent); }
.message-content code {
background: var(--bg3);
padding: 1px 4px;
border-radius: 3px;
font-family: var(--mono);
font-size: 11px;
}
.tool-label {
font-size: 10px;
color: var(--accent);
font-weight: 600;
margin-bottom: 4px;
text-transform: uppercase;
}
/* Input Area */
.input-area {
padding: 10px 12px;
border-top: 1px solid var(--border);
background: var(--bg2);
flex-shrink: 0;
}
.input-row {
display: flex;
gap: 8px;
align-items: flex-end;
}
.input-row textarea {
flex: 1;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
padding: 8px 12px;
font-family: var(--font);
font-size: 13px;
resize: none;
max-height: 120px;
line-height: 1.4;
}
.input-row textarea:focus { outline: none; border-color: var(--accent); }
.send-btn {
width: 36px; height: 36px;
border-radius: 50%;
background: var(--accent);
border: none;
color: white;
font-size: 14px;
cursor: pointer;
flex-shrink: 0;
transition: background 0.2s;
}
.send-btn:hover { background: var(--accent2); }
.send-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.input-options {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 6px;
}
.checkbox-label {
font-size: 11px;
color: var(--text2);
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
}
.stop-btn {
background: var(--red);
color: white;
border: none;
border-radius: 4px;
padding: 4px 10px;
font-size: 11px;
cursor: pointer;
}
.hidden { display: none !important; }
/* Actions Tab */
.actions-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
padding: 12px;
}
.action-btn {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
padding: 12px 10px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
text-align: left;
}
.action-btn:hover { border-color: var(--accent); background: var(--bg3); }
.action-result {
flex: 1;
overflow-y: auto;
padding: 12px;
font-family: var(--mono);
font-size: 11px;
white-space: pre-wrap;
color: var(--text2);
}
/* Dev Tab */
.dev-section {
padding: 12px;
border-bottom: 1px solid var(--border);
}
.dev-section h3 {
font-size: 12px;
color: var(--accent);
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.code-input, .code-input-line {
width: 100%;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text);
font-family: var(--mono);
font-size: 12px;
padding: 8px;
resize: vertical;
}
.code-input-line { resize: none; height: 32px; }
.code-input:focus, .code-input-line:focus { outline: none; border-color: var(--accent); }
.exec-btn {
margin-top: 6px;
background: var(--bg3);
border: 1px solid var(--border);
color: var(--text);
border-radius: 4px;
padding: 5px 12px;
font-size: 11px;
cursor: pointer;
}
.exec-btn:hover { border-color: var(--accent); }
.code-output {
margin-top: 8px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
padding: 8px;
font-family: var(--mono);
font-size: 11px;
color: var(--green);
max-height: 200px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
}
.code-output.small { font-size: 10px; color: var(--text2); }
.mcp-status {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
margin-bottom: 8px;
}
.mcp-dot {
width: 8px; height: 8px;
border-radius: 50%;
background: var(--green);
}
.mcp-dot.offline { background: var(--red); }
/* Modal */
.modal {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: 12px;
width: 90%;
max-width: 400px;
max-height: 90vh;
overflow-y: auto;
padding: 20px;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.modal-header h2 { font-size: 16px; }
.close-btn {
background: none;
border: none;
color: var(--text2);
font-size: 18px;
cursor: pointer;
}
.form-group { margin-bottom: 12px; }
.form-group label {
display: block;
font-size: 11px;
color: var(--text2);
margin-bottom: 4px;
font-weight: 500;
}
.form-group input, .form-group textarea {
width: 100%;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text);
padding: 8px;
font-size: 12px;
font-family: var(--mono);
}
.form-group input:focus, .form-group textarea:focus { outline: none; border-color: var(--accent); }
.save-btn {
width: 100%;
background: var(--accent);
color: white;
border: none;
border-radius: var(--radius);
padding: 10px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
margin-top: 8px;
}
.save-btn:hover { background: var(--accent2); }
/* Scrollbar */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--text2); }
/* Loading animation */
.typing-indicator {
display: flex;
gap: 4px;
padding: 4px 0;
}
.typing-indicator span {
width: 6px; height: 6px;
background: var(--text2);
border-radius: 50%;
animation: bounce 1.4s infinite;
}
.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
@keyframes bounce {
0%, 60%, 100% { transform: translateY(0); }
30% { transform: translateY(-4px); }
}
/* Screenshot in chat */
.message img {
max-width: 100%;
border-radius: 4px;
margin-top: 6px;
border: 1px solid var(--border);
}
+138
View File
@@ -0,0 +1,138 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Browser Pilot</title>
<link rel="stylesheet" href="sidepanel.css">
</head>
<body>
<!-- Header -->
<header class="header">
<div class="header-left">
<span class="logo">🚀</span>
<span class="title">Browser Pilot</span>
</div>
<div class="header-right">
<select id="providerSelect" class="provider-select">
<option value="custom">Custom Endpoint</option>
<option value="openrouter">OpenRouter</option>
<option value="ollama">Ollama (local)</option>
<option value="anthropic">Anthropic</option>
<option value="openai">OpenAI</option>
</select>
<div class="status-dot" id="statusDot" title="Disconnected"></div>
<button class="icon-btn" id="settingsBtn" title="Settings">⚙️</button>
</div>
</header>
<!-- Tab Bar -->
<nav class="tabs">
<button class="tab active" data-tab="chat">Fragen</button>
<button class="tab" data-tab="act">Handeln</button>
<button class="tab" data-tab="dev">Dev</button>
</nav>
<!-- Chat Tab -->
<div class="tab-content active" id="tab-chat">
<div class="messages" id="chatMessages">
<div class="message assistant">
<div class="message-content">
Hallo! Ich bin <strong>Browser Pilot</strong>. Ich kann diese Seite lesen, Fragen beantworten, auf Buttons klicken, Formulare ausfüllen und mehrstufige Aufgaben automatisieren.
</div>
</div>
</div>
<div class="input-area">
<div class="input-row">
<textarea id="chatInput" placeholder="Frage stellen oder Aufgabe beschreiben..." rows="1"></textarea>
<button class="send-btn" id="sendBtn" title="Senden"></button>
</div>
<div class="input-options">
<label class="checkbox-label">
<input type="checkbox" id="dangerousMode"> Modus ohne Nachfragen
</label>
<button class="stop-btn hidden" id="stopBtn">⬛ Stopp</button>
</div>
</div>
</div>
<!-- Act Tab (quick actions) -->
<div class="tab-content" id="tab-act">
<div class="actions-grid">
<button class="action-btn" data-action="screenshot">📸 Screenshot</button>
<button class="action-btn" data-action="get-text">📄 Seitentext</button>
<button class="action-btn" data-action="get-links">🔗 Alle Links</button>
<button class="action-btn" data-action="get-forms">📝 Formulare</button>
<button class="action-btn" data-action="get-interactive">🎯 Interaktive Elemente</button>
<button class="action-btn" data-action="console-logs">🖥️ Console Logs</button>
<button class="action-btn" data-action="page-info">️ Page Info</button>
<button class="action-btn" data-action="summarize">🧠 Seite zusammenfassen</button>
</div>
<div class="action-result" id="actionResult"></div>
</div>
<!-- Dev Tab -->
<div class="tab-content" id="tab-dev">
<div class="dev-section">
<h3>JavaScript Console</h3>
<textarea id="jsInput" class="code-input" placeholder="// JavaScript hier eingeben..." rows="4">document.title</textarea>
<button class="exec-btn" id="execJsBtn">▶ Ausführen</button>
<pre class="code-output" id="jsOutput"></pre>
</div>
<div class="dev-section">
<h3>CSS Selector Test</h3>
<input type="text" id="selectorInput" class="code-input-line" placeholder="z.B. button.primary, #login-form">
<button class="exec-btn" id="testSelectorBtn">🔍 Testen</button>
<pre class="code-output" id="selectorOutput"></pre>
</div>
<div class="dev-section">
<h3>MCP Server Status</h3>
<div id="mcpStatus" class="mcp-status">
<span class="mcp-dot offline"></span> MCP Server nicht verbunden
</div>
<pre class="code-output small" id="mcpConfig">{
"mcpServers": {
"browser-pilot": {
"url": "http://127.0.0.1:9224/mcp"
}
}
}</pre>
</div>
</div>
<!-- Settings Modal -->
<div class="modal hidden" id="settingsModal">
<div class="modal-content">
<div class="modal-header">
<h2>⚙️ Einstellungen</h2>
<button class="close-btn" id="closeSettings"></button>
</div>
<div class="settings-form">
<div class="form-group">
<label>API Base URL</label>
<input type="text" id="cfgBaseUrl" placeholder="https://openrouter.ai/api/v1">
</div>
<div class="form-group">
<label>Model</label>
<input type="text" id="cfgModel" placeholder="anthropic/claude-sonnet-4-20250514">
</div>
<div class="form-group">
<label>API Key</label>
<input type="password" id="cfgApiKey" placeholder="sk-...">
</div>
<div class="form-group">
<label>System Prompt (optional)</label>
<textarea id="cfgSystemPrompt" rows="4" placeholder="Custom system prompt..."></textarea>
</div>
<div class="form-group">
<label>Max Iterations (Agent Loop)</label>
<input type="number" id="cfgMaxIter" value="15" min="1" max="50">
</div>
<button class="save-btn" id="saveSettings">💾 Speichern</button>
</div>
</div>
</div>
<script src="sidepanel.js"></script>
</body>
</html>
+632
View File
@@ -0,0 +1,632 @@
/**
* Browser Pilot Side Panel Logic
*
* This is the in-browser agent. It:
* 1. Provides a chat UI for the user
* 2. Connects to a configurable LLM endpoint (OpenRouter, Anthropic, Ollama, custom)
* 3. Runs an agentic tool-use loop: LLM decides which browser tools to call
* 4. Shows results in real-time
*/
// ============================================================
// State
// ============================================================
let config = {
baseUrl: 'https://openrouter.ai/api/v1',
model: 'anthropic/claude-sonnet-4-20250514',
apiKey: '',
systemPrompt: '',
maxIterations: 15,
provider: 'openrouter'
};
let isRunning = false;
let abortController = null;
let conversationHistory = [];
// ============================================================
// DOM refs
// ============================================================
const $ = (sel) => document.querySelector(sel);
const chatMessages = $('#chatMessages');
const chatInput = $('#chatInput');
const sendBtn = $('#sendBtn');
const stopBtn = $('#stopBtn');
const settingsBtn = $('#settingsBtn');
const settingsModal = $('#settingsModal');
const closeSettings = $('#closeSettings');
const saveSettingsBtn = $('#saveSettings');
const statusDot = $('#statusDot');
const providerSelect = $('#providerSelect');
const actionResult = $('#actionResult');
// ============================================================
// Init
// ============================================================
document.addEventListener('DOMContentLoaded', async () => {
await loadConfig();
setupTabs();
setupChat();
setupActions();
setupDev();
setupSettings();
checkConnection();
});
async function loadConfig() {
const stored = await chrome.storage.local.get(['bp_config']);
if (stored.bp_config) {
config = { ...config, ...stored.bp_config };
}
providerSelect.value = config.provider || 'custom';
applyProviderDefaults(config.provider);
}
async function saveConfig() {
await chrome.storage.local.set({ bp_config: config });
}
// ============================================================
// Tabs
// ============================================================
function setupTabs() {
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
tab.classList.add('active');
$(`#tab-${tab.dataset.tab}`).classList.add('active');
});
});
}
// ============================================================
// Chat
// ============================================================
function setupChat() {
sendBtn.addEventListener('click', sendMessage);
chatInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
// Auto-resize textarea
chatInput.addEventListener('input', () => {
chatInput.style.height = 'auto';
chatInput.style.height = Math.min(chatInput.scrollHeight, 120) + 'px';
});
stopBtn.addEventListener('click', stopAgent);
}
async function sendMessage() {
const text = chatInput.value.trim();
if (!text || isRunning) return;
chatInput.value = '';
chatInput.style.height = 'auto';
addMessage('user', text);
conversationHistory.push({ role: 'user', content: text });
await runAgentLoop();
}
function stopAgent() {
if (abortController) abortController.abort();
isRunning = false;
stopBtn.classList.add('hidden');
sendBtn.disabled = false;
}
// ============================================================
// Agent Loop the core
// ============================================================
async function runAgentLoop() {
isRunning = true;
sendBtn.disabled = true;
stopBtn.classList.remove('hidden');
abortController = new AbortController();
const tools = await getToolsForLLM();
const systemPrompt = config.systemPrompt || getDefaultSystemPrompt();
let iterations = 0;
try {
while (iterations < config.maxIterations && isRunning) {
iterations++;
// Show thinking indicator
const thinkingEl = addTypingIndicator();
// Call LLM
const messages = [
{ role: 'system', content: systemPrompt },
...conversationHistory
];
let response;
try {
response = await callLLM(messages, tools);
} catch (err) {
removeElement(thinkingEl);
if (err.name === 'AbortError') break;
addMessage('error', `LLM Error: ${err.message}`);
break;
}
removeElement(thinkingEl);
const assistantMsg = response.choices?.[0]?.message;
if (!assistantMsg) {
addMessage('error', 'No response from LLM');
break;
}
// Add to history
conversationHistory.push(assistantMsg);
// If there's text content, show it
if (assistantMsg.content) {
addMessage('assistant', assistantMsg.content);
}
// If no tool calls, we're done
if (!assistantMsg.tool_calls || assistantMsg.tool_calls.length === 0) {
break;
}
// Execute tool calls
for (const toolCall of assistantMsg.tool_calls) {
if (!isRunning) break;
const funcName = toolCall.function.name;
let args = {};
try { args = JSON.parse(toolCall.function.arguments || '{}'); } catch {}
// Show tool call in chat
addMessage('tool', `🔧 ${funcName}\n${JSON.stringify(args, null, 2)}`);
// Execute via background
let result;
try {
const resp = await chrome.runtime.sendMessage({ type: 'TOOL_CALL', tool: funcName, params: args });
if (resp.success) {
result = JSON.stringify(resp.result, null, 2);
// Show screenshot inline
if (resp.result?.image) {
addScreenshot(resp.result.image);
result = '(screenshot captured)';
} else {
// Truncate long results in display
const display = result.length > 500 ? result.slice(0, 500) + '\n...' : result;
addMessage('tool', `✓ Result:\n${display}`);
}
} else {
result = `Error: ${resp.error}`;
addMessage('error', result);
}
} catch (err) {
result = `Error: ${err.message}`;
addMessage('error', result);
}
// Add tool result to conversation
conversationHistory.push({
role: 'tool',
tool_call_id: toolCall.id,
content: result.slice(0, 8000) // Limit context size
});
}
}
if (iterations >= config.maxIterations) {
addMessage('assistant', '⚠️ Maximale Iterationen erreicht. Stoppe.');
}
} finally {
isRunning = false;
sendBtn.disabled = false;
stopBtn.classList.add('hidden');
}
}
// ============================================================
// LLM API Call
// ============================================================
async function callLLM(messages, tools) {
const payload = {
model: config.model,
messages,
tools,
tool_choice: 'auto',
temperature: 0.1,
max_tokens: 4096
};
// Provider-specific headers
const headers = {
'Content-Type': 'application/json'
};
if (config.apiKey) {
if (config.provider === 'anthropic') {
headers['x-api-key'] = config.apiKey;
headers['anthropic-version'] = '2023-06-01';
} else {
headers['Authorization'] = `Bearer ${config.apiKey}`;
}
}
// Anthropic uses a different format
let url = `${config.baseUrl}/chat/completions`;
if (config.provider === 'anthropic') {
url = `${config.baseUrl}/messages`;
// Convert to Anthropic format
const anthropicPayload = convertToAnthropicFormat(messages, tools, config.model);
const resp = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(anthropicPayload),
signal: abortController.signal
});
if (!resp.ok) {
const err = await resp.text();
throw new Error(`${resp.status}: ${err}`);
}
const data = await resp.json();
return convertFromAnthropicFormat(data);
}
const resp = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(payload),
signal: abortController.signal
});
if (!resp.ok) {
const err = await resp.text();
throw new Error(`${resp.status}: ${err}`);
}
return await resp.json();
}
function convertToAnthropicFormat(messages, tools, model) {
const system = messages.find(m => m.role === 'system')?.content || '';
const msgs = messages.filter(m => m.role !== 'system').map(m => {
if (m.role === 'tool') {
return { role: 'user', content: [{ type: 'tool_result', tool_use_id: m.tool_call_id, content: m.content }] };
}
if (m.tool_calls) {
return {
role: 'assistant',
content: [
...(m.content ? [{ type: 'text', text: m.content }] : []),
...m.tool_calls.map(tc => ({ type: 'tool_use', id: tc.id, name: tc.function.name, input: JSON.parse(tc.function.arguments || '{}') }))
]
};
}
return m;
});
const anthropicTools = tools.map(t => ({
name: t.function.name,
description: t.function.description,
input_schema: t.function.parameters
}));
return { model, system, messages: msgs, tools: anthropicTools, max_tokens: 4096 };
}
function convertFromAnthropicFormat(data) {
const content = data.content || [];
const textParts = content.filter(c => c.type === 'text').map(c => c.text).join('');
const toolUses = content.filter(c => c.type === 'tool_use');
const message = {
role: 'assistant',
content: textParts || null,
tool_calls: toolUses.length > 0 ? toolUses.map(tu => ({
id: tu.id,
type: 'function',
function: { name: tu.name, arguments: JSON.stringify(tu.input) }
})) : undefined
};
return { choices: [{ message, finish_reason: data.stop_reason === 'end_turn' ? 'stop' : 'tool_calls' }] };
}
// ============================================================
// Tools schema for LLM
// ============================================================
async function getToolsForLLM() {
const resp = await chrome.runtime.sendMessage({ type: 'GET_TOOLS' });
return resp.tools.map(t => ({
type: 'function',
function: {
name: t.name,
description: t.description,
parameters: buildJsonSchema(t.params)
}
}));
}
function buildJsonSchema(params) {
const properties = {};
const required = [];
for (const [key, type] of Object.entries(params)) {
const isOptional = type.endsWith('?');
const baseType = type.replace('?', '');
properties[key] = { type: baseType === 'array' ? 'array' : baseType };
if (!isOptional) required.push(key);
}
return { type: 'object', properties, required };
}
// ============================================================
// System Prompt
// ============================================================
function getDefaultSystemPrompt() {
return `Du bist Browser Pilot, ein KI-Agent der einen Webbrowser steuert.
Du hast Zugriff auf Browser-Automatisierungs-Tools. Nutze sie Schritt für Schritt um die Aufgabe des Nutzers zu erledigen.
Regeln:
- Starte mit browser_get_page_info oder browser_get_interactive_elements um den aktuellen Zustand zu verstehen
- Nutze browser_screenshot wenn du die Seite visuell sehen musst
- Nutze browser_execute_js für komplexe DOM-Operationen
- Sei methodisch: navigieren → beobachten → handeln → verifizieren
- Wenn du fertig bist, fasse zusammen was du getan hast
- Antworte auf Deutsch wenn der Nutzer Deutsch spricht
- Bei Fragen über den Seiteninhalt: lies zuerst die Seite mit browser_get_text
Verfügbare Tools:
- browser_navigate: Zu URL navigieren
- browser_get_page_info: Seiten-URL, Titel, Viewport
- browser_get_text: Text der Seite oder eines Elements
- browser_get_html: HTML der Seite oder eines Elements
- browser_query_selector: Elemente abfragen mit CSS-Selektor
- browser_get_interactive_elements: Alle klickbaren Elemente finden
- browser_click: Element anklicken
- browser_type: Text in Input eingeben
- browser_select: Option in Select wählen
- browser_scroll: Seite scrollen
- browser_execute_js: JavaScript ausführen
- browser_screenshot: Screenshot machen
- browser_press_key: Taste drücken
- browser_wait: Auf Element oder Timeout warten
- browser_highlight: Element visuell hervorheben
- browser_get_console_logs: Console-Logs lesen`;
}
// ============================================================
// UI Helpers
// ============================================================
function addMessage(type, content) {
const div = document.createElement('div');
div.className = `message ${type}`;
if (type === 'tool') {
div.innerHTML = `<div class="tool-label">Tool</div><div class="message-content">${escapeHtml(content)}</div>`;
} else {
div.innerHTML = `<div class="message-content">${formatContent(content)}</div>`;
}
chatMessages.appendChild(div);
chatMessages.scrollTop = chatMessages.scrollHeight;
return div;
}
function addScreenshot(dataUrl) {
const div = document.createElement('div');
div.className = 'message assistant';
div.innerHTML = `<div class="tool-label">Screenshot</div><img src="${dataUrl}" alt="Screenshot">`;
chatMessages.appendChild(div);
chatMessages.scrollTop = chatMessages.scrollHeight;
}
function addTypingIndicator() {
const div = document.createElement('div');
div.className = 'message assistant';
div.innerHTML = '<div class="typing-indicator"><span></span><span></span><span></span></div>';
chatMessages.appendChild(div);
chatMessages.scrollTop = chatMessages.scrollHeight;
return div;
}
function removeElement(el) {
if (el && el.parentNode) el.parentNode.removeChild(el);
}
function escapeHtml(text) {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function formatContent(text) {
// Basic markdown-like formatting
let html = escapeHtml(text);
html = html.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
html = html.replace(/`(.*?)`/g, '<code>$1</code>');
return html;
}
// ============================================================
// Quick Actions (Act Tab)
// ============================================================
function setupActions() {
document.querySelectorAll('.action-btn').forEach(btn => {
btn.addEventListener('click', () => executeAction(btn.dataset.action));
});
}
async function executeAction(action) {
actionResult.textContent = 'Executing...';
try {
let result;
switch (action) {
case 'screenshot':
result = await chrome.runtime.sendMessage({ type: 'TOOL_CALL', tool: 'browser_screenshot', params: {} });
if (result.success && result.result.image) {
actionResult.innerHTML = `<img src="${result.result.image}" style="max-width:100%;border-radius:4px;">`;
return;
}
break;
case 'get-text':
result = await chrome.runtime.sendMessage({ type: 'TOOL_CALL', tool: 'browser_get_text', params: {} });
break;
case 'get-links':
result = await chrome.runtime.sendMessage({ type: 'TOOL_CALL', tool: 'browser_query_selector', params: { selector: 'a[href]', limit: 50 } });
break;
case 'get-forms':
result = await chrome.runtime.sendMessage({ type: 'TOOL_CALL', tool: 'browser_query_selector', params: { selector: 'form, input, select, textarea', limit: 30 } });
break;
case 'get-interactive':
result = await chrome.runtime.sendMessage({ type: 'TOOL_CALL', tool: 'browser_get_interactive_elements', params: {} });
break;
case 'console-logs':
result = await chrome.runtime.sendMessage({ type: 'TOOL_CALL', tool: 'browser_get_console_logs', params: {} });
break;
case 'page-info':
result = await chrome.runtime.sendMessage({ type: 'TOOL_CALL', tool: 'browser_get_page_info', params: {} });
break;
case 'summarize':
// Get text and send to LLM for summary
const textResult = await chrome.runtime.sendMessage({ type: 'TOOL_CALL', tool: 'browser_get_text', params: {} });
if (textResult.success) {
// Switch to chat tab and ask for summary
document.querySelector('[data-tab="chat"]').click();
chatInput.value = 'Fasse den Inhalt dieser Seite zusammen.';
sendMessage();
return;
}
break;
}
if (result?.success) {
actionResult.textContent = JSON.stringify(result.result, null, 2);
} else {
actionResult.textContent = 'Error: ' + (result?.error || 'Unknown');
}
} catch (err) {
actionResult.textContent = 'Error: ' + err.message;
}
}
// ============================================================
// Dev Tab
// ============================================================
function setupDev() {
$('#execJsBtn').addEventListener('click', async () => {
const code = $('#jsInput').value;
try {
const result = await chrome.runtime.sendMessage({ type: 'TOOL_CALL', tool: 'browser_execute_js', params: { code } });
$('#jsOutput').textContent = result.success
? JSON.stringify(result.result, null, 2)
: 'Error: ' + result.error;
$('#jsOutput').style.color = result.success ? 'var(--green)' : 'var(--red)';
} catch (err) {
$('#jsOutput').textContent = 'Error: ' + err.message;
$('#jsOutput').style.color = 'var(--red)';
}
});
$('#testSelectorBtn').addEventListener('click', async () => {
const selector = $('#selectorInput').value;
try {
const result = await chrome.runtime.sendMessage({ type: 'TOOL_CALL', tool: 'browser_query_selector', params: { selector, limit: 10 } });
if (result.success) {
$('#selectorOutput').textContent = JSON.stringify(result.result, null, 2);
$('#selectorOutput').style.color = 'var(--green)';
// Highlight first match
if (result.result?.length > 0) {
await chrome.runtime.sendMessage({ type: 'TOOL_CALL', tool: 'browser_highlight', params: { selector } });
}
} else {
$('#selectorOutput').textContent = 'Error: ' + result.error;
$('#selectorOutput').style.color = 'var(--red)';
}
} catch (err) {
$('#selectorOutput').textContent = 'Error: ' + err.message;
}
});
// JS input: Ctrl+Enter to execute
$('#jsInput').addEventListener('keydown', (e) => {
if (e.key === 'Enter' && e.ctrlKey) {
e.preventDefault();
$('#execJsBtn').click();
}
});
}
// ============================================================
// Settings
// ============================================================
function setupSettings() {
settingsBtn.addEventListener('click', () => {
$('#cfgBaseUrl').value = config.baseUrl;
$('#cfgModel').value = config.model;
$('#cfgApiKey').value = config.apiKey;
$('#cfgSystemPrompt').value = config.systemPrompt;
$('#cfgMaxIter').value = config.maxIterations;
settingsModal.classList.remove('hidden');
});
closeSettings.addEventListener('click', () => settingsModal.classList.add('hidden'));
saveSettingsBtn.addEventListener('click', async () => {
config.baseUrl = $('#cfgBaseUrl').value.trim();
config.model = $('#cfgModel').value.trim();
config.apiKey = $('#cfgApiKey').value.trim();
config.systemPrompt = $('#cfgSystemPrompt').value.trim();
config.maxIterations = parseInt($('#cfgMaxIter').value) || 15;
config.provider = providerSelect.value;
await saveConfig();
settingsModal.classList.add('hidden');
checkConnection();
});
providerSelect.addEventListener('change', (e) => {
config.provider = e.target.value;
applyProviderDefaults(e.target.value);
});
}
function applyProviderDefaults(provider) {
switch (provider) {
case 'openrouter':
config.baseUrl = 'https://openrouter.ai/api/v1';
config.model = 'anthropic/claude-sonnet-4-20250514';
break;
case 'ollama':
config.baseUrl = 'http://localhost:11434/v1';
config.model = 'qwen2.5:14b';
break;
case 'anthropic':
config.baseUrl = 'https://api.anthropic.com/v1';
config.model = 'claude-sonnet-4-20250514';
break;
case 'openai':
config.baseUrl = 'https://api.openai.com/v1';
config.model = 'gpt-4o';
break;
}
}
// ============================================================
// Connection check
// ============================================================
async function checkConnection() {
try {
const resp = await chrome.runtime.sendMessage({ type: 'PING' });
if (resp?.pong) {
statusDot.classList.add('connected');
statusDot.title = `Connected ${resp.tools} tools`;
}
} catch {
statusDot.classList.remove('connected');
statusDot.title = 'Disconnected';
}
}