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
19 changes: 16 additions & 3 deletions app/blitztext_linux.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,14 +277,24 @@ def init_ui(self) -> None:
self.edit_llm_model.setPlaceholderText(DEFAULTS["llm_model"])

self.combo_tone = QComboBox()
self.combo_tone.addItems(["formal", "neutral", "locker"])
self.combo_tone.setCurrentText(self.config.text_improver_tone)
for value in ("locker", "neutral", "formal"):
self.combo_tone.addItem(t(f"tone.{value}"), value)
tone_index = self.combo_tone.findData(self.config.text_improver_tone)
if tone_index < 0:
tone_index = self.combo_tone.findData("neutral")
self.combo_tone.setCurrentIndex(tone_index if tone_index >= 0 else 0)

self.combo_writing_preset = QComboBox()
for key in WRITING_PRESET_KEYS:
self.combo_writing_preset.addItem(t(f"preset.{key}.name"), key)
self.combo_writing_preset.setCurrentIndex(preset_index(self.config.writing_preset))

self.edit_compose_custom_preset = QPlainTextEdit()
self.edit_compose_custom_preset.setPlainText(self.config.compose_custom_preset_text)
self.edit_compose_custom_preset.setPlaceholderText(t("settings.compose_custom_preset.placeholder"))
self.edit_compose_custom_preset.setMinimumHeight(60)
self.edit_compose_custom_preset.setMaximumHeight(110)

self.combo_emoji = QComboBox()
self.combo_emoji.addItems(["wenig", "mittel", "viel"])
self.combo_emoji.setCurrentText(self.config.emoji_density)
Expand Down Expand Up @@ -332,6 +342,8 @@ def init_ui(self) -> None:
form_llm.addRow(t("settings.tone.label"), self.combo_tone)
form_llm.addRow(t("settings.writing_preset.label"), self.combo_writing_preset)
form_llm.addRow(create_help_label(t("settings.writing_preset.help")))
form_llm.addRow(t("settings.compose_custom_preset.label"), self.edit_compose_custom_preset)
form_llm.addRow(create_help_label(t("settings.compose_custom_preset.help")))
form_llm.addRow(t("settings.emoji_density.label"), self.combo_emoji)

form_llm.addRow(t("settings.dampf_prompt.label"), self.edit_dampf_prompt)
Expand Down Expand Up @@ -495,8 +507,9 @@ def save_settings(self) -> None:
self.config.llm_provider = self.combo_llm_provider.currentData()
self.config.llm_base_url = self.edit_base_url.text().strip()
self.config.llm_model = self.edit_llm_model.text().strip()
self.config.text_improver_tone = self.combo_tone.currentText()
self.config.text_improver_tone = self.combo_tone.currentData()
self.config.writing_preset = self.combo_writing_preset.currentData()
self.config.compose_custom_preset_text = self.edit_compose_custom_preset.toPlainText()
self.config.emoji_density = self.combo_emoji.currentText()
self.config.dampf_system_prompt = self.edit_dampf_prompt.toPlainText().strip()
self.config.custom_terms = self._collect_custom_terms()
Expand Down
103 changes: 99 additions & 4 deletions app/compose_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@

from app.i18n import t
from app.llm_service import LLMService
from app.config import Config
from app.config import Config, VALID_TONES
from app.paste_service import PasteService, PasteServiceError
from app.workflows import WorkflowType
from app.writing_presets import WRITING_PRESET_KEYS, preset_index
from app.writing_presets import DEFAULT_PRESET_KEY, WRITING_PRESET_KEYS, preset_index

logger = logging.getLogger("blitztext.compose")

Expand All @@ -36,6 +36,15 @@
WorkflowType.EMOJI_TEXT,
)

# Reihenfolge des Tonfall-Selektors im Compose-Fenster. Die Werte bleiben intern
# unverändert (formal/neutral/locker); nur die Anzeige erfolgt über i18n-Labels
# (z. B. "professionell" für "formal").
COMPOSE_TONE_ORDER: tuple[str, ...] = ("locker", "neutral", "formal")

# Sentinel-Schlüssel für den zusätzlichen Compose-Eintrag „Eigene Vorlage…“.
# Bewusst kein echtes Writing-Preset, damit Hauptfenster/Tray unberührt bleiben.
COMPOSE_CUSTOM_PRESET_KEY = "__compose_custom__"

# In-memory ring buffer of successful generations for the current window
# session. Oldest variants are trimmed once the cap is exceeded.
MAX_COMPOSE_VARIANTS = 10
Expand Down Expand Up @@ -73,12 +82,16 @@ def __init__(
workflow: WorkflowType,
text: str,
writing_preset: str,
tone: Optional[str] = None,
custom_prompt: Optional[str] = None,
) -> None:
super().__init__()
self._service = service
self._workflow = workflow
self._text = text
self._writing_preset = writing_preset
self._tone = tone
self._custom_prompt = custom_prompt
self._cancelled = False

