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
13 changes: 12 additions & 1 deletion app/blitztext_linux.py
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,15 @@ def init_ui(self) -> None:
form_general.addRow(t("settings.ui_language.label"), self.combo_ui_language)
form_general.addRow(create_help_label(t("settings.ui_language.help")))

self.edit_compose_signature = QPlainTextEdit()
self.edit_compose_signature.setPlainText(self.config.compose_signature_text)
self.edit_compose_signature.setMaximumHeight(80)
form_general.addRow(t("settings.general.signature_label"), self.edit_compose_signature)

self.check_compose_signature_auto_append = QCheckBox(t("settings.general.signature_auto_append"))
self.check_compose_signature_auto_append.setChecked(self.config.compose_signature_auto_append)
form_general.addRow(self.check_compose_signature_auto_append)

self.btn_open_config = QPushButton(t("settings.open_config.button"))
self.btn_open_config.clicked.connect(self._open_config_file)
form_general.addRow(self.btn_open_config)
Expand Down Expand Up @@ -496,6 +505,8 @@ def save_settings(self) -> None:
self.config.notes_folder = self.edit_notes_folder.text().strip()
self.config.history_size = int(self.spin_history_size.currentText())
self.config.ui_language = self.combo_ui_language.currentData()
self.config.compose_signature_text = self.edit_compose_signature.toPlainText()
self.config.compose_signature_auto_append = self.check_compose_signature_auto_append.isChecked()

self.config.save()
set_language(self.config.ui_language)
Expand Down Expand Up @@ -1103,7 +1114,7 @@ def show_tts_window(self) -> None:

def _ensure_compose_window(self) -> ComposeWindow:
if self._compose_window is None:
window = ComposeWindow(self.llm_service, self.paste_service)
window = ComposeWindow(self.llm_service, self.paste_service, self.config)
try:
from app import theme
window.setWindowIcon(theme.create_app_icon())
Expand Down
73 changes: 73 additions & 0 deletions app/compose_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from __future__ import annotations

import logging
import re
from typing import Optional

from PyQt6.QtCore import QObject, QThread, Qt, pyqtSignal, pyqtSlot
Expand All @@ -22,6 +23,7 @@

from app.i18n import t
from app.llm_service import LLMService
from app.config import Config
from app.paste_service import PasteService, PasteServiceError
from app.workflows import WorkflowType
from app.writing_presets import WRITING_PRESET_KEYS, preset_index
Expand All @@ -38,6 +40,20 @@
# session. Oldest variants are trimmed once the cap is exceeded.
MAX_COMPOSE_VARIANTS = 10

# Curated signature placeholders an LLM commonly emits at the end of an email,
# in German and English. We deliberately anchor on the known closing tokens
# (Name / Vorname / Nachname / Absender / Sender / Signature) rather than any
# bracketed text, so unrelated bracketed content is never replaced. An optional
# trailing comma is swallowed to avoid a dangling comma after substitution.
SIGNATURE_PLACEHOLDER_PATTERN = re.compile(
r"\[\s*"
r"(?:(?:dein[er]?|deine|ihr[er]?|ihre|mein[er]?|meine|your|my)\s+)?"
r"(?:vorname|nachname|full\s+name|name|absender|sender|signature|unterschrift)"
r"(?:\s+nachname)?"
r"\s*\]\s*,?",
re.IGNORECASE,
)


def _scrub_secret(text: str, secret: str) -> str:
if secret and text:
Expand Down Expand Up @@ -95,11 +111,13 @@ def __init__(
self,
llm_service: LLMService,
paste_service: PasteService,
config: Config,
parent: Optional[QWidget] = None,
) -> None:
super().__init__(parent)
self._llm_service = llm_service
self._paste_service = paste_service
self._config = config
self._worker_thread: Optional[QThread] = None
self._worker: Optional[_ComposeWorker] = None
self._detached_threads: list[QThread] = []
Expand Down Expand Up @@ -221,6 +239,10 @@ def _setup_ui(self) -> None:
self.btnPaste.clicked.connect(self._on_paste_clicked)
footer_row.addWidget(self.btnPaste)

