neuer chat auf grund 100 dateien in chat implementiert und check ob bilder doppelt

This commit is contained in:
duffyduck 2025-12-27 02:42:34 +01:00
parent 095385a4a5
commit 90791b9ff6
4 changed files with 355 additions and 12 deletions

View File

@ -177,9 +177,12 @@ Claude verwendet diese Befehle in eckigen Klammern:
| Taste | Funktion | | Taste | Funktion |
|-------|----------| |-------|----------|
| **M** | Mikrofon Mute/Unmute | | **M** | Mikrofon Mute/Unmute |
| **N** | Neuer Chat (bei 100-Bilder-Limit) |
| **Q** | Bridge beenden | | **Q** | Bridge beenden |
| **Ctrl+C** | 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 ## Sicherheit

View File

@ -234,10 +234,16 @@ python chat_audio_bridge.py -c config.local.yaml
| Taste | Funktion | | Taste | Funktion |
|-------|----------| |-------|----------|
| **M** | Mikrofon Mute/Unmute - schaltet STT stumm | | **M** | Mikrofon Mute/Unmute - schaltet STT stumm |
| **N** | Neuer Chat starten (bei Bilder-Limit) |
| **Q** | Bridge beenden | | **Q** | Bridge beenden |
| **Ctrl+C** | 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!)
--- ---

View File

