Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 65 additions & 16 deletions app/blitztext_linux.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand All @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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()
Comment on lines 994 to +996

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Cancel routing when the compose window closes mid-recording

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 👍 / 👎.

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")

Expand All @@ -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")

Expand All @@ -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")
Expand All @@ -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)

Expand All @@ -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")

Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the existing draft when routing voice input

When voice routing is enabled for a compose window that already contains a draft, the completed transcription is passed to set_input_text(), which replaces the entire draft and also clears its output and variant history. This makes dictating an addition into an existing draft cause irreversible text loss, despite the checkbox tooltip saying the recording will be inserted into the draft field; the routed text should be inserted or appended without discarding existing content.

Useful? React with 👍 / 👎.

self._add_to_history(result_text, is_dictation=self._dictation_mode)
if self._dictation_mode:
notify_service.notify(
Expand Down Expand Up @@ -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

Expand Down
Loading