The technical term for chatbots sold by OpenAI, Claude and Google is LLM, Large Language Models. These large language models are nothing more than numeric weights that run on the GPUs of servers that now sprawl all over America. These models are called large because they weigh hundreds of gigabytes. But not all language models weigh that much. There are models that weigh 2-5 GB. They are called Small Language Models, or SLMs. A modern laptop can easily run them. What happens is the model is loaded into RAM and a tiny program called llama.cpp does the calculations on the CPU. These small models won’t write a book that will win a Pulitzer Prize, but they translate much better than Google Translate. Big benefit is that no data is leaving your computer. All computations are made by your hardware.

Language model:

https://huggingface.co/unsloth/Qwen3-4B-Instruct-2507-GGUF/tree/main

llama-cpp:

yay -S llama.cpp

python -m venv venv
source venv/bin/activate
pip install requests nltk PySide6


translator.py:

import sys
import os
import re
import time
import subprocess
import requests
import nltk
from nltk.tokenize import sent_tokenize
from PySide6.QtWidgets import (
    QApplication, QMainWindow, QWidget,
    QVBoxLayout, QHBoxLayout,
    QPushButton, QTextEdit, QProgressBar,
    QLabel, QFileDialog
)
from PySide6.QtCore import QThread, Signal, Qt

# configuration
LLAMA_SERVER_URL = "http://127.0.0.1:8080/completion"
SERVER_EXECUTABLE_PATH = '/usr/bin/llama-server'
MODEL_PATH = 'Qwen3-4B-Instruct-2507-Q4_K_M.gguf'

MODEL_PARAMS = {
    "repeat_penalty": 1.0,
    "temperature": 0.6,
    "top_k": 20,
    "top_p": 0.95,
}

SERVER_ARGS = [
    "-m", MODEL_PATH,
    "-c", "4096",
    "-t", "4",
    "--port", "8080",
    "--host", "127.0.0.1"
]
SERVER_STARTUP_TIMEOUT = 300

BATCH_TOKEN_TARGET = 100
BATCH_TOKEN_MIN = 50

# worker
class TranslationWorker(QThread):
    progress = Signal(int)
    status_msg = Signal(str)
    chunk_done = Signal(str, bool)
    finished = Signal()
    error = Signal(str)

    def __init__(self, text):
        super().__init__()
        self.raw_text = text
        self.server_process = None

    def run(self):
        try:
            try:
                nltk.data.find('tokenizers/punkt')
            except LookupError:
                nltk.download('punkt', quiet=True)
                nltk.download('punkt_tab', quiet=True)

            if not self.is_server_ready():
                self.status_msg.emit("Launching llama-server...")
                self.server_process = self.start_server()
                if not self.server_process:
                    self.error.emit(f"Failed to launch server at {SERVER_EXECUTABLE_PATH}")
                    return

            paragraphs = [p.strip() for p in re.split(r'\n\s*\n', self.raw_text) if p.strip()]
            batches = self.create_batches(paragraphs)
            self.status_msg.emit(f"The text is split into batches. Count: {len(batches)}")
            
            last_p_idx = -1

            for i, (p_idx, batch_text) in enumerate(batches):
                is_new_paragraph = (p_idx != last_p_idx)
                if len(batch_text.split()) < 2:
                    translated = batch_text
                else:
                    translated = self.translate_batch_api(batch_text)
                self.chunk_done.emit(translated + " ", is_new_paragraph)
                last_p_idx = p_idx
                self.progress.emit(int(((i + 1) / len(batches)) * 100))
                
            self.finished.emit()

        except Exception as e:
            self.error.emit(f"Worker Exception: {str(e)}")
        finally:
            self.cleanup_server()

    def is_server_ready(self):
        try:
            r = requests.get("http://127.0.0.1:8080/health", timeout=2)
            return r.status_code == 200
        except:
            return False

    def start_server(self):
        if not os.path.exists(MODEL_PATH):
            return None
            
        proc = subprocess.Popen(
            [SERVER_EXECUTABLE_PATH] + SERVER_ARGS,
            stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
        )
        
        for _ in range(SERVER_STARTUP_TIMEOUT):
            if self.is_server_ready():
                return proc
            time.sleep(1)
        return None
    
    # batching functions
    @staticmethod
    def estimate_tokens(text):
        return int(len(text.split()) * 1.3)
             
    @staticmethod
    def create_batches(paragraphs):
        batches = []
    
        for p_idx, paragraph in enumerate(paragraphs):
            sentences = sent_tokenize(paragraph)
            
            if not sentences:
                continue
            
            sentence_sizes = [TranslationWorker.estimate_tokens(s) for s in sentences]
            total_paragraph_tokens = sum(sentence_sizes)
            
            current_batch_sentences = []
            current_batch_tokens = 0
            processed_tokens = 0
    
            for idx, sentence in enumerate(sentences):
                sentence_tokens = sentence_sizes[idx]
                remaining_tokens_in_paragraph = total_paragraph_tokens - processed_tokens
                if current_batch_sentences and (current_batch_tokens + sentence_tokens > BATCH_TOKEN_TARGET):
                    if remaining_tokens_in_paragraph < BATCH_TOKEN_MIN:
                        pass 
                    else:
                        batches.append((p_idx, " ".join(current_batch_sentences)))
                        current_batch_sentences = []
                        current_batch_tokens = 0
                current_batch_sentences.append(sentence)
                current_batch_tokens += sentence_tokens
                processed_tokens += sentence_tokens
                
            if current_batch_sentences:
                batches.append((p_idx, " ".join(current_batch_sentences)))

        return batches

    def translate_batch_api(self, batch_text):
        prompt_text = (
            f"<|im_start|>user\nYou are a translator. Do not translate word for word. Compose translation in English expressions. Return only translation.\nTranslate to English:\n{batch_text}\n<|im_end|>\n"
            f"<|im_start|>assistant\n"
        )
        
        payload = {
            "prompt": prompt_text,
            "repeat_penalty": MODEL_PARAMS['repeat_penalty'],
            "temperature": MODEL_PARAMS['temperature'],
            "top_k": MODEL_PARAMS['top_k'],
            "top_p": MODEL_PARAMS['top_p'],
            "stop": ["<|im_end|>", "<|file_separator|>"],
            "stream": False
        }
        
        try:
            res = requests.post(LLAMA_SERVER_URL, json=payload, timeout=120)
            res.raise_for_status()
            return res.json().get('content', '').strip()
        except Exception as e:
            return f"[Error: {str(e)[:20]}]"

    def cleanup_server(self):
        if self.server_process:
            self.server_process.terminate()

