keyboard_input.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638
  1. # -*- coding: utf-8 -*-
  2. """
  3. Keyboard Input Service.
  4. Ermöglicht Text-Eingabe über die Konsole als Alternative zur Spracheingabe.
  5. Cross-platform: Windows, Linux, macOS, Raspberry Pi OS.
  6. Features:
  7. - Ctrl+I zum Öffnen des Text-Inputs
  8. - Automatische Wiederöffnung bei Follow-Up-Fragen
  9. - Integration mit Conversation-System
  10. - Sendet TextInput Command an Server
  11. Benötigt: pip install prompt_toolkit
  12. """
  13. import asyncio
  14. import sys
  15. import uuid
  16. from typing import TYPE_CHECKING, Callable, Awaitable
  17. from trixy_core.network.cmd import (
  18. TextInput, FollowUpRequest, TextInputAck,
  19. MusicPlayPause, MusicNext, MusicStop, MediaStopAll,
  20. )
  21. from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn
  22. if TYPE_CHECKING:
  23. from trixy_core.application import IApplication
  24. from trixy_core.network.client_connection import ClientConnection
  25. # Lazy import für prompt_toolkit
  26. _prompt_toolkit_available = None
  27. def _check_prompt_toolkit() -> bool:
  28. """Prüft ob prompt_toolkit verfügbar ist."""
  29. global _prompt_toolkit_available
  30. if _prompt_toolkit_available is None:
  31. try:
  32. import prompt_toolkit
  33. _prompt_toolkit_available = True
  34. except ImportError:
  35. _prompt_toolkit_available = False
  36. return _prompt_toolkit_available
  37. class KeyboardInputService:
  38. """
  39. Service für Keyboard-Eingabe mit Ctrl+I Trigger.
  40. Ermöglicht Text-Eingabe als Alternative zu Sprache.
  41. Integriert sich mit dem Conversation-System.
  42. Features:
  43. - Ctrl+I öffnet Text-Input-Prompt
  44. - Enter sendet Text an Server
  45. - Automatische Wiederöffnung bei Follow-Ups
  46. - Conversation-Tracking
  47. Usage:
  48. service = KeyboardInputService(app, connection)
  49. await service.start()
  50. # Drücke Ctrl+I für Eingabe
  51. """
  52. def __init__(
  53. self,
  54. application: "IApplication",
  55. connection: "ClientConnection | None" = None,
  56. prompt: str = ">>> ",
  57. ) -> None:
  58. """
  59. Initialisiert den Keyboard-Input-Service.
  60. Args:
  61. application: Die Trixy-Anwendung
  62. connection: Client-Verbindung zum Server (für Client-Modus)
  63. prompt: Eingabe-Prompt
  64. """
  65. self._app = application
  66. self._connection = connection
  67. self._prompt = prompt
  68. self._running = False
  69. self._input_active = False
  70. # Tasks
  71. self._key_listener_task: asyncio.Task | None = None
  72. self._input_task: asyncio.Task | None = None
  73. # Conversation State
  74. self._current_session_id: str = ""
  75. self._awaiting_follow_up = False
  76. self._follow_up_number = 0
  77. self._follow_up_question = ""
  78. # Event für Input-Trigger
  79. self._input_requested = asyncio.Event()
  80. @property
  81. def is_running(self) -> bool:
  82. """Läuft der Service?"""
  83. return self._running
  84. @property
  85. def has_active_session(self) -> bool:
  86. """Gibt es eine aktive Session?"""
  87. return bool(self._current_session_id)
  88. def set_connection(self, connection: "ClientConnection") -> None:
  89. """Setzt die Server-Verbindung."""
  90. self._connection = connection
  91. async def start(self) -> bool:
  92. """
  93. Startet den Keyboard-Input-Service.
  94. Returns:
  95. True wenn erfolgreich gestartet
  96. """
  97. if not _check_prompt_toolkit():
  98. pwarn(
  99. "Keyboard-Input (Ctrl+I) deaktiviert: prompt_toolkit nicht installiert. "
  100. "Installieren mit: pip install prompt_toolkit"
  101. )
  102. return False
  103. if self._running:
  104. return True
  105. self._running = True
  106. # Key-Listener starten
  107. self._key_listener_task = asyncio.create_task(self._key_listener_loop())
  108. # Input-Handler starten
  109. self._input_task = asyncio.create_task(self._input_handler_loop())
  110. pinfo("Keyboard-Shortcuts: Ctrl+I=Eingabe, Ctrl+P=Play/Pause, Ctrl+N=Next, Ctrl+S=Stop")
  111. return True
  112. async def stop(self) -> None:
  113. """Stoppt den Keyboard-Input-Service."""
  114. self._running = False
  115. self._input_requested.set() # Unblock waiting
  116. for task in [self._key_listener_task, self._input_task]:
  117. if task and not task.done():
  118. task.cancel()
  119. try:
  120. await task
  121. except asyncio.CancelledError:
  122. pass
  123. pdebug("Keyboard-Input gestoppt")
  124. async def request_input(self, follow_up_question: str = "", session_id: str = "") -> None:
  125. """
  126. Fordert eine Texteingabe an (z.B. für Follow-Up).
  127. Args:
  128. follow_up_question: Die Follow-Up-Frage (falls vorhanden)
  129. session_id: Session-ID für die Fortsetzung
  130. """
  131. if follow_up_question:
  132. self._awaiting_follow_up = True
  133. self._follow_up_question = follow_up_question
  134. self._follow_up_number += 1
  135. if session_id:
  136. self._current_session_id = session_id
  137. pdebug(f"Follow-Up Input angefordert: {follow_up_question[:50]}...")
  138. else:
  139. self._awaiting_follow_up = False
  140. self._follow_up_question = ""
  141. self._follow_up_number = 0
  142. self._current_session_id = ""
  143. self._input_requested.set()
  144. def handle_follow_up_request(self, request: FollowUpRequest) -> None:
  145. """
  146. Behandelt eine Follow-Up-Anfrage vom Server.
  147. Args:
  148. request: Die Follow-Up-Anfrage
  149. """
  150. asyncio.create_task(self.request_input(
  151. follow_up_question=request.question,
  152. session_id=request.session_id,
  153. ))
  154. def handle_conversation_end(self, session_id: str) -> None:
  155. """
  156. Behandelt das Ende einer Conversation.
  157. Args:
  158. session_id: Die Session-ID
  159. """
  160. if session_id == self._current_session_id:
  161. self._current_session_id = ""
  162. self._awaiting_follow_up = False
  163. self._follow_up_question = ""
  164. self._follow_up_number = 0
  165. pdebug("Conversation beendet")
  166. async def _key_listener_loop(self) -> None:
  167. """
  168. Lauscht auf Tastenkombinationen.
  169. Unterstützte Shortcuts:
  170. - Ctrl+I / Tab: Text-Eingabe öffnen
  171. - Ctrl+P: Musik Play/Pause
  172. - Ctrl+N: Nächstes Lied
  173. - Ctrl+S: Alles stoppen (Musik + TTS)
  174. - Ctrl+C: Anwendung beenden
  175. """
  176. from prompt_toolkit.input import create_input
  177. from prompt_toolkit.keys import Keys
  178. try:
  179. # Input erstellen
  180. inp = create_input()
  181. with inp.raw_mode():
  182. while self._running:
  183. # Auf Tastendruck warten
  184. keys = await asyncio.get_event_loop().run_in_executor(
  185. None, inp.read_keys
  186. )
  187. for key_press in keys:
  188. # Ctrl+I = Tab im Terminal -> Text-Eingabe
  189. if key_press.key == Keys.ControlI or key_press.key == Keys.Tab:
  190. if not self._input_active:
  191. self._input_requested.set()
  192. # Ctrl+P -> Musik Play/Pause
  193. elif key_press.key == Keys.ControlP:
  194. await self._send_music_play_pause()
  195. # Ctrl+N -> Nächstes Lied
  196. elif key_press.key == Keys.ControlN:
  197. await self._send_music_next()
  198. # Ctrl+S -> Alles stoppen (Musik + TTS)
  199. elif key_press.key == Keys.ControlS:
  200. await self._send_media_stop_all()
  201. # Ctrl+C -> Anwendung beenden
  202. elif key_press.key == Keys.ControlC:
  203. pinfo("Ctrl+C empfangen, beende Anwendung...")
  204. self._app.shutdown()
  205. return
  206. except asyncio.CancelledError:
  207. pass
  208. except Exception as e:
  209. # Fallback: Einfacher Modus ohne Shortcuts
  210. pdebug(f"Key-Listener-Fehler: {e}, wechsle zu einfachem Modus")
  211. await self._simple_key_listener()
  212. async def _simple_key_listener(self) -> None:
  213. """Einfacher Key-Listener als Fallback."""
  214. # Im einfachen Modus: Drücke Enter für Eingabe
  215. pinfo("Einfacher Modus: Drücke Enter für Texteingabe")
  216. loop = asyncio.get_running_loop()
  217. while self._running:
  218. try:
  219. # Warte auf Enter
  220. await loop.run_in_executor(None, sys.stdin.readline)
  221. if not self._input_active:
  222. self._input_requested.set()
  223. except asyncio.CancelledError:
  224. break
  225. except Exception:
  226. await asyncio.sleep(0.1)
  227. async def _input_handler_loop(self) -> None:
  228. """Hauptschleife für Input-Handling."""
  229. from prompt_toolkit import PromptSession
  230. from prompt_toolkit.history import InMemoryHistory
  231. from prompt_toolkit.patch_stdout import patch_stdout
  232. history = InMemoryHistory()
  233. session: PromptSession = PromptSession(history=history)
  234. while self._running:
  235. try:
  236. # Warte auf Input-Request
  237. await self._input_requested.wait()
  238. self._input_requested.clear()
  239. if not self._running:
  240. break
  241. self._input_active = True
  242. # Prompt vorbereiten
  243. if self._awaiting_follow_up and self._follow_up_question:
  244. # Follow-Up: Frage anzeigen
  245. print(f"\n[Rückfrage] {self._follow_up_question}")
  246. prompt = f"Antwort> "
  247. else:
  248. # Neue Eingabe
  249. print() # Neue Zeile
  250. prompt = self._prompt
  251. # Text einlesen
  252. with patch_stdout():
  253. text = await session.prompt_async(prompt)
  254. self._input_active = False
  255. if not text or not text.strip():
  256. continue
  257. text = text.strip()
  258. pdebug(f"Keyboard-Input: {text}")
  259. # Text verarbeiten
  260. await self._process_input(text)
  261. except EOFError:
  262. # Ctrl+D - Eingabe abbrechen
  263. self._input_active = False
  264. continue
  265. except KeyboardInterrupt:
  266. # Ctrl+C - Ignorieren
  267. self._input_active = False
  268. continue
  269. except asyncio.CancelledError:
  270. break
  271. except Exception as e:
  272. perror(f"Fehler in Input-Handler: {e}")
  273. self._input_active = False
  274. await asyncio.sleep(0.1)
  275. async def _process_input(self, text: str) -> None:
  276. """
  277. Verarbeitet die Texteingabe.
  278. Args:
  279. text: Der eingegebene Text
  280. """
  281. # Session-ID generieren falls neue Conversation
  282. if not self._current_session_id:
  283. self._current_session_id = f"kb-{uuid.uuid4().hex[:8]}"
  284. # Room und Alias ermitteln
  285. room = getattr(self._app, "room", "local")
  286. alias = getattr(self._app, "alias", "keyboard")
  287. if self._connection and self._connection.is_connected:
  288. # Client-Modus: TextInput Command an Server senden
  289. cmd = TextInput(
  290. satellite_id=alias,
  291. text=text,
  292. session_id=self._current_session_id,
  293. room_id=room,
  294. language="de",
  295. is_follow_up=self._awaiting_follow_up,
  296. follow_up_number=self._follow_up_number,
  297. )
  298. success = await self._connection.send_command(cmd)
  299. if success:
  300. pdebug(f"TextInput gesendet: session={self._current_session_id}")
  301. else:
  302. perror("Fehler beim Senden des TextInput Commands")
  303. # Follow-Up zurücksetzen (wird bei neuer Anfrage wieder gesetzt)
  304. self._awaiting_follow_up = False
  305. self._follow_up_question = ""
  306. else:
  307. # Standalone-Modus: Direkt Event auslösen
  308. from trixy_core.events.event_data.basic import SpeechRecognized
  309. event_data = SpeechRecognized(
  310. text=text,
  311. room_id=room,
  312. satellite_id=alias,
  313. confidence=1.0,
  314. language="de",
  315. is_final=True,
  316. source="keyboard",
  317. )
  318. await self._app.events.trigger("speech_recognized", event_data)
  319. # Follow-Up zurücksetzen
  320. self._awaiting_follow_up = False
  321. self._follow_up_question = ""
  322. async def _send_music_play_pause(self) -> None:
  323. """Toggle Musik Play/Pause."""
  324. alias = getattr(self._app, "alias", "keyboard")
  325. if self._connection and self._connection.is_connected:
  326. # Client-Modus: Command an Server senden
  327. cmd = MusicPlayPause(satellite_id=alias)
  328. success = await self._connection.send_command(cmd)
  329. if success:
  330. pdebug("MusicPlayPause Command an Server gesendet")
  331. else:
  332. perror("Fehler beim Senden des MusicPlayPause Commands")
  333. else:
  334. # Standalone-Modus: Lokale Wiedergabe steuern
  335. audio_manager = getattr(self._app, "audio_manager", None)
  336. if audio_manager:
  337. is_paused = audio_manager.toggle_music_pause()
  338. status = "pausiert" if is_paused else "fortgesetzt"
  339. pinfo(f"Musik {status}")
  340. event_name = "music_paused" if is_paused else "music_resumed"
  341. await self._app.events.emit(event_name, {"satellite_id": alias})
  342. else:
  343. await self._app.events.emit("music_play_pause", {"satellite_id": alias})
  344. pdebug("MusicPlayPause Event ausgelöst")
  345. async def _send_music_next(self) -> None:
  346. """Nächstes Lied - mit Crossfade wenn aktiviert."""
  347. alias = getattr(self._app, "alias", "keyboard")
  348. if self._connection and self._connection.is_connected:
  349. # Client-Modus: Command an Server senden
  350. cmd = MusicNext(satellite_id=alias, use_crossfade=True)
  351. success = await self._connection.send_command(cmd)
  352. if success:
  353. pdebug("MusicNext Command an Server gesendet")
  354. else:
  355. perror("Fehler beim Senden des MusicNext Commands")
  356. else:
  357. # Standalone-Modus: Lokales Event auslösen
  358. await self._app.events.emit(
  359. "music_next", {"satellite_id": alias, "use_crossfade": True}
  360. )
  361. pdebug("MusicNext Event ausgelöst")
  362. async def _send_media_stop_all(self) -> None:
  363. """Stoppt alle Medien-Wiedergabe (Musik + TTS)."""
  364. alias = getattr(self._app, "alias", "keyboard")
  365. # Lokale Wiedergabe sofort stoppen (behebt ALSA underrun)
  366. audio_manager = getattr(self._app, "audio_manager", None)
  367. if audio_manager:
  368. audio_manager.stop_all()
  369. pinfo("Lokale Wiedergabe gestoppt")
  370. if self._connection and self._connection.is_connected:
  371. # Client-Modus: Command an Server senden
  372. cmd = MediaStopAll(satellite_id=alias)
  373. success = await self._connection.send_command(cmd)
  374. if success:
  375. pdebug("MediaStopAll Command an Server gesendet")
  376. else:
  377. perror("Fehler beim Senden des MediaStopAll Commands")
  378. else:
  379. # Standalone-Modus: Lokale Wiedergabe stoppen
  380. audio_manager = getattr(self._app, "audio_manager", None)
  381. if audio_manager:
  382. audio_manager.stop_all()
  383. pinfo("Wiedergabe gestoppt")
  384. await self._app.events.emit("media_stopped", {"satellite_id": alias})
  385. else:
  386. await self._app.events.emit("music_stop", {"satellite_id": alias})
  387. await self._app.events.emit("tts_stop", {"satellite_id": alias})
  388. pinfo("Wiedergabe gestoppt")
  389. class SimpleKeyboardInput:
  390. """
  391. Einfache Keyboard-Input-Klasse ohne prompt_toolkit.
  392. Fallback wenn prompt_toolkit nicht installiert ist.
  393. Kein Ctrl+I Support - nur Enter für Eingabe.
  394. """
  395. def __init__(
  396. self,
  397. application: "IApplication",
  398. connection: "ClientConnection | None" = None,
  399. prompt: str = ">>> ",
  400. ) -> None:
  401. """Initialisiert SimpleKeyboardInput."""
  402. self._app = application
  403. self._connection = connection
  404. self._prompt = prompt
  405. self._running = False
  406. self._task: asyncio.Task | None = None
  407. # Conversation State
  408. self._current_session_id: str = ""
  409. self._awaiting_follow_up = False
  410. self._follow_up_number = 0
  411. self._follow_up_question = ""
  412. def set_connection(self, connection: "ClientConnection") -> None:
  413. """Setzt die Server-Verbindung."""
  414. self._connection = connection
  415. async def start(self) -> bool:
  416. """Startet den Input-Service."""
  417. if self._running:
  418. return True
  419. self._running = True
  420. self._task = asyncio.create_task(self._input_loop())
  421. pinfo("Keyboard-Input aktiviert (Enter für Texteingabe)")
  422. return True
  423. async def stop(self) -> None:
  424. """Stoppt den Input-Service."""
  425. self._running = False
  426. if self._task and not self._task.done():
  427. self._task.cancel()
  428. try:
  429. await self._task
  430. except asyncio.CancelledError:
  431. pass
  432. async def request_input(self, follow_up_question: str = "", session_id: str = "") -> None:
  433. """Fordert eine Texteingabe an."""
  434. if follow_up_question:
  435. self._awaiting_follow_up = True
  436. self._follow_up_question = follow_up_question
  437. self._follow_up_number += 1
  438. if session_id:
  439. self._current_session_id = session_id
  440. print(f"\n[Rückfrage] {follow_up_question}")
  441. def handle_follow_up_request(self, request: FollowUpRequest) -> None:
  442. """Behandelt eine Follow-Up-Anfrage."""
  443. asyncio.create_task(self.request_input(
  444. follow_up_question=request.question,
  445. session_id=request.session_id,
  446. ))
  447. def handle_conversation_end(self, session_id: str) -> None:
  448. """Behandelt das Ende einer Conversation."""
  449. if session_id == self._current_session_id:
  450. self._current_session_id = ""
  451. self._awaiting_follow_up = False
  452. self._follow_up_question = ""
  453. self._follow_up_number = 0
  454. async def _input_loop(self) -> None:
  455. """Hauptschleife für einfachen Input."""
  456. loop = asyncio.get_running_loop()
  457. while self._running:
  458. try:
  459. # Prompt
  460. if self._awaiting_follow_up:
  461. prompt = "Antwort> "
  462. else:
  463. prompt = self._prompt
  464. # Input in Thread ausführen
  465. text = await loop.run_in_executor(None, lambda: input(prompt))
  466. if not text or not text.strip():
  467. continue
  468. text = text.strip()
  469. await self._process_input(text)
  470. except EOFError:
  471. break
  472. except KeyboardInterrupt:
  473. continue
  474. except asyncio.CancelledError:
  475. break
  476. except Exception as e:
  477. perror(f"Fehler in Input-Loop: {e}")
  478. await asyncio.sleep(0.1)
  479. async def _process_input(self, text: str) -> None:
  480. """Verarbeitet die Texteingabe."""
  481. if not self._current_session_id:
  482. self._current_session_id = f"kb-{uuid.uuid4().hex[:8]}"
  483. room = getattr(self._app, "room", "local")
  484. alias = getattr(self._app, "alias", "keyboard")
  485. if self._connection and self._connection.is_connected:
  486. cmd = TextInput(
  487. satellite_id=alias,
  488. text=text,
  489. session_id=self._current_session_id,
  490. room_id=room,
  491. language="de",
  492. is_follow_up=self._awaiting_follow_up,
  493. follow_up_number=self._follow_up_number,
  494. )
  495. await self._connection.send_command(cmd)
  496. else:
  497. from trixy_core.events.event_data.basic import SpeechRecognized
  498. event_data = SpeechRecognized(
  499. text=text,
  500. room_id=room,
  501. satellite_id=alias,
  502. confidence=1.0,
  503. language="de",
  504. is_final=True,
  505. source="keyboard",
  506. )
  507. await self._app.events.trigger("speech_recognized", event_data)
  508. self._awaiting_follow_up = False
  509. self._follow_up_question = ""
  510. def create_keyboard_input(
  511. application: "IApplication",
  512. connection: "ClientConnection | None" = None,
  513. prompt: str = ">>> ",
  514. ) -> KeyboardInputService | SimpleKeyboardInput:
  515. """
  516. Factory-Funktion für Keyboard-Input.
  517. Wählt automatisch die beste verfügbare Implementierung.
  518. Args:
  519. application: Die Trixy-Anwendung
  520. connection: Server-Verbindung (für Client-Modus)
  521. prompt: Eingabe-Prompt
  522. Returns:
  523. KeyboardInputService oder SimpleKeyboardInput
  524. """
  525. if _check_prompt_toolkit():
  526. return KeyboardInputService(application, connection, prompt)
  527. else:
  528. pwarn("prompt_toolkit nicht gefunden, nutze einfachen Input-Modus")
  529. return SimpleKeyboardInput(application, connection, prompt)