def request_cancel(self) -> None:
Expand All @@ -93,6 +106,8 @@ def run(self) -> None:
self._workflow,
self._text,
writing_preset=self._writing_preset,
tone=self._tone,
custom_prompt=self._custom_prompt,
)
if self._cancelled or QThread.currentThread().isInterruptionRequested():
return
Expand Down Expand Up @@ -157,6 +172,13 @@ def _setup_ui(self) -> None:
self.cmbPreset.setMinimumWidth(180)
header_row.addWidget(self.cmbPreset, 1)

self.lblTone = QLabel()
header_row.addWidget(self.lblTone)

self.cmbTone = QComboBox()
self.cmbTone.setMinimumWidth(130)
header_row.addWidget(self.cmbTone, 1)

self.chkVoiceRouting = QCheckBox()
self.chkVoiceRouting.setEnabled(False)
self.chkVoiceRouting.setToolTip(t("compose.voice_routing.help"))
Expand Down Expand Up @@ -259,6 +281,10 @@ def _setup_ui(self) -> None:
self._install_shortcuts()
self._populate_workflow_combo()
self._populate_preset_combo()
self._populate_tone_combo()
self.cmbWorkflow.currentIndexChanged.connect(self._on_selectors_changed)
self.cmbPreset.currentIndexChanged.connect(self._on_selectors_changed)
self._update_tone_state()

def _install_shortcuts(self) -> None:
self._add_shortcut(self.txtInput, "Ctrl+Return", self._on_improve_clicked)
Expand Down Expand Up @@ -292,6 +318,8 @@ def _populate_preset_combo(self, selected: Optional[str] = None) -> None:
self.cmbPreset.clear()
for key in WRITING_PRESET_KEYS:
self.cmbPreset.addItem(t(f"preset.{key}.name"), key)
# Zusätzlicher, nur im Compose-Fenster sichtbarer Freitext-Eintrag.
self.cmbPreset.addItem(t("compose.preset.custom"), COMPOSE_CUSTOM_PRESET_KEY)
target = selected or preset_index(self._llm_service.writing_preset)
if isinstance(target, str):
index = self.cmbPreset.findData(target)
Expand All @@ -310,6 +338,54 @@ def _selected_preset(self) -> str:
return preset
return self._llm_service.writing_preset

def _populate_tone_combo(self, selected: Optional[str] = None) -> None:
if selected is None and hasattr(self, "cmbTone"):
selected = self._selected_tone()
self.cmbTone.blockSignals(True)
self.cmbTone.clear()
for value in COMPOSE_TONE_ORDER:
self.cmbTone.addItem(t(f"tone.{value}"), value)
target = selected or self._config.text_improver_tone
index = self.cmbTone.findData(target)
if index < 0:
index = self.cmbTone.findData("neutral")
self.cmbTone.setCurrentIndex(index if index >= 0 else 0)
self.cmbTone.blockSignals(False)

def _selected_tone(self) -> str:
tone = self.cmbTone.currentData()
if isinstance(tone, str) and tone in VALID_TONES:
return tone
return self._config.text_improver_tone

def _is_custom_preset(self) -> bool:
return self._selected_preset() == COMPOSE_CUSTOM_PRESET_KEY

@pyqtSlot()
def _on_selectors_changed(self) -> None:
self._update_tone_state()

def _update_tone_state(self) -> None:
"""Sichtbarkeit/Aktivierung des Tonfall-Selektors je nach Workflow+Vorlage.

Tonfall ist nur sinnvoll im Text-Verbesserer (Blitztext+) mit der Vorlage
„Standard“. Bei anderen Vorlagen bestimmt die Vorlage den Stil selbst, der
Selektor bleibt sichtbar, aber ausgegraut. Bei Dampf/Emoji entfällt er.
"""
workflow = self._selected_workflow()
is_text_improver = workflow == WorkflowType.TEXT_IMPROVER
self.lblTone.setVisible(is_text_improver)
self.cmbTone.setVisible(is_text_improver)
if not is_text_improver:
return
is_standard = self._selected_preset() == DEFAULT_PRESET_KEY
self.cmbTone.setEnabled(is_standard)
self.cmbTone.setToolTip(
t("compose.tone.tooltip_active")
if is_standard
else t("compose.tone.tooltip_preset_overrides")
)

def _has_input(self) -> bool:
return bool(self.txtInput.toPlainText().strip())

