diff --git a/README.de.md b/README.de.md
index 101a467..7eab48c 100644
--- a/README.de.md
+++ b/README.de.md
@@ -148,7 +148,7 @@ Blitztext registriert globale Hotkeys via `evdev`. Mit diesen Kombinationen hast
| **Blitztext :)** | Meta + Shift + E | ✅ | Ergänzt deine Nachricht passend mit Emojis. |
> [!NOTE]
-> **LLM-Workflows** (`Blitztext+`, `Blitztext $%&!`, `Blitztext :)`) setzen einen gültigen **API-Key** voraus. Lege ihn am einfachsten in `~/.config/blitztext-linux/secrets.env` ab, indem du dort die Variable mit deinem Key als Wert setzt (Zeilenformat `NAME=WERT`, z. B. `OPENAI_API_KEY=sk-…`). `./run.sh` und der systemd-Service laden diese Datei automatisch. Ohne diesen Key sind diese Funktionen im Menü und über die Hotkeys deaktiviert bzw. führen zu einer Fehlermeldung.
+> **LLM-Workflows** (`Blitztext+`, `Blitztext $%&!`, `Blitztext :)`) setzen einen gültigen **API-Key** voraus. Lege ihn am einfachsten in `~/.config/blitztext-linux/secrets.env` ab, indem du dort die Variable mit deinem Key als Wert setzt (Zeilenformat `NAME=WERT`, z. B. `OPENAI_API_KEY` mit deinem Key als Wert). `./run.sh` und der systemd-Service laden diese Datei automatisch. Ohne diesen Key sind diese Funktionen im Menü und über die Hotkeys deaktiviert bzw. führen zu einer Fehlermeldung.
## KI-Workflows
diff --git a/README.md b/README.md
index d99c032..e1ea984 100644
--- a/README.md
+++ b/README.md
@@ -148,7 +148,7 @@ Blitztext registers global hotkeys via `evdev`. With these combinations you have
| **Blitztext :)** | Meta + Shift + E | ✅ | Enriches your message with fitting emojis. |
> [!NOTE]
-> **LLM workflows** (`Blitztext+`, `Blitztext $%&!`, `Blitztext :)`) require a valid **API key**. The easiest way is to place it in `~/.config/blitztext-linux/secrets.env` using the format `NAME=VALUE` (e.g. `OPENAI_API_KEY=sk-…`). `./run.sh` and the systemd service load this file automatically. Without a key, these functions are disabled in the menu and via hotkeys, or result in an error message.
+> **LLM workflows** (`Blitztext+`, `Blitztext $%&!`, `Blitztext :)`) require a valid **API key**. The easiest way is to place it in `~/.config/blitztext-linux/secrets.env` using the format `NAME=VALUE` (e.g. `OPENAI_API_KEY` set to your key). `./run.sh` and the systemd service load this file automatically. Without a key, these functions are disabled in the menu and via hotkeys, or result in an error message.
---
diff --git a/app/compose_window.py b/app/compose_window.py
index 8da66a7..0da04a5 100644
--- a/app/compose_window.py
+++ b/app/compose_window.py
@@ -70,6 +70,51 @@ def _scrub_secret(text: str, secret: str) -> str:
return text
+class PromptEditorDialog(QDialog):
+ """Shows the resolved system prompt + user message and allows editing before sending."""
+
+ def __init__(
+ self,
+ system_prompt: str,
+ user_message: str,
+ parent: Optional[QWidget] = None,
+ ) -> None:
+ super().__init__(parent)
+ self.setWindowTitle(t("compose.prompt_preview.title"))
+ self.setModal(True)
+ self.resize(640, 500)
+
+ layout = QVBoxLayout(self)
+ layout.setSpacing(8)
+
+ layout.addWidget(QLabel(t("compose.prompt_preview.system_label")))
+ self._system_edit = QPlainTextEdit(system_prompt)
+ self._system_edit.setMinimumHeight(160)
+ layout.addWidget(self._system_edit, 2)
+
+ layout.addWidget(QLabel(t("compose.prompt_preview.user_label")))
+ self._user_edit = QPlainTextEdit(user_message)
+ self._user_edit.setMinimumHeight(80)
+ layout.addWidget(self._user_edit, 1)
+
+ btn_row = QHBoxLayout()
+ btn_row.addStretch(1)
+ btn_send = QPushButton(t("compose.prompt_preview.send_btn"))
+ btn_send.setDefault(True)
+ btn_send.clicked.connect(self.accept)
+ btn_row.addWidget(btn_send)
+ btn_cancel = QPushButton(t("compose.prompt_preview.cancel_btn"))
+ btn_cancel.clicked.connect(self.reject)
+ btn_row.addWidget(btn_cancel)
+ layout.addLayout(btn_row)
+
+ def get_system_prompt(self) -> str:
+ return self._system_edit.toPlainText()
+
+ def get_user_message(self) -> str:
+ return self._user_edit.toPlainText()
+
+
class _ComposeWorker(QObject):
"""Background worker for manual text rewriting."""
@@ -84,6 +129,8 @@ def __init__(
writing_preset: str,
tone: Optional[str] = None,
custom_prompt: Optional[str] = None,
+ raw_system_prompt: Optional[str] = None,
+ raw_user_message: Optional[str] = None,
) -> None:
super().__init__()
self._service = service
@@ -92,6 +139,8 @@ def __init__(
self._writing_preset = writing_preset
self._tone = tone
self._custom_prompt = custom_prompt
+ self._raw_system_prompt = raw_system_prompt
+ self._raw_user_message = raw_user_message
self._cancelled = False
def request_cancel(self) -> None:
@@ -102,13 +151,17 @@ def run(self) -> None:
try:
if self._cancelled or QThread.currentThread().isInterruptionRequested():
return
- result = self._service.rewrite_text(
- self._workflow,
- self._text,
- writing_preset=self._writing_preset,
- tone=self._tone,
- custom_prompt=self._custom_prompt,
- )
+ if self._raw_system_prompt is not None:
+ user_msg = self._raw_user_message if self._raw_user_message is not None else self._text
+ result = self._service.rewrite_raw(self._raw_system_prompt, user_msg)
+ else:
+ result = self._service.rewrite_text(
+ 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
self.finished.emit(result)
@@ -211,6 +264,10 @@ def _setup_ui(self) -> None:
self.btnAction.clicked.connect(self._on_improve_clicked)
action_row.addWidget(self.btnAction)
+ self.btnShowPrompt = QPushButton()
+ self.btnShowPrompt.clicked.connect(self._on_show_prompt_clicked)
+ action_row.addWidget(self.btnShowPrompt)
+
self.lblStatus = QLabel()
self.lblStatus.setVisible(False)
self.lblStatus.setWordWrap(True)
@@ -410,6 +467,7 @@ def _set_busy(self, busy: bool, *, keep_status: bool = False) -> None:
self.txtInput.setReadOnly(busy)
if busy:
self.btnAction.setEnabled(False)
+ self.btnShowPrompt.setEnabled(False)
self.btnCopy.setEnabled(False)
self.btnPaste.setEnabled(False)
self._show_status(t("compose.status.processing"))
@@ -426,6 +484,7 @@ def _sync_state(self) -> None:
self.btnPaste.setEnabled(False)
return
self.btnAction.setEnabled(self._has_input())
+ self.btnShowPrompt.setEnabled(self._has_input())
has_output = self._has_output()
self.btnCopy.setEnabled(has_output)
self.btnPaste.setEnabled(has_output)
@@ -562,6 +621,8 @@ def retranslate_ui(self) -> None:
self.lblInput.setText(t("compose.input.label"))
self.lblOutput.setText(t("compose.output.label"))
self.btnAction.setText(t("compose.button.improve"))
+ self.btnShowPrompt.setText(t("compose.btn.show_prompt"))
+ self.btnShowPrompt.setToolTip(t("compose.prompt_preview.tooltip"))
self.btnCopy.setText(t("compose.button.copy"))
self.btnPaste.setText(t("compose.button.insert_close"))
self.btnClose.setText(t("compose.button.close"))
@@ -653,6 +714,54 @@ def _on_improve_clicked(self) -> None:
return
self._start_worker(text)
+ @pyqtSlot()
+ def _on_show_prompt_clicked(self) -> None:
+ if self._busy:
+ return
+ text = self.txtInput.toPlainText()
+ if not text.strip():
+ self._show_status(t("compose.status.empty_input"), error=True)
+ return
+
+ workflow = self._selected_workflow()
+ tone = self._selected_tone()
+ custom_prompt: Optional[str] = None
+ writing_preset = self._selected_preset()
+ if self._is_custom_preset():
+ writing_preset = DEFAULT_PRESET_KEY
+ custom_prompt = self._config.compose_custom_preset_text
+
+ system_prompt = self._llm_service.build_system_prompt(
+ workflow, writing_preset=writing_preset, tone=tone, custom_prompt=custom_prompt
+ )
+ dialog = PromptEditorDialog(system_prompt, text.strip(), parent=self)
+ if dialog.exec() == QDialog.DialogCode.Accepted:
+ self._start_worker_raw(dialog.get_system_prompt(), dialog.get_user_message())
+
+ def _start_worker_raw(self, system_prompt: str, user_message: str) -> None:
+ thread = QThread(self)
+ worker = _ComposeWorker(
+ self._llm_service,
+ self._selected_workflow(),
+ user_message,
+ self._selected_preset(),
+ raw_system_prompt=system_prompt,
+ raw_user_message=user_message,
+ )
+ worker.moveToThread(thread)
+ thread.started.connect(worker.run)
+ worker.finished.connect(self._on_worker_result)
+ worker.error.connect(self._on_worker_error)
+ worker.finished.connect(thread.quit)
+ worker.error.connect(thread.quit)
+ thread.finished.connect(worker.deleteLater)
+ thread.finished.connect(thread.deleteLater)
+ thread.finished.connect(self._on_worker_thread_finished)
+ self._worker_thread = thread
+ self._worker = worker
+ self._set_busy(True)
+ thread.start()
+
@pyqtSlot(str)
def _on_worker_result(self, result_text: str) -> None:
logger.info("Compose rewrite success (%d chars)", len(result_text))
diff --git a/app/i18n.py b/app/i18n.py
index 5b57fc4..c13c987 100644
--- a/app/i18n.py
+++ b/app/i18n.py
@@ -170,6 +170,13 @@
"compose.variant.prev": "Vorherige Variante",
"compose.variant.next": "Nächste Variante",
"compose.variant.none": "Keine Varianten",
+ "compose.btn.show_prompt": "Prompt",
+ "compose.prompt_preview.title": "Prompt-Vorschau",
+ "compose.prompt_preview.system_label": "System-Prompt:",
+ "compose.prompt_preview.user_label": "Nutzernachricht:",
+ "compose.prompt_preview.send_btn": "Senden",
+ "compose.prompt_preview.cancel_btn": "Abbrechen",
+ "compose.prompt_preview.tooltip": "Prompt vor dem Senden anzeigen & bearbeiten",
"mainwindow.button.discard": "↺ Verwerfen",
"mainwindow.button.dictation": "✎ Diktat",
"mainwindow.button.history": "≡ Verlauf ({count})",
@@ -362,6 +369,13 @@
"compose.variant.prev": "Previous variant",
"compose.variant.next": "Next variant",
"compose.variant.none": "No variants",
+ "compose.btn.show_prompt": "Prompt",
+ "compose.prompt_preview.title": "Prompt Preview",
+ "compose.prompt_preview.system_label": "System Prompt:",
+ "compose.prompt_preview.user_label": "User Message:",
+ "compose.prompt_preview.send_btn": "Send",
+ "compose.prompt_preview.cancel_btn": "Cancel",
+ "compose.prompt_preview.tooltip": "Preview & edit prompt before sending",
"mainwindow.button.discard": "↺ Discard",
"mainwindow.button.dictation": "✎ Dictation",
"mainwindow.button.history": "≡ History ({count})",
diff --git a/app/llm_service.py b/app/llm_service.py
index eef6fbe..9c2df12 100644
--- a/app/llm_service.py
+++ b/app/llm_service.py
@@ -269,3 +269,56 @@ def rewrite_text(
tone=tone,
custom_prompt=custom_prompt,
)
+
+ def build_system_prompt(
+ self,
+ workflow: WorkflowType,
+ writing_preset: Optional[str] = None,
+ tone: Optional[str] = None,
+ custom_prompt: Optional[str] = None,
+ ) -> str:
+ """Return the effective system prompt for the given parameters without making an API call.
+
+ Mirrors the logic of ``_rewrite_for_workflow`` so the caller can preview
+ exactly what would be sent to the LLM.
+ """
+ if workflow == WorkflowType.DAMPF_ABLASSEN:
+ return (self.dampf_system_prompt.strip() or _DAMPF_SYSTEM) + self._custom_terms_instruction()
+ if workflow == WorkflowType.TEXT_IMPROVER:
+ effective_tone = tone if tone is not None else self.tone
+ if custom_prompt is not None and custom_prompt.strip():
+ effective_system = custom_prompt.strip()
+ else:
+ preset_system = get_preset(writing_preset or self.writing_preset).system_prompt
+ effective_system = preset_system or _TEXT_IMPROVER_SYSTEM_TEMPLATE.format(tone=effective_tone)
+ return effective_system + self._custom_terms_instruction()
+ if workflow == WorkflowType.EMOJI_TEXT:
+ return _EMOJI_SYSTEM_TEMPLATE.format(density=self.emoji_density) + self._custom_terms_instruction()
+ raise LLMServiceError(f"Unsupported workflow: {workflow}")
+
+ def rewrite_raw(self, system_prompt: str, user_message: str) -> str:
+ """Make an API call with explicitly provided prompts, bypassing all preset logic.
+
+ Used by the prompt-preview dialog after the user has reviewed and
+ optionally edited both the system prompt and the user message.
+ """
+ self._check_openai()
+ if not user_message or not user_message.strip():
+ raise ValueError("user_message must not be empty")
+ try:
+ response = self.client.chat.completions.create(
+ model=self.model,
+ messages=[
+ {"role": "system", "content": system_prompt},
+ {"role": "user", "content": user_message.strip()},
+ ],
+ temperature=0.7,
+ )
+ content = response.choices[0].message.content
+ if content is None:
+ raise LLMServiceError("OpenAI hat eine leere Antwort zurückgegeben.")
+ return content.strip()
+ except Exception as exc:
+ if isinstance(exc, LLMServiceError):
+ raise
+ raise LLMServiceError(f"OpenAI API-Fehler: {exc}") from exc
diff --git a/tests/test_compose_window.py b/tests/test_compose_window.py
index ebb9b62..6f09be1 100644
--- a/tests/test_compose_window.py
+++ b/tests/test_compose_window.py
@@ -42,6 +42,23 @@ def rewrite_text(
raise self.error
return self.result
+ def build_system_prompt(
+ self,
+ workflow: WorkflowType,
+ writing_preset: str | None = None,
+ tone: str | None = None,
+ custom_prompt: str | None = None,
+ ) -> str:
+ return f"[FAKE_SYSTEM:{workflow.value}]"
+
+ def rewrite_raw(self, system_prompt: str, user_message: str) -> str:
+ self.calls.append((WorkflowType.TEXT_IMPROVER, user_message, None))
+ self.tone_calls.append(None)
+ self.custom_prompt_calls.append(system_prompt)
+ if self.error is not None:
+ raise self.error
+ return self.result
+
@property
def last_tone(self) -> str | None:
return self.tone_calls[-1] if self.tone_calls else None
diff --git a/tests/test_llm_service.py b/tests/test_llm_service.py
index b90f890..5597e47 100644
--- a/tests/test_llm_service.py
+++ b/tests/test_llm_service.py
@@ -300,3 +300,119 @@ def test_tone_and_custom_prompt_default_none_keeps_legacy_behavior(self, mock_cl
service = LLMService(api_key=DUMMY_API_KEY, client=mock_client, tone="neutral")
service.rewrite_text(WorkflowType.TEXT_IMPROVER, RAW_TRANSCRIPT)
assert "Ton: neutral" in self._system_message(mock_client)
+
+
+class TestBuildSystemPrompt:
+ """Paket J: build_system_prompt() gibt den aufgelösten Prompt zurück ohne API-Call."""
+
+ def test_standard_preset_with_tone_uses_template(self, mock_client):
+ service = LLMService(api_key=DUMMY_API_KEY, client=mock_client, tone="formal")
+ prompt = service.build_system_prompt(WorkflowType.TEXT_IMPROVER, writing_preset="standard")
+ assert "Ton: formal" in prompt
+ assert "Formuliere es zu einem sauberen" in prompt
+
+ def test_tone_override_reflected_in_prompt(self, mock_client):
+ service = LLMService(api_key=DUMMY_API_KEY, client=mock_client, tone="neutral")
+ prompt = service.build_system_prompt(
+ WorkflowType.TEXT_IMPROVER, writing_preset="standard", tone="locker"
+ )
+ assert "Ton: locker" in prompt
+
+ def test_non_standard_preset_uses_preset_prompt(self, mock_client):
+ service = LLMService(api_key=DUMMY_API_KEY, client=mock_client)
+ prompt = service.build_system_prompt(WorkflowType.TEXT_IMPROVER, writing_preset="email_formal")
+ from app.writing_presets import WRITING_PRESETS
+ assert WRITING_PRESETS["email_formal"].system_prompt in prompt
+
+ def test_custom_prompt_overrides_preset(self, mock_client):
+ service = LLMService(api_key=DUMMY_API_KEY, client=mock_client)
+ prompt = service.build_system_prompt(
+ WorkflowType.TEXT_IMPROVER,
+ writing_preset="email_formal",
+ custom_prompt="Schreibe als Pressemitteilung.",
+ )
+ assert "Schreibe als Pressemitteilung." in prompt
+ from app.writing_presets import WRITING_PRESETS
+ assert WRITING_PRESETS["email_formal"].system_prompt not in prompt
+
+ def test_empty_custom_prompt_falls_back_to_preset(self, mock_client):
+ service = LLMService(api_key=DUMMY_API_KEY, client=mock_client, tone="neutral")
+ prompt = service.build_system_prompt(
+ WorkflowType.TEXT_IMPROVER, writing_preset="standard", custom_prompt=" "
+ )
+ assert "Formuliere es zu einem sauberen" in prompt
+
+ def test_custom_terms_appended(self, mock_client):
+ service = LLMService(api_key=DUMMY_API_KEY, client=mock_client, custom_terms=CUSTOM_TERMS)
+ prompt = service.build_system_prompt(WorkflowType.TEXT_IMPROVER)
+ assert ", ".join(CUSTOM_TERMS) in prompt
+
+ def test_dampf_ablassen_uses_dampf_system(self, mock_client):
+ service = LLMService(api_key=DUMMY_API_KEY, client=mock_client)
+ prompt = service.build_system_prompt(WorkflowType.DAMPF_ABLASSEN)
+ assert "respektvolle" in prompt
+
+ def test_dampf_ablassen_custom_system_prompt_attribute(self, mock_client):
+ service = LLMService(api_key=DUMMY_API_KEY, client=mock_client, dampf_system_prompt="Mein Custom.")
+ prompt = service.build_system_prompt(WorkflowType.DAMPF_ABLASSEN)
+ assert "Mein Custom." in prompt
+
+ def test_emoji_text_uses_density(self, mock_client):
+ service = LLMService(api_key=DUMMY_API_KEY, client=mock_client, emoji_density="viel")
+ prompt = service.build_system_prompt(WorkflowType.EMOJI_TEXT)
+ assert "viel" in prompt
+
+ def test_no_api_call_made(self, mock_client):
+ service = LLMService(api_key=DUMMY_API_KEY, client=mock_client)
+ service.build_system_prompt(WorkflowType.TEXT_IMPROVER)
+ mock_client.chat.completions.create.assert_not_called()
+
+ def test_prompt_matches_actual_api_call(self, mock_client):
+ service = LLMService(api_key=DUMMY_API_KEY, client=mock_client, tone="neutral")
+ preview = service.build_system_prompt(WorkflowType.TEXT_IMPROVER, writing_preset="standard")
+ service.rewrite_text(WorkflowType.TEXT_IMPROVER, RAW_TRANSCRIPT, writing_preset="standard")
+ messages = mock_client.chat.completions.create.call_args.kwargs["messages"]
+ actual_system = next(m["content"] for m in messages if m["role"] == "system")
+ assert preview == actual_system
+
+
+class TestRewriteRaw:
+ """Paket J: rewrite_raw() sendet die Prompts direkt ohne Preset-Logik."""
+
+ def test_sends_provided_system_and_user_message(self, mock_client):
+ service = LLMService(api_key=DUMMY_API_KEY, client=mock_client)
+ service.rewrite_raw("Mein System-Prompt.", "Meine Nutzernachricht.")
+ kwargs = mock_client.chat.completions.create.call_args.kwargs
+ messages = kwargs["messages"]
+ assert messages[0] == {"role": "system", "content": "Mein System-Prompt."}
+ assert messages[1] == {"role": "user", "content": "Meine Nutzernachricht."}
+
+ def test_returns_api_response(self, mock_client):
+ service = LLMService(api_key=DUMMY_API_KEY, client=mock_client)
+ result = service.rewrite_raw("System.", "User.")
+ assert result == "OK"
+
+ def test_empty_user_message_raises(self, mock_client):
+ service = LLMService(api_key=DUMMY_API_KEY, client=mock_client)
+ with pytest.raises(ValueError, match="user_message"):
+ service.rewrite_raw("System.", " ")
+
+ def test_no_custom_terms_added(self, mock_client):
+ service = LLMService(
+ api_key=DUMMY_API_KEY, client=mock_client, custom_terms=CUSTOM_TERMS
+ )
+ service.rewrite_raw("System.", "User.")
+ kwargs = mock_client.chat.completions.create.call_args.kwargs
+ system = next(m["content"] for m in kwargs["messages"] if m["role"] == "system")
+ assert "muessen exakt so geschrieben werden" not in system
+
+ def test_api_error_wrapped_as_llm_service_error(self, mock_client):
+ mock_client.chat.completions.create.side_effect = RuntimeError("Verbindungsfehler")
+ service = LLMService(api_key=DUMMY_API_KEY, client=mock_client)
+ with pytest.raises(LLMServiceError, match="OpenAI API-Fehler"):
+ service.rewrite_raw("System.", "User.")
+
+ def test_unavailable_service_raises(self, mock_client):
+ service = LLMService(api_key="", client=mock_client)
+ with pytest.raises(LLMServiceError):
+ service.rewrite_raw("System.", "User.")