self.btnSignature = QPushButton()
self.btnSignature.clicked.connect(self._on_append_signature_clicked)
footer_row.addWidget(self.btnSignature)

self.btnClose = QPushButton()
self.btnClose.clicked.connect(self.close)
footer_row.addWidget(self.btnClose)
Expand Down Expand Up @@ -332,6 +354,19 @@ def _sync_state(self) -> None:
self.btnCopy.setEnabled(has_output)
self.btnPaste.setEnabled(has_output)

raw_sig = self._config.compose_signature_text
if has_output and raw_sig.strip():
sig = raw_sig.rstrip()
text = self.txtOutput.toPlainText()
# Enable only while appending would actually change something:
# a placeholder is still present, or the signature isn't there yet.
has_placeholder = SIGNATURE_PLACEHOLDER_PATTERN.search(text) is not None
self.btnSignature.setEnabled(has_placeholder or not text.endswith(sig))
self.btnSignature.setVisible(True)
else:
self.btnSignature.setEnabled(False)
self.btnSignature.setVisible(bool(raw_sig.strip()))

def _update_variant_nav(self) -> None:
total = len(self._variants)
has_variants = total > 0
Expand Down Expand Up @@ -394,6 +429,40 @@ def _on_output_text_changed(self) -> None:
self._variants[self._variant_index] = self.txtOutput.toPlainText()
self._sync_state()

def _append_signature(self) -> None:
raw_sig = self._config.compose_signature_text
if not raw_sig.strip():
return

# Strip trailing whitespace/newlines/tabs the user may have saved by
# accident, so substitution never leaves dangling blank lines.
sig = raw_sig.rstrip()
original = self.txtOutput.toPlainText()

# If the LLM left a closing placeholder like [Ihr Name] or [Your Name],
# replace it in place (incl. an optional trailing comma) instead of
# appending a second signature below it.
text, replaced = SIGNATURE_PLACEHOLDER_PATTERN.subn(sig, original)
if not replaced:
# No placeholder: append classically at the bottom.
if text.endswith(sig):
return
if text and not text.endswith("\n\n"):
text += "\n" if text.endswith("\n") else "\n\n"
text += sig

if text == original:
return

self._set_output_guarded(text)
if 0 <= self._variant_index < len(self._variants):
self._variants[self._variant_index] = text
self._sync_state()

@pyqtSlot()
def _on_append_signature_clicked(self) -> None:
self._append_signature()

def set_input_text(self, text: str) -> None:
self.txtInput.setPlainText(text)
self._variants = []
Expand All @@ -418,6 +487,8 @@ def retranslate_ui(self) -> None:
self.btnCopy.setText(t("compose.button.copy"))
self.btnPaste.setText(t("compose.button.insert_close"))
self.btnClose.setText(t("compose.button.close"))
self.btnSignature.setText(t("compose.btn_append_signature"))
self.btnSignature.setToolTip(t("compose.tooltip_append_signature"))
self.btnPrev.setToolTip(t("compose.variant.prev"))
self.btnNext.setToolTip(t("compose.variant.next"))

Expand Down Expand Up @@ -491,6 +562,8 @@ def _on_improve_clicked(self) -> None:
def _on_worker_result(self, result_text: str) -> None:
logger.info("Compose rewrite success (%d chars)", len(result_text))
self._append_variant(result_text)
if self._config.compose_signature_auto_append:
self._append_signature()
self._set_busy(False)

@pyqtSlot(str)
Expand Down
22 changes: 22 additions & 0 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@
"writing_preset": DEFAULT_PRESET_KEY,
},
"ui_language": I18N_DEFAULT_LANGUAGE,
"compose_signature_text": "",
"compose_signature_auto_append": False,
}