Expand Down Expand Up @@ -475,10 +551,12 @@ def retranslate_ui(self) -> None:
"""Refresh visible text to the active UI language."""
current_workflow = self._selected_workflow()
current_preset = self._selected_preset()
current_tone = self._selected_tone()

self.setWindowTitle(t("compose.window_title"))
self.lblWorkflow.setText(t("compose.workflow.label"))
self.lblPreset.setText(t("compose.preset.label"))
self.lblTone.setText(t("compose.tone.label"))
self.chkVoiceRouting.setText(t("compose.voice_routing.label"))
self.chkVoiceRouting.setToolTip(t("compose.voice_routing.help"))
self.lblInput.setText(t("compose.input.label"))
Expand All @@ -494,6 +572,8 @@ def retranslate_ui(self) -> None:

self._populate_workflow_combo(current_workflow)
self._populate_preset_combo(current_preset)
self._populate_tone_combo(current_tone)
self._update_tone_state()

if self._busy:
self._show_status(t("compose.status.processing"))
Expand All @@ -504,10 +584,25 @@ def retranslate_ui(self) -> None:

def _start_worker(self, text: str) -> None:
workflow = self._selected_workflow()
writing_preset = self._selected_preset()
tone = self._selected_tone()
custom_prompt: Optional[str] = None
if self._is_custom_preset():
# Freitext-Vorlage: Basis bleibt „Standard“, der freie System-Prompt
# wird separat durchgereicht (greift nur im Text-Verbesserer).
writing_preset = DEFAULT_PRESET_KEY
custom_prompt = self._config.compose_custom_preset_text
else:
writing_preset = self._selected_preset()

thread = QThread(self)
worker = _ComposeWorker(self._llm_service, workflow, text, writing_preset)
worker = _ComposeWorker(
self._llm_service,
workflow,
text,
writing_preset,
tone=tone,
custom_prompt=custom_prompt,
)
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.finished.connect(self._on_worker_result)
Expand Down
12 changes: 12 additions & 0 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"ui_language": I18N_DEFAULT_LANGUAGE,
"compose_signature_text": "",
"compose_signature_auto_append": False,
"compose_custom_preset_text": "",
}

VALID_MODELS = {"tiny", "base", "small", "medium", "large", "large-v2", "large-v3", "large-v3-turbo"}
Expand Down Expand Up @@ -418,6 +419,15 @@ def compose_signature_auto_append(self) -> bool:
def compose_signature_auto_append(self, value: bool) -> None:
self._data["compose_signature_auto_append"] = bool(value)

@property
def compose_custom_preset_text(self) -> str:
value = self._data.get("compose_custom_preset_text", "")
return value if isinstance(value, str) else ""

@compose_custom_preset_text.setter
def compose_custom_preset_text(self, value: str) -> None:
self._data["compose_custom_preset_text"] = value if isinstance(value, str) else ""

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

Expand Down Expand Up @@ -456,6 +466,8 @@ def _validate_and_sanitize(self) -> None:
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))
if not isinstance(self._data.get("compose_custom_preset_text", ""), str):
self._data["compose_custom_preset_text"] = ""