class TranslatorApp(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Local Translator")
        self.resize(1000, 655)

        container = QWidget()
        self.main_layout = QVBoxLayout(container)
        self.editor_layout = QHBoxLayout()
        self.editor_layout.setSpacing(0)

        input_container = QWidget()
        input_layout = QVBoxLayout(input_container)
        input_layout.addWidget(QLabel("Input:"))
        self.input_area = QTextEdit()
        self.input_area.setAcceptRichText(False)
        input_layout.addWidget(self.input_area)

        output_container = QWidget()
        output_layout = QVBoxLayout(output_container)
        output_layout.addWidget(QLabel("Output:"))
        self.output_area = QTextEdit()
        self.output_area.setReadOnly(True)
        output_layout.addWidget(self.output_area)

        self.editor_layout.addWidget(input_container)
        self.editor_layout.addWidget(output_container)

        self.progress_bar = QProgressBar()
        self.status_label = QLabel("Ready")
        self.btn = QPushButton("Translate")
        self.btn.clicked.connect(self.start)
        
        self.save_btn = QPushButton("Save as...")
        self.save_btn.clicked.connect(self.save_output)

        buttons_layout = QHBoxLayout()
        buttons_layout.addWidget(self.btn)
        buttons_layout.addWidget(self.save_btn)

        self.main_layout.addLayout(self.editor_layout)
        self.main_layout.addWidget(self.progress_bar)
        self.main_layout.addWidget(self.status_label)
        self.main_layout.addLayout(buttons_layout)

        self.setCentralWidget(container)

    def start(self):
        text = self.input_area.toPlainText().strip()
        if not text:
            return

        self.btn.setEnabled(False)
        self.output_area.clear()
        self.progress_bar.setValue(0)

        self.worker = TranslationWorker(text)
        self.worker.status_msg.connect(self.status_label.setText)
        self.worker.progress.connect(self.progress_bar.setValue)
        self.worker.chunk_done.connect(self.on_chunk_done)
        self.worker.error.connect(self.on_error)
        self.worker.finished.connect(self.on_finish)
        self.worker.start()

    def on_chunk_done(self, text, is_new_paragraph):
        scrollbar = self.output_area.verticalScrollBar()
        at_bottom = scrollbar.value() >= (scrollbar.maximum() - 10)

        if is_new_paragraph and self.output_area.toPlainText().strip():
            self.output_area.insertPlainText("\n\n")
        
        self.output_area.insertPlainText(text)
        
        if at_bottom:
            scrollbar.setValue(scrollbar.maximum())

    def on_error(self, msg):
        self.status_label.setText(msg)
        self.btn.setEnabled(True)

    def on_finish(self):
        self.status_label.setText("Success")
        self.btn.setEnabled(True)
        
    def save_output(self):
        text = self.output_area.toPlainText()
    
        if not text.strip():
            return
    
        file_path, _ = QFileDialog.getSaveFileName(
            self,
            "Save file",
            "translation.txt",
            "Text Files (*.txt);;All Files (*)"
        )
    
        if file_path:
            with open(file_path, "w", encoding="utf-8") as f:
                f.write(text)
    
            self.status_label.setText(f"Saved: {file_path}")
        
    def closeEvent(self, event):
        if hasattr(self, 'worker') and self.worker.isRunning():
            self.worker.cleanup_server()
            self.worker.terminate()
            self.worker.wait()
        event.accept()

if __name__ == "__main__":
    app = QApplication(sys.argv)
    
    app.setStyleSheet("""
        QTextEdit {
            border: 1px solid #c0c0c0;
            border-radius: 4px;
            padding: 8px;
        }
        QPushButton {
            padding: 10px;
            border: 1px solid #c0c0c0;
            border-radius: 4px;
        }
    """)
    
    from PySide6.QtGui import QFont
    app.setFont(QFont("Adwaita Sans", 13))
    
    window = TranslatorApp()
    window.show()
    sys.exit(app.exec())