VALID_MODELS = {"tiny", "base", "small", "medium", "large", "large-v2", "large-v3", "large-v3-turbo"}
Expand Down Expand Up @@ -400,6 +402,22 @@ def custom_terms(self) -> list[str]:
def custom_terms(self, value: list[str]) -> None:
self._data["workflows"]["custom_terms"] = _sanitize_terms(value)

@property
def compose_signature_text(self) -> str:
return self._data.get("compose_signature_text", "")

@compose_signature_text.setter
def compose_signature_text(self, value: str) -> None:
self._data["compose_signature_text"] = value

@property
def compose_signature_auto_append(self) -> bool:
return bool(self._data.get("compose_signature_auto_append", False))

@compose_signature_auto_append.setter
def compose_signature_auto_append(self, value: bool) -> None:
self._data["compose_signature_auto_append"] = bool(value)

def as_dict(self) -> dict[str, Any]:
return copy.deepcopy(self._data)

Expand Down Expand Up @@ -435,6 +453,10 @@ def _validate_and_sanitize(self) -> None:
self._data["tts_openai_voice"] = DEFAULTS["tts_openai_voice"]
self._data["tts_openai_consent"] = bool(self._data.get("tts_openai_consent", False))

if not isinstance(self._data.get("compose_signature_text", ""), str):
self._data["compose_signature_text"] = ""
self._data["compose_signature_auto_append"] = bool(self._data.get("compose_signature_auto_append", False))

self._data["openai_api_key_env"] = _normalize_env_var_name(
self._data.get("openai_api_key_env", DEFAULTS["openai_api_key_env"])
)
Expand Down
8 changes: 8 additions & 0 deletions app/i18n.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@
"settings.notes_folder.help": "Ordner für Diktat-Notizen (muss innerhalb von ~ liegen). Leer = Speichern deaktiviert.",
"settings.history_size.label": "Verlauf-Größe:",
"settings.history_size.help": "Maximale Anzahl der im Verlauf gespeicherten Einträge.",
"settings.general.signature_label": "Signatur für Compose-Fenster",
"settings.general.signature_auto_append": "Automatisch nach Generierung anfügen",
"settings.ui_language.label": "Oberflächen-Sprache:",
"settings.ui_language.help": "Sprache für Fenster, Tray-Menü und Dialoge. Änderung wird nach dem Speichern übernommen.",
"settings.open_config.button": "📄 Konfigurationsdatei öffnen",
Expand Down Expand Up @@ -149,6 +151,8 @@
"compose.button.copy": "Kopieren",
"compose.button.insert_close": "Einfügen & Schließen",
"compose.button.close": "Schließen",
"compose.btn_append_signature": "Signatur",
"compose.tooltip_append_signature": "Signaturblock am Ende anfügen",
"compose.status.processing": "Verbessere…",
"compose.status.error": "Fehler: {message}",
"compose.status.empty_input": "Bitte zuerst einen Text eingeben.",
Expand Down Expand Up @@ -242,6 +246,8 @@
"settings.notes_folder.help": "Folder for dictation notes (must be inside ~). Empty = saving disabled.",
"settings.history_size.label": "History size:",
"settings.history_size.help": "Maximum number of entries stored in history.",
"settings.general.signature_label": "Compose Window Signature",
"settings.general.signature_auto_append": "Auto-append after generation",
"settings.ui_language.label": "Interface language:",
"settings.ui_language.help": "Language for windows, tray menu, and dialogs. Changes apply after saving.",
"settings.open_config.button": "📄 Open configuration file",
Expand Down Expand Up @@ -327,6 +333,8 @@
"compose.button.copy": "Copy",
"compose.button.insert_close": "Insert & Close",
"compose.button.close": "Close",
"compose.btn_append_signature": "Signature",
"compose.tooltip_append_signature": "Append signature block at the end",
"compose.status.processing": "Improving…",
"compose.status.error": "Error: {message}",
"compose.status.empty_input": "Enter some text first.",
Expand Down
Loading