trainer_optional_view.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318
  1. # -*- coding: utf-8 -*-
  2. """
  3. Trainer SubView: Optional (F4).
  4. Zeigt optionale Konfigurationsfelder und Aktions-Buttons.
  5. Wird ausgeblendet wenn der Trainer kein optionales Schema liefert.
  6. """
  7. from __future__ import annotations
  8. import asyncio
  9. from textual.app import ComposeResult
  10. from textual.containers import Vertical, Horizontal
  11. from textual.widgets import Static, Input, Button, Checkbox
  12. from textual.timer import Timer
  13. from trixy_core.tui.views.base import MainView
  14. from trixy_core.tui.widgets.progress_dialog import ProgressDialog
  15. class TrainerOptionalView(MainView):
  16. """F4 im Trainer-Kontext: Optionale Daten + Aktionen."""
  17. TITLE = "Optional"
  18. VIEW_ID = "trainer_optional"
  19. def __init__(self, **kwargs) -> None:
  20. super().__init__(**kwargs)
  21. self._trainer_data: dict = {}
  22. self._detail_data: object | None = None
  23. self._has_schema: bool = False
  24. self._field_ids: list[str] = []
  25. self._multi_select_fields: dict[str, list[str]] = {} # name -> list of checkbox IDs
  26. self._multi_select_values: dict[str, list[tuple[str, str]]] = {} # name -> [(cb_id, value)]
  27. self._action_buttons: list[tuple[str, str]] = [] # (action_name, field_name)
  28. def set_trainer(self, trainer_data: dict) -> None:
  29. """Setzt die Trainer-Basisdaten."""
  30. self._trainer_data = trainer_data
  31. self._detail_data = None
  32. self._has_schema = False
  33. self._field_ids = []
  34. self._multi_select_fields = {}
  35. self._multi_select_values = {}
  36. self._action_buttons = []
  37. @property
  38. def has_schema(self) -> bool:
  39. """Ob ein optionales Schema vorhanden ist."""
  40. return self._has_schema
  41. def compose(self) -> ComposeResult:
  42. yield Static("", id="trainer-optional-header")
  43. yield Vertical(id="trainer-optional-form", classes="trainer-form-container")
  44. yield Static("", id="trainer-optional-status")
  45. async def on_activate(self) -> None:
  46. """Laedt optionales Schema."""
  47. await self._load_detail()
  48. async def _load_detail(self) -> None:
  49. """Laedt Detail-Daten und baut optional ein Formular auf."""
  50. if not self.connection or not self._trainer_data:
  51. return
  52. trainer_id = self._trainer_data.get("trainer_id", "")
  53. if not trainer_id:
  54. return
  55. try:
  56. detail = await self.connection.request_trainer_detail(trainer_id)
  57. except Exception:
  58. return
  59. if detail is None or detail.error:
  60. return
  61. self._detail_data = detail
  62. optional_schema = getattr(detail, "optional_schema", None)
  63. header = self.query_one("#trainer-optional-header", Static)
  64. if optional_schema is None:
  65. self._has_schema = False
  66. header.update("[dim]Keine optionalen Einstellungen fuer diesen Trainer[/dim]")
  67. return
  68. self._has_schema = True
  69. header.update("[bold]Optionale Einstellungen / Datengenerierung[/bold]")
  70. if not self._field_ids and not self._action_buttons and not self._multi_select_fields:
  71. await self._build_form(optional_schema)
  72. async def _build_form(self, schema: dict) -> None:
  73. """Baut das Formular aus dem optionalen Schema."""
  74. container = self.query_one("#trainer-optional-form", Vertical)
  75. for child in list(container.children):
  76. await child.remove()
  77. self._field_ids = []
  78. self._multi_select_fields = {}
  79. self._multi_select_values = {}
  80. self._action_buttons = []
  81. fields = schema.get("fields", []) if isinstance(schema, dict) else []
  82. for field_def in fields:
  83. name = field_def.get("name", "")
  84. label = field_def.get("label", name)
  85. help_text = field_def.get("help_text", "")
  86. default = field_def.get("default")
  87. field_type = field_def.get("field_type", "text")
  88. if field_type == "action":
  89. # Aktions-Button
  90. action_name = field_def.get("action_name", name)
  91. btn_id = f"trainer-action-{action_name}"
  92. self._action_buttons.append((action_name, name))
  93. variant = field_def.get("variant", "primary")
  94. btn = Button(label, id=btn_id, variant=variant)
  95. await container.mount(btn)
  96. if help_text:
  97. help_widget = Static(
  98. f"[dim]{help_text}[/dim]", classes="trainer-field-help"
  99. )
  100. await container.mount(help_widget)
  101. elif field_type == "multi_select":
  102. # Checkbox-Liste fuer Mehrfachauswahl
  103. options = field_def.get("options", [])
  104. defaults = default if isinstance(default, list) else []
  105. label_widget = Static(label, classes="trainer-field-label")
  106. await container.mount(label_widget)
  107. cb_ids = []
  108. cb_values = []
  109. for opt_idx, opt in enumerate(options):
  110. opt_value = opt.get("value", "") if isinstance(opt, dict) else str(opt)
  111. opt_label = opt.get("label", opt_value) if isinstance(opt, dict) else str(opt)
  112. cb_id = f"trainer-ms-{name}-{opt_idx}"
  113. is_checked = opt_value in defaults
  114. cb = Checkbox(opt_label, id=cb_id, value=is_checked)
  115. await container.mount(cb)
  116. cb_ids.append(cb_id)
  117. cb_values.append((cb_id, opt_value))
  118. self._multi_select_fields[name] = cb_ids
  119. self._multi_select_values[name] = cb_values
  120. if help_text:
  121. help_widget = Static(
  122. f"[dim]{help_text}[/dim]", classes="trainer-field-help"
  123. )
  124. await container.mount(help_widget)
  125. else:
  126. # Normales Eingabefeld
  127. field_id = f"trainer-opt-{name}"
  128. self._field_ids.append(field_id)
  129. label_widget = Static(label, classes="trainer-field-label")
  130. await container.mount(label_widget)
  131. widget = Input(
  132. id=field_id,
  133. value=str(default) if default is not None else "",
  134. placeholder=field_def.get("placeholder", ""),
  135. )
  136. await container.mount(widget)
  137. if help_text:
  138. help_widget = Static(
  139. f"[dim]{help_text}[/dim]", classes="trainer-field-help"
  140. )
  141. await container.mount(help_widget)
  142. def _collect_params(self) -> dict:
  143. """Sammelt alle Formularwerte als Parameter-Dict."""
  144. params = {}
  145. # Normale Input-Felder
  146. for field_id in self._field_ids:
  147. try:
  148. widget = self.query_one(f"#{field_id}", Input)
  149. name = field_id.replace("trainer-opt-", "", 1)
  150. params[name] = widget.value
  151. except Exception:
  152. pass
  153. # Multi-Select-Felder (Checkbox-Listen)
  154. for name, cb_values in self._multi_select_values.items():
  155. selected = []
  156. for cb_id, value in cb_values:
  157. try:
  158. cb = self.query_one(f"#{cb_id}", Checkbox)
  159. if cb.value:
  160. selected.append(value)
  161. except Exception:
  162. pass
  163. params[name] = selected
  164. return params
  165. # Aktionen die einen Progress-Dialog anzeigen sollen
  166. _PROGRESS_ACTIONS = {"generate_tts", "generate_negative", "generate_background_mix",
  167. "generate_from_raw"}
  168. async def on_button_pressed(self, event: Button.Pressed) -> None:
  169. """Behandelt Klicks auf Aktions-Buttons."""
  170. btn_id = event.button.id or ""
  171. if not btn_id.startswith("trainer-action-"):
  172. return
  173. action_name = btn_id.replace("trainer-action-", "", 1)
  174. trainer_id = self._trainer_data.get("trainer_id", "")
  175. if not trainer_id or not self.connection:
  176. return
  177. # Aktuelle Formularwerte als Parameter sammeln
  178. params = self._collect_params()
  179. if action_name in self._PROGRESS_ACTIONS:
  180. await self._run_with_progress(trainer_id, action_name, params)
  181. else:
  182. await self._run_simple(trainer_id, action_name, params)
  183. async def _run_with_progress(self, trainer_id: str, action_name: str, params: dict) -> None:
  184. """Fuehrt eine Aktion mit Progress-Dialog aus."""
  185. dialog = ProgressDialog(title="Generierung laeuft ...")
  186. self.app.push_screen(dialog)
  187. dialog.update_progress(0, 100, "Starte ...")
  188. # Aktion als Background-Task starten
  189. action_task = asyncio.create_task(
  190. self.connection.execute_trainer_action(trainer_id, action_name, params)
  191. )
  192. # Progress-Polling per Timer (alle 0.5s)
  193. poll_timer: Timer | None = None
  194. async def _poll_progress() -> None:
  195. if action_task.done() or dialog.cancelled:
  196. if poll_timer is not None:
  197. poll_timer.stop()
  198. return
  199. try:
  200. progress = await self.connection.execute_trainer_action(
  201. trainer_id, "get_generation_progress", {},
  202. )
  203. if progress and progress.data:
  204. current = progress.data.get("current", 0)
  205. total = progress.data.get("total", 0)
  206. msg = progress.data.get("message", "")
  207. if total > 0:
  208. dialog.update_progress(current, total, msg)
  209. except Exception:
  210. pass
  211. poll_timer = self.set_interval(0.5, _poll_progress)
  212. # Auf Ergebnis warten
  213. try:
  214. result = await action_task
  215. except Exception as e:
  216. result = None
  217. if not dialog.cancelled:
  218. dialog.show_error(f"Fehler: {e}")
  219. # Polling stoppen
  220. if poll_timer is not None:
  221. poll_timer.stop()
  222. if dialog.cancelled:
  223. return
  224. status = self.query_one("#trainer-optional-status", Static)
  225. if result and result.success:
  226. dialog.finish(result.message)
  227. status.update(f"[green]{result.message}[/green]")
  228. elif result:
  229. dialog.show_error(result.message)
  230. status.update(f"[red]{result.message}[/red]")
  231. else:
  232. if not dialog.cancelled:
  233. dialog.show_error("Keine Antwort vom Server")
  234. status.update("[red]Keine Antwort vom Server[/red]")
  235. async def _run_simple(self, trainer_id: str, action_name: str, params: dict) -> None:
  236. """Fuehrt eine einfache Aktion ohne Progress-Dialog aus."""
  237. status = self.query_one("#trainer-optional-status", Static)
  238. status.update(f"[yellow]Aktion '{action_name}' wird ausgefuehrt...[/yellow]")
  239. try:
  240. result = await self.connection.execute_trainer_action(
  241. trainer_id, action_name, params
  242. )
  243. if result and result.success:
  244. status.update(f"[green]{result.message}[/green]")
  245. # Nach install_*-Aktionen: Schema neu laden (Stimmen erscheinen)
  246. if action_name.startswith("install_"):
  247. refresh = getattr(result, "refresh_schema", False)
  248. if refresh or action_name.startswith("install_"):
  249. self._field_ids = []
  250. self._multi_select_fields = {}
  251. self._multi_select_values = {}
  252. self._action_buttons = []
  253. await self._load_detail()
  254. elif result:
  255. status.update(f"[red]{result.message}[/red]")
  256. else:
  257. status.update("[red]Keine Antwort vom Server[/red]")
  258. except Exception as e:
  259. status.update(f"[red]Fehler: {e}[/red]")