@ -94,9 +94,11 @@ class ClaudesEyesAudioBridge:
""" """
def __init__(self, config_path: str): def __init__(self, config_path: str):
self.config_path = Path(config_path)
self.config = self._load_config(config_path) self.config = self._load_config(config_path)
self.running = False self.running = False
self.stats = BridgeStats() self.stats = BridgeStats()
self._previous_chat_id: Optional[str] = None # Für Referenz zum alten Chat
# Komponenten (werden in initialize() erstellt) # Komponenten (werden in initialize() erstellt)
self.chat: Optional[ClaudeChatInterface] = None self.chat: Optional[ClaudeChatInterface] = None
@ -119,7 +121,8 @@ class ClaudesEyesAudioBridge:
self._sending.set() # Anfangs nicht am Senden (set = frei) self._sending.set() # Anfangs nicht am Senden (set = frei)
# Mute-Flag: Wenn True, ignoriert STT alle Eingaben # 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() self._mute_lock = threading.Lock()
def _load_config(self, config_path: str) -> dict: def _load_config(self, config_path: str) -> dict:
@ -132,6 +135,9 @@ class ClaudesEyesAudioBridge:
path = local_path path = local_path
logger.info(f"Nutze lokale Config: {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(): if not path.exists():
logger.error(f"Config nicht gefunden: {path}") logger.error(f"Config nicht gefunden: {path}")
sys.exit(1) sys.exit(1)
@ -139,6 +145,48 @@ class ClaudesEyesAudioBridge:
with open(path, 'r', encoding='utf-8') as f: with open(path, 'r', encoding='utf-8') as f:
return yaml.safe_load(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: def initialize(self) -> bool:
"""Initialisiert alle Komponenten""" """Initialisiert alle Komponenten"""
@ -266,7 +314,8 @@ class ClaudesEyesAudioBridge:
threads.append(t4) threads.append(t4)
console.print("[cyan]Bridge läuft![/cyan]") 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] # Sende Startsignal an Claude und warte auf [READY]
if not self._send_start_signal(): if not self._send_start_signal():
@ -314,17 +363,30 @@ class ClaudesEyesAudioBridge:
console.print("\n[green]Bridge beendet.[/green]") 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. Sendet das Startsignal und die Instruktionen an Claude.
Args:
previous_chat_id: Optional - Chat-ID des vorherigen Chats (für Kontinuität)
Returns: Returns:
True wenn Claude mit [READY] antwortet True wenn Claude mit [READY] antwortet
""" """
# Instruktionen für Claude # Kontext vom vorherigen Chat, falls vorhanden
intro_message = """[START] Hallo Claude! Du steuerst jetzt einen echten Roboter - "Claude's Eyes"! 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: Du hast Zugriff auf einen ESP32-Roboter mit:
- **Kamera** (OV5640, 120° Weitwinkel) - deine Augen - **Kamera** (OV5640, 120° Weitwinkel) - deine Augen
- **4 Motoren** - deine Beine - **4 Motoren** - deine Beine
@ -458,12 +520,26 @@ Erst dann starten die automatischen TICKs mit Bildern!"""
try: try:
# Erst Bild hochladen wenn aktiviert # Erst Bild hochladen wenn aktiviert
image_uploaded = False
if upload_images: if upload_images:
# Bild holen und hochladen # Bild holen
if not self.chat.fetch_image_from_esp32(): if not self.chat.fetch_image_from_esp32():
logger.warning("Konnte kein Bild vom ESP32 holen") logger.warning("Konnte kein Bild vom ESP32 holen")
elif not self.chat.upload_image_to_chat(): else:
logger.warning("Konnte Bild nicht hochladen") # 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 # Nachricht zusammenbauen
if stefan_text: if stefan_text:
@ -589,17 +665,18 @@ Erst dann starten die automatischen TICKs mit Bildern!"""
def _keyboard_loop(self): def _keyboard_loop(self):
""" """
Hört auf Tastatureingaben für Mute-Toggle. Hört auf Tastatureingaben für Mute-Toggle und andere Befehle.
Tasten: Tasten:
- M: Mute/Unmute Toggle - M: Mute/Unmute Toggle
- N: Neuer Chat starten (bei Bilder-Limit)
- Q: Beenden (alternativ zu Ctrl+C) - Q: Beenden (alternativ zu Ctrl+C)
""" """
import sys import sys
import tty import tty
import termios 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 # Speichere ursprüngliche Terminal-Settings
old_settings = None old_settings = None
@ -621,6 +698,8 @@ Erst dann starten die automatischen TICKs mit Bildern!"""
if char.lower() == 'm': if char.lower() == 'm':
self.toggle_mute() self.toggle_mute()
elif char.lower() == 'n':
self._start_new_chat_with_instructions()
elif char.lower() == 'q' or char == '\x03': # q oder Ctrl+C elif char.lower() == 'q' or char == '\x03': # q oder Ctrl+C
console.print("\n[yellow]Beende Bridge...[/yellow]") console.print("\n[yellow]Beende Bridge...[/yellow]")
self.running = False self.running = False
@ -633,6 +712,36 @@ Erst dann starten die automatischen TICKs mit Bildern!"""
if old_settings: if old_settings:
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, 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]: def _get_and_clear_stefan_buffer(self) -> Optional[str]:
""" """
Holt den Stefan-Buffer und leert ihn. Holt den Stefan-Buffer und leert ihn.

View File

@ -11,6 +11,7 @@ wenn Claude.ai sein UI ändert.
import time import time
import logging import logging
import tempfile import tempfile
import hashlib
import requests import requests
from requests.adapters import HTTPAdapter from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry from urllib3.util.retry import Retry
@ -50,6 +51,26 @@ class ClaudeChatInterface:
WICHTIG: Du musst beim ersten Start manuell einloggen! 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) # CSS Selektoren für Claude.ai (Stand: Dezember 2025)
# Diese müssen angepasst werden wenn sich das UI ändert! # Diese müssen angepasst werden wenn sich das UI ändert!
SELECTORS = { SELECTORS = {
@ -106,6 +127,10 @@ class ClaudeChatInterface:
self._last_message_id = 0 self._last_message_id = 0
self._temp_image_path = Path(tempfile.gettempdir()) / "robot_view.jpg" 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) # HTTP Session mit größerem Connection Pool (vermeidet "pool full" Warnungen)
self._http_session = requests.Session() self._http_session = requests.Session()
adapter = HTTPAdapter(pool_connections=10, pool_maxsize=10) adapter = HTTPAdapter(pool_connections=10, pool_maxsize=10)
@ -162,6 +187,10 @@ class ClaudeChatInterface:
self.driver.get(url) self.driver.get(url)
self.chat_url = url self.chat_url = url
# Reset Bild-Counter bei neuem Chat
self._last_image_hash = None
self._images_uploaded = 0
# Warte auf Seitenladung # Warte auf Seitenladung
time.sleep(3) time.sleep(3)
@ -180,6 +209,76 @@ class ClaudeChatInterface:
logger.info("Login erfolgreich!") logger.info("Login erfolgreich!")
time.sleep(2) 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: def send_message(self, text: str, wait_for_response: bool = False) -> bool:
""" """
Sendet eine Nachricht in den Chat. Sendet eine Nachricht in den Chat.
@ -680,6 +779,7 @@ class ClaudeChatInterface:
file_input.send_keys(str(self._temp_image_path.absolute())) file_input.send_keys(str(self._temp_image_path.absolute()))
logger.info("Bild hochgeladen!") logger.info("Bild hochgeladen!")
self._images_uploaded += 1
# Kurz warten bis Upload verarbeitet ist # Kurz warten bis Upload verarbeitet ist
time.sleep(1.5) time.sleep(1.5)
@ -690,6 +790,131 @@ class ClaudeChatInterface:
logger.error(f"Fehler beim Bild-Upload: {e}") logger.error(f"Fehler beim Bild-Upload: {e}")
return False 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): def _find_file_input(self):
"""Findet das File-Upload Input Element""" """Findet das File-Upload Input Element"""
selectors = [ selectors = [