first commit
This commit is contained in:
18
README.md
Normal file
18
README.md
Normal file
@@ -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"}'
|
||||
|
||||
275
translate_ai.py
Normal file
275
translate_ai.py
Normal file
@@ -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())
|
||||
Reference in New Issue
Block a user