diff --git a/README.md b/README.md index e17ecd7..89710a3 100644 --- a/README.md +++ b/README.md @@ -177,9 +177,12 @@ Claude verwendet diese Befehle in eckigen Klammern: | Taste | Funktion | |-------|----------| | **M** | Mikrofon Mute/Unmute | +| **N** | Neuer Chat (bei 100-Bilder-Limit) | | **Q** | Bridge beenden | | **Ctrl+C** | Bridge beenden | +**Hinweis:** Claude.ai erlaubt max. 100 Bilder pro Chat. Die Bridge warnt bei 90/95 Bildern. Mit **N** startest du einen neuen Chat. + --- ## Sicherheit diff --git a/docs/setup_guide.md b/docs/setup_guide.md index 8375ca4..15943de 100644 --- a/docs/setup_guide.md +++ b/docs/setup_guide.md @@ -234,10 +234,16 @@ python chat_audio_bridge.py -c config.local.yaml | Taste | Funktion | |-------|----------| | **M** | Mikrofon Mute/Unmute - schaltet STT stumm | +| **N** | Neuer Chat starten (bei Bilder-Limit) | | **Q** | Bridge beenden | | **Ctrl+C** | Bridge beenden | -**Tipp:** Nutze Mute wenn du nicht willst dass Hintergrundgeräusche oder Gespräche mit anderen aufgenommen werden. Das Mikrofon nimmt sonst alles auf! +**Tipps:** +- Nutze **Mute** wenn du nicht willst dass Hintergrundgeräusche aufgenommen werden +- Das Mikrofon startet standardmäßig **gemutet** - drücke **M** zum Aktivieren +- Claude.ai erlaubt max. **100 Bilder pro Chat**. Bei 90/95 Bildern warnt die Bridge +- Mit **N** startest du einen neuen Chat und die Instruktionen werden erneut gesendet +- Bilder werden nur hochgeladen wenn sie sich geändert haben (spart Limit!) --- diff --git a/python_bridge/chat_audio_bridge.py b/python_bridge/chat_audio_bridge.py index ada729e..6764446 100755 --- a/python_bridge/chat_audio_bridge.py +++ b/python_bridge/chat_audio_bridge.py @@ -94,9 +94,11 @@ class ClaudesEyesAudioBridge: """ def __init__(self, config_path: str): + self.config_path = Path(config_path) self.config = self._load_config(config_path) self.running = False self.stats = BridgeStats() + self._previous_chat_id: Optional[str] = None # Für Referenz zum alten Chat # Komponenten (werden in initialize() erstellt) self.chat: Optional[ClaudeChatInterface] = None @@ -119,7 +121,8 @@ class ClaudesEyesAudioBridge: self._sending.set() # Anfangs nicht am Senden (set = frei) # Mute-Flag: Wenn True, ignoriert STT alle Eingaben - self._muted = False + # Startet gemutet um ungewollte Aufnahmen zu vermeiden + self._muted = True self._mute_lock = threading.Lock() def _load_config(self, config_path: str) -> dict: @@ -132,6 +135,9 @@ class ClaudesEyesAudioBridge: path = local_path logger.info(f"Nutze lokale Config: {path}") + # Merke den tatsächlichen Pfad für späteres Speichern + self._actual_config_path = path + if not path.exists(): logger.error(f"Config nicht gefunden: {path}") sys.exit(1) @@ -139,6 +145,48 @@ class ClaudesEyesAudioBridge: with open(path, 'r', encoding='utf-8') as f: return yaml.safe_load(f) + def _save_chat_url_to_config(self, new_url: str): + """ + Speichert die neue Chat-URL in der config.yaml. + + Aktualisiert nur den chat.url Wert, behält Rest der Datei bei. + """ + try: + config_path = getattr(self, '_actual_config_path', self.config_path) + + # Config-Datei lesen und als Text bearbeiten (um Kommentare zu erhalten) + with open(config_path, 'r', encoding='utf-8') as f: + content = f.read() + + # URL im Text ersetzen (regex für chat.url Zeile) + import re + old_url = self.config.get("chat", {}).get("url", "") + + if old_url: + # Ersetze die alte URL mit der neuen + content = content.replace(old_url, new_url) + else: + # Fallback: Ersetze die Zeile mit url: + content = re.sub( + r'(url:\s*["\'])([^"\']+)(["\'])', + rf'\g<1>{new_url}\g<3>', + content + ) + + # Speichern + with open(config_path, 'w', encoding='utf-8') as f: + f.write(content) + + # Config im Speicher auch aktualisieren + self.config["chat"]["url"] = new_url + + logger.info(f"Neue Chat-URL in Config gespeichert: {new_url}") + console.print(f"[green]✓ Config aktualisiert: {config_path}[/green]") + + except Exception as e: + logger.error(f"Fehler beim Speichern der Chat-URL: {e}") + console.print(f"[yellow]⚠ Konnte Config nicht aktualisieren: {e}[/yellow]") + def initialize(self) -> bool: """Initialisiert alle Komponenten""" @@ -266,7 +314,8 @@ class ClaudesEyesAudioBridge: threads.append(t4) console.print("[cyan]Bridge läuft![/cyan]") - console.print("[dim]Drücke 'M' für Mute/Unmute, Ctrl+C zum Beenden[/dim]\n") + console.print("[bold red]🎤 Mikrofon ist GEMUTET[/bold red] - Drücke 'M' zum Aktivieren") + console.print("[dim]Tasten: M=Mute/Unmute, N=Neuer Chat, Q=Beenden[/dim]\n") # Sende Startsignal an Claude und warte auf [READY] if not self._send_start_signal(): @@ -314,17 +363,30 @@ class ClaudesEyesAudioBridge: console.print("\n[green]Bridge beendet.[/green]") - def _send_start_signal(self) -> bool: + def _send_start_signal(self, previous_chat_id: Optional[str] = None) -> bool: """ Sendet das Startsignal und die Instruktionen an Claude. + Args: + previous_chat_id: Optional - Chat-ID des vorherigen Chats (für Kontinuität) + Returns: True wenn Claude mit [READY] antwortet """ - # Instruktionen für Claude - intro_message = """[START] Hallo Claude! Du steuerst jetzt einen echten Roboter - "Claude's Eyes"! + # Kontext vom vorherigen Chat, falls vorhanden + previous_chat_context = "" + if previous_chat_id: + previous_chat_context = f""" +## WICHTIG: Fortsetzung eines vorherigen Chats! +Dies ist eine Fortsetzung unserer Erkundung. Der vorherige Chat hatte die ID: {previous_chat_id} +Falls du dich an den Kontext erinnerst, kannst du dort weitermachen wo wir aufgehört haben. +Der Chat-Wechsel war nötig weil Claude.ai ein Limit von 100 Bildern pro Chat hat. -## Deine Fähigkeiten +""" + + # Instruktionen für Claude + intro_message = f"""[START] Hallo Claude! Du steuerst jetzt einen echten Roboter - "Claude's Eyes"! +{previous_chat_context}## Deine Fähigkeiten Du hast Zugriff auf einen ESP32-Roboter mit: - **Kamera** (OV5640, 120° Weitwinkel) - deine Augen - **4 Motoren** - deine Beine @@ -458,12 +520,26 @@ Erst dann starten die automatischen TICKs mit Bildern!""" try: # Erst Bild hochladen wenn aktiviert + image_uploaded = False if upload_images: - # Bild holen und hochladen + # Bild holen if not self.chat.fetch_image_from_esp32(): logger.warning("Konnte kein Bild vom ESP32 holen") - elif not self.chat.upload_image_to_chat(): - logger.warning("Konnte Bild nicht hochladen") + else: + # Nur hochladen wenn sich das Bild geändert hat (100 Bilder Limit!) + if self.chat.upload_image_if_changed(): + image_uploaded = True + uploaded_count = self.chat.get_images_uploaded_count() + + # Warnungen bei Annäherung ans Limit + if uploaded_count >= 95: + console.print(f"[bold red]⚠️ {uploaded_count}/100 Bilder! Drücke 'N' für neuen Chat![/bold red]") + elif uploaded_count >= 90: + console.print(f"[yellow]⚠️ {uploaded_count}/100 Bilder - Limit fast erreicht![/yellow]") + elif uploaded_count % 10 == 0: + console.print(f"[dim]📷 {uploaded_count} Bilder hochgeladen (Limit: 100)[/dim]") + else: + logger.debug("Bild unverändert, übersprungen") # Nachricht zusammenbauen if stefan_text: @@ -589,17 +665,18 @@ Erst dann starten die automatischen TICKs mit Bildern!""" def _keyboard_loop(self): """ - Hört auf Tastatureingaben für Mute-Toggle. + Hört auf Tastatureingaben für Mute-Toggle und andere Befehle. Tasten: - M: Mute/Unmute Toggle + - N: Neuer Chat starten (bei Bilder-Limit) - Q: Beenden (alternativ zu Ctrl+C) """ import sys import tty import termios - logger.info("Keyboard-Loop gestartet (M=Mute, Q=Quit)") + logger.info("Keyboard-Loop gestartet (M=Mute, N=New Chat, Q=Quit)") # Speichere ursprüngliche Terminal-Settings old_settings = None @@ -621,6 +698,8 @@ Erst dann starten die automatischen TICKs mit Bildern!""" if char.lower() == 'm': self.toggle_mute() + elif char.lower() == 'n': + self._start_new_chat_with_instructions() elif char.lower() == 'q' or char == '\x03': # q oder Ctrl+C console.print("\n[yellow]Beende Bridge...[/yellow]") self.running = False @@ -633,6 +712,36 @@ Erst dann starten die automatischen TICKs mit Bildern!""" if old_settings: termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings) + def _start_new_chat_with_instructions(self): + """Startet einen neuen Chat und sendet die Instruktionen erneut""" + console.print("\n[yellow]Starte neuen Chat...[/yellow]") + + # Heartbeat pausieren + self._claude_ready.clear() + + with self._lock: + new_url, old_chat_id = self.chat.start_new_chat() + + if new_url: + console.print(f"[green]Neuer Chat: {new_url}[/green]") + if old_chat_id: + console.print(f"[dim]Vorheriger Chat: {old_chat_id}[/dim]") + + # Neue URL in config.yaml speichern + self._save_chat_url_to_config(new_url) + + console.print("[cyan]Sende Instruktionen mit Referenz zum alten Chat...[/cyan]") + + # Instruktionen erneut senden (mit Referenz zum alten Chat) + if self._send_start_signal(previous_chat_id=old_chat_id): + console.print("[bold green]Claude ist bereit! Heartbeat läuft weiter.[/bold green]\n") + else: + console.print("[red]Claude hat nicht mit [READY] geantwortet.[/red]") + else: + console.print("[red]Konnte keinen neuen Chat starten![/red]") + # Heartbeat wieder aktivieren + self._claude_ready.set() + def _get_and_clear_stefan_buffer(self) -> Optional[str]: """ Holt den Stefan-Buffer und leert ihn. diff --git a/python_bridge/chat_web_interface.py b/python_bridge/chat_web_interface.py index 70ffe97..ced5773 100644 --- a/python_bridge/chat_web_interface.py +++ b/python_bridge/chat_web_interface.py @@ -11,6 +11,7 @@ wenn Claude.ai sein UI ändert. import time import logging import tempfile +import hashlib import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry @@ -50,6 +51,26 @@ class ClaudeChatInterface: WICHTIG: Du musst beim ersten Start manuell einloggen! """ + @staticmethod + def extract_chat_id(url: str) -> Optional[str]: + """ + Extrahiert die Chat-ID aus einer Claude.ai URL. + + Args: + url: z.B. "https://claude.ai/chat/21ac7549-1009-44cc-a143-3e4bd3c64b2d" + + Returns: + Chat-ID z.B. "21ac7549-1009-44cc-a143-3e4bd3c64b2d", oder None + """ + if not url or '/chat/' not in url: + return None + try: + # Extrahiere alles nach /chat/ + chat_id = url.split('/chat/')[-1].split('?')[0].split('#')[0] + return chat_id if chat_id else None + except: + return None + # CSS Selektoren für Claude.ai (Stand: Dezember 2025) # Diese müssen angepasst werden wenn sich das UI ändert! SELECTORS = { @@ -106,6 +127,10 @@ class ClaudeChatInterface: self._last_message_id = 0 self._temp_image_path = Path(tempfile.gettempdir()) / "robot_view.jpg" + # Für Bild-Deduplizierung (100 Bilder pro Chat Limit!) + self._last_image_hash: Optional[str] = None + self._images_uploaded = 0 + # HTTP Session mit größerem Connection Pool (vermeidet "pool full" Warnungen) self._http_session = requests.Session() adapter = HTTPAdapter(pool_connections=10, pool_maxsize=10) @@ -162,6 +187,10 @@ class ClaudeChatInterface: self.driver.get(url) self.chat_url = url + # Reset Bild-Counter bei neuem Chat + self._last_image_hash = None + self._images_uploaded = 0 + # Warte auf Seitenladung time.sleep(3) @@ -180,6 +209,76 @@ class ClaudeChatInterface: logger.info("Login erfolgreich!") time.sleep(2) + def start_new_chat(self) -> tuple[Optional[str], Optional[str]]: + """ + Startet einen neuen Chat auf Claude.ai. + + WICHTIG: Nutze dies wenn das 100-Bilder-Limit erreicht ist! + + Returns: + Tuple (neue_url, alte_chat_id) oder (None, None) bei Fehler + """ + try: + # Alte Chat-ID merken bevor wir wechseln + old_chat_id = self.extract_chat_id(self.chat_url) + logger.info(f"Starte neuen Chat... (alter Chat: {old_chat_id})") + + # Methode 1: "New Chat" Button suchen + new_chat_selectors = [ + "button[aria-label*='New']", + "button[aria-label*='new']", + "a[href='/new']", + "a[href*='/chat/new']", + "[data-testid*='new-chat']", + "button:has-text('New chat')", + ] + + for selector in new_chat_selectors: + try: + elements = self.driver.find_elements(By.CSS_SELECTOR, selector) + for elem in elements: + if elem.is_displayed(): + elem.click() + time.sleep(2) + new_url = self.driver.current_url + logger.info(f"Neuer Chat gestartet: {new_url}") + + # Reset Counter + self._last_image_hash = None + self._images_uploaded = 0 + self.chat_url = new_url + + return new_url, old_chat_id + except: + continue + + # Methode 2: Direkt zu /new navigieren + base_url = self.chat_url.split('/chat/')[0] if '/chat/' in self.chat_url else "https://claude.ai" + new_url = f"{base_url}/new" + logger.info(f"Versuche direkte Navigation zu: {new_url}") + self.driver.get(new_url) + time.sleep(3) + + # Prüfe ob es geklappt hat + current = self.driver.current_url + if current != self.chat_url: + logger.info(f"Neuer Chat: {current}") + self._last_image_hash = None + self._images_uploaded = 0 + self.chat_url = current + return current, old_chat_id + + logger.warning("Konnte keinen neuen Chat starten") + return None, None + + except Exception as e: + logger.error(f"Fehler beim Starten eines neuen Chats: {e}") + return None, None + + def get_current_chat_url(self) -> str: + """Gibt die aktuelle Chat-URL zurück""" + return self.driver.current_url + def send_message(self, text: str, wait_for_response: bool = False) -> bool: """ Sendet eine Nachricht in den Chat. @@ -680,6 +779,7 @@ class ClaudeChatInterface: file_input.send_keys(str(self._temp_image_path.absolute())) logger.info("Bild hochgeladen!") + self._images_uploaded += 1 # Kurz warten bis Upload verarbeitet ist time.sleep(1.5) @@ -690,6 +790,131 @@ class ClaudeChatInterface: logger.error(f"Fehler beim Bild-Upload: {e}") return False + def _calculate_image_hash(self) -> Optional[str]: + """ + Berechnet einen Hash des aktuellen Bildes. + + Returns: + MD5 Hash als Hex-String, oder None bei Fehler + """ + if not self._temp_image_path.exists(): + return None + + try: + with open(self._temp_image_path, "rb") as f: + return hashlib.md5(f.read()).hexdigest() + except Exception as e: + logger.debug(f"Hash-Berechnung fehlgeschlagen: {e}") + return None + + def has_image_changed(self) -> bool: + """ + Prüft ob sich das Bild seit dem letzten Upload geändert hat. + + WICHTIG: Claude.ai hat ein Limit von 100 Dateien pro Chat! + Diese Funktion hilft, unnötige Uploads zu vermeiden. + + Returns: + True wenn Bild neu/geändert, False wenn identisch zum letzten + """ + current_hash = self._calculate_image_hash() + + if current_hash is None: + return True # Kein Bild = als "geändert" behandeln + + if self._last_image_hash is None: + return True # Erster Upload + + return current_hash != self._last_image_hash + + def upload_image_if_changed(self) -> bool: + """ + Lädt Bild nur hoch wenn es sich geändert hat. + + Returns: + True wenn hochgeladen, False wenn übersprungen oder Fehler + """ + if not self.has_image_changed(): + logger.debug("Bild unverändert - überspringe Upload") + return False + + # Hash vor dem Upload speichern + new_hash = self._calculate_image_hash() + + if self.upload_image_to_chat(): + self._last_image_hash = new_hash + return True + + return False + + def delete_uploaded_images(self) -> int: + """ + Versucht hochgeladene Bilder aus dem Chat-Eingabefeld zu löschen. + + HINWEIS: Funktioniert nur für Bilder die noch nicht gesendet wurden! + Bereits gesendete Bilder können nicht gelöscht werden. + + Returns: + Anzahl gelöschter Bilder + """ + deleted = 0 + + try: + # Suche nach Bild-Vorschau-Elementen im Eingabebereich + # Diese haben meist einen X/Close Button + preview_selectors = [ + "button[aria-label*='Remove']", + "button[aria-label*='remove']", + "button[aria-label*='Delete']", + "button[aria-label*='delete']", + "[class*='preview'] button[class*='close']", + "[class*='attachment'] button[class*='remove']", + "[class*='file'] button[class*='delete']", + ] + + for selector in preview_selectors: + try: + buttons = self.driver.find_elements(By.CSS_SELECTOR, selector) + for btn in buttons: + if btn.is_displayed(): + btn.click() + deleted += 1 + time.sleep(0.3) + except: + continue + + # JavaScript-Fallback + if deleted == 0: + try: + deleted = self.driver.execute_script(""" + let count = 0; + // Suche nach Remove-Buttons in Attachment-Previews + const buttons = document.querySelectorAll( + '[class*="preview"] button, [class*="attachment"] button' + ); + buttons.forEach(btn => { + if (btn.offsetParent) { + btn.click(); + count++; + } + }); + return count; + """) or 0 + except: + pass + + if deleted > 0: + logger.info(f"{deleted} Bild(er) aus Eingabefeld gelöscht") + + except Exception as e: + logger.debug(f"Bild-Löschung fehlgeschlagen: {e}") + + return deleted + + def get_images_uploaded_count(self) -> int: + """Gibt Anzahl der in dieser Session hochgeladenen Bilder zurück""" + return self._images_uploaded + def _find_file_input(self): """Findet das File-Upload Input Element""" selectors = [