commit c6eef172e35de9d8f470ff74553518a1e1adb7cc Author: Rafał Paluch Date: Tue Jun 16 18:00:51 2026 +0200 first commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..317caf6 --- /dev/null +++ b/README.md @@ -0,0 +1,18 @@ +# 🌐 AI Translator (Llama.cpp Backend) + +Aplikacja GUI napisana w **PyQt6**, umożliwiająca tłumaczenie tekstu przy użyciu lokalnie hostowanego modelu językowego AI. Komunikacja z backendem odbywa się przez HTTP REST API (port `8081`), co zapewnia asynchroniczne przetwarzanie i brak blokowania interfejsu podczas generowania. + +## 🛠️ Wymagania +- Python 3.9+ +- Zainstalowany [llama.cpp](https://github.com/ggerganov/llama.cpp) (komponent `llama-server`) +- Plik `requirements.txt` + +## 🚀 Uruchomienie backendu (llama-server) +Aby aplikacja działała poprawnie, najpierw uruchom serwer AI: +```bash +llama-server -m /mnt/nvme_data/LLamaModels/gpt-oss-20b-Q4_K_M.gguf \ + -c 60000 \ + --port 8081 \ + --host 0.0.0.0 \ + --chat-template-kwargs '{"reasoning_effort":"low"}' + diff --git a/translate_ai.py b/translate_ai.py new file mode 100644 index 0000000..d838c03 --- /dev/null +++ b/translate_ai.py @@ -0,0 +1,275 @@ +import sys +import subprocess +import requests +import argparse +import json +from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, + QHBoxLayout, QTextBrowser, QPushButton, QLabel, + QProgressBar, QTextEdit) +from PyQt6.QtCore import QThread, pyqtSignal, Qt, QTimer +from PyQt6.QtGui import QFont + +# Przejście na oficjalny i bezpieczny endpoint chat/completions dla llama.cpp +LLAMA_URL = "http://127.0.0.1:8081/v1/chat/completions" + +class LlamaWorker(QThread): + """Wątek w tle do strumieniowej komunikacji przez API Chat Completions""" + chunk_received = pyqtSignal(str) + finished_generating = pyqtSignal() + error_occurred = pyqtSignal(str) + + def __init__(self, text, system_prompt): + super().__init__() + self.text = text + self.system_prompt = system_prompt + self._is_stopped = False + + def stop(self): + self._is_stopped = True + + def run(self): + payload = { + "messages": [ + {"role": "system", "content": self.system_prompt}, + {"role": "user", "content": self.text} + ], + "temperature": 0.3, + "stream": True # Strumieniowanie tokenów na żywo + } + try: + with requests.post(LLAMA_URL, json=payload, timeout=10, stream=True) as response: + response.raise_for_status() + for line in response.iter_lines(): + if self._is_stopped: + break + if line: + decoded = line.decode('utf-8').strip() + if decoded.startswith("data: "): + data_str = decoded[6:].strip() + if data_str == "[DONE]": + break + try: + data_json = json.loads(data_str) + choices = data_json.get("choices", []) + if choices: + delta = choices[0].get("delta", {}) + chunk = delta.get("content", "") + if chunk: + self.chunk_received.emit(chunk) + except json.JSONDecodeError: + pass + self.finished_generating.emit() + except requests.exceptions.RequestException as e: + self.error_occurred.emit(f"\nBłąd komunikacji z serwerem llama.cpp:\n{e}") + +class PotNativeApp(QMainWindow): + def __init__(self, mode="clip"): + super().__init__() + self.monitor_active = False + self.last_clipboard_text = "" + + # Pobranie systemowego schowka Qt + self.clipboard = QApplication.clipboard() + + self.init_ui() + + # Podpięcie natywnego zdarzenia zmiany zawartości schowka + self.clipboard.dataChanged.connect(self.on_clipboard_changed) + + if mode == "ocr": + QTimer.singleShot(200, self.trigger_ocr) + elif mode == "clip": + startup_text = self.clipboard.text().strip() + if startup_text: + self.last_clipboard_text = startup_text + self.process_text(startup_text) + + def init_ui(self): + self.setWindowTitle("Pot-Native v2 (Llama.cpp)") + self.resize(650, 750) + + main_widget = QWidget() + layout = QVBoxLayout() + main_widget.setLayout(layout) + self.setCentralWidget(main_widget) + + # --- Panel Kontrolny --- + control_layout = QHBoxLayout() + + self.btn_ocr = QPushButton("📷 Wykonaj OCR (Zaznacz ekran)") + self.btn_ocr.clicked.connect(self.trigger_ocr) + + self.btn_monitor = QPushButton("👁️ Monitorowanie Schowka: OFF") + self.btn_monitor.setCheckable(True) + self.btn_monitor.clicked.connect(self.toggle_monitor) + + control_layout.addWidget(self.btn_ocr) + control_layout.addWidget(self.btn_monitor) + layout.addLayout(control_layout) + + # --- Prompt --- + self.label_prompt = QLabel("Prompt systemowy (AI):") + self.label_prompt.setFont(QFont("Arial", 9, QFont.Weight.Bold)) + self.text_prompt = QTextEdit() + self.text_prompt.setMaximumHeight(65) + self.text_prompt.setPlainText("Jesteś zaawansowanym asystentem AI. Przetłumacz na język polski poniższy tekst, zachowując techniczny i specjalistyczny kontekst. Jeśli widzisz fragmenty kodu lub specyfikacje, nie psuj ich struktury.") + + layout.addWidget(self.label_prompt) + layout.addWidget(self.text_prompt) + + # --- Oryginał --- + self.label_source = QLabel("Tekst źródłowy:") + self.label_source.setFont(QFont("Arial", 9, QFont.Weight.Bold)) + self.text_source = QTextBrowser() + self.text_source.setMaximumHeight(100) + + layout.addWidget(self.label_source) + layout.addWidget(self.text_source) + + # --- Wynik --- + self.label_result = QLabel("Wynik z LLM:") + self.label_result.setFont(QFont("Arial", 9, QFont.Weight.Bold)) + self.text_result = QTextBrowser() + self.text_result.setPlaceholderText("Oczekiwanie na dane...") + + self.progress_bar = QProgressBar() + self.progress_bar.setRange(0, 0) + self.progress_bar.hide() + + layout.addWidget(self.label_result) + layout.addWidget(self.text_result) + layout.addWidget(self.progress_bar) + + # --- Przyciski dolne --- + btn_layout = QHBoxLayout() + + self.btn_stop = QPushButton("🛑 STOP") + self.btn_stop.setDisabled(True) + self.btn_stop.setStyleSheet("background-color: #c62828; color: white; font-weight: bold;") + self.btn_stop.clicked.connect(self.stop_translation) + + self.btn_copy = QPushButton("📋 Kopiuj wynik") + self.btn_copy.setDisabled(True) + self.btn_copy.clicked.connect(self.copy_to_clipboard) + + self.btn_close = QPushButton("Zamknij") + self.btn_close.clicked.connect(self.close) + + btn_layout.addWidget(self.btn_stop) + btn_layout.addWidget(self.btn_copy) + btn_layout.addWidget(self.btn_close) + layout.addLayout(btn_layout) + + def toggle_monitor(self): + if self.btn_monitor.isChecked(): + self.monitor_active = True + self.btn_monitor.setText("👁️ Monitorowanie Schowka: ON") + self.btn_monitor.setStyleSheet("background-color: #2e7d32; color: white; font-weight: bold;") + self.last_clipboard_text = self.clipboard.text().strip() + else: + self.monitor_active = False + self.btn_monitor.setText("👁️ Monitorowanie Schowka: OFF") + self.btn_monitor.setStyleSheet("") + + def on_clipboard_changed(self): + """Wywoływane automatycznie przez system, gdy schowek się zmienia""" + if not self.monitor_active: + return + + text = self.clipboard.text().strip() + if text and text != self.last_clipboard_text: + self.last_clipboard_text = text + self.process_text(text) + + def trigger_ocr(self): + self.hide() + QApplication.processEvents() + + # Małe opóźnienie dające czas kompozytorowi okien na ukrycie aplikacji + QTimer.singleShot(250, self._execute_ocr_cli) + + def _execute_ocr_cli(self): + try: + # Wywołanie maim oraz tesseract + subprocess.run(["maim", "-u", "-s", "/tmp/ocr_select.png"], check=True) + output = subprocess.check_output(["tesseract", "/tmp/ocr_select.png", "stdout", "-l", "pol+eng"], text=True) + text = output.strip() + + self.show() + if text: + self.process_text(text) + else: + self.text_source.setText("OCR nie wykrył żadnego tekstu na zaznaczonym obszarze.") + except subprocess.CalledProcessError: + self.show() # Anulowano (np. klawiszem ESC) + except FileNotFoundError: + self.show() + self.text_source.setText("Błąd: Nadal brakuje pakietów systemowych 'maim' lub 'tesseract'. Zainstaluj je przez pacmana.") + + def process_text(self, text): + if not text: + return + if hasattr(self, 'worker') and self.worker.isRunning(): + self.stop_translation() + + self.text_source.setText(text) + self.start_translation(text) + + def start_translation(self, text): + self.progress_bar.show() + self.text_result.clear() + self.btn_copy.setDisabled(True) + self.btn_copy.setText("📋 Kopiuj wynik") + self.btn_stop.setDisabled(False) + + current_prompt = self.text_prompt.toPlainText().strip() + + self.worker = LlamaWorker(text, current_prompt) + self.worker.chunk_received.connect(self.on_chunk) + self.worker.finished_generating.connect(self.on_finished) + self.worker.error_occurred.connect(self.on_error) + self.worker.start() + + def on_chunk(self, chunk): + cursor = self.text_result.textCursor() + cursor.movePosition(cursor.MoveOperation.End) + cursor.insertText(chunk) + self.text_result.setTextCursor(cursor) + + def stop_translation(self): + if hasattr(self, 'worker') and self.worker.isRunning(): + self.worker.stop() + self.text_result.append("\n\n[PRZERWANO GENEROWANIE]") + self.on_finished() + + def on_finished(self): + self.progress_bar.hide() + self.btn_stop.setDisabled(True) + self.btn_copy.setDisabled(False) + + def on_error(self, error_msg): + self.progress_bar.hide() + self.text_result.append(error_msg) + self.btn_stop.setDisabled(True) + + def copy_to_clipboard(self): + text_to_copy = self.text_result.toPlainText() + # Aktualizacja filtra, aby aplikacja nie próbowała przetłumaczyć tego, co sama skopiowała + self.last_clipboard_text = text_to_copy + self.clipboard.setText(text_to_copy) + self.btn_copy.setText("✅ Skopiowano!") + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--ocr", action="store_true") + args = parser.parse_args() + + mode = "ocr" if args.ocr else "clip" + + app = QApplication(sys.argv) + window = PotNativeApp(mode=mode) + + window.setWindowFlags(window.windowFlags() | Qt.WindowType.WindowStaysOnTopHint) + window.show() + + sys.exit(app.exec())