From f943261c3cbf9c1b39ee2a15fbd8ab7fcd804d24 Mon Sep 17 00:00:00 2001 From: Till Tomczak Date: Fri, 7 Aug 2026 17:30:08 +0200 Subject: [PATCH] feat: Gemini bridge, browser extension hardening, shared memory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nexus gateway (measured 2026-08-07, not assumed): - Gemini speaks Google-GenAI (/v1beta/models/{id}:streamGenerateContent, header api-key), NOT the Azure-OpenAI path — that returned 404 "no Route matched" and was the cause of the reported failures. New agent/gemini_bridge.py translates Bedrock Converse <-> Gemini in both directions. - Only gemini-3.6-flash is subscribed; 2.5-flash/2.5-pro/3.1-flash-lite give 403, every other name 404. Catalog corrected. - Four Gemini rules, each previously an HTTP 400, now covered by tests: thought signatures are mandatory, they belong to the TURN (not the individual call), functionResponse turns must be homogeneous, arrays need `items`. - Prompt caching is NOT available: cachePoint is accepted and ignored. System prompt: - Was an f-string; a code sample containing braces broke build_system_prompt at request time (CLI and web both 500, import stayed green). Now a plain template with __TOKEN__ placeholders. Regression guards in tests/test_system_prompt.py. CLI: - `agent resume` now prints the stored transcript. The history was always loaded into the model context, only the terminal stayed empty. Memory (new, all three surfaces): - agent/memory.py stores notes about the user in one local file, written atomically; memory.cnull.net remains an optional mirror that can never fail a write. Tools memory_save/search/forget, injected into the prompt with a budget. HTTP surface /api/memory for the extension. Browser extension (agent/extension, first commit of the source): - driveMode 'direct' talks to Nexus without the Python broker: Claude via Bedrock converse, GPT via Azure-OpenAI, Gemini via Google-GenAI. - browser_type no longer guesses the focused element — that wrote whole mails into Outlook's subject line. Read-back now reports where the text actually landed, so a mis-target is visible instead of silent. - aria-labelledby is resolved across all ids (it is a list); contenteditable is interactive and marked editable. Without this, subject and message body look identical to the model. - Hard block against sending mail, independent of riskMode. - Runs survive the panel: events are buffered and replayed by sequence number. - Image input (paste, file, drag&drop), on-page glow/spotlight, memory tools. Cost: fixed tokens per round 11434 -> 6540 (-43%) by trimming tool schemas, dropping gateway docs from the browser prompt and sending site knowledge only where it applies. Co-Authored-By: Claude Opus 5 --- README.md | 151 ++ esbuild.config.mjs | 51 + icons/icon128.png | Bin 0 -> 13977 bytes icons/icon16.png | Bin 0 -> 701 bytes icons/icon32.png | Bin 0 -> 1719 bytes icons/icon48.png | Bin 0 -> 3093 bytes manifest.json | 56 + mcp-bridge/README.md | 302 ++++ mcp-bridge/package.json | 25 + mcp-bridge/server.js | 1393 +++++++++++++++++ package-lock.json | 659 ++++++++ package.json | 23 + src/background/agent.ts | 602 ++++++++ src/background/cdp.ts | 738 +++++++++ src/background/computer.ts | 862 +++++++++++ src/background/conversations.ts | 176 +++ src/background/index.ts | 1759 +++++++++++++++++++++ src/background/llm.ts | 1229 +++++++++++++++ src/background/memory.ts | 203 +++ src/background/risk.ts | 378 +++++ src/content/console-hook.ts | 148 ++ src/content/cursor.ts | 286 ++++ src/content/inject.ts | 389 +++++ src/content/spotlight.ts | 201 +++ src/shared/config.ts | 321 ++++ src/shared/protocol.ts | 231 +++ src/shared/tool-schemas.generated.ts | 115 ++ src/sidepanel/app.ts | 2096 ++++++++++++++++++++++++++ src/sidepanel/index.html | 1163 ++++++++++++++ tsconfig.json | 18 + 30 files changed, 13575 insertions(+) create mode 100644 README.md create mode 100644 esbuild.config.mjs create mode 100644 icons/icon128.png create mode 100644 icons/icon16.png create mode 100644 icons/icon32.png create mode 100644 icons/icon48.png create mode 100644 manifest.json create mode 100644 mcp-bridge/README.md create mode 100644 mcp-bridge/package.json create mode 100644 mcp-bridge/server.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/background/agent.ts create mode 100644 src/background/cdp.ts create mode 100644 src/background/computer.ts create mode 100644 src/background/conversations.ts create mode 100644 src/background/index.ts create mode 100644 src/background/llm.ts create mode 100644 src/background/memory.ts create mode 100644 src/background/risk.ts create mode 100644 src/content/console-hook.ts create mode 100644 src/content/cursor.ts create mode 100644 src/content/inject.ts create mode 100644 src/content/spotlight.ts create mode 100644 src/shared/config.ts create mode 100644 src/shared/protocol.ts create mode 100644 src/shared/tool-schemas.generated.ts create mode 100644 src/sidepanel/app.ts create mode 100644 src/sidepanel/index.html create mode 100644 tsconfig.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..04e2135 --- /dev/null +++ b/README.md @@ -0,0 +1,151 @@ +# Nexus Browser Pilot + +Chrome/Brave-Extension, die den Browser **wie ein Mensch bedient**: echter Mauszeiger, +echte Tastatur, echte Ereignisse. Sie handelt selbstständig und fragt nicht nach — es sei +denn, du schaltest den Nachfrage-Modus ein. + +--- + +## Neue Features + +| | | +|---|---| +| **Markdown-Output** | Assistenten-Antworten werden als Markdown gerendert — Code-Blöcke, Listen, Tabellen, Links. | +| **Konversationsverlauf** | Gespräche werden persistent gespeichert und überleben Panel-Schließen und Browser-Neustart. Über das Dropdown wechselst du zwischen Konversationen. | +| **Memory-Anbindung** | Anbindung an `memory.cnull.net` — relevante Erinnerungen werden automatisch als Kontext injiziert, Gespräche können per Klick synchronisiert werden. | +| **Provider-Wahl** | Neben Anthropic und OpenAI jetzt auch **Requesty** (OpenAI-kompatibel). Basis-URL und Modell frei wählbar. | +| **Coding-Agent-Anbindung** | Im Broker-Modus verbindet sich die Extension mit dem lokalen Coding-Agent (CLI/Web UI via `agent-web.bat`). | +| **System-Prompt** | Frei editierbarer System-Prompt in den Einstellungen (driveMode 'direct'). | + +--- + +## Was sie anders macht + +| | | +|---|---| +| **Echte Eingaben** | Alle Klicks und Tastendrücke laufen über `chrome.debugger` + CDP `Input.dispatch*` und tragen `isTrusted=true`. Damit funktionieren Canvas/WebGL, natives Drag & Drop, native `- + * Dropdowns, Datei-Dialoge, Bot-Erkennung und viele Enterprise-SPAs. Echte Events gibt es in + * einer Chrome-Extension ausschließlich über chrome.debugger + die CDP-Input-Domain. + * + * Der Preis: Chrome zeigt eine "Wird debuggt"-Leiste. Die lässt sich nur per Startflag + * --silent-debugger-extension-api oder Enterprise-Policy unterdrücken. + * + * Koordinaten: Input.dispatchMouseEvent erwartet CSS-Pixel relativ zum VIEWPORT, + * unabhängig vom devicePixelRatio. Screenshots können skaliert sein — der Aufrufer muss + * mit dem zurückgegebenen `scale` zurückrechnen (siehe captureScreenshot). + */ + +export type MouseButton = 'left' | 'right' | 'middle'; +export type Modifier = 'alt' | 'ctrl' | 'shift' | 'meta'; + +export class CdpUnavailableError extends Error { + constructor(message: string) { + super(message); + this.name = 'CdpUnavailableError'; + } +} + +const PROTOCOL_VERSION = '1.3'; + +/** Zustand je Tab. */ +interface Session { + attached: boolean; + networkEnabled: boolean; + /** Läuft gerade ein attach()? Verhindert paralleles Doppel-Attach. */ + attaching: Promise | null; +} + +const sessions = new Map(); + +function sessionOf(tabId: number): Session { + let s = sessions.get(tabId); + if (!s) { + s = { attached: false, networkEnabled: false, attaching: null }; + sessions.set(tabId, s); + } + return s; +} + +// ─── Verfügbarkeit & Lebenszyklus ───────────────────────────────────────────── + +export function cdpAvailable(): boolean { + return typeof chrome !== 'undefined' && !!chrome.debugger && typeof chrome.debugger.attach === 'function'; +} + +export function isAttached(tabId: number): boolean { + return sessions.get(tabId)?.attached === true; +} + +export async function attach(tabId: number): Promise { + if (!cdpAvailable()) { + throw new CdpUnavailableError( + 'chrome.debugger steht nicht zur Verfügung — die "debugger"-Permission fehlt im Manifest.', + ); + } + + const s = sessionOf(tabId); + if (s.attached) return; + if (s.attaching) return s.attaching; + + s.attaching = (async () => { + try { + await chrome.debugger.attach({ tabId }, PROTOCOL_VERSION); + s.attached = true; + } catch (e: any) { + const msg = String(e?.message || e); + // Häufigster Fall: die DevTools des Nutzers sind auf diesem Tab offen. Kein Grund, + // die Aktion scheitern zu lassen — der Aufrufer weicht auf synthetische Events aus. + if (/already attached/i.test(msg)) { + throw new CdpUnavailableError( + `Ein anderer Debugger ist bereits an diesem Tab (vermutlich die geöffneten DevTools): ${msg}`, + ); + } + if (/Cannot access|chrome:\/\/|devtools:\/\/|Extensions gallery/i.test(msg)) { + throw new CdpUnavailableError(`Dieser Tab lässt kein Debugging zu: ${msg}`); + } + throw new CdpUnavailableError(`Debugger-Attach fehlgeschlagen: ${msg}`); + } finally { + s.attaching = null; + } + })(); + + return s.attaching; +} + +export async function detach(tabId: number): Promise { + const s = sessions.get(tabId); + if (!s?.attached) { sessions.delete(tabId); return; } + try { + await chrome.debugger.detach({ tabId }); + } catch { /* Tab evtl. schon zu */ } + networkBuffers.delete(tabId); + requestStarts.delete(tabId); + sessions.delete(tabId); +} + +export async function detachAll(): Promise { + await Promise.all([...sessions.keys()].map(id => detach(id))); +} + +export async function send(tabId: number, method: string, params?: object): Promise { + if (!cdpAvailable()) throw new CdpUnavailableError('chrome.debugger steht nicht zur Verfügung.'); + await attach(tabId); + try { + return await chrome.debugger.sendCommand({ tabId }, method, params as any) as T; + } catch (e: any) { + const msg = String(e?.message || e); + // Verbindung verloren (Navigation, Crash, Nutzer hat die Leiste geschlossen): + // Zustand zurücksetzen, damit der nächste Aufruf sauber neu attacht. + if (/Detached|not attached|No target|Target closed/i.test(msg)) { + const s = sessions.get(tabId); + if (s) s.attached = false; + } + throw new Error(`CDP ${method}: ${msg}`); + } +} + +// Chrome meldet ein Detach auch, wenn der Nutzer die Debug-Leiste wegklickt. +if (cdpAvailable()) { + chrome.debugger.onDetach.addListener((source) => { + if (source.tabId != null) { + const s = sessions.get(source.tabId); + if (s) { s.attached = false; s.networkEnabled = false; } + } + }); +} + +if (typeof chrome !== 'undefined' && chrome.tabs?.onRemoved) { + chrome.tabs.onRemoved.addListener((tabId) => { + sessions.delete(tabId); + networkBuffers.delete(tabId); + requestStarts.delete(tabId); + }); +} + +// ─── Modifier ───────────────────────────────────────────────────────────────── + +/** CDP-Bitmaske: Alt=1, Ctrl=2, Meta=4, Shift=8. */ +function modifierMask(mods?: Modifier[]): number { + if (!mods?.length) return 0; + let mask = 0; + for (const m of mods) { + switch (m) { + case 'alt': mask |= 1; break; + case 'ctrl': mask |= 2; break; + case 'meta': mask |= 4; break; + case 'shift': mask |= 8; break; + } + } + return mask; +} + +/** buttons-Bitmaske: links=1, rechts=2, mitte=4. */ +function buttonMask(button: MouseButton): number { + return button === 'right' ? 2 : button === 'middle' ? 4 : 1; +} + +const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)); + +// ─── Maus ───────────────────────────────────────────────────────────────────── + +export async function mouseMove( + tabId: number, x: number, y: number, + opts: { modifiers?: Modifier[]; buttons?: number } = {}, +): Promise { + await send(tabId, 'Input.dispatchMouseEvent', { + type: 'mouseMoved', + x: Math.round(x), y: Math.round(y), + modifiers: modifierMask(opts.modifiers), + buttons: opts.buttons ?? 0, + pointerType: 'mouse', + }); +} + +export async function mouseDown( + tabId: number, x: number, y: number, + opts: { button?: MouseButton; clickCount?: number; modifiers?: Modifier[] } = {}, +): Promise { + const button = opts.button ?? 'left'; + await send(tabId, 'Input.dispatchMouseEvent', { + type: 'mousePressed', + x: Math.round(x), y: Math.round(y), + button, + buttons: buttonMask(button), + clickCount: opts.clickCount ?? 1, + modifiers: modifierMask(opts.modifiers), + pointerType: 'mouse', + }); +} + +export async function mouseUp( + tabId: number, x: number, y: number, + opts: { button?: MouseButton; clickCount?: number; modifiers?: Modifier[] } = {}, +): Promise { + const button = opts.button ?? 'left'; + await send(tabId, 'Input.dispatchMouseEvent', { + type: 'mouseReleased', + x: Math.round(x), y: Math.round(y), + button, + buttons: 0, + clickCount: opts.clickCount ?? 1, + modifiers: modifierMask(opts.modifiers), + pointerType: 'mouse', + }); +} + +/** + * Ein Klick, wie ihn ein Mensch erzeugt. + * + * Zwei Feinheiten, ohne die es auf vielen Seiten nicht funktioniert: + * 1. Vorher ein mouseMoved auf dieselbe Stelle — Menüs und Tooltips öffnen sich erst + * beim Hover, und manche Widgets werten einen Klick ohne vorheriges Hover nicht aus. + * 2. Bei clickCount > 1 müssen die vorangehenden Klicks mitgesendet werden. Chrome + * erkennt einen Doppelklick nur an der Folge press(1)/release(1)/press(2)/release(2). + */ +export async function mouseClick( + tabId: number, x: number, y: number, + opts: { button?: MouseButton; clickCount?: number; modifiers?: Modifier[]; delayMs?: number } = {}, +): Promise { + const button = opts.button ?? 'left'; + const total = Math.max(1, Math.min(3, opts.clickCount ?? 1)); + const delay = opts.delayMs ?? 12; + + await mouseMove(tabId, x, y, { modifiers: opts.modifiers }); + for (let n = 1; n <= total; n++) { + await mouseDown(tabId, x, y, { button, clickCount: n, modifiers: opts.modifiers }); + await sleep(delay); + await mouseUp(tabId, x, y, { button, clickCount: n, modifiers: opts.modifiers }); + if (n < total) await sleep(delay); + } +} + +/** + * Ziehen mit gedrückter Maustaste. + * + * Die Zwischenschritte sind Pflicht: ohne mehrere mouseMoved-Ereignisse mit gesetzter + * buttons-Maske erkennt keine einzige echte Drag-Implementierung die Bewegung. + */ +export async function mouseDrag( + tabId: number, + from: { x: number; y: number }, + to: { x: number; y: number }, + opts: { steps?: number; modifiers?: Modifier[]; holdMs?: number } = {}, +): Promise { + const steps = Math.max(2, opts.steps ?? 12); + const hold = opts.holdMs ?? 60; + const mods = opts.modifiers; + + await mouseMove(tabId, from.x, from.y, { modifiers: mods }); + await mouseDown(tabId, from.x, from.y, { button: 'left', modifiers: mods }); + await sleep(hold); + + for (let i = 1; i <= steps; i++) { + const t = i / steps; + // Sanfte Beschleunigung/Abbremsung — manche Bibliotheken verwerfen ruckartige Sprünge. + const e = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; + await mouseMove(tabId, from.x + (to.x - from.x) * e, from.y + (to.y - from.y) * e, { + modifiers: mods, buttons: 1, + }); + await sleep(16); + } + + await sleep(hold); + await mouseUp(tabId, to.x, to.y, { button: 'left', modifiers: mods }); +} + +export async function mouseWheel( + tabId: number, x: number, y: number, deltaX: number, deltaY: number, + opts: { modifiers?: Modifier[] } = {}, +): Promise { + await send(tabId, 'Input.dispatchMouseEvent', { + type: 'mouseWheel', + x: Math.round(x), y: Math.round(y), + deltaX: Math.round(deltaX), deltaY: Math.round(deltaY), + modifiers: modifierMask(opts.modifiers), + pointerType: 'mouse', + }); +} + +// ─── Tastatur ───────────────────────────────────────────────────────────────── + +interface KeySpec { + code: string; + key: string; + keyCode: number; + /** Nur für druckbare Zeichen. Fehlt er, wird rawKeyDown gesendet. */ + text?: string; +} + +/** Sondertasten. windowsVirtualKeyCode ist Pflicht — ohne ihn ignorieren viele Seiten die Taste. */ +const SPECIAL_KEYS: Record = { + enter: { code: 'Enter', key: 'Enter', keyCode: 13, text: '\r' }, + tab: { code: 'Tab', key: 'Tab', keyCode: 9, text: '\t' }, + escape: { code: 'Escape', key: 'Escape', keyCode: 27 }, + backspace: { code: 'Backspace', key: 'Backspace', keyCode: 8 }, + delete: { code: 'Delete', key: 'Delete', keyCode: 46 }, + insert: { code: 'Insert', key: 'Insert', keyCode: 45 }, + home: { code: 'Home', key: 'Home', keyCode: 36 }, + end: { code: 'End', key: 'End', keyCode: 35 }, + pageup: { code: 'PageUp', key: 'PageUp', keyCode: 33 }, + pagedown: { code: 'PageDown', key: 'PageDown', keyCode: 34 }, + arrowup: { code: 'ArrowUp', key: 'ArrowUp', keyCode: 38 }, + arrowdown: { code: 'ArrowDown', key: 'ArrowDown', keyCode: 40 }, + arrowleft: { code: 'ArrowLeft', key: 'ArrowLeft', keyCode: 37 }, + arrowright: { code: 'ArrowRight', key: 'ArrowRight', keyCode: 39 }, + space: { code: 'Space', key: ' ', keyCode: 32, text: ' ' }, + capslock: { code: 'CapsLock', key: 'CapsLock', keyCode: 20 }, + shift: { code: 'ShiftLeft', key: 'Shift', keyCode: 16 }, + control: { code: 'ControlLeft', key: 'Control', keyCode: 17 }, + alt: { code: 'AltLeft', key: 'Alt', keyCode: 18 }, + meta: { code: 'MetaLeft', key: 'Meta', keyCode: 91 }, +}; + +for (let i = 1; i <= 12; i++) { + SPECIAL_KEYS['f' + i] = { code: 'F' + i, key: 'F' + i, keyCode: 111 + i }; +} + +/** Schreibweisen, die Modelle und xdotool-Notation üblicherweise verwenden. */ +const KEY_ALIASES: Record = { + return: 'enter', ret: 'enter', kp_enter: 'enter', + esc: 'escape', + del: 'delete', kp_delete: 'delete', + back_space: 'backspace', bksp: 'backspace', + page_up: 'pageup', prior: 'pageup', + page_down: 'pagedown', next: 'pagedown', + up: 'arrowup', down: 'arrowdown', left: 'arrowleft', right: 'arrowright', + ctrl: 'control', ctl: 'control', + cmd: 'meta', command: 'meta', super: 'meta', win: 'meta', + opt: 'alt', option: 'alt', + spacebar: 'space', +}; + +/** Zeichen → Virtual-Key-Code für die Standard-Tastaturbelegung. */ +const PUNCT_KEYCODES: Record = { + ';': { code: 'Semicolon', keyCode: 186 }, ':': { code: 'Semicolon', keyCode: 186 }, + '=': { code: 'Equal', keyCode: 187 }, '+': { code: 'Equal', keyCode: 187 }, + ',': { code: 'Comma', keyCode: 188 }, '<': { code: 'Comma', keyCode: 188 }, + '-': { code: 'Minus', keyCode: 189 }, '_': { code: 'Minus', keyCode: 189 }, + '.': { code: 'Period', keyCode: 190 }, '>': { code: 'Period', keyCode: 190 }, + '/': { code: 'Slash', keyCode: 191 }, '?': { code: 'Slash', keyCode: 191 }, + '`': { code: 'Backquote', keyCode: 192 }, '~': { code: 'Backquote', keyCode: 192 }, + '[': { code: 'BracketLeft', keyCode: 219 }, '{': { code: 'BracketLeft', keyCode: 219 }, + '\\': { code: 'Backslash', keyCode: 220 }, '|': { code: 'Backslash', keyCode: 220 }, + ']': { code: 'BracketRight', keyCode: 221 }, '}': { code: 'BracketRight', keyCode: 221 }, + "'": { code: 'Quote', keyCode: 222 }, '"': { code: 'Quote', keyCode: 222 }, +}; + +/** Auf der US-Belegung mit Shift erreichbare Zeichen. */ +const SHIFTED_DIGITS: Record = { + '!': '1', '@': '2', '#': '3', '$': '4', '%': '5', + '^': '6', '&': '7', '*': '8', '(': '9', ')': '0', +}; + +function resolveKey(name: string): KeySpec { + const raw = name.trim(); + const lower = raw.toLowerCase(); + const canonical = KEY_ALIASES[lower] ?? lower; + + if (SPECIAL_KEYS[canonical]) return SPECIAL_KEYS[canonical]; + + if (raw.length === 1) { + const ch = raw; + if (/[a-z]/.test(ch)) return { code: 'Key' + ch.toUpperCase(), key: ch, keyCode: ch.toUpperCase().charCodeAt(0), text: ch }; + if (/[A-Z]/.test(ch)) return { code: 'Key' + ch, key: ch, keyCode: ch.charCodeAt(0), text: ch }; + if (/[0-9]/.test(ch)) return { code: 'Digit' + ch, key: ch, keyCode: ch.charCodeAt(0), text: ch }; + if (SHIFTED_DIGITS[ch]) { + const base = SHIFTED_DIGITS[ch]; + return { code: 'Digit' + base, key: ch, keyCode: base.charCodeAt(0), text: ch }; + } + if (PUNCT_KEYCODES[ch]) { + const p = PUNCT_KEYCODES[ch]; + return { code: p.code, key: ch, keyCode: p.keyCode, text: ch }; + } + // Unbekanntes Einzelzeichen (Umlaute, Sonderzeichen): ohne keyCode senden, + // der text-Anteil trägt die Information. + return { code: '', key: ch, keyCode: 0, text: ch }; + } + + throw new Error(`Unbekannte Taste: "${name}"`); +} + +/** + * Zerlegt 'ctrl+shift+k', 'Control+A', 'ctrl++' (literales Plus) in Modifier und Haupttaste. + */ +function parseCombo(combo: string): { key: string; modifiers: Modifier[] } { + const modifiers: Modifier[] = []; + const parts: string[] = []; + let buf = ''; + + for (let i = 0; i < combo.length; i++) { + const ch = combo[i]; + if (ch === '+') { + // Ein '+' direkt am Ende oder gefolgt von nichts ist das Zeichen selbst. + if (buf === '' && parts.length > 0) { buf = '+'; continue; } + if (buf === '') { buf = '+'; continue; } + parts.push(buf); + buf = ''; + } else { + buf += ch; + } + } + if (buf !== '') parts.push(buf); + + const key = parts.pop() ?? ''; + for (const p of parts) { + const m = KEY_ALIASES[p.toLowerCase()] ?? p.toLowerCase(); + if (m === 'control') modifiers.push('ctrl'); + else if (m === 'alt' || m === 'shift' || m === 'meta') modifiers.push(m as Modifier); + } + return { key, modifiers }; +} + +async function dispatchKey( + tabId: number, spec: KeySpec, type: 'keyDown' | 'keyUp' | 'rawKeyDown', mask: number, +): Promise { + const params: Record = { + type, + key: spec.key, + code: spec.code || undefined, + windowsVirtualKeyCode: spec.keyCode || undefined, + nativeVirtualKeyCode: spec.keyCode || undefined, + modifiers: mask, + }; + // text nur beim Niederdrücken und nur, wenn kein Ctrl/Meta aktiv ist — sonst + // fügt Chrome bei Ctrl+A ein Zeichen ein, statt "alles markieren" auszulösen. + if (type === 'keyDown' && spec.text && !(mask & 2) && !(mask & 4)) { + params.text = spec.text; + params.unmodifiedText = spec.text; + } + await send(tabId, 'Input.dispatchKeyEvent', params); +} + +export async function keyDown(tabId: number, key: string, opts: { modifiers?: Modifier[] } = {}): Promise { + const spec = resolveKey(key); + const mask = modifierMask(opts.modifiers); + await dispatchKey(tabId, spec, spec.text ? 'keyDown' : 'rawKeyDown', mask); +} + +export async function keyUp(tabId: number, key: string, opts: { modifiers?: Modifier[] } = {}): Promise { + await dispatchKey(tabId, resolveKey(key), 'keyUp', modifierMask(opts.modifiers)); +} + +/** + * Drückt eine Taste oder Tastenkombination. + * + * Modifier werden als eigene keyDown-Ereignisse VOR und keyUp NACH der Haupttaste + * gesendet — zusätzlich zur Bitmaske. Seiten, die auf keydown von 'Control' lauschen + * (etwa um einen Mehrfachauswahl-Modus zu aktivieren), brauchen genau das. + */ +export async function pressKey( + tabId: number, key: string, opts: { modifiers?: Modifier[]; repeat?: number } = {}, +): Promise { + const parsed = parseCombo(key); + const mods = [...new Set([...(opts.modifiers ?? []), ...parsed.modifiers])]; + const mask = modifierMask(mods); + const spec = resolveKey(parsed.key); + const repeat = Math.max(1, opts.repeat ?? 1); + + for (const m of mods) { + const mk = SPECIAL_KEYS[m === 'ctrl' ? 'control' : m]; + if (mk) await dispatchKey(tabId, mk, 'rawKeyDown', mask); + } + + try { + for (let i = 0; i < repeat; i++) { + await dispatchKey(tabId, spec, spec.text ? 'keyDown' : 'rawKeyDown', mask); + await dispatchKey(tabId, spec, 'keyUp', mask); + if (repeat > 1) await sleep(8); + } + } finally { + // Modifier in umgekehrter Reihenfolge freigeben — auch wenn oben etwas schiefging, + // sonst bleibt die Taste aus Sicht der Seite dauerhaft gedrückt. + for (const m of [...mods].reverse()) { + const mk = SPECIAL_KEYS[m === 'ctrl' ? 'control' : m]; + if (mk) await dispatchKey(tabId, mk, 'keyUp', 0).catch(() => {}); + } + } +} + +/** + * Tippt Text. + * + * Ohne perCharDelayMs über Input.insertText — schnell und zuverlässig für normale Felder. + * Mit perCharDelayMs Zeichen für Zeichen mit echten Key-Ereignissen: nötig für Editoren, + * Autovervollständigung und alles, was auf keydown/keyup reagiert statt auf input. + */ +export async function typeText( + tabId: number, text: string, opts: { perCharDelayMs?: number } = {}, +): Promise { + if (!text) return; + + if (opts.perCharDelayMs == null) { + await send(tabId, 'Input.insertText', { text }); + return; + } + + for (const ch of text) { + if (ch === '\n') { + await pressKey(tabId, 'enter'); + } else { + const spec = resolveKey(ch); + await dispatchKey(tabId, spec, 'keyDown', 0); + await dispatchKey(tabId, spec, 'keyUp', 0); + } + if (opts.perCharDelayMs > 0) await sleep(opts.perCharDelayMs); + } +} + +// ─── Seite: Maße, Screenshot, Auswertung ────────────────────────────────────── + +export interface LayoutMetrics { + viewport: { w: number; h: number }; + content: { w: number; h: number }; + scroll: { x: number; y: number }; + devicePixelRatio: number; +} + +export async function getLayoutMetrics(tabId: number): Promise { + const m = await send(tabId, 'Page.getLayoutMetrics'); + const vp = m.cssLayoutViewport ?? m.layoutViewport ?? {}; + const cs = m.cssContentSize ?? m.contentSize ?? {}; + const vis = m.cssVisualViewport ?? m.visualViewport ?? {}; + return { + viewport: { w: vp.clientWidth ?? 0, h: vp.clientHeight ?? 0 }, + content: { w: cs.width ?? 0, h: cs.height ?? 0 }, + scroll: { x: vp.pageX ?? 0, y: vp.pageY ?? 0 }, + devicePixelRatio: vis.scale ?? 1, + }; +} + +export interface ScreenshotResult { + dataUrl: string; + width: number; + height: number; + /** + * Verkleinerungsfaktor des Bildes gegenüber CSS-Pixeln. + * Der Aufrufer MUSS damit zurückrechnen: cssX = bildX / scale. + * Ohne das klickt der Agent systematisch daneben, sobald die Seite breiter als maxWidth ist. + */ + scale: number; +} + +/** Sehr hohe Seiten deckeln — sonst reißt captureBeyondViewport den Renderer in den OOM. */ +const MAX_FULLPAGE_HEIGHT = 8000; + +export async function captureScreenshot( + tabId: number, + opts: { format?: 'jpeg' | 'png'; quality?: number; fullPage?: boolean; maxWidth?: number } = {}, +): Promise { + const format = opts.format ?? 'jpeg'; + const quality = opts.quality ?? 70; + const maxWidth = opts.maxWidth ?? 1400; + const m = await getLayoutMetrics(tabId); + + let x: number, y: number, width: number, height: number; + if (opts.fullPage) { + x = 0; y = 0; + width = Math.max(1, m.content.w || m.viewport.w); + height = Math.min(Math.max(1, m.content.h || m.viewport.h), MAX_FULLPAGE_HEIGHT); + } else { + // clip-Koordinaten sind Dokument-Koordinaten: der Viewport beginnt beim Scroll-Offset. + x = m.scroll.x; y = m.scroll.y; + width = Math.max(1, m.viewport.w); + height = Math.max(1, m.viewport.h); + } + + const scale = Math.min(1, maxWidth / width); + + const params: Record = { + format, + clip: { x, y, width, height, scale }, + captureBeyondViewport: !!opts.fullPage, + }; + if (format === 'jpeg') params.quality = quality; + + const res = await send<{ data: string }>(tabId, 'Page.captureScreenshot', params); + const mime = format === 'png' ? 'image/png' : 'image/jpeg'; + + return { + dataUrl: `data:${mime};base64,${res.data}`, + width: Math.round(width * scale), + height: Math.round(height * scale), + scale, + }; +} + +/** + * Führt Code im MAIN world der Seite aus. + * + * Das ist der Unterschied zu chrome.scripting.executeScript: dort läuft alles im + * ISOLATED world, wo window.React, window.angular, __NEXT_DATA__ und jeder App-State + * unsichtbar sind. + */ +export async function evaluateInMainWorld( + tabId: number, expression: string, + opts: { awaitPromise?: boolean; returnByValue?: boolean } = {}, +): Promise { + const res = await send(tabId, 'Runtime.evaluate', { + expression, + awaitPromise: opts.awaitPromise ?? false, + returnByValue: opts.returnByValue ?? true, + userGesture: true, + allowUnsafeEvalBlockedByCSP: true, + }); + if (res.exceptionDetails) { + const d = res.exceptionDetails; + throw new Error(d.exception?.description || d.text || 'Fehler beim Auswerten'); + } + return res.result?.value as T; +} + +/** + * Befüllt ein ohne Datei-Dialog. Der einzige Weg dafür. + * Die Pfade müssen absolut und für den Browser-Prozess lesbar sein. + */ +export async function setFileInputFiles(tabId: number, selector: string, files: string[]): Promise { + const doc = await send(tabId, 'DOM.getDocument', { depth: 0 }); + const node = await send(tabId, 'DOM.querySelector', { + nodeId: doc.root.nodeId, + selector, + }); + if (!node?.nodeId) throw new Error(`Datei-Feld nicht gefunden: ${selector}`); + await send(tabId, 'DOM.setFileInputFiles', { nodeId: node.nodeId, files }); +} + +// ─── Netzwerk ───────────────────────────────────────────────────────────────── + +export interface NetworkEntry { + url: string; + method: string; + status?: number; + statusText?: string; + type?: string; + ts: number; + durationMs?: number; + requestHeaders?: Record; + responseHeaders?: Record; + bodySnippet?: string; +} + +const MAX_NETWORK_ENTRIES = 300; +const networkBuffers = new Map>(); +const requestStarts = new Map>(); + +function bufferOf(tabId: number): Map { + let b = networkBuffers.get(tabId); + if (!b) { b = new Map(); networkBuffers.set(tabId, b); } + return b; +} + +// Ein einziger globaler Listener für alle Tabs — nicht einer pro attach(). +if (cdpAvailable()) { + chrome.debugger.onEvent.addListener((source, method, params: any) => { + const tabId = source.tabId; + if (tabId == null || !params) return; + + const buf = bufferOf(tabId); + + if (method === 'Network.requestWillBeSent') { + let starts = requestStarts.get(tabId); + if (!starts) { starts = new Map(); requestStarts.set(tabId, starts); } + starts.set(params.requestId, Date.now()); + + buf.set(params.requestId, { + url: params.request?.url ?? '', + method: params.request?.method ?? 'GET', + type: params.type, + ts: Date.now(), + requestHeaders: params.request?.headers, + }); + + // Ringpuffer: älteste Einträge verwerfen. + while (buf.size > MAX_NETWORK_ENTRIES) { + const oldest = buf.keys().next().value; + if (oldest === undefined) break; + buf.delete(oldest); + } + } else if (method === 'Network.responseReceived') { + const entry = buf.get(params.requestId); + if (entry) { + entry.status = params.response?.status; + entry.statusText = params.response?.statusText; + entry.responseHeaders = params.response?.headers; + entry.type = params.type ?? entry.type; + } + } else if (method === 'Network.loadingFinished' || method === 'Network.loadingFailed') { + const entry = buf.get(params.requestId); + const start = requestStarts.get(tabId)?.get(params.requestId); + if (entry && start) entry.durationMs = Date.now() - start; + if (method === 'Network.loadingFailed' && entry) { + entry.statusText = params.errorText || 'failed'; + } + requestStarts.get(tabId)?.delete(params.requestId); + } + }); +} + +export async function enableNetworkCapture(tabId: number): Promise { + const s = sessionOf(tabId); + if (s.networkEnabled && s.attached) return; + await send(tabId, 'Network.enable', { maxTotalBufferSize: 10_000_000, maxResourceBufferSize: 5_000_000 }); + s.networkEnabled = true; +} + +export function getNetworkRequests( + tabId: number, + opts: { filter?: string; method?: string; limit?: number; includeBody?: boolean } = {}, +): NetworkEntry[] { + const buf = networkBuffers.get(tabId); + if (!buf) return []; + + let entries = [...buf.values()]; + if (opts.filter) { + const needle = opts.filter.toLowerCase(); + entries = entries.filter(e => e.url.toLowerCase().includes(needle)); + } + if (opts.method) { + const want = opts.method.toUpperCase(); + entries = entries.filter(e => e.method.toUpperCase() === want); + } + if (!opts.includeBody) { + entries = entries.map(({ requestHeaders, responseHeaders, ...rest }) => rest); + } + return entries.slice(-(opts.limit ?? 50)); +} + +export function clearNetworkRequests(tabId: number): void { + networkBuffers.delete(tabId); + requestStarts.delete(tabId); +} diff --git a/src/background/computer.ts b/src/background/computer.ts new file mode 100644 index 0000000..c2e7c42 --- /dev/null +++ b/src/background/computer.ts @@ -0,0 +1,862 @@ +/** + * computer.ts — das 'computer'-Tool: Bedienung auf Pixelkoordinaten. + * + * Der Agent sieht einen Screenshot und nennt Koordinaten IM BILD. Dieses Modul + * uebersetzt sie in echte CDP-Eingaben (Z1: isTrusted=true) und haelt pro Tab + * einen virtuellen Mauszeiger nach, denn CDP kennt keinen persistenten Zeiger: + * jedes Input.dispatchMouseEvent traegt seine Koordinaten selbst. + * + * ── Koordinatensysteme (die subtilste Fehlerquelle im ganzen Projekt) ──────── + * + * 1. MODELL-RAUM Pixel im Screenshot-Bild, das das Modell gesehen hat. + * captureScreenshot() skaliert das Bild (maxWidth) und meldet + * den Faktor als `scale`: bildPixel = cssPixel * scale. + * 2. CSS-RAUM Layout-Pixel der Seite, das was getBoundingClientRect() + * liefert. CDP Input.* UND der Overlay-Zeiger (position:fixed) + * arbeiten beide hier. + * + * Rueckrechnung vor JEDEM Maus-Aufruf: cssX = modelX / scale + * + * Wird das vergessen, klickt der Agent auf einem HiDPI-Screen oder nach einem + * verkleinerten Screenshot systematisch zu weit links/oben — und zwar plausibel + * genug, dass es wie ein Modellfehler aussieht. Der zuletzt gemeldete `scale` + * wird deshalb pro Tab gemerkt (Default 1, solange kein Screenshot lief). + * + * Sonderfall fullPage: dann ist der Bildursprung der SEITEN-Ursprung (0,0 des + * Dokuments), nicht der Viewport. Aus modelX/scale wird also eine Seiten-, keine + * Viewport-Koordinate. Wir holen live den Scroll-Offset, ziehen ihn ab und + * scrollen den Punkt notfalls in den sichtbaren Bereich — sonst landet der Klick + * irgendwo oder gar nicht. + */ + +import { + CdpUnavailableError, + attach, + cdpAvailable, + captureScreenshot, + evaluateInMainWorld, + getLayoutMetrics, + isAttached, + keyDown, + keyUp, + mouseClick, + mouseDown, + mouseDrag, + mouseMove, + mouseUp, + mouseWheel, + pressKey, + typeText, + type Modifier, + type MouseButton, +} from './cdp'; +import { loadConfig } from '../shared/config'; + +// ─── Oeffentlicher Vertrag ──────────────────────────────────────────────────── + +export interface ComputerResult { + action: string; + [k: string]: unknown; +} + +/** Kanonische Aktionsnamen (identisch zu Claudes computer-Tool). */ +export const COMPUTER_ACTIONS: readonly string[] = [ + 'screenshot', + 'cursor_position', + 'mouse_move', + 'left_click', + 'right_click', + 'middle_click', + 'double_click', + 'triple_click', + 'left_click_drag', + 'left_mouse_down', + 'left_mouse_up', + 'scroll', + 'type', + 'key', + 'hold_key', + 'wait', +]; + +const ACTION_SET = new Set(COMPUTER_ACTIONS); + +/** Tolerierte Schreibweisen — Modelle variieren, das darf nicht am Namen scheitern. */ +const ACTION_ALIASES: Record = { + click: 'left_click', + left_click_at: 'left_click', + leftclick: 'left_click', + rightclick: 'right_click', + middleclick: 'middle_click', + doubleclick: 'double_click', + tripleclick: 'triple_click', + move: 'mouse_move', + mouse_position: 'cursor_position', + get_cursor_position: 'cursor_position', + drag: 'left_click_drag', + mouse_down: 'left_mouse_down', + mouse_up: 'left_mouse_up', + scroll_wheel: 'scroll', + type_text: 'type', + key_press: 'key', + press_key: 'key', + screen_shot: 'screenshot', + sleep: 'wait', +}; + +// ─── Zustand pro Tab ────────────────────────────────────────────────────────── + +interface PointerState { + /** Letzte bekannte Zeigerposition im CSS-Raum. */ + x: number; + y: number; + /** scale des juengsten Screenshots (bildPixel = cssPixel * scale). */ + scale: number; + /** War der juengste Screenshot ein Ganzseiten-Bild? */ + fullPage: boolean; + /** Masse des juengsten Screenshots in Bildpixeln; 0 = noch keiner gemacht. */ + imgW: number; + imgH: number; +} + +const pointers = new Map(); + +function stateFor(tabId: number): PointerState { + let s = pointers.get(tabId); + if (!s) { + s = { x: 0, y: 0, scale: 1, fullPage: false, imgW: 0, imgH: 0 }; + pointers.set(tabId, s); + } + return s; +} + +/** Gemerkte Zeigerposition im CSS-Raum. {0,0}, solange nichts bewegt wurde. */ +export function getCursorPosition(tabId: number): { x: number; y: number } { + const s = pointers.get(tabId); + return s ? { x: s.x, y: s.y } : { x: 0, y: 0 }; +} + +// Geschlossene Tabs nicht ewig mitschleppen. +chrome.tabs?.onRemoved?.addListener((tabId) => { + pointers.delete(tabId); +}); + +// ─── Kleinkram ──────────────────────────────────────────────────────────────── + +const MAX_WAIT_SECONDS = 30; +const WHEEL_PIXELS_PER_CLICK = 100; + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +/** + * Warten in 5-s-Haeppchen. Zwischendurch eine chrome.tabs-API anfassen, damit der + * MV3-Service-Worker nicht in seinen 30-s-Idle-Timeout laeuft — und damit ein + * inzwischen geschlossener Tab sofort auffliegt statt erst nach dem Warten. + */ +async function keepAliveSleep(tabId: number, ms: number): Promise { + const deadline = Date.now() + ms; + while (Date.now() < deadline) { + const slice = Math.min(5000, deadline - Date.now()); + await sleep(slice); + await chrome.tabs.get(tabId); + } +} + +function round(n: number): number { + return Math.round(n * 10) / 10; +} + +function clamp(n: number, lo: number, hi: number): number { + return Math.min(hi, Math.max(lo, n)); +} + +function toNum(v: unknown): number | null { + if (typeof v === 'number') return Number.isFinite(v) ? v : null; + if (typeof v === 'string' && v.trim() !== '') { + const n = Number(v); + return Number.isFinite(n) ? n : null; + } + return null; +} + +function fail(action: string, code: string, message: string, retryable = false): ComputerResult { + return { action, error: { code, message, retryable }, _method: 'cdp' }; +} + +// ─── Eingabe-Parsing ────────────────────────────────────────────────────────── + +type CoordParse = + | { ok: true; x: number; y: number } + | { ok: false; message: string } + | null; + +/** + * Akzeptiert [x,y], {x,y} und numerische Strings. Fehlt der Wert -> null + * (Aufrufer nimmt dann die gemerkte Position). Negatives wird auf 0 geklemmt. + */ +function readCoordinate(v: unknown, name: string): CoordParse { + if (v === undefined || v === null) return null; + + if (Array.isArray(v)) { + if (v.length !== 2) { + return { ok: false, message: `${name} braucht genau 2 Werte [x, y], bekam ${v.length}` }; + } + const x = toNum(v[0]); + const y = toNum(v[1]); + if (x === null || y === null) { + return { ok: false, message: `${name} muss aus zwei endlichen Zahlen bestehen, bekam ${JSON.stringify(v)}` }; + } + return { ok: true, x: Math.max(0, x), y: Math.max(0, y) }; + } + + if (typeof v === 'object') { + const o = v as Record; + const x = toNum(o.x); + const y = toNum(o.y); + if (x !== null && y !== null) return { ok: true, x: Math.max(0, x), y: Math.max(0, y) }; + } + + return { ok: false, message: `${name} muss ein Array [x, y] sein, bekam ${JSON.stringify(v)}` }; +} + +/** coordinate, sonst x//y auf oberster Ebene. */ +function readCoordinateInput(input: Record, key: string): CoordParse { + const direct = readCoordinate(input[key], key); + if (direct !== null) return direct; + if (key === 'coordinate' && (input.x !== undefined || input.y !== undefined)) { + return readCoordinate({ x: input.x, y: input.y }, 'coordinate'); + } + return null; +} + +const MODIFIER_ALIASES: Record = { + alt: 'alt', + option: 'alt', + opt: 'alt', + ctrl: 'ctrl', + control: 'ctrl', + ctl: 'ctrl', + shift: 'shift', + meta: 'meta', + cmd: 'meta', + command: 'meta', + super: 'meta', + win: 'meta', + windows: 'meta', + os: 'meta', +}; + +function readModifiers(v: unknown): { ok: true; mods: Modifier[] } | { ok: false; message: string } { + if (v === undefined || v === null) return { ok: true, mods: [] }; + const raw = Array.isArray(v) ? v : typeof v === 'string' ? v.split(/[+,\s]+/) : null; + if (!raw) return { ok: false, message: `modifiers muss eine Liste sein, bekam ${JSON.stringify(v)}` }; + const mods: Modifier[] = []; + for (const item of raw) { + if (typeof item !== 'string' || item.trim() === '') continue; + const m = MODIFIER_ALIASES[item.trim().toLowerCase()]; + if (!m) return { ok: false, message: `Unbekannter Modifier '${String(item)}' (erlaubt: alt, ctrl, shift, meta)` }; + if (!mods.includes(m)) mods.push(m); + } + return { ok: true, mods }; +} + +/** + * xdotool-/X11-Namen auf DOM-KeyboardEvent.key abbilden. cdp.ts erwartet den + * DOM-Namen und baut daraus keyCode/code. + */ +const KEY_ALIASES: Record = { + return: 'Enter', + enter: 'Enter', + kp_enter: 'Enter', + esc: 'Escape', + escape: 'Escape', + tab: 'Tab', + space: ' ', + spacebar: ' ', + backspace: 'Backspace', + back_space: 'Backspace', + bs: 'Backspace', + delete: 'Delete', + del: 'Delete', + insert: 'Insert', + ins: 'Insert', + up: 'ArrowUp', + down: 'ArrowDown', + left: 'ArrowLeft', + right: 'ArrowRight', + arrowup: 'ArrowUp', + arrowdown: 'ArrowDown', + arrowleft: 'ArrowLeft', + arrowright: 'ArrowRight', + page_up: 'PageUp', + pageup: 'PageUp', + prior: 'PageUp', + page_down: 'PageDown', + pagedown: 'PageDown', + next: 'PageDown', + home: 'Home', + end: 'End', + caps_lock: 'CapsLock', + capslock: 'CapsLock', + num_lock: 'NumLock', + menu: 'ContextMenu', + contextmenu: 'ContextMenu', + print: 'PrintScreen', + printscreen: 'PrintScreen', + plus: '+', + kp_add: '+', + minus: '-', + kp_subtract: '-', + equal: '=', + comma: ',', + period: '.', + slash: '/', + backslash: '\\', + semicolon: ';', + apostrophe: "'", + quote: "'", + grave: '`', + bracketleft: '[', + bracketright: ']', + // Modifier als eigenstaendige Taste (z. B. hold_key 'shift') + shift: 'Shift', + ctrl: 'Control', + control: 'Control', + alt: 'Alt', + meta: 'Meta', + cmd: 'Meta', + command: 'Meta', + super: 'Meta', + win: 'Meta', +}; + +function canonicalKey(token: string): string { + if (token.length === 1) return token; // Gross-/Kleinschreibung bewusst erhalten + const lower = token.toLowerCase(); + if (KEY_ALIASES[lower]) return KEY_ALIASES[lower]; + if (/^f\d{1,2}$/.test(lower)) return 'F' + lower.slice(1); // f5 -> F5 + return token; // z. B. 'MediaPlayPause' unveraendert durchreichen +} + +/** + * 'ctrl+shift+t' -> { mods:['ctrl','shift'], key:'t' }. + * '+' selbst ist zulaessig ('ctrl++'), deshalb kein naives split(). + */ +function parseChord(text: string): { ok: true; mods: Modifier[]; key: string } | { ok: false; message: string } { + const parts: string[] = []; + let cur = ''; + for (const ch of text) { + if (ch === '+') { + if (cur === '') { + cur = '+'; // '+' direkt nach einem Trenner ist die Taste selbst + continue; + } + parts.push(cur); + cur = ''; + } else { + cur += ch; + } + } + if (cur !== '') parts.push(cur); + if (parts.length === 0) return { ok: false, message: 'Leere Tastenkombination' }; + + const mods: Modifier[] = []; + let key = ''; + for (let i = 0; i < parts.length; i++) { + const token = parts[i]!; + const asMod = MODIFIER_ALIASES[token.toLowerCase()]; + // Letztes Element ist immer die Taste — auch wenn es ein Modifier-Name ist + // ('ctrl+shift' heisst: Ctrl halten, Shift druecken). + if (asMod && i < parts.length - 1) { + if (!mods.includes(asMod)) mods.push(asMod); + } else { + key = canonicalKey(token); + } + } + if (key === '') return { ok: false, message: `Keine Taste in '${text}' gefunden` }; + return { ok: true, mods, key }; +} + +// ─── CDP-Vorbereitung ───────────────────────────────────────────────────────── + +async function ensureCdp(tabId: number): Promise { + if (!cdpAvailable()) { + throw new CdpUnavailableError( + 'chrome.debugger steht nicht zur Verfuegung (Permission "debugger" fehlt oder ein anderer Debugger haengt am Tab)', + ); + } + if (!isAttached(tabId)) await attach(tabId); +} + +// ─── Modellkoordinate -> CSS-Koordinate ─────────────────────────────────────── + +interface ViewportPoint { + x: number; + y: number; + /** true, wenn wir scrollen mussten, damit der Punkt ueberhaupt erreichbar war. */ + scrolled: boolean; + /** true, wenn der Punkt trotz allem nicht im sichtbaren Bereich liegt (nur fullPage). */ + outside: boolean; +} + +async function toViewport( + tabId: number, + s: PointerState, + mx: number, + my: number, + opts?: { allowScroll?: boolean }, +): Promise { + const scale = Number.isFinite(s.scale) && s.scale > 0 ? s.scale : 1; + const px = Math.max(0, mx) / scale; + const py = Math.max(0, my) / scale; + + // Normalfall: Bildursprung == Viewport-Ursprung, also sind wir schon fertig. + if (!s.fullPage) return { x: round(px), y: round(py), scrolled: false, outside: false }; + + // fullPage: px/py sind Dokument-Koordinaten. Maus-Events gibt es aber nur im + // Viewport — Offset abziehen und notfalls hinscrollen. + let m = await getLayoutMetrics(tabId); + let vx = px - m.scroll.x; + let vy = py - m.scroll.y; + + const margin = 2; + const inside = vx >= margin && vy >= margin && vx <= m.viewport.w - margin && vy <= m.viewport.h - margin; + if (inside) return { x: round(vx), y: round(vy), scrolled: false, outside: false }; + + // allowScroll:false — der Aufrufer braucht die Ansicht so, wie sie ist + // (z. B. der Zielpunkt eines Drags, dessen Startpunkt sichtbar bleiben muss). + if (opts?.allowScroll === false) { + return { + x: round(clamp(vx, 0, m.viewport.w)), + y: round(clamp(vy, 0, m.viewport.h)), + scrolled: false, + outside: true, + }; + } + + const left = clamp(px - m.viewport.w / 2, 0, Math.max(0, m.content.w - m.viewport.w)); + const top = clamp(py - m.viewport.h / 2, 0, Math.max(0, m.content.h - m.viewport.h)); + await evaluateInMainWorld( + tabId, + `window.scrollTo({ left: ${left}, top: ${top}, behavior: 'instant' }); true;`, + { returnByValue: true }, + ); + await sleep(80); // Scroll-Commit abwarten, sonst lesen wir den alten Offset + m = await getLayoutMetrics(tabId); + vx = px - m.scroll.x; + vy = py - m.scroll.y; + const stillOutside = vx < 0 || vy < 0 || vx > m.viewport.w || vy > m.viewport.h; + return { + x: round(clamp(vx, 0, m.viewport.w)), + y: round(clamp(vy, 0, m.viewport.h)), + scrolled: true, + outside: stillOutside, + }; +} + +/** + * Liegt die Modellkoordinate ausserhalb des Bildes, das das Modell gesehen hat, + * ist sie geraten. Lieber sofort und mit Hinweis ablehnen, als ins Leere klicken + * — ein Klick auf y=1400 bei 900 px Viewport passiert einfach gar nichts, und das + * Modell haelt die Aktion faelschlich fuer erfolgreich. + */ +function outOfBounds(s: PointerState, x: number, y: number): string | null { + if (s.imgW <= 0 || s.imgH <= 0) return null; // noch kein Screenshot — nichts zu pruefen + if (x <= s.imgW && y <= s.imgH) return null; + return ( + `coordinate [${x}, ${y}] liegt ausserhalb des letzten Screenshots (${s.imgW}x${s.imgH}). ` + + 'Erst scrollen, dann einen neuen Screenshot machen — Scrollen verschiebt jede Koordinate.' + ); +} + +/** CSS-Position zurueck in den Modellraum, damit das Modell sie direkt wiederverwenden kann. */ +function toModel(s: PointerState, x: number, y: number): [number, number] { + const scale = Number.isFinite(s.scale) && s.scale > 0 ? s.scale : 1; + return [round(x * scale), round(y * scale)]; +} + +// ─── Overlay-Zeiger ─────────────────────────────────────────────────────────── + +/** + * Den sichtbaren Zeiger im Content-Script nachziehen. Koordinaten sind CSS-Pixel, + * das Overlay liegt position:fixed im selben Raum. Fehler sind hier belanglos: + * chrome://-Seiten, PDF-Viewer und der Web Store haben kein Content-Script. + */ +async function paintCursor(tabId: number, x: number, y: number, click?: 'left' | 'right' | 'double'): Promise { + try { + const cfg = await loadConfig(); + if (!cfg.showCursor) return; + const msg: { type: string; x: number; y: number; click?: 'left' | 'right' | 'double' } = { + type: 'NEXUS_CURSOR', + x, + y, + }; + if (click) msg.click = click; + // Nicht ewig warten, falls die Seite gerade navigiert. + await Promise.race([chrome.tabs.sendMessage(tabId, msg), sleep(400)]); + } catch { + /* absichtlich still */ + } +} + +// ─── Ausfuehrung ────────────────────────────────────────────────────────────── + +/** + * Fuehrt eine computer-Aktion aus. Wirft nicht: Fehler kommen als + * { error: { code, message, retryable } } zurueck, damit das Modell sie lesen + * und selbst korrigieren kann. + */ +export async function runComputer(tabId: number, input: Record): Promise { + const raw = typeof input.action === 'string' ? input.action.trim() : ''; + if (!raw) { + return fail('', 'MISSING_ACTION', `action fehlt. Erlaubt: ${COMPUTER_ACTIONS.join(', ')}`); + } + const action = ACTION_SET.has(raw) ? raw : (ACTION_ALIASES[raw.toLowerCase()] ?? raw); + if (!ACTION_SET.has(action)) { + return fail(raw, 'UNKNOWN_ACTION', `Unbekannte action '${raw}'. Erlaubt: ${COMPUTER_ACTIONS.join(', ')}`); + } + + try { + return await execute(tabId, action, input); + } catch (e: unknown) { + if (e instanceof CdpUnavailableError) { + return fail(action, 'CDP_UNAVAILABLE', e.message, false); + } + const message = e instanceof Error ? e.message : String(e); + // Tab weg / Target neu geladen -> erneuter Versuch kann klappen. + const retryable = /detach|target closed|no tab|cannot access|not attached|frame/i.test(message); + return fail(action, 'CDP_ERROR', message, retryable); + } +} + +async function execute(tabId: number, action: string, input: Record): Promise { + const s = stateFor(tabId); + + // ── Aktionen ohne CDP ── + if (action === 'cursor_position') { + const [mx, my] = toModel(s, s.x, s.y); + // x/y im Modellraum, damit sie direkt als coordinate zurueckgereicht werden koennen. + return { action, x: mx, y: my, coordinate: [mx, my], css: { x: s.x, y: s.y }, scale: s.scale, _method: 'cdp' }; + } + + if (action === 'wait') { + const secs = toNum(input.duration) ?? toNum(input.seconds) ?? 1; + if (secs < 0) return fail(action, 'BAD_DURATION', `duration muss >= 0 sein, bekam ${secs}`); + const capped = Math.min(secs, MAX_WAIT_SECONDS); + await keepAliveSleep(tabId, Math.round(capped * 1000)); + return { action, duration: capped, capped: capped < secs, _method: 'cdp' }; + } + + // ── Ab hier braucht alles den Debugger ── + await ensureCdp(tabId); + + if (action === 'screenshot') { + const fullPage = input.fullPage === true || input.full_page === true; + const format = input.format === 'png' ? 'png' : 'jpeg'; + const quality = toNum(input.quality); + const maxWidth = toNum(input.maxWidth) ?? toNum(input.max_width); + + const opts: { format: 'jpeg' | 'png'; fullPage: boolean; quality?: number; maxWidth?: number } = { + format, + fullPage, + }; + if (quality !== null) opts.quality = clamp(Math.round(quality), 1, 100); + if (maxWidth !== null) opts.maxWidth = Math.max(200, Math.round(maxWidth)); + + const shot = await captureScreenshot(tabId, opts); + + // Massgeblich fuer jede folgende Koordinaten-Rueckrechnung. + s.scale = Number.isFinite(shot.scale) && shot.scale > 0 ? shot.scale : 1; + s.fullPage = fullPage; + s.imgW = Number.isFinite(shot.width) && shot.width > 0 ? shot.width : 0; + s.imgH = Number.isFinite(shot.height) && shot.height > 0 ? shot.height : 0; + + const [cx, cy] = toModel(s, s.x, s.y); + return { + action, + image: shot.dataUrl, + width: shot.width, + height: shot.height, + scale: s.scale, + fullPage, + cursor: { x: cx, y: cy }, + _method: 'cdp', + }; + } + + const modsParse = readModifiers(input.modifiers ?? input.modifier); + if (!modsParse.ok) return fail(action, 'BAD_MODIFIER', modsParse.message); + const modifiers = modsParse.mods; + + switch (action) { + // ── Zeigerbewegung ── + case 'mouse_move': { + const c = readCoordinateInput(input, 'coordinate'); + if (c === null) return fail(action, 'BAD_COORDINATE', 'mouse_move braucht coordinate [x, y]'); + if (!c.ok) return fail(action, 'BAD_COORDINATE', c.message); + const oob = outOfBounds(s, c.x, c.y); + if (oob) return fail(action, 'BAD_COORDINATE', oob, true); + const p = await toViewport(tabId, s, c.x, c.y); + await mouseMove(tabId, p.x, p.y, { modifiers }); + s.x = p.x; + s.y = p.y; + await paintCursor(tabId, p.x, p.y); + return mouseResult(action, s, p, { modifiers }); + } + + // ── Klicks ── + case 'left_click': + case 'right_click': + case 'middle_click': + case 'double_click': + case 'triple_click': { + const button: MouseButton = + action === 'right_click' ? 'right' : action === 'middle_click' ? 'middle' : 'left'; + const clicks = action === 'double_click' ? 2 : action === 'triple_click' ? 3 : 1; + + const p = await resolvePoint(tabId, s, input); + if ('error' in p) return fail(action, 'BAD_COORDINATE', p.error); + + // Erst hinfahren, dann klicken: Hover-abhaengige Menues und Tooltips + // brauchen das mousemove, sonst ist das Ziel beim Klick noch gar nicht da. + if (p.moved) await mouseMove(tabId, p.x, p.y, { modifiers }); + + // Echter Doppel-/Dreifachklick = mehrere Press/Release-Paare mit + // aufsteigendem clickCount. Blink leitet dblclick daraus ab. + for (let i = 1; i <= clicks; i++) { + await mouseClick(tabId, p.x, p.y, { button, clickCount: i, modifiers }); + if (i < clicks) await sleep(40); // deutlich unter der Doppelklick-Schwelle + } + + s.x = p.x; + s.y = p.y; + await paintCursor(tabId, p.x, p.y, clicks > 1 ? 'double' : button === 'right' ? 'right' : 'left'); + return mouseResult(action, s, p, { button, clickCount: clicks, modifiers }); + } + + case 'left_mouse_down': + case 'left_mouse_up': { + const p = await resolvePoint(tabId, s, input); + if ('error' in p) return fail(action, 'BAD_COORDINATE', p.error); + if (p.moved) await mouseMove(tabId, p.x, p.y, { modifiers }); + if (action === 'left_mouse_down') { + await mouseDown(tabId, p.x, p.y, { button: 'left', clickCount: 1, modifiers }); + } else { + await mouseUp(tabId, p.x, p.y, { button: 'left', clickCount: 1, modifiers }); + } + s.x = p.x; + s.y = p.y; + await paintCursor(tabId, p.x, p.y, action === 'left_mouse_down' ? 'left' : undefined); + return mouseResult(action, s, p, { button: 'left', modifiers }); + } + + // ── Ziehen ── + case 'left_click_drag': { + const to = readCoordinateInput(input, 'coordinate'); + if (to === null) return fail(action, 'BAD_COORDINATE', 'left_click_drag braucht coordinate [x, y] als Ziel'); + if (!to.ok) return fail(action, 'BAD_COORDINATE', to.message); + + const startRaw = readCoordinateInput(input, 'start_coordinate'); + if (startRaw !== null && !startRaw.ok) return fail(action, 'BAD_COORDINATE', startRaw.message); + + const oobTo = outOfBounds(s, to.x, to.y); + if (oobTo) return fail(action, 'BAD_COORDINATE', oobTo, true); + if (startRaw !== null && startRaw.ok) { + const oobFrom = outOfBounds(s, startRaw.x, startRaw.y); + if (oobFrom) return fail(action, 'BAD_COORDINATE', oobFrom, true); + } + + // Reihenfolge ist wichtig: erst den Startpunkt sichtbar machen (dort setzt + // die Geste an), danach das Ziel OHNE weiteres Scrollen umrechnen — sonst + // wuerde der zweite Aufruf die Ansicht unter dem gedrueckten Knopf wegziehen. + const from: ViewportPoint = startRaw + ? await toViewport(tabId, s, startRaw.x, startRaw.y) + : { x: s.x, y: s.y, scrolled: false, outside: false }; + const target = await toViewport(tabId, s, to.x, to.y, { allowScroll: false }); + if (target.outside) { + return fail( + action, + 'DRAG_OUT_OF_VIEW', + 'Start- und Zielpunkt eines Drags muessen gleichzeitig sichtbar sein. Erst so scrollen, dass beide im Bild liegen, dann neuen Screenshot machen.', + true, + ); + } + + const steps = toNum(input.steps); + const holdMs = toNum(input.hold_ms) ?? toNum(input.holdMs); + const dragOpts: { steps?: number; modifiers?: Modifier[]; holdMs?: number } = { modifiers }; + if (steps !== null) dragOpts.steps = clamp(Math.round(steps), 2, 200); + if (holdMs !== null) dragOpts.holdMs = clamp(Math.round(holdMs), 0, 5000); + + // Startpunkt zuerst anfahren — HTML5-DnD verlangt ein mousemove vor mousedown. + await mouseMove(tabId, from.x, from.y, { modifiers }); + await paintCursor(tabId, from.x, from.y); + await mouseDrag(tabId, { x: from.x, y: from.y }, { x: target.x, y: target.y }, dragOpts); + + s.x = target.x; + s.y = target.y; + await paintCursor(tabId, target.x, target.y, 'left'); + return { + action, + from: { x: from.x, y: from.y }, + to: { x: target.x, y: target.y }, + coordinate: toModel(s, target.x, target.y), + start_coordinate: toModel(s, from.x, from.y), + scale: s.scale, + scrolled: from.scrolled || target.scrolled, + modifiers, + _method: 'cdp', + }; + } + + // ── Scrollen ── + case 'scroll': { + const dirRaw = (input.scroll_direction ?? input.direction ?? 'down'); + const dir = typeof dirRaw === 'string' ? dirRaw.trim().toLowerCase() : ''; + if (!['up', 'down', 'left', 'right'].includes(dir)) { + return fail(action, 'BAD_DIRECTION', `scroll_direction muss up|down|left|right sein, bekam '${String(dirRaw)}'`); + } + const amountRaw = toNum(input.scroll_amount) ?? toNum(input.amount) ?? 3; + const amount = clamp(Math.abs(amountRaw), 0, 100); + + const p = await resolvePoint(tabId, s, input); + if ('error' in p) return fail(action, 'BAD_COORDINATE', p.error); + + const px = amount * WHEEL_PIXELS_PER_CLICK; + const deltaX = dir === 'right' ? px : dir === 'left' ? -px : 0; + const deltaY = dir === 'down' ? px : dir === 'up' ? -px : 0; + + // Der Zeiger muss ueber dem Ziel stehen, sonst scrollt der falsche Container. + if (p.moved) await mouseMove(tabId, p.x, p.y, { modifiers }); + await mouseWheel(tabId, p.x, p.y, deltaX, deltaY, { modifiers }); + + s.x = p.x; + s.y = p.y; + await paintCursor(tabId, p.x, p.y); + return { + action, + scroll_direction: dir, + scroll_amount: amount, + deltaX, + deltaY, + css: { x: p.x, y: p.y }, + coordinate: toModel(s, p.x, p.y), + scale: s.scale, + modifiers, + _method: 'cdp', + }; + } + + // ── Tastatur ── + case 'type': { + const text = input.text; + if (typeof text !== 'string') { + return fail(action, 'BAD_TEXT', `type braucht text als String, bekam ${JSON.stringify(text)}`); + } + if (text === '') return { action, typed: 0, _method: 'cdp' }; + + const delay = toNum(input.per_char_delay_ms) ?? toNum(input.delay_ms); + const typeOpts: { perCharDelayMs?: number } = {}; + if (delay !== null) typeOpts.perCharDelayMs = clamp(Math.round(delay), 0, 500); + + await typeText(tabId, text, typeOpts); + await paintCursor(tabId, s.x, s.y); + return { action, typed: text.length, _method: 'cdp' }; + } + + case 'key': { + const text = typeof input.text === 'string' ? input.text : typeof input.key === 'string' ? input.key : ''; + if (text === '') { + return fail(action, 'BAD_KEY', "key braucht text, z. B. 'ctrl+a' oder 'Return'"); + } + // Mehrere Kombis duerfen leerzeichengetrennt kommen ('ctrl+a ctrl+c'). + const chords = text.trim() === '' ? [' '] : text.trim().split(/\s+/); + const repeat = clamp(Math.round(toNum(input.repeat) ?? 1), 1, 50); + + const pressed: string[] = []; + for (const chord of chords) { + const parsed = parseChord(chord); + if (!parsed.ok) return fail(action, 'BAD_KEY', parsed.message); + const combined = parsed.mods.length ? [...new Set([...modifiers, ...parsed.mods])] : modifiers; + await pressKey(tabId, parsed.key, { modifiers: combined, repeat }); + pressed.push((combined.length ? combined.join('+') + '+' : '') + parsed.key); + } + await paintCursor(tabId, s.x, s.y); + return { action, keys: pressed, repeat, _method: 'cdp' }; + } + + case 'hold_key': { + const text = typeof input.text === 'string' ? input.text : typeof input.key === 'string' ? input.key : ''; + if (text === '') return fail(action, 'BAD_KEY', "hold_key braucht text, z. B. 'shift'"); + const parsed = parseChord(text.trim()); + if (!parsed.ok) return fail(action, 'BAD_KEY', parsed.message); + + const secs = toNum(input.duration) ?? toNum(input.seconds) ?? 1; + if (secs < 0) return fail(action, 'BAD_DURATION', `duration muss >= 0 sein, bekam ${secs}`); + const capped = Math.min(secs, MAX_WAIT_SECONDS); + const combined = parsed.mods.length ? [...new Set([...modifiers, ...parsed.mods])] : modifiers; + + await keyDown(tabId, parsed.key, { modifiers: combined }); + try { + await keepAliveSleep(tabId, Math.round(capped * 1000)); + } finally { + // Taste MUSS wieder hoch — sonst haengt die Seite in einem gedrueckten + // Modifier fest und jede weitere Eingabe ist verfaelscht. + await keyUp(tabId, parsed.key, { modifiers: combined }).catch(() => {}); + } + await paintCursor(tabId, s.x, s.y); + return { action, key: parsed.key, modifiers: combined, duration: capped, _method: 'cdp' }; + } + + default: + // ACTION_SET ist gefiltert — hier landet nur ein Programmierfehler. + return fail(action, 'UNKNOWN_ACTION', `Aktion '${action}' ist nicht implementiert`); + } +} + +// ─── Helfer fuer Aktionen mit optionaler Koordinate ─────────────────────────── + +interface ResolvedPoint { + x: number; + y: number; + scrolled: boolean; + /** false, wenn wir schon dort stehen — dann sparen wir uns das mousemove. */ + moved: boolean; +} + +/** + * coordinate auswerten; fehlt sie, gilt die gemerkte Zeigerposition + * (Claudes computer-Tool erlaubt Klicks ohne Koordinate genau dafuer). + */ +async function resolvePoint( + tabId: number, + s: PointerState, + input: Record, +): Promise { + const c = readCoordinateInput(input, 'coordinate'); + if (c === null) return { x: s.x, y: s.y, scrolled: false, moved: false }; + if (!c.ok) return { error: c.message }; + const oob = outOfBounds(s, c.x, c.y); + if (oob) return { error: oob }; + const p = await toViewport(tabId, s, c.x, c.y); + return { x: p.x, y: p.y, scrolled: p.scrolled, moved: p.x !== s.x || p.y !== s.y }; +} + +function mouseResult( + action: string, + s: PointerState, + p: { x: number; y: number; scrolled: boolean }, + extra: Record, +): ComputerResult { + return { + action, + css: { x: p.x, y: p.y }, + coordinate: toModel(s, p.x, p.y), + scale: s.scale, + scrolled: p.scrolled, + ...extra, + _method: 'cdp', + }; +} diff --git a/src/background/conversations.ts b/src/background/conversations.ts new file mode 100644 index 0000000..fa3e0c8 --- /dev/null +++ b/src/background/conversations.ts @@ -0,0 +1,176 @@ +/** + * Conversation persistence — stores chat history in chrome.storage.local + * so conversations survive panel close / browser restart. + * + * Also syncs to memory.cnull.net when enabled. + */ + +import { logConversation, type ConversationMessage } from './memory'; +import { loadConfig } from '../shared/config'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface ChatMessage { + id: string; + role: 'user' | 'assistant' | 'system' | 'tool'; + content: string; + timestamp: number; + /** Tool name if role=tool */ + toolName?: string; + /** Whether this is an error message */ + isError?: boolean; +} + +export interface Conversation { + id: string; + title: string; + messages: ChatMessage[]; + createdAt: number; + updatedAt: number; +} + +// ─── Storage Keys ───────────────────────────────────────────────────────────── + +const CONVERSATIONS_KEY = 'nexus_conversations'; +const ACTIVE_CONV_KEY = 'nexus_active_conversation'; +const MAX_CONVERSATIONS = 50; + +// ─── Public API ─────────────────────────────────────────────────────────────── + +/** Get all conversations (metadata only, no messages). */ +export async function listConversations(): Promise> { + const stored = await chrome.storage.local.get([CONVERSATIONS_KEY]); + const convs: Conversation[] = stored[CONVERSATIONS_KEY] || []; + return convs.map(c => ({ id: c.id, title: c.title, updatedAt: c.updatedAt })); +} + +/** Get a full conversation by ID. */ +export async function getConversation(id: string): Promise { + const stored = await chrome.storage.local.get([CONVERSATIONS_KEY]); + const convs: Conversation[] = stored[CONVERSATIONS_KEY] || []; + return convs.find(c => c.id === id) || null; +} + +/** Get or create the active conversation. */ +export async function getActiveConversation(): Promise { + const stored = await chrome.storage.local.get([CONVERSATIONS_KEY, ACTIVE_CONV_KEY]); + const convs: Conversation[] = stored[CONVERSATIONS_KEY] || []; + const activeId: string | undefined = stored[ACTIVE_CONV_KEY]; + + if (activeId) { + const existing = convs.find(c => c.id === activeId); + if (existing) return existing; + } + + // Create new conversation + const conv: Conversation = { + id: crypto.randomUUID(), + title: 'Neue Konversation', + messages: [], + createdAt: Date.now(), + updatedAt: Date.now(), + }; + convs.unshift(conv); + trimConversations(convs); + await chrome.storage.local.set({ + [CONVERSATIONS_KEY]: convs, + [ACTIVE_CONV_KEY]: conv.id, + }); + return conv; +} + +/** Start a new conversation (sets it as active). */ +export async function newConversation(): Promise { + const conv: Conversation = { + id: crypto.randomUUID(), + title: 'Neue Konversation', + messages: [], + createdAt: Date.now(), + updatedAt: Date.now(), + }; + + const stored = await chrome.storage.local.get([CONVERSATIONS_KEY]); + const convs: Conversation[] = stored[CONVERSATIONS_KEY] || []; + convs.unshift(conv); + trimConversations(convs); + + await chrome.storage.local.set({ + [CONVERSATIONS_KEY]: convs, + [ACTIVE_CONV_KEY]: conv.id, + }); + return conv; +} + +/** Switch to an existing conversation. */ +export async function switchConversation(id: string): Promise { + const conv = await getConversation(id); + if (!conv) return null; + await chrome.storage.local.set({ [ACTIVE_CONV_KEY]: id }); + return conv; +} + +/** Add a message to the active conversation. */ +export async function addMessage(msg: Omit): Promise { + const stored = await chrome.storage.local.get([CONVERSATIONS_KEY, ACTIVE_CONV_KEY]); + const convs: Conversation[] = stored[CONVERSATIONS_KEY] || []; + const activeId: string | undefined = stored[ACTIVE_CONV_KEY]; + if (!activeId) return; + + const conv = convs.find(c => c.id === activeId); + if (!conv) return; + + const message: ChatMessage = { + ...msg, + id: crypto.randomUUID(), + timestamp: Date.now(), + }; + conv.messages.push(message); + conv.updatedAt = Date.now(); + + // Auto-title from first user message + if (msg.role === 'user' && conv.title === 'Neue Konversation') { + conv.title = msg.content.slice(0, 60) + (msg.content.length > 60 ? '…' : ''); + } + + await chrome.storage.local.set({ [CONVERSATIONS_KEY]: convs }); +} + +/** Delete a conversation. */ +export async function deleteConversation(id: string): Promise { + const stored = await chrome.storage.local.get([CONVERSATIONS_KEY, ACTIVE_CONV_KEY]); + const convs: Conversation[] = stored[CONVERSATIONS_KEY] || []; + const filtered = convs.filter(c => c.id !== id); + const updates: Record = { [CONVERSATIONS_KEY]: filtered }; + + if (stored[ACTIVE_CONV_KEY] === id) { + updates[ACTIVE_CONV_KEY] = filtered[0]?.id || ''; + } + await chrome.storage.local.set(updates); +} + +/** Sync the active conversation to memory.cnull.net. */ +export async function syncToMemory(): Promise { + const cfg = await loadConfig(); + if (!cfg.memoryEnabled || !cfg.memoryToken) return false; + + const conv = await getActiveConversation(); + if (!conv.messages.length) return false; + + const messages: ConversationMessage[] = conv.messages + .filter(m => m.role === 'user' || m.role === 'assistant') + .map(m => ({ + role: m.role as 'user' | 'assistant', + content: m.content, + timestamp: new Date(m.timestamp).toISOString(), + })); + + return logConversation(messages, conv.title); +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function trimConversations(convs: Conversation[]): void { + while (convs.length > MAX_CONVERSATIONS) { + convs.pop(); + } +} diff --git a/src/background/index.ts b/src/background/index.ts new file mode 100644 index 0000000..1def4de --- /dev/null +++ b/src/background/index.ts @@ -0,0 +1,1759 @@ +/** + * Nexus Browser Pilot – Background Service Worker + * + * Der Service Worker ist die einzige Stelle, an der Browser-Tools wirklich laufen. + * Alle drei Antriebe münden in denselben Engpass runTool(): + * + * Broker (Python, WebSocket) ─┐ + * MCP-Bridge (WebSocket) ─┼─→ runTool() → Risiko-Gate → dispatchTool() + * Eingebauter Loop (agent.ts)─┘ + * + * Dadurch gilt der Risikomodus für jeden Antrieb, nicht nur für den Chat im Panel. + * Der Agent-Loop lebt bewusst hier und nicht im Side Panel: ein geschlossenes Panel + * darf einen laufenden Auftrag nicht abbrechen. + */ + +import type { + BrokerMessage, + ExtHello, + ExtToolResult, + ExtUserMessage, + ExtAbort, + ExtApprovalResponse, + PanelMessage, + RiskVerdict, + InteractionMethod, +} from '../shared/protocol'; +import { + loadConfig, + patchConfig, + EXTENSION_VERSION, + RECONNECT_INTERVAL_MS, + RECONNECT_MAX_ATTEMPTS, + type ExtensionConfig, +} from '../shared/config'; +import { BROWSER_TOOL_SCHEMAS } from '../shared/tool-schemas.generated'; +import * as cdp from './cdp'; +import { runComputer } from './computer'; +import { gate, setScaleProvider } from './risk'; +import { localSave as memoryLocalSave, localSearch as memoryLocalSearch } from './memory'; +import { runAgentTurn, abortAgent, isAgentRunning, resetConversation, queueMessage } from './agent'; +import type { LlmToolDef } from './llm'; + +// ─── State ──────────────────────────────────────────────────────────────────── + +let ws: WebSocket | null = null; +let reconnectAttempts = 0; +let reconnectTimer: ReturnType | null = null; +let sessionSecret = ''; +let panelPort: chrome.runtime.Port | null = null; + +/** + * Target-Tab: Der Agent arbeitet auf diesem Tab, unabhängig davon, welcher Tab + * gerade im Vordergrund ist. Wird beim ersten Tool-Aufruf auf den aktiven Tab + * gesetzt und bleibt dort, bis der Nutzer explizit wechselt (browser_tabs_select) + * oder eine neue Konversation startet. + */ +let targetTabId: number | null = null; + +/** + * Pause zwischen Tool-Aufrufen in ms. Macht den Agent weniger hektisch und gibt + * Seiten Zeit zum Rendern. Konfigurierbar über die Config. + */ +const TOOL_PACE_MS = 600; + +/** Offene Risiko-Rückfragen: requestId → Auflöser. */ +const pendingRisk = new Map void>(); + +/** + * Wartezeit für eine Risiko-Rückfrage. Läuft sie ab, wird AUSGEFÜHRT, nicht abgebrochen. + * Der Nutzer hat Autonomie verlangt; Schweigen darf keinen stillen Abbruch bedeuten. + * Nur eine ausdrückliche Ablehnung stoppt. + */ +const RISK_TIMEOUT_MS = 120_000; + +/** Konsolenpuffer, gefüllt aus dem Content-Script. */ +const consoleLogs: Array<{ level: string; text: string; ts: number; url?: string }> = []; +const MAX_CONSOLE_LOGS = 300; + +/** Letzter Screenshot-Maßstab je Tab — für die Koordinaten-Rückrechnung. */ +const lastScale = new Map(); + +// Die Risiko-Engine muss Modell-Koordinaten in CSS-Pixel zurückrechnen können, +// bevor sie per elementFromPoint nachsieht, worauf da eigentlich geklickt wird. +setScaleProvider((tabId) => lastScale.get(tabId) ?? 1); + +// ─── Session Secret ─────────────────────────────────────────────────────────── + +async function getOrCreateSecret(): Promise { + const stored = await chrome.storage.session.get(['nexus_secret']); + if (stored.nexus_secret) return stored.nexus_secret; + const secret = crypto.randomUUID(); + await chrome.storage.session.set({ nexus_secret: secret }); + return secret; +} + +// ─── WebSocket zum Antrieb (Broker oder MCP-Bridge) ─────────────────────────── + +async function connect(): Promise { + if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return; + + const config = await loadConfig(); + sessionSecret = await getOrCreateSecret(); + + try { + ws = new WebSocket(config.brokerUrl); + } catch (e) { + console.error('[Nexus] WS connect error:', e); + scheduleReconnect(); + return; + } + + ws.onopen = () => { + reconnectAttempts = 0; + const hello: ExtHello = { type: 'hello', version: EXTENSION_VERSION, secret: sessionSecret }; + ws!.send(JSON.stringify(hello)); + sendToPanel({ type: 'connection_status', connected: true }); + }; + + ws.onmessage = (event) => { + try { + handleBrokerMessage(JSON.parse(event.data as string) as BrokerMessage); + } catch (e) { + console.error('[Nexus] Failed to parse broker message:', e); + } + }; + + ws.onclose = () => { + ws = null; + sendToPanel({ type: 'connection_status', connected: false }); + scheduleReconnect(); + }; + + ws.onerror = () => { /* onclose folgt */ }; +} + +function scheduleReconnect(): void { + if (reconnectTimer) return; + if (reconnectAttempts >= RECONNECT_MAX_ATTEMPTS) { + sendToPanel({ type: 'connection_status', connected: false, maxRetries: true }); + return; + } + reconnectAttempts++; + const delay = Math.min(RECONNECT_INTERVAL_MS * reconnectAttempts, 30_000); + reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(); }, delay); +} + +function sendToBroker(msg: object): void { + if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg)); +} + +async function handleBrokerMessage(msg: BrokerMessage): Promise { + switch (msg.type) { + case 'welcome': + case 'text_delta': + case 'round': + case 'done': + case 'error': + case 'approval_request': + case 'tool_result_event': + case 'mcp_status': + sendToPanel(msg); + break; + case 'tool_call': + await executeForBroker(msg.callId, msg.name, msg.input); + break; + case 'ping': + sendToBroker({ type: 'pong', ts: msg.ts }); + break; + } +} + +// ─── Tool-Ausführung ────────────────────────────────────────────────────────── + +async function executeForBroker(callId: string, name: string, input: Record): Promise { + const start = performance.now(); + let method: InteractionMethod = 'synthetic'; + + try { + sendToPanel({ type: 'tool_executing', callId, name, input }); + const result = await runTool(name, input); + method = (result?._method as InteractionMethod) || 'synthetic'; + if (result && typeof result === 'object') delete result._method; + + const durationMs = Math.round(performance.now() - start); + sendToBroker({ type: 'tool_result', callId, result, durationMs, method } satisfies ExtToolResult); + reportToolDone(callId, name, result, undefined, durationMs, method); + } catch (e: unknown) { + const error = e instanceof Error ? e.message : String(e); + const durationMs = Math.round(performance.now() - start); + sendToBroker({ type: 'tool_result', callId, result: null, error, durationMs, method } satisfies ExtToolResult); + reportToolDone(callId, name, null, error, durationMs, method); + } +} + +/** Ergebnis ans Panel — Bilder gehen getrennt, damit tool_done klein bleibt. */ +function reportToolDone( + callId: string, name: string, result: any, error: string | undefined, + durationMs: number, method: InteractionMethod, +): void { + if (result && typeof result === 'object' && typeof result.image === 'string') { + sendToPanel({ type: 'screenshot', callId, dataUrl: result.image }); + const { image, ...rest } = result; + sendToPanel({ type: 'tool_done', callId, name, result: { ...rest, image: '[siehe Vorschau]' }, error, durationMs, method }); + return; + } + sendToPanel({ type: 'tool_done', callId, name, result, error, durationMs, method }); +} + +/** Sendet den aktuellen Target-Tab ans Panel. */ +async function notifyTargetTab(): Promise { + if (targetTabId === null) { + sendToPanel({ type: 'target_tab', tabId: null }); + return; + } + try { + const tab = await chrome.tabs.get(targetTabId); + sendToPanel({ type: 'target_tab', tabId: targetTabId, title: tab.title, url: tab.url }); + } catch { + sendToPanel({ type: 'target_tab', tabId: null }); + } +} + +/** + * DER Engpass. Jeder Antrieb geht hier durch, also greift das Risiko-Gate überall. + * Bei riskMode='off' (Standard) kostet das Gate nichts und führt sofort aus. + */ +async function runTool(name: string, input: Record): Promise { + // Gedächtnis braucht keinen Tab und kein Risiko-Gate — es fasst die Seite nicht an. + if (name === 'memory_save') { + const text = String(input.text ?? '').trim(); + if (!text) return errorResult('NO_TEXT', 'text erforderlich', false); + const note = await memoryLocalSave( + text, String(input.type ?? 'fakt'), + Array.isArray(input.tags) ? (input.tags as string[]) : [], + ); + return note + ? { saved: note.id, type: note.type, text: note.text } + : errorResult('MEMORY_UNAVAILABLE', + 'Der lokale Agent ist nicht erreichbar — die Notiz wurde NICHT gespeichert. ' + + 'Arbeite ohne sie weiter.', false); + } + if (name === 'memory_search') { + const notes = await memoryLocalSearch( + String(input.query ?? ''), Number(input.limit) || 8, + ); + return { count: notes.length, notes }; + } + + const tabId = await currentTabId(); + const { allowed, verdict } = await gate(tabId, name, input, (v) => askUser(v, name)); + + if (!allowed) { + return { + error: { + code: 'USER_DECLINED', + message: `Vom Nutzer abgelehnt: ${verdict.reason}`, + retryable: false, + }, + _method: 'synthetic', + }; + } + + const result = await dispatchTool(name, input); + + // Pace: kurze Pause nach jeder Aktion, damit Seiten rendern können und der + // Agent nicht hektisch wirkt. Screenshots und reine Lese-Tools brauchen keine Pause. + const readOnly = name === 'browser_screenshot' || name === 'browser_read_page' + || name === 'browser_read_network' || name === 'browser_read_console' + || name === 'browser_tabs_list' || name === 'browser_find'; + if (!readOnly) { + const cfg = await loadConfig(); + const pace = cfg.paceMs ?? TOOL_PACE_MS; + if (pace > 0) await new Promise(r => setTimeout(r, pace)); + } + + return result; +} + +/** Fragt den Nutzer. Ohne offenes Panel wird gehandelt — Autonomie hat Vorrang. */ +function askUser(verdict: RiskVerdict, toolName: string): Promise { + if (!panelPort) return Promise.resolve(true); + + const requestId = crypto.randomUUID(); + sendToPanel({ type: 'risk_request', requestId, name: toolName, verdict, timeoutMs: RISK_TIMEOUT_MS }); + + return new Promise((resolve) => { + const timer = setTimeout(() => { + pendingRisk.delete(requestId); + resolve(true); // Zeitablauf = ausführen, siehe RISK_TIMEOUT_MS + }, RISK_TIMEOUT_MS); + + pendingRisk.set(requestId, (approved) => { + clearTimeout(timer); + pendingRisk.delete(requestId); + resolve(approved); + }); + }); +} + +// ─── Helfer ─────────────────────────────────────────────────────────────────── + +async function getActiveTab(): Promise { + // Wenn ein Target-Tab gesetzt ist, diesen verwenden (Hintergrundarbeit). + if (targetTabId !== null) { + try { + const tab = await chrome.tabs.get(targetTabId); + if (tab && tab.id) return tab; + } catch { + // Tab existiert nicht mehr — zurückfallen auf aktiven Tab. + targetTabId = null; + } + } + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (!tab || !tab.id) throw new ToolError('NO_ACTIVE_TAB', 'Kein aktiver Tab gefunden', false); + // Beim ersten Aufruf den Target-Tab setzen. + targetTabId = tab.id!; + notifyTargetTab(); + return tab; +} + +async function currentTabId(): Promise { + if (targetTabId !== null) { + try { + await chrome.tabs.get(targetTabId); + return targetTabId; + } catch { + targetTabId = null; + } + } + try { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + return tab?.id; + } catch { + return undefined; + } +} + +async function execInTab(tabId: number, func: (...args: any[]) => T, args: any[] = []): Promise { + const results = await chrome.scripting.executeScript({ target: { tabId }, func: func as any, args }); + return results?.[0]?.result as T; +} + +function resolveTarget(input: Record): string | null { + if (input.ref_id) return `[data-nexus-ref="${input.ref_id}"]`; + if (input.selector) return input.selector as string; + return null; +} + +class ToolError extends Error { + code: string; + retryable: boolean; + constructor(code: string, message: string, retryable: boolean) { + super(message); + this.code = code; + this.retryable = retryable; + } +} + +function errorResult(code: string, message: string, retryable: boolean): any { + return { error: { code, message, retryable }, _method: 'synthetic' }; +} + +/** Liest Position und Kennung eines Elements im Viewport. Magnet-Logik: wenn der + * exakte Selektor nichts findet, wird per Textsuche das nächstliegende interaktive + * Element gesucht (übernommen aus browser-pilot). */ +async function elementBox(tabId: number, selector: string): Promise<{ x: number; y: number; tag: string; text: string } | null> { + return await execInTab(tabId, (sel: string) => { + let el = document.querySelector(sel) as HTMLElement | null; + + // Magnet-Fallback: Textsuche auf interaktiven Elementen + if (!el) { + const searchText = sel.replace(/[^a-zA-Z0-9äöüÄÖÜß ]/g, '').toLowerCase().trim(); + if (searchText) { + const interactives = Array.from(document.querySelectorAll( + 'a, button, input, select, textarea, [role="button"], [onclick], [tabindex]:not([tabindex="-1"]), summary, label' + )); + for (const candidate of interactives) { + const cText = ((candidate as HTMLElement).innerText || (candidate as HTMLInputElement).value || + candidate.getAttribute('aria-label') || '').toLowerCase(); + if (cText.includes(searchText) || searchText.includes(cText.trim())) { + el = candidate as HTMLElement; + break; + } + } + } + } + + if (!el) return null; + el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' as ScrollBehavior }); + const r = el.getBoundingClientRect(); + if (r.width === 0 && r.height === 0) return null; + return { + x: r.x + r.width / 2, + y: r.y + r.height / 2, + tag: el.tagName.toLowerCase(), + text: ((el as HTMLElement).innerText || (el as HTMLInputElement).value || '').trim().slice(0, 80), + }; + }, [selector]); +} + +/** + * Entscheidet je nach inputMode, ob eine echte (CDP) oder synthetische Eingabe erzeugt wird. + * 'auto' versucht CDP und fällt bei fehlender Permission oder belegtem Debugger zurück. + */ +async function wantsTrusted(tabId: number): Promise { + const cfg = await loadConfig(); + if (cfg.inputMode === 'synthetic') return false; + if (!cdp.cdpAvailable()) return false; + if (cfg.inputMode === 'trusted') return true; + try { + await cdp.attach(tabId); + return true; + } catch { + return false; + } +} + +// ─── Interaktion ────────────────────────────────────────────────────────────── + +async function clickElement(tabId: number, selector: string, forceTrusted: boolean): Promise { + const trusted = forceTrusted || await wantsTrusted(tabId); + + if (trusted) { + const box = await elementBox(tabId, selector); + if (!box) throw new ToolError('ELEMENT_NOT_FOUND', `Element nicht gefunden oder unsichtbar: ${selector}`, true); + try { + await cdp.attach(tabId); + await cdp.mouseClick(tabId, box.x, box.y); + notifyCursor(tabId, box.x, box.y, 'left'); + return { clicked: selector, tag: box.tag, text: box.text, _method: 'cdp' }; + } catch (e: any) { + // Kein harter Fehler: lieber synthetisch klicken als gar nicht handeln. + console.warn('[Nexus] CDP-Klick fehlgeschlagen, weiche auf synthetisch aus:', e?.message); + } + } + + const result = await execInTab(tabId, (sel: string) => { + let el = document.querySelector(sel) as HTMLElement | null; + + // Magnet-Fallback: Textsuche auf interaktiven Elementen + if (!el) { + const searchText = sel.replace(/[^a-zA-Z0-9äöüÄÖÜß ]/g, '').toLowerCase().trim(); + if (searchText) { + const interactives = Array.from(document.querySelectorAll( + 'a, button, input, select, textarea, [role="button"], [onclick], [tabindex]:not([tabindex="-1"]), summary, label' + )); + for (const candidate of interactives) { + const cText = ((candidate as HTMLElement).innerText || (candidate as HTMLInputElement).value || + candidate.getAttribute('aria-label') || '').toLowerCase(); + if (cText.includes(searchText) || searchText.includes(cText.trim())) { + el = candidate as HTMLElement; + break; + } + } + } + } + + if (!el) return { error: 'NOT_FOUND' }; + el.scrollIntoView({ block: 'center', behavior: 'instant' as ScrollBehavior }); + + // Vollständige Maus-Event-Sequenz für maximale Kompatibilität + const rect = el.getBoundingClientRect(); + const x = rect.left + rect.width / 2; + const y = rect.top + rect.height / 2; + const eventOpts: MouseEventInit = { + bubbles: true, cancelable: true, view: window, + clientX: x, clientY: y, button: 0, buttons: 1, + }; + el.dispatchEvent(new PointerEvent('pointerover', eventOpts)); + el.dispatchEvent(new PointerEvent('pointerenter', { ...eventOpts, bubbles: false })); + el.dispatchEvent(new MouseEvent('mouseover', eventOpts)); + el.dispatchEvent(new MouseEvent('mouseenter', { ...eventOpts, bubbles: false })); + el.dispatchEvent(new PointerEvent('pointerdown', eventOpts)); + el.dispatchEvent(new MouseEvent('mousedown', eventOpts)); + (el as HTMLElement).focus?.(); + el.dispatchEvent(new PointerEvent('pointerup', eventOpts)); + el.dispatchEvent(new MouseEvent('mouseup', eventOpts)); + el.dispatchEvent(new MouseEvent('click', eventOpts)); + + return { clicked: sel, tag: el.tagName.toLowerCase(), text: (el.innerText || '').trim().slice(0, 80), coordinates: { x: Math.round(x), y: Math.round(y) } }; + }, [selector]); + + if (!result || (result as any).error === 'NOT_FOUND') { + throw new ToolError('ELEMENT_NOT_FOUND', `Element nicht gefunden: ${selector}`, true); + } + return { ...result, _method: 'synthetic' }; +} + +/** + * Riegel gegen versehentliches Absenden von Mails. + * + * "Nur Entwurf" als Prompt-Regel ist eine Bitte — ein Modell kann sie ignorieren, + * und eine verschickte Mail holt niemand zurück. Deshalb hier hart, unabhängig + * vom Risikomodus. Greift nur, wenn BEIDES zutrifft: die Schaltfläche heißt nach + * Senden, UND die Seite ist erkennbar eine Mail-Oberfläche. Ein "Senden"-Knopf in + * einem Kontaktformular bleibt damit bedienbar. + * + * Grenze, die man kennen muss: browser_computer klickt auf Pixelkoordinaten. Was + * dort getroffen wird, ist vorher nicht bekannt — dieser Riegel sieht es nicht. + */ +async function mailSendBlock(tabId: number, selector: string): Promise { + const cfg = await loadConfig(); + if (!cfg.blockMailSend) return null; + + const hit = await execInTab(tabId, (sel: string) => { + const el = document.querySelector(sel) as HTMLElement | null; + if (!el) return null; + + const label = [ + el.getAttribute('aria-label'), el.getAttribute('title'), + (el as HTMLInputElement).value, el.innerText || el.textContent, + ].filter(Boolean).join(' ').trim().toLowerCase().replace(/\s+/g, ' ').slice(0, 120); + + // Wortgrenzen: "Absender", "Gesendet" oder "Nachsenden" sind keine Sende-Knöpfe. + const isSend = /(^|\s)(senden|send|absenden|abschicken|versenden|jetzt senden|send now)(\s|$)/.test(label); + if (!isSend) return null; + + const host = location.hostname.toLowerCase(); + const mailHost = /(outlook|office|mail|owa|gmail|zimbra|roundcube)/.test(host) + || /(outlook|owa|mail)/.test(location.pathname.toLowerCase()); + // Oder ein erkennbares Verfassen-Formular auf der Seite. + const compose = !!document.querySelector( + '[aria-label*="Nachrichtentext" i], [aria-label*="message body" i], ' + + '[aria-label*="Betreff" i], [aria-label*="subject" i], [name="subject"]', + ); + if (!mailHost && !compose) return null; + + return { label, host }; + }, [selector]); + + if (!hit) return null; + + return errorResult( + 'MAIL_SEND_BLOCKED', + `Absenden ist gesperrt: "${hit.label}" auf ${hit.host}. Du legst nur Entwürfe an — ` + + 'das Verschicken macht der Nutzer selbst. Speichere den Entwurf und melde, dass er ' + + 'bereitliegt. (Abschaltbar in den Einstellungen: "Senden blockieren".)', + false, + ); +} + +/** + * Beschreibt, WO der Text wirklich gelandet ist — gemeintes und tatsächlich + * fokussiertes Element im Vergleich. + * + * Der CDP-Pfad fokussiert über einen Klick auf die zuvor gemessene Box. Rendert + * die Seite zwischen Messung und Klick neu (in SPAs der Normalfall), landet der + * Klick woanders und der Text im falschen Feld. Die frühere Rücklese-Prüfung las + * den Wert des GEMEINTEN Elements und meldete deshalb Erfolg, während der Text + * im Betreff stand. Genau dieser blinde Fleck hat in Outlook ganze Mails in die + * Betreffzeile geschrieben. + */ +async function describeTypeTarget(tabId: number, selector: string): Promise { + return await execInTab(tabId, (sel: string) => { + const describe = (el: any) => { + if (!el || !el.tagName) return null; + // aria-labelledby zuerst: in Outlook Web wird der Nachrichtentext genau so + // beschriftet, und ohne diese Auflösung sieht er aus wie das Betreff-Feld. + let label = el.getAttribute?.('aria-label') || ''; + const by = el.getAttribute?.('aria-labelledby'); + if (!label && by) { + label = by.split(/\s+/) + .map((id: string) => document.getElementById(id)?.textContent || '') + .join(' ').trim(); + } + if (!label) { + label = (el.labels?.[0]?.textContent || el.getAttribute?.('placeholder') + || el.getAttribute?.('name') || '').trim(); + } + return { + ref: el.getAttribute?.('data-nexus-ref') || undefined, + tag: String(el.tagName).toLowerCase(), + type: el.type || undefined, + role: el.getAttribute?.('role') || undefined, + label: label.slice(0, 80) || undefined, + editable: el.isContentEditable || undefined, + value: String(el.value ?? el.textContent ?? '').slice(0, 200), + }; + }; + const intended = document.querySelector(sel); + const active = document.activeElement; + return { intended: describe(intended), active: describe(active), same: !!intended && intended === active }; + }, [selector]); +} + +/** Ergebnis einer Eingabe, das den Fehlgriff sichtbar macht statt ihn zu verschlucken. */ +function typeResult(text: string, selector: string, check: any, submit: boolean, method: string): any { + const landed = check?.active ?? check?.intended ?? null; + const out: any = { + typed: text.length, + selector, + typed_into: landed, + value_after: landed?.value ?? '', + submitted: submit, + _method: method, + }; + if (check && !check.same) { + // Laut und maschinenlesbar: das Modell soll das korrigieren, nicht weiterschreiben. + out.target_mismatch = true; + out.intended_target = check.intended; + out.warning = 'Der Text ist NICHT im gemeinten Element gelandet, sondern in ' + + `"${landed?.label || landed?.tag || 'unbekannt'}". Inhalt dort prüfen, ggf. leeren ` + + 'und mit dem richtigen ref_id erneut tippen.'; + } + return out; +} + +async function typeInElement( + tabId: number, selector: string, text: string, clear: boolean, submit: boolean, +): Promise { + const trusted = await wantsTrusted(tabId); + + if (trusted) { + const box = await elementBox(tabId, selector); + if (!box) throw new ToolError('ELEMENT_NOT_FOUND', `Element nicht gefunden: ${selector}`, true); + try { + await cdp.attach(tabId); + // Fokus über einen echten Klick — so verhält es sich wie bei einem Menschen. + await cdp.mouseClick(tabId, box.x, box.y); + notifyCursor(tabId, box.x, box.y, 'left'); + if (clear) { + await cdp.pressKey(tabId, 'a', { modifiers: ['ctrl'] }); + await cdp.pressKey(tabId, 'Delete'); + } + await cdp.typeText(tabId, text); + // VOR dem Absenden prüfen: Enter verschiebt den Fokus oft legitim, danach + // wäre der Vergleich wertlos. + const check = await describeTypeTarget(tabId, selector); + if (submit) await cdp.pressKey(tabId, 'Enter'); + return typeResult(text, selector, check, submit, 'cdp'); + } catch (e: any) { + console.warn('[Nexus] CDP-Eingabe fehlgeschlagen, weiche auf synthetisch aus:', e?.message); + } + } + + const result = await execInTab(tabId, (sel: string, txt: string, clr: boolean, sub: boolean) => { + const el = document.querySelector(sel) as HTMLInputElement | HTMLTextAreaElement | null; + if (!el) return { error: 'NOT_FOUND' }; + el.focus(); + + // Der native Setter muss zum konkreten Elementtyp passen. Nimmt man immer den von + // HTMLInputElement, wirft .call() auf ein + + + +
Memory (memory.cnull.net)
+
+ + +
+
+ + +
+ + + + + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ + +
+ +
Änderungen werden sofort gespeichert.
+ + + + +
+
+
+ KI sieht… + +
+
+ Was die KI gerade sieht +
+
+
+ +
+ + + +
+ + + + + +
+ + + + + diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..eab4822 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "lib": ["ES2022", "DOM"], + "types": ["chrome-types"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +}