-
Notifications
You must be signed in to change notification settings - Fork 0
feat: improve compose UX and simplify writing prompts #53
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
72be6bc
500bac2
8b545c4
0639cb7
083b231
96174c8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -32,7 +32,11 @@ | |
|
|
||
| from app.config import Config, DEFAULTS, VALID_HOTKEY_KEYS | ||
| from app.llm_service import LLMService, WorkflowType, LLM_WORKFLOWS | ||
| from app.writing_presets import WRITING_PRESET_KEYS, get_preset, preset_index | ||
| from app.writing_presets import ( | ||
| CUSTOM_PRESET_KEY, | ||
| WRITING_PRESET_KEYS, | ||
| get_preset, | ||
| ) | ||
| from app.hotkey_service import HotkeyWorker, hotkey_display_name | ||
| from app.audio_recorder import AudioRecorder, AudioRecorderError | ||
| from app.transcribe import transcribe, TranscribeError | ||
|
|
@@ -286,8 +290,17 @@ def init_ui(self) -> None: | |
|
|
||
| self.combo_writing_preset = QComboBox() | ||
| for key in WRITING_PRESET_KEYS: | ||
| if key == CUSTOM_PRESET_KEY: | ||
| self.combo_writing_preset.insertSeparator( | ||
| self.combo_writing_preset.count() | ||
| ) | ||
| self.combo_writing_preset.addItem(t(f"preset.{key}.name"), key) | ||
| self.combo_writing_preset.setCurrentIndex(preset_index(self.config.writing_preset)) | ||
| selected_preset = self.combo_writing_preset.findData( | ||
| self.config.writing_preset | ||
| ) | ||
| self.combo_writing_preset.setCurrentIndex( | ||
| selected_preset if selected_preset >= 0 else 0 | ||
| ) | ||
|
|
||
| self.edit_compose_custom_preset = QPlainTextEdit() | ||
| self.edit_compose_custom_preset.setPlainText(self.config.compose_custom_preset_text) | ||
|
|
@@ -554,6 +567,7 @@ def __init__( | |
| autopaste: bool, | ||
| paste_service: PasteService, | ||
| custom_terms: Optional[list[str]] = None, | ||
| route_to_compose: bool = False, | ||
| ) -> None: | ||
| super().__init__() | ||
| self.signals = _WorkerSignals() | ||
|
|
@@ -566,6 +580,7 @@ def __init__( | |
| self.autopaste = autopaste | ||
| self.paste_service = paste_service | ||
| self.custom_terms = list(custom_terms or []) | ||
| self.route_to_compose = route_to_compose | ||
|
|
||
| def _emit(self, signal_name: str, *args) -> None: | ||
| try: | ||
|
|
@@ -587,19 +602,22 @@ def run(self) -> None: | |
| if not transcript or not transcript.strip(): | ||
| raise TranscribeError("Keine Sprache im Audio erkannt.") | ||
|
|
||
| # LLM rewrite if it is an LLM workflow. rewrite() meldet einen | ||
| # fehlenden API-Key selbst als LLMServiceError mit Env-Var-Hinweis. | ||
| if self.workflow in LLM_WORKFLOWS: | ||
| # Compose routing always receives the raw recognized text; the | ||
| # compose window owns any later rewrite workflow selected there. | ||
| # rewrite() reports a missing API key as LLMServiceError itself. | ||
| if self.workflow in LLM_WORKFLOWS and not self.route_to_compose: | ||
| self._emit("status_changed", "rewriting") | ||
| result_text = self.llm_service.rewrite(self.workflow, transcript) | ||
| else: | ||
| result_text = transcript | ||
|
|
||
| # Paste | ||
| if self.autopaste: | ||
| self.paste_service.paste(result_text) | ||
| else: | ||
| self.paste_service.clipboard_only(result_text) | ||
| # Compose routing is delivered by the GUI coordinator. Keeping it | ||
| # out of PasteService preserves the focused application's contents. | ||
| if not self.route_to_compose: | ||
| if self.autopaste: | ||
| self.paste_service.paste(result_text) | ||
| else: | ||
| self.paste_service.clipboard_only(result_text) | ||
|
|
||
| self._emit("result", result_text) | ||
| except Exception as e: | ||
|
|
@@ -633,6 +651,7 @@ def __init__(self, app: QApplication) -> None: | |
| self.current_workflow: Optional[WorkflowType] = None | ||
| self._tray_error_message: Optional[str] = None | ||
| self._active_workers: list[_TranscribeWorker] = [] | ||
| self._recording_routes_to_compose = False | ||
|
|
||
| # Diktat-/Verlauf-/TTS-Zustand | ||
| self._dictation_mode = False | ||
|
|
@@ -668,8 +687,15 @@ def _build_llm_service(self) -> LLMService: | |
| writing_preset=self.config.writing_preset, | ||
| base_url=base_url, | ||
| model=self.config.llm_model, | ||
| writing_custom_prompt=self.config.compose_custom_preset_text, | ||
| ) | ||
|
|
||
| def _rebuild_llm_service(self) -> None: | ||
| """Baut den Service neu und aktualisiert ein bereits offenes Compose.""" | ||
| self.llm_service = self._build_llm_service() | ||
| if self._compose_window is not None: | ||
| self._compose_window.set_llm_service(self.llm_service) | ||
|
|
||
| def setup_tray(self) -> None: | ||
| self.tray_icon = QSystemTrayIcon(self) | ||
| self._tray_icons = { | ||
|
|
@@ -735,6 +761,8 @@ def setup_tray(self) -> None: | |
| self.preset_action_group.setExclusive(True) | ||
| self.preset_actions: dict[str, QAction] = {} | ||
| for key in WRITING_PRESET_KEYS: | ||
| if key == CUSTOM_PRESET_KEY: | ||
| self.menu_preset.addSeparator() | ||
| preset_action = QAction(t(f"preset.{key}.name"), self) | ||
| preset_action.setCheckable(True) | ||
| preset_action.triggered.connect( | ||
|
|
@@ -868,7 +896,7 @@ def _on_writing_preset_selected(self, key: str) -> None: | |
| return | ||
| self.config.writing_preset = key | ||
| self.config.save() | ||
| self.llm_service = self._build_llm_service() | ||
| self._rebuild_llm_service() | ||
| self.update_menu_availability() | ||
| if self._main_window is not None: | ||
| self._main_window.set_preset(key) | ||
|
|
@@ -880,7 +908,7 @@ def main_window_preset_changed(self, key: str) -> None: | |
| return | ||
| self.config.writing_preset = key | ||
| self.config.save() | ||
| self.llm_service = self._build_llm_service() | ||
| self._rebuild_llm_service() | ||
| self.update_menu_availability() | ||
| self._refresh_preset_menu() | ||
| logger.info("Writing preset changed via main window: %s", key) | ||
|
|
@@ -915,7 +943,7 @@ def show_settings_dialog(self) -> None: | |
| dialog = SettingsDialog(self.config) | ||
| if dialog.exec() == QDialog.DialogCode.Accepted: | ||
| # Update LLM Service parameters from saved configuration | ||
| self.llm_service = self._build_llm_service() | ||
| self._rebuild_llm_service() | ||
| self._refresh_i18n_texts() | ||
| self.update_menu_availability() | ||
| # Preset kann im Dialog geändert worden sein -> Häkchen + Combo angleichen. | ||
|
|
@@ -962,13 +990,16 @@ def _on_workflow_triggered(self, workflow: WorkflowType) -> None: | |
| logger.info("Ignored hotkey trigger %s while busy", workflow) | ||
|
|
||
| def _start_recording(self, workflow: WorkflowType) -> None: | ||
| self._recording_routes_to_compose = False | ||
| try: | ||
| self.audio_recorder.start(device=self.config.audio_device) | ||
| self._recording_routes_to_compose = self._compose_voice_routing_enabled() | ||
| self.current_workflow = workflow | ||
| self._set_state("RECORDING", f"workflow {workflow.value} started") | ||
| except AudioRecorderError as e: | ||
| logger.error("Failed to start recording: %s", e) | ||
| self.show_tray_error(t("error.recording.title"), t("error.recording.start_failed").format(error=e)) | ||
| self._recording_routes_to_compose = False | ||
| self.current_workflow = None | ||
| self._set_state("IDLE", "recording start failed") | ||
|
|
||
|
|
@@ -988,6 +1019,7 @@ def gui_discard(self) -> None: | |
| """Laufende Aufnahme verwerfen, ohne zu transkribieren.""" | ||
| if self.state == "RECORDING": | ||
| self.audio_recorder.discard() | ||
| self._recording_routes_to_compose = False | ||
| self.current_workflow = None | ||
| self._set_state("IDLE", "discarded via gui") | ||
|
|
||
|
|
@@ -1002,6 +1034,8 @@ def _on_recording_stop(self) -> None: | |
|
|
||
| def _stop_recording_and_process(self) -> None: | ||
| try: | ||
| route_to_compose = self._recording_routes_to_compose | ||
| self._recording_routes_to_compose = False | ||
| wav_path = self.audio_recorder.stop() | ||
| if not wav_path: | ||
| logger.warning("No audio was recorded") | ||
|
|
@@ -1027,10 +1061,15 @@ def _stop_recording_and_process(self) -> None: | |
| autopaste=self.config.autopaste, | ||
| paste_service=self.paste_service, | ||
| custom_terms=self.config.custom_terms, | ||
| route_to_compose=route_to_compose, | ||
| ) | ||
|
|
||
| worker.signals.status_changed.connect(self._on_worker_status_changed) | ||
| worker.signals.result.connect(self._on_worker_result) | ||
| worker.signals.result.connect( | ||
| lambda result_text, routed=route_to_compose: self._on_worker_result( | ||
| result_text, route_to_compose=routed | ||
| ) | ||
| ) | ||
| worker.signals.error.connect(self._on_worker_error) | ||
| worker.signals.finished.connect(self._on_worker_finished) | ||
|
|
||
|
|
@@ -1040,6 +1079,7 @@ def _stop_recording_and_process(self) -> None: | |
| except AudioRecorderError as e: | ||
| logger.error("Failed to stop recording: %s", e) | ||
| self.show_tray_error(t("error.recording.title"), t("error.recording.stop_failed").format(error=e)) | ||
| self._recording_routes_to_compose = False | ||
| self.current_workflow = None | ||
| self._set_state("IDLE", "recording stop failed") | ||
|
|
||
|
|
@@ -1052,9 +1092,10 @@ def _on_worker_status_changed(self, status: str) -> None: | |
| else: | ||
| self.update_tray_state() | ||
|
|
||
| @pyqtSlot(str) | ||
| def _on_worker_result(self, result_text: str) -> None: | ||
| def _on_worker_result(self, result_text: str, route_to_compose: bool = False) -> None: | ||
| logger.info("Transcription/Rewrite success. Result length: %d chars", len(result_text)) | ||
| if route_to_compose: | ||
| self._ensure_compose_window().set_input_text(result_text) | ||
|
Comment on lines
+1097
to
+1098
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When voice routing is enabled for a compose window that already contains a draft, the completed transcription is passed to Useful? React with 👍 / 👎. |
||
| self._add_to_history(result_text, is_dictation=self._dictation_mode) | ||
| if self._dictation_mode: | ||
| notify_service.notify( | ||
|
|
@@ -1160,6 +1201,14 @@ def show_compose_window(self, text: str = "") -> None: | |
| window.raise_() | ||
| window.activateWindow() | ||
|
|
||
| def _compose_voice_routing_enabled(self) -> bool: | ||
| window = self._compose_window | ||
| return bool( | ||
| window is not None | ||
| and window.isVisible() | ||
| and window.voice_routing_enabled() | ||
| ) | ||
|
|
||
| def _on_tts_closed(self, _result: int) -> None: | ||
| self._tts_window = None | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Routing is captured once when recording starts, so if the user closes the compose window before stopping the recording,
closeEvent()unchecks the routing control but this saved flag remains true. The worker consequently skips both paste and clipboard delivery, and_on_worker_result()writes the transcription into the now-hidden window, contrary to the close handler's guarantee that a hidden window is not a routing target.Useful? React with 👍 / 👎.