self._data["openai_api_key_env"] = _normalize_env_var_name(
self._data.get("openai_api_key_env", DEFAULTS["openai_api_key_env"])
Expand Down
20 changes: 20 additions & 0 deletions app/i18n.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@
"settings.tone.label": "Text-Verbesserer Tonfall:",
"settings.writing_preset.label": "Schreibstil-Vorlage:",
"settings.writing_preset.help": "Vorlage für den Text-Verbesserer (z. B. E-Mail formell, Stichpunkte). Bei 'Standard' greift der Tonfall oben; jede andere Vorlage bestimmt den Schreibstil selbst und ersetzt den Tonfall.",
"settings.compose_custom_preset.label": "Eigene Vorlage (Compose):",
"settings.compose_custom_preset.help": "Freier System-Prompt für die Vorlage „Eigene Vorlage…“ im Compose-Fenster.",
"settings.compose_custom_preset.placeholder": "z. B. Formuliere den Text als sachliche Pressemitteilung…",
"settings.emoji_density.label": "Emoji-Dichte:",
"settings.dampf_prompt.label": "Dampf-Umschreiber Prompt:",
"settings.dampf_prompt.placeholder": "Standard-Systemprompt verwenden...",
Expand Down Expand Up @@ -143,6 +146,13 @@
"compose.window_title": "Text verfassen",
"compose.workflow.label": "Workflow:",
"compose.preset.label": "Schreibstil:",
"compose.preset.custom": "Eigene Vorlage…",
"compose.tone.label": "Tonfall:",
"compose.tone.tooltip_active": "Tonfall des Text-Verbesserers (nur bei Vorlage „Standard“).",
"compose.tone.tooltip_preset_overrides": "Diese Vorlage bestimmt den Schreibstil selbst — der Tonfall wird ignoriert.",
"tone.locker": "locker",
"tone.neutral": "neutral",
"tone.formal": "professionell",
"compose.voice_routing.label": "Sprachaufnahme hierher umleiten",
"compose.voice_routing.help": "TODO/Future-Hook: In Phase I-1 noch nicht verdrahtet.",
"compose.input.label": "Entwurf",
Expand Down Expand Up @@ -231,6 +241,9 @@
"settings.tone.label": "Text improver tone:",
"settings.writing_preset.label": "Writing style preset:",
"settings.writing_preset.help": "Preset for the text improver (e.g. formal email, bullet points). With 'Standard', the tone above is used; every other preset determines the writing style itself and replaces the tone.",
"settings.compose_custom_preset.label": "Custom preset (Compose):",
"settings.compose_custom_preset.help": "Free system prompt for the 'Custom preset…' entry in the compose window.",
"settings.compose_custom_preset.placeholder": "e.g. Rewrite the text as a factual press release…",
"settings.emoji_density.label": "Emoji density:",
"settings.dampf_prompt.label": "Dampf rewrite prompt:",
"settings.dampf_prompt.placeholder": "Use default system prompt...",
Expand Down Expand Up @@ -325,6 +338,13 @@
"compose.window_title": "Compose Text",
"compose.workflow.label": "Workflow:",
"compose.preset.label": "Style:",
"compose.preset.custom": "Custom preset…",
"compose.tone.label": "Tone:",
"compose.tone.tooltip_active": "Text improver tone (only with the 'Standard' preset).",
"compose.tone.tooltip_preset_overrides": "This preset defines the writing style itself — the tone is ignored.",
"tone.locker": "casual",
"tone.neutral": "neutral",
"tone.formal": "professional",
"compose.voice_routing.label": "Route voice input here",
"compose.voice_routing.help": "TODO/future hook: not wired in phase I-1 yet.",
"compose.input.label": "Draft",
Expand Down
30 changes: 26 additions & 4 deletions app/llm_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,8 @@ def _rewrite_for_workflow(
workflow: WorkflowType,
text: str,
writing_preset: Optional[str] = None,
tone: Optional[str] = None,
custom_prompt: Optional[str] = None,
) -> str:
if workflow not in LLM_WORKFLOWS:
raise LLMServiceError(f"rewrite() only allowed for LLM workflows, got {workflow!r}")
Expand All @@ -157,8 +159,15 @@ def _rewrite_for_workflow(
if workflow == WorkflowType.DAMPF_ABLASSEN:
return self.dampf_ablassen(text, custom_system_prompt=self.dampf_system_prompt)
if workflow == WorkflowType.TEXT_IMPROVER:
preset = get_preset(writing_preset or self.writing_preset)
return self.text_improver(text, tone=self.tone, custom_prompt=preset.system_prompt)
# Ein expliziter Freitext-Prompt (z. B. „Eigene Vorlage…“) hat
# Vorrang vor der gewählten Vorlage. Sonst gilt die Vorlage; nur
# bei „Standard“ (leerer Preset-Prompt) greift der Tonfall.
effective_tone = tone if tone is not None else self.tone
if custom_prompt is not None and custom_prompt.strip():
system_prompt = custom_prompt
else:
system_prompt = get_preset(writing_preset or self.writing_preset).system_prompt
return self.text_improver(text, tone=effective_tone, custom_prompt=system_prompt)
if workflow == WorkflowType.EMOJI_TEXT:
return self.emoji_text(text, density=self.emoji_density)
raise LLMServiceError(f"Unsupported workflow: {workflow}")
Expand Down Expand Up @@ -244,6 +253,19 @@ def rewrite_text(
workflow: WorkflowType,
text: str,
writing_preset: Optional[str] = None,
tone: Optional[str] = None,
custom_prompt: Optional[str] = None,
) -> str:
"""Direct text entry point for the compose window."""
return self._rewrite_for_workflow(workflow, text, writing_preset=writing_preset)
"""Direct text entry point for the compose window.

``tone`` und ``custom_prompt`` sind optional und rückwärtskompatibel:
``tone=None`` nutzt weiterhin den global konfigurierten Ton des Service,
``custom_prompt=None`` lässt die gewählte Vorlage unverändert greifen.
"""
return self._rewrite_for_workflow(
workflow,
text,
writing_preset=writing_preset,
tone=tone,
custom_prompt=custom_prompt,
)
Loading