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
2 changes: 1 addition & 1 deletion README.de.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ Blitztext registriert globale Hotkeys via `evdev`. Mit diesen Kombinationen hast
| **Blitztext :)** | <kbd>Meta</kbd> + <kbd>Shift</kbd> + <kbd>E</kbd> | ✅ | 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

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ Blitztext registers global hotkeys via `evdev`. With these combinations you have
| **Blitztext :)** | <kbd>Meta</kbd> + <kbd>Shift</kbd> + <kbd>E</kbd> | ✅ | 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.

---

Expand Down
123 changes: 116 additions & 7 deletions app/compose_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand All @@ -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
Expand All @@ -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:
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"))
Expand All @@ -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)
Expand Down Expand Up @@ -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"))
Expand Down Expand Up @@ -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))
Expand Down
14 changes: 14 additions & 0 deletions app/i18n.py
Original file line number Diff line number Diff line change
Expand Up @@ -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})",
Expand Down Expand Up @@ -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})",
Expand Down
53 changes: 53 additions & 0 deletions app/llm_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
17 changes: 17 additions & 0 deletions tests/test_compose_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading