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:
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
.env
|
||||
*.log
|
||||
dist/
|
||||
@@ -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
|
||||
|
||||
 
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 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
@@ -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
@@ -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 {}
|
||||
});
|
||||
})();
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 797 B |
Binary file not shown.
|
After Width: | Height: | Size: 156 B |
Binary file not shown.
|
After Width: | Height: | Size: 315 B |
@@ -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
@@ -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... │
|
||||
└─────────────────────────────────────────────────┘
|
||||
`);
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user