summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMasoud Jami <[email protected]>2025-06-23 14:09:46 +0200
committerMasoud Jami <[email protected]>2025-07-11 13:53:20 +0200
commit1f7b381787184d1a903e4cb3e8a487d445535eee (patch)
tree42f118141d11d82ddc81be25abbd99ff4f3808fb
parentd0db4581ec8232641a25c0c6685b40aa4d877c4a (diff)
Linguist: Add AI translationHEADdev
This patch adds AI translation for TS files opened on Linguist. The included documentation explains how to set it up. Fixes: QTBUG-4502 Change-Id: I67292e704b32363a906aafb9eca20c3ab2728ef9 Reviewed-by: Joerg Bornemann <[email protected]>
-rw-r--r--src/linguist/linguist/CMakeLists.txt7
-rw-r--r--src/linguist/linguist/doc/images/linguist-ai-translation-dialog.webpbin0 -> 18198 bytes
-rw-r--r--src/linguist/linguist/doc/src/linguist-manual.qdoc57
-rw-r--r--src/linguist/linguist/machinetranslationdialog.cpp319
-rw-r--r--src/linguist/linguist/machinetranslationdialog.h59
-rw-r--r--src/linguist/linguist/machinetranslationdialog.ui250
-rw-r--r--src/linguist/linguist/mainwindow.cpp16
-rw-r--r--src/linguist/linguist/mainwindow.h3
-rw-r--r--src/linguist/linguist/mainwindow.ui74
-rw-r--r--src/linguist/linguist/messagemodel.cpp3
-rw-r--r--src/linguist/linguist/messagemodel.h4
-rw-r--r--src/linguist/linguist/qmlformpreviewview.cpp5
-rw-r--r--src/linguist/shared/auto-translation/machinetranslator.cpp103
-rw-r--r--src/linguist/shared/auto-translation/machinetranslator.h55
-rw-r--r--src/linguist/shared/auto-translation/ollama.cpp234
-rw-r--r--src/linguist/shared/auto-translation/ollama.h41
-rw-r--r--src/linguist/shared/auto-translation/translationprotocol.h53
17 files changed, 1238 insertions, 45 deletions
diff --git a/src/linguist/linguist/CMakeLists.txt b/src/linguist/linguist/CMakeLists.txt
index 66422f37f..b33b64a93 100644
--- a/src/linguist/linguist/CMakeLists.txt
+++ b/src/linguist/linguist/CMakeLists.txt
@@ -17,6 +17,11 @@ qt_internal_add_app(linguist
../shared/ts.cpp
../shared/xliff.cpp
../shared/xmlparser.cpp ../shared/xmlparser.h
+ ../shared/auto-translation/translationprotocol.h
+ ../shared/auto-translation/ollama.cpp
+ ../shared/auto-translation/ollama.h
+ ../shared/auto-translation/machinetranslator.cpp
+ ../shared/auto-translation/machinetranslator.h
batchtranslation.ui
batchtranslationdialog.cpp batchtranslationdialog.h
errorsview.cpp errorsview.h
@@ -40,6 +45,7 @@ qt_internal_add_app(linguist
translatedialog.cpp translatedialog.h translatedialog.ui
translationsettings.ui
translationsettingsdialog.cpp translationsettingsdialog.h
+ machinetranslationdialog.h machinetranslationdialog.cpp machinetranslationdialog.ui
DEFINES
QFORMINTERNAL_NAMESPACE
QT_KEYWORDS
@@ -115,6 +121,7 @@ qt_add_ui(linguist
statistics.ui
translatedialog.ui
translationsettings.ui
+ machinetranslationdialog.ui
)
# Resources:
diff --git a/src/linguist/linguist/doc/images/linguist-ai-translation-dialog.webp b/src/linguist/linguist/doc/images/linguist-ai-translation-dialog.webp
new file mode 100644
index 000000000..5c641086e
--- /dev/null
+++ b/src/linguist/linguist/doc/images/linguist-ai-translation-dialog.webp
Binary files differ
diff --git a/src/linguist/linguist/doc/src/linguist-manual.qdoc b/src/linguist/linguist/doc/src/linguist-manual.qdoc
index 55509e32e..eb7910ba5 100644
--- a/src/linguist/linguist/doc/src/linguist-manual.qdoc
+++ b/src/linguist/linguist/doc/src/linguist-manual.qdoc
@@ -771,6 +771,7 @@
\li \l {Reusing translations}
\li \l {Validating translations}
\li \l {Translating multiple languages simultaneously}
+ \li \l {AI translation}
\endlist
*/
@@ -1324,7 +1325,7 @@
\target multiple languages
\previouspage Validating translations
- \nextpage Developers
+ \nextpage AI translation
\title Translating multiple languages simultaneously
@@ -1346,10 +1347,62 @@
*/
/*!
+ \page linguist-ai-translation.html
+ \target ai translation
+
+ \previouspage Translating multiple languages simultaneously
+ \nextpage Developers
+
+ \title AI translation
+
+ The AI Translation feature lets you automatically generate
+translations for the open TS file using a local LLM via Ollama.
+
+ To use AI Translation you must first install
+\l{https://github.com/jmorganca/ollama}{Ollama} and pull at least one model, for example:
+
+ \list
+ \li \c{ollama pull 7shi/llama-translate:8b-q4_K_M}
+ \li \c{ollama pull granite3-dense:2b}
+ \endlist
+
+ Then start the Ollama server if not already started:
+
+ \code
+ ollama serve
+ \endcode
+
+ In Linguist, choose \uicontrol{Translation > AI Translation}
+to open the AI Translation dialog:
+
+ \image linguist-ai-translation-dialog.webp
+
+ The dialog provides:
+
+ \list
+ \li \uicontrol {Ollama Server}: the REST endpoint where Ollama listens
+ (default \c http://127.0.0.1:11434).
+ \li \uicontrol Model: drop-down of locally installed models.
+ \li \uicontrol File: the TS file to translate.
+ \li \uicontrol Filter (optional): limit to strings in a specific group (context or label).
+ \li \uicontrol Translate button: start the AI translation.
+ \li \uicontrol {Apply Translations} button: apply the translated items into the TS file.
+ \endlist
+
+During translation, progress messages appear in the status bar and in the \uicontrol {Translation
+Log}. When complete, you can check out the translated texts on the log. Upon clicking on
+\uicontrol{Apply Translations}, AI-generated translations are inserted into the TS file.
+
+\note We suggest \e{7shi/llama-translate:8b-q4_K_M} as a balanced, general-purpose
+ model. Feel free to try other models to find the best combination
+ of speed, quality, and resource usage.
+*/
+
+/*!
\page linguist-programmers.html
\title Developers
- \previouspage Translating multiple languages simultaneously
+ \previouspage AI translation
\nextpage TS File Format
\image front-coding.png
diff --git a/src/linguist/linguist/machinetranslationdialog.cpp b/src/linguist/linguist/machinetranslationdialog.cpp
new file mode 100644
index 000000000..a98ca9a79
--- /dev/null
+++ b/src/linguist/linguist/machinetranslationdialog.cpp
@@ -0,0 +1,319 @@
+// Copyright (C) 2025 The Qt Company Ltd.
+// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0
+
+#include "machinetranslationdialog.h"
+#include "ui_machinetranslationdialog.h"
+
+#include "messagemodel.h"
+#include "auto-translation/machinetranslator.h"
+
+#include <QtWidgets/qmessagebox.h>
+
+using namespace Qt::Literals::StringLiterals;
+
+QT_BEGIN_NAMESPACE
+
+MachineTranslationDialog::MachineTranslationDialog(QWidget *parent)
+ : QDialog(parent),
+ m_ui(std::make_unique<Ui::MachineTranslationDialog>()),
+ m_translator(std::make_unique<MachineTranslator>())
+{
+ m_ui->setupUi(this);
+ m_ui->statusLabel->setWordWrap(true);
+ m_ui->statusLabel->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
+ connect(m_ui->translateButton, &QPushButton::clicked, this,
+ &MachineTranslationDialog::translateSelection);
+ connect(m_ui->filesComboBox, &QComboBox::currentIndexChanged, this, [this] {
+ m_ui->filterComboBox->setCurrentIndex(0);
+ updateStatus();
+ });
+ connect(m_translator.get(), &MachineTranslator::batchTranslated, this,
+ &MachineTranslationDialog::onBatchTranslated);
+ connect(m_translator.get(), &MachineTranslator::translationFailed, this,
+ &MachineTranslationDialog::onTranslationFailed);
+ connect(m_ui->groupComboBox, &QComboBox::currentIndexChanged, this, [this] { updateStatus(); });
+ connect(m_ui->doneButton, &QPushButton::clicked, this, [this] {
+ if (discardTranslations())
+ accept();
+ });
+ connect(m_ui->cancelButton, &QPushButton::clicked, this, [this] {
+ if (discardTranslations())
+ reject();
+ });
+ connect(m_ui->applyButton, &QPushButton::clicked, this,
+ &MachineTranslationDialog::applyTranslations);
+
+ connect(m_ui->stopButton, &QToolButton::clicked, this, &MachineTranslationDialog::stop);
+ connect(m_ui->connectButton, &QPushButton::clicked, this,
+ &MachineTranslationDialog::connectToOllama);
+ connect(m_translator.get(), &MachineTranslator::modelsReceived, this,
+ [this](const QStringList &models) {
+ m_ui->modelComboBox->clear();
+ m_ui->modelComboBox->addItems(models);
+ });
+ connect(this, &QDialog::finished, m_translator.get(), &MachineTranslator::stop);
+ connect(m_ui->filterComboBox, &QComboBox::currentIndexChanged, this,
+ &MachineTranslationDialog::onFilterChanged);
+}
+
+void MachineTranslationDialog::setDataModel(MultiDataModel *dm)
+{
+ m_dataModel = dm;
+ refresh(true);
+}
+
+void MachineTranslationDialog::refresh(bool init)
+{
+ if (init) {
+ m_ui->filesComboBox->clear();
+ m_ui->filesComboBox->addItems(m_dataModel->srcFileNames());
+ m_ui->filesComboBox->setCurrentIndex(0);
+ m_ui->translationLog->setText(tr("Translation Log"));
+ m_ui->translateButton->setEnabled(true);
+ m_ui->stopButton->setEnabled(false);
+ connectToOllama();
+ }
+ m_sentTexts = 0;
+ m_failedTranslations = 0;
+ m_receivedTranslations.clear();
+ m_ongoingTranslations.clear();
+ m_ui->applyButton->setEnabled(false);
+ m_ui->progressBar->setVisible(false);
+ m_translator->start();
+}
+
+void MachineTranslationDialog::logProgress(const QList<QStringList> &table)
+{
+ const qsizetype receivedCount = m_receivedTranslations.size();
+ m_ui->statusLabel->setText(
+ tr("Translation status: %1/%2 source texts translated, %3/%2 failed.")
+ .arg(receivedCount)
+ .arg(m_sentTexts)
+ .arg(m_failedTranslations));
+ m_ui->progressBar->setValue((receivedCount + m_failedTranslations) * 100 / m_sentTexts);
+ if (!table.empty()) {
+ QString html = "<hr/><table cellpadding=\"4\""
+ "style=\""
+ "width:100%; "
+ "margin-left:10px; "
+ "\">"_L1;
+ for (const QStringList &row : table) {
+ html += "<tr>"_L1;
+ for (const QString &col : row)
+ html += "<td>%1</td>"_L1.arg(col);
+ html += "</tr>"_L1;
+ }
+ html += "</table>"_L1;
+ m_ui->translationLog->append(html);
+ }
+
+ if (receivedCount + m_failedTranslations == m_sentTexts) {
+ m_ui->translationLog->append(
+ tr("<hr/><b>Translation completed: %1/%2 translated, %3/%2 failed.</b>")
+ .arg(receivedCount)
+ .arg(m_sentTexts)
+ .arg(m_failedTranslations));
+ m_ui->translateButton->setEnabled(true);
+ m_ui->stopButton->setEnabled(false);
+ m_ui->applyButton->setEnabled(true);
+ m_ui->progressBar->setVisible(false);
+ } else {
+ m_ui->translateButton->setEnabled(false);
+ m_ui->stopButton->setEnabled(true);
+ m_ui->progressBar->setVisible(true);
+ }
+}
+
+void MachineTranslationDialog::logInfo(const QString &info)
+{
+ m_ui->translationLog->append("<hr/>"_L1);
+ m_ui->translationLog->append(info);
+}
+
+void MachineTranslationDialog::logError(const QString &error)
+{
+ m_ui->translationLog->append("<hr/>"_L1);
+ m_ui->translationLog->append(
+ "<span style=\"color:red; font-weight: bold; \">%1</span>"_L1.arg(error));
+}
+
+bool MachineTranslationDialog::discardTranslations()
+{
+ return (m_receivedTranslations.empty()
+ || QMessageBox::warning(
+ this, tr("Qt Linguist"),
+ tr("The already %n translated item(s) will be discarded. Continue?", 0,
+ m_receivedTranslations.size()),
+ QMessageBox::Yes | QMessageBox::No)
+ == QMessageBox::Yes);
+}
+
+void MachineTranslationDialog::stop()
+{
+ m_translator->stop();
+ m_ui->stopButton->setEnabled(false);
+ m_ui->translateButton->setEnabled(true);
+ refresh(false);
+ logError(tr("Translation Stopped."));
+}
+
+void MachineTranslationDialog::translateSelection()
+{
+ const QString model = m_ui->modelComboBox->currentText();
+ const int id = m_ui->filesComboBox->currentIndex();
+ if (model.isEmpty()) {
+ logError(tr("Please verify the service URL is valid, "
+ "then select a translation model."));
+ return;
+ }
+ if (id < 0) {
+ logError(tr("Please select a file for translation."));
+ return;
+ }
+ if (!discardTranslations())
+ return;
+ refresh(false);
+
+ const int filter = m_ui->filterComboBox->currentIndex();
+ const int group = m_ui->groupComboBox->currentIndex();
+ const DataModel *dm = m_dataModel->model(id);
+ Messages messages;
+ if (filter == 0) {
+ QMutexLocker lock(&m_mutex);
+ for (DataModelIterator it(TEXTBASED, dm); it.isValid(); ++it) {
+ const TranslatorMessage *tm = &it.current()->message();
+ if (tm->translation().isEmpty()) {
+ messages.items.append(tm);
+ m_ongoingTranslations[tm] =
+ MultiDataIndex{ it.translationType(), id, it.group(), it.message() };
+ }
+ }
+ for (DataModelIterator it(IDBASED, dm); it.isValid(); ++it) {
+ const TranslatorMessage *tm = &it.current()->message();
+ if (tm->translation().isEmpty()) {
+ messages.items.append(tm);
+ m_ongoingTranslations[tm] =
+ MultiDataIndex{ it.translationType(), id, it.group(), it.message() };
+ }
+ }
+ } else {
+ QMutexLocker lock(&m_mutex);
+ const auto type = (filter == 1) ? TEXTBASED : IDBASED;
+ GroupItem *g = dm->groupItem(group, type);
+ for (int i = 0; i < g->messageCount(); i++) {
+ const TranslatorMessage *tm = &g->messageItem(i)->message();
+ if (tm->translation().isEmpty()) {
+ messages.items.append(tm);
+ m_ongoingTranslations[tm] = MultiDataIndex{ type, id, group, i };
+ }
+ }
+ }
+ messages.srcLang = QLocale::languageToString(dm->sourceLanguage());
+ messages.tgtLang = QLocale::languageToString(dm->language());
+ m_sentTexts += messages.items.size();
+ m_translator->setTranslationModel(model);
+ m_translator->translate(messages);
+ logInfo(tr("Translation Started"));
+ logProgress({});
+}
+
+void MachineTranslationDialog::onBatchTranslated(
+ QHash<const TranslatorMessage *, QString> translations)
+{
+ QList<QStringList> log;
+ log.reserve(translations.size());
+ QMutexLocker lock(&m_mutex);
+ for (const auto &[msg, translation] : translations.asKeyValueRange()) {
+ log.append({ msg->sourceText().simplified(), translation.simplified() });
+ m_receivedTranslations.append(std::make_pair(m_ongoingTranslations.take(msg), translation));
+ }
+ logInfo(tr("Translation Batch:"));
+ logProgress(log);
+}
+
+void MachineTranslationDialog::onFilterChanged(int id)
+{
+ m_ui->groupLabel->setEnabled(id != 0);
+ m_ui->groupComboBox->setEnabled(id != 0);
+ m_ui->groupComboBox->clear();
+ int modelId = m_ui->filesComboBox->currentIndex();
+ if (modelId < 0)
+ return;
+
+ if (id == 1) {
+ for (int i = 0; i < m_dataModel->model(modelId)->contextCount(); i++)
+ m_ui->groupComboBox->addItem(
+ m_dataModel->model(modelId)->groupItem(i, TEXTBASED)->group());
+ } else if (id == 2) {
+ for (int i = 0; i < m_dataModel->model(modelId)->labelCount(); i++)
+ m_ui->groupComboBox->addItem(
+ m_dataModel->model(modelId)->groupItem(i, IDBASED)->group());
+ }
+ m_ui->groupComboBox->setCurrentIndex(0);
+}
+
+void MachineTranslationDialog::applyTranslations()
+{
+ QMutexLocker lock(&m_mutex);
+ for (const auto &[item, translation] : std::as_const(m_receivedTranslations))
+ m_dataModel->setTranslation(item, translation);
+ refresh(false);
+ logInfo(tr("Translations Applied."));
+}
+
+void MachineTranslationDialog::onTranslationFailed(QList<const TranslatorMessage *> failed)
+{
+ QList<QStringList> log;
+ log.reserve(failed.size() + 1);
+
+ QMutexLocker lock(&m_mutex);
+ m_failedTranslations += failed.size();
+ for (const TranslatorMessage *m : failed) {
+ log << QStringList{ m->sourceText().simplified() };
+ m_ongoingTranslations.remove(m);
+ }
+ logError(tr("Failed Translation(s):"));
+ logProgress(log);
+}
+
+void MachineTranslationDialog::updateStatus()
+{
+ const int model = m_ui->filesComboBox->currentIndex();
+ const int filter = m_ui->filterComboBox->currentIndex();
+ const int group = m_ui->groupComboBox->currentIndex();
+ if (model < 0 || filter < 0 || (filter > 0 && group < 0)) {
+ m_ui->statusLabel->setText(tr("Translation status: -"));
+ } else if (filter == 0) {
+ int count = 0;
+ for (DataModelIterator it(IDBASED, m_dataModel->model(model)); it.isValid(); ++it)
+ if (it.current()->translation().isEmpty())
+ count++;
+ for (DataModelIterator it(TEXTBASED, m_dataModel->model(model)); it.isValid(); ++it)
+ if (it.current()->translation().isEmpty())
+ count++;
+
+ m_ui->statusLabel->setText(tr("Translation status: %n item(s). For best results, "
+ "translate in Context / Label batches.",
+ 0, count));
+ } else if (group >= 0) {
+ const auto type = (filter == 1) ? TEXTBASED : IDBASED;
+ int count = 0;
+ GroupItem *g = m_dataModel->model(model)->groupItem(group, type);
+ for (int i = 0; i < g->messageCount(); i++)
+ if (g->messageItem(i)->message().translation().isEmpty())
+ count++;
+ m_ui->statusLabel->setText(tr("Translation status: %n item(s).", 0, count));
+ }
+}
+
+void MachineTranslationDialog::connectToOllama()
+{
+ if (m_ui->serverText->text().isEmpty())
+ return;
+ m_translator->setUrl(m_ui->serverText->text());
+ m_translator->requestModels();
+}
+
+MachineTranslationDialog::~MachineTranslationDialog() = default;
+
+QT_END_NAMESPACE
diff --git a/src/linguist/linguist/machinetranslationdialog.h b/src/linguist/linguist/machinetranslationdialog.h
new file mode 100644
index 000000000..50e198ea4
--- /dev/null
+++ b/src/linguist/linguist/machinetranslationdialog.h
@@ -0,0 +1,59 @@
+// Copyright (C) 2025 The Qt Company Ltd.
+// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0
+
+#ifndef MACHINETRANSLATIONDIALOG_H
+#define MACHINETRANSLATIONDIALOG_H
+
+#include <QDialog>
+#include <QMutex>
+
+QT_BEGIN_NAMESPACE
+
+namespace Ui {
+class MachineTranslationDialog;
+}
+
+class MultiDataModel;
+class MachineTranslator;
+class TranslatorMessage;
+class MultiDataIndex;
+
+class MachineTranslationDialog : public QDialog
+{
+ Q_OBJECT
+public:
+ explicit MachineTranslationDialog(QWidget *parent = nullptr);
+ ~MachineTranslationDialog();
+
+ void setDataModel(MultiDataModel *dm);
+
+private:
+ void refresh(bool init);
+ void logProgress(const QList<QStringList> &table);
+ void logInfo(const QString &info);
+ void logError(const QString &error);
+ bool discardTranslations();
+ void updateStatus();
+
+ MultiDataModel *m_dataModel;
+ QHash<const TranslatorMessage *, MultiDataIndex> m_ongoingTranslations;
+ QList<std::pair<MultiDataIndex, QString>> m_receivedTranslations;
+ QMutex m_mutex;
+ std::unique_ptr<Ui::MachineTranslationDialog> m_ui;
+ std::unique_ptr<MachineTranslator> m_translator;
+ int m_failedTranslations = 0;
+ int m_sentTexts = 0;
+
+private slots:
+ void stop();
+ void translateSelection();
+ void onBatchTranslated(QHash<const TranslatorMessage *, QString> translations);
+ void onFilterChanged(int id);
+ void applyTranslations();
+ void onTranslationFailed(QList<const TranslatorMessage *>);
+ void connectToOllama();
+};
+
+QT_END_NAMESPACE
+
+#endif // MACHINETRANSLATIONDIALOG_H
diff --git a/src/linguist/linguist/machinetranslationdialog.ui b/src/linguist/linguist/machinetranslationdialog.ui
new file mode 100644
index 000000000..80e325354
--- /dev/null
+++ b/src/linguist/linguist/machinetranslationdialog.ui
@@ -0,0 +1,250 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>MachineTranslationDialog</class>
+ <widget class="QDialog" name="MachineTranslationDialog">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>821</width>
+ <height>470</height>
+ </rect>
+ </property>
+ <property name="windowTitle">
+ <string>Auto Translation Dialog</string>
+ </property>
+ <layout class="QGridLayout" name="gridLayout_2" rowstretch="0">
+ <item row="0" column="0">
+ <layout class="QGridLayout" name="gridLayout" rowstretch="0,0,0,0,0,0,0,0,1,0,0">
+ <property name="sizeConstraint">
+ <enum>QLayout::SizeConstraint::SetDefaultConstraint</enum>
+ </property>
+ <property name="leftMargin">
+ <number>10</number>
+ </property>
+ <property name="topMargin">
+ <number>10</number>
+ </property>
+ <property name="rightMargin">
+ <number>10</number>
+ </property>
+ <property name="bottomMargin">
+ <number>10</number>
+ </property>
+ <item row="6" column="19">
+ <widget class="QToolButton" name="stopButton">
+ <property name="enabled">
+ <bool>false</bool>
+ </property>
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Fixed" vsizetype="Fixed">
+ <horstretch>100</horstretch>
+ <verstretch>100</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="text">
+ <string/>
+ </property>
+ <property name="icon">
+ <iconset theme="QIcon::ThemeIcon::ProcessStop"/>
+ </property>
+ <property name="checkable">
+ <bool>false</bool>
+ </property>
+ </widget>
+ </item>
+ <item row="1" column="1" rowspan="3" colspan="19">
+ <widget class="QComboBox" name="filesComboBox"/>
+ </item>
+ <item row="0" column="9">
+ <widget class="QToolButton" name="connectButton">
+ <property name="text">
+ <string>...</string>
+ </property>
+ <property name="icon">
+ <iconset theme="QIcon::ThemeIcon::SystemSearch"/>
+ </property>
+ </widget>
+ </item>
+ <item row="0" column="10" colspan="10">
+ <widget class="QComboBox" name="modelComboBox">
+ <property name="minimumSize">
+ <size>
+ <width>150</width>
+ <height>0</height>
+ </size>
+ </property>
+ </widget>
+ </item>
+ <item row="0" column="0" alignment="Qt::AlignmentFlag::AlignRight">
+ <widget class="QLabel" name="serverLabel">
+ <property name="text">
+ <string>Ollama Server:</string>
+ </property>
+ <property name="buddy">
+ <cstring>serverText</cstring>
+ </property>
+ </widget>
+ </item>
+ <item row="1" column="0" rowspan="3" alignment="Qt::AlignmentFlag::AlignRight">
+ <widget class="QLabel" name="filesLabel">
+ <property name="text">
+ <string>File:</string>
+ </property>
+ <property name="buddy">
+ <cstring>filesComboBox</cstring>
+ </property>
+ </widget>
+ </item>
+ <item row="6" column="0" colspan="16">
+ <widget class="QLabel" name="statusLabel">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Preferred" vsizetype="Preferred">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="text">
+ <string>Translation status: -</string>
+ </property>
+ </widget>
+ </item>
+ <item row="9" column="18" rowspan="2" colspan="2">
+ <widget class="QPushButton" name="doneButton">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Minimum" vsizetype="Fixed">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="maximumSize">
+ <size>
+ <width>16777215</width>
+ <height>16777215</height>
+ </size>
+ </property>
+ <property name="text">
+ <string>Done</string>
+ </property>
+ </widget>
+ </item>
+ <item row="4" column="0" rowspan="2" alignment="Qt::AlignmentFlag::AlignRight">
+ <widget class="QLabel" name="filterLabel">
+ <property name="text">
+ <string>Filter:</string>
+ </property>
+ </widget>
+ </item>
+ <item row="4" column="7" rowspan="2" colspan="13">
+ <widget class="QComboBox" name="groupComboBox">
+ <property name="enabled">
+ <bool>false</bool>
+ </property>
+ </widget>
+ </item>
+ <item row="4" column="6" rowspan="2">
+ <widget class="QLabel" name="groupLabel">
+ <property name="enabled">
+ <bool>false</bool>
+ </property>
+ <property name="text">
+ <string>Group:</string>
+ </property>
+ </widget>
+ </item>
+ <item row="4" column="1" rowspan="2" colspan="5">
+ <widget class="QComboBox" name="filterComboBox">
+ <item>
+ <property name="text">
+ <string>All Items</string>
+ </property>
+ </item>
+ <item>
+ <property name="text">
+ <string>Text Based</string>
+ </property>
+ </item>
+ <item>
+ <property name="text">
+ <string>ID Based</string>
+ </property>
+ </item>
+ </widget>
+ </item>
+ <item row="0" column="1" colspan="8">
+ <widget class="QLineEdit" name="serverText">
+ <property name="text">
+ <string>http://localhost:11434</string>
+ </property>
+ </widget>
+ </item>
+ <item row="7" column="0" rowspan="2" colspan="20">
+ <widget class="QTextEdit" name="translationLog">
+ <property name="readOnly">
+ <bool>true</bool>
+ </property>
+ <property name="html">
+ <string>&lt;!DOCTYPE HTML PUBLIC &quot;-//W3C//DTD HTML 4.0//EN&quot; &quot;http://www.w3.org/TR/REC-html40/strict.dtd&quot;&gt;
+&lt;html&gt;&lt;head&gt;&lt;meta name=&quot;qrichtext&quot; content=&quot;1&quot; /&gt;&lt;meta charset=&quot;utf-8&quot; /&gt;&lt;style type=&quot;text/css&quot;&gt;
+p, li { white-space: pre-wrap; }
+hr { height: 1px; border-width: 0; }
+li.unchecked::marker { content: &quot;\2610&quot;; }
+li.checked::marker { content: &quot;\2612&quot;; }
+&lt;/style&gt;&lt;/head&gt;&lt;body style=&quot; font-family:'.AppleSystemUIFont'; font-size:13pt; font-weight:400; font-style:normal;&quot;&gt;
+&lt;p style=&quot; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;&quot;&gt;Translation Log&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
+ </property>
+ </widget>
+ </item>
+ <item row="6" column="18">
+ <widget class="QPushButton" name="translateButton">
+ <property name="text">
+ <string>Translate</string>
+ </property>
+ </widget>
+ </item>
+ <item row="9" column="17" rowspan="2">
+ <widget class="QPushButton" name="cancelButton">
+ <property name="text">
+ <string>Cancel</string>
+ </property>
+ </widget>
+ </item>
+ <item row="9" column="0" rowspan="2" colspan="5">
+ <widget class="QPushButton" name="applyButton">
+ <property name="enabled">
+ <bool>false</bool>
+ </property>
+ <property name="text">
+ <string>Apply Translations</string>
+ </property>
+ </widget>
+ </item>
+ <item row="6" column="16" colspan="2">
+ <widget class="QProgressBar" name="progressBar">
+ <property name="enabled">
+ <bool>true</bool>
+ </property>
+ <property name="value">
+ <number>0</number>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ </layout>
+ </widget>
+ <tabstops>
+ <tabstop>serverText</tabstop>
+ <tabstop>connectButton</tabstop>
+ <tabstop>modelComboBox</tabstop>
+ <tabstop>filesComboBox</tabstop>
+ <tabstop>filterComboBox</tabstop>
+ <tabstop>groupComboBox</tabstop>
+ <tabstop>stopButton</tabstop>
+ <tabstop>translationLog</tabstop>
+ <tabstop>applyButton</tabstop>
+ <tabstop>doneButton</tabstop>
+ </tabstops>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/src/linguist/linguist/mainwindow.cpp b/src/linguist/linguist/mainwindow.cpp
index 3746b09b6..6cb64695a 100644
--- a/src/linguist/linguist/mainwindow.cpp
+++ b/src/linguist/linguist/mainwindow.cpp
@@ -9,6 +9,7 @@
#include "mainwindow.h"
#include "batchtranslationdialog.h"
+#include "machinetranslationdialog.h"
#include "errorsview.h"
#include "finddialog.h"
#include "uiformpreviewview.h"
@@ -1809,11 +1810,6 @@ void MainWindow::selectedMessageChanged(const QModelIndex &sortedIndex, const QM
void MainWindow::translationChanged(const MultiDataIndex &index)
{
- // We get that as a result of batch translation or search & translate,
- // so the current model is known to match.
- if (index != m_currentIndex)
- return;
-
m_messageEditor->showMessage(index);
updateDanger(index, true);
@@ -1905,6 +1901,14 @@ void MainWindow::toggleFinished(const QModelIndex &index)
m_dataModel->setFinished(dataIndex, !m->isFinished());
}
+void MainWindow::openMachineTranslateDialog()
+{
+ if (!m_machineTranslationDialog)
+ m_machineTranslationDialog = new MachineTranslationDialog(this);
+ m_machineTranslationDialog->setDataModel(m_dataModel);
+ m_machineTranslationDialog->open();
+}
+
/*
* Receives a context index in the sorted messages model and returns the next
* logical context index in the same model, based on the sort order of the
@@ -2218,6 +2222,8 @@ void MainWindow::setupMenuBar()
// Translation menu
// when updating the accelerators, remember the status bar
+ connect(m_ui.actionAuto_Translation, &QAction::triggered, this,
+ &MainWindow::openMachineTranslateDialog);
connect(m_ui.actionPrevUnfinished, &QAction::triggered, this, &MainWindow::prevUnfinished);
connect(m_ui.actionNextUnfinished, &QAction::triggered, this, &MainWindow::nextUnfinished);
connect(m_ui.actionNext, &QAction::triggered, this, &MainWindow::next);
diff --git a/src/linguist/linguist/mainwindow.h b/src/linguist/linguist/mainwindow.h
index 6f2b143a6..72863cef6 100644
--- a/src/linguist/linguist/mainwindow.h
+++ b/src/linguist/linguist/mainwindow.h
@@ -45,6 +45,7 @@ class Statistics;
class TranslateDialog;
class TranslationSettingsDialog;
class SortedGroupsModel;
+class MachineTranslationDialog;
class MainWindow : public QMainWindow
{
@@ -128,6 +129,7 @@ private slots:
void updateActiveModel(int);
void refreshItemViews();
void toggleFinished(const QModelIndex &index);
+ void openMachineTranslateDialog();
void prevUnfinished();
void nextUnfinished();
void findNext(const QString &text, DataModel::FindLocation where,
@@ -251,6 +253,7 @@ private:
Ui::MainWindow m_ui; // menus and actions
Statistics *m_statistics;
RecentFiles m_recentFiles;
+ MachineTranslationDialog *m_machineTranslationDialog = 0;
};
QT_END_NAMESPACE
diff --git a/src/linguist/linguist/mainwindow.ui b/src/linguist/linguist/mainwindow.ui
index 0e07787ab..701aabce2 100644
--- a/src/linguist/linguist/mainwindow.ui
+++ b/src/linguist/linguist/mainwindow.ui
@@ -1,5 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
-<comment>
+ <comment>
* Copyright (C) 2016 The Qt Company Ltd.
* SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0
</comment>
@@ -23,7 +24,7 @@
<x>0</x>
<y>0</y>
<width>673</width>
- <height>28</height>
+ <height>24</height>
</rect>
</property>
<widget class="QMenu" name="menuPhrases">
@@ -121,6 +122,8 @@
<property name="title">
<string>&amp;Translation</string>
</property>
+ <addaction name="actionAuto_Translation"/>
+ <addaction name="separator"/>
<addaction name="actionPrevUnfinished"/>
<addaction name="actionNextUnfinished"/>
<addaction name="actionPrev"/>
@@ -209,7 +212,7 @@
<string>Ctrl+Q</string>
</property>
<property name="menuRole">
- <enum>QAction::QuitRole</enum>
+ <enum>QAction::MenuRole::QuitRole</enum>
</property>
</action>
<action name="actionSave">
@@ -264,7 +267,7 @@
<string>Ctrl+Z</string>
</property>
<property name="menuRole">
- <enum>QAction::NoRole</enum>
+ <enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionRedo">
@@ -281,7 +284,7 @@
<string>Ctrl+Y</string>
</property>
<property name="menuRole">
- <enum>QAction::NoRole</enum>
+ <enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionCut">
@@ -354,7 +357,7 @@
<string>Ctrl+F</string>
</property>
<property name="menuRole">
- <enum>QAction::NoRole</enum>
+ <enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionFindNext">
@@ -371,7 +374,7 @@
<string>F3</string>
</property>
<property name="menuRole">
- <enum>QAction::NoRole</enum>
+ <enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionPrevUnfinished">
@@ -388,7 +391,7 @@
<string>Ctrl+K</string>
</property>
<property name="menuRole">
- <enum>QAction::NoRole</enum>
+ <enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionNextUnfinished">
@@ -405,7 +408,7 @@
<string>Ctrl+J</string>
</property>
<property name="menuRole">
- <enum>QAction::NoRole</enum>
+ <enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionPrev">
@@ -422,7 +425,7 @@
<string>Ctrl+Shift+K</string>
</property>
<property name="menuRole">
- <enum>QAction::NoRole</enum>
+ <enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionNext">
@@ -439,7 +442,7 @@
<string>Ctrl+Shift+J</string>
</property>
<property name="menuRole">
- <enum>QAction::NoRole</enum>
+ <enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionDoneAndNext">
@@ -456,7 +459,7 @@
<string>Mark this item as done and move to the next unfinished item.</string>
</property>
<property name="menuRole">
- <enum>QAction::NoRole</enum>
+ <enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionBeginFromSource">
@@ -479,7 +482,7 @@
<string>Ctrl+B</string>
</property>
<property name="menuRole">
- <enum>QAction::NoRole</enum>
+ <enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionAccelerators">
@@ -496,7 +499,7 @@
<string>Toggles the validity check of accelerators, i.e. whether the number of ampersands in the source and translation text is the same. If the check fails, a message is shown in the warnings window.</string>
</property>
<property name="menuRole">
- <enum>QAction::NoRole</enum>
+ <enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionSurroundingWhitespace">
@@ -513,7 +516,7 @@
<string>Toggles the validity check of surrounding whitespace. If the check fails, a message is shown in the warnings window.</string>
</property>
<property name="menuRole">
- <enum>QAction::NoRole</enum>
+ <enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionEndingPunctuation">
@@ -530,7 +533,7 @@
<string>Toggles the validity check of ending punctuation. If the check fails, a message is shown in the warnings window.</string>
</property>
<property name="menuRole">
- <enum>QAction::NoRole</enum>
+ <enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionPhraseMatches">
@@ -547,7 +550,7 @@
<string>Toggles checking that phrase suggestions are used. If the check fails, a message is shown in the warnings window.</string>
</property>
<property name="menuRole">
- <enum>QAction::NoRole</enum>
+ <enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionPlaceMarkerMatches">
@@ -564,7 +567,7 @@
<string>Toggles the validity check of place markers, i.e. whether %1, %2, ... are used consistently in the source text and translation text. If the check fails, a message is shown in the warnings window.</string>
</property>
<property name="menuRole">
- <enum>QAction::NoRole</enum>
+ <enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionNewPhraseBook">
@@ -578,7 +581,7 @@
<string>Ctrl+N</string>
</property>
<property name="menuRole">
- <enum>QAction::NoRole</enum>
+ <enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionOpenPhraseBook">
@@ -592,7 +595,7 @@
<string>Ctrl+H</string>
</property>
<property name="menuRole">
- <enum>QAction::NoRole</enum>
+ <enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionResetSorting">
@@ -609,7 +612,7 @@
<string>Sort the items back in the same order as in the message file.</string>
</property>
<property name="menuRole">
- <enum>QAction::NoRole</enum>
+ <enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionDisplayGuesses">
@@ -626,7 +629,7 @@
<string>Set whether or not to display translation guesses.</string>
</property>
<property name="menuRole">
- <enum>QAction::NoRole</enum>
+ <enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionStatistics">
@@ -643,7 +646,7 @@
<string>Display translation statistics.</string>
</property>
<property name="menuRole">
- <enum>QAction::NoRole</enum>
+ <enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionQmlPreview">
@@ -660,7 +663,7 @@
<string>Display a preview of QML documents.</string>
</property>
<property name="menuRole">
- <enum>QAction::NoRole</enum>
+ <enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionManual">
@@ -674,7 +677,7 @@
<string>F1</string>
</property>
<property name="menuRole">
- <enum>QAction::NoRole</enum>
+ <enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionAbout">
@@ -682,7 +685,7 @@
<string>About Qt Linguist</string>
</property>
<property name="menuRole">
- <enum>QAction::AboutRole</enum>
+ <enum>QAction::MenuRole::AboutRole</enum>
</property>
</action>
<action name="actionAboutQt">
@@ -693,7 +696,7 @@
<string>Display information about the Qt toolkit by Digia.</string>
</property>
<property name="menuRole">
- <enum>QAction::AboutQtRole</enum>
+ <enum>QAction::MenuRole::AboutQtRole</enum>
</property>
</action>
<action name="actionWhatsThis">
@@ -719,7 +722,7 @@
<string>Shift+F1</string>
</property>
<property name="menuRole">
- <enum>QAction::NoRole</enum>
+ <enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionSearchAndTranslate">
@@ -733,7 +736,7 @@
<string>Replace the translation on all entries that matches the search source text.</string>
</property>
<property name="menuRole">
- <enum>QAction::NoRole</enum>
+ <enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionBatchTranslation">
@@ -747,7 +750,7 @@
<string>Batch translate all entries using the information in the phrase books.</string>
</property>
<property name="menuRole">
- <enum>QAction::NoRole</enum>
+ <enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionReleaseAs">
@@ -833,7 +836,7 @@
<string>F5</string>
</property>
<property name="menuRole">
- <enum>QAction::NoRole</enum>
+ <enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionTranslationFileSettings">
@@ -844,7 +847,7 @@
<string>Translation File &amp;Settings...</string>
</property>
<property name="menuRole">
- <enum>QAction::NoRole</enum>
+ <enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionAddToPhraseBook">
@@ -970,7 +973,7 @@
<string>Mark this item as done.</string>
</property>
<property name="menuRole">
- <enum>QAction::NoRole</enum>
+ <enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionFindPrev">
@@ -984,6 +987,11 @@
<string>Shift+F3</string>
</property>
</action>
+ <action name="actionAuto_Translation">
+ <property name="text">
+ <string>AI Translation...</string>
+ </property>
+ </action>
</widget>
<resources/>
<connections/>
diff --git a/src/linguist/linguist/messagemodel.cpp b/src/linguist/linguist/messagemodel.cpp
index bc144c219..343ca81d8 100644
--- a/src/linguist/linguist/messagemodel.cpp
+++ b/src/linguist/linguist/messagemodel.cpp
@@ -625,7 +625,8 @@ QString DataModel::prettifyFileName(const QString &fn)
*
*****************************************************************************/
-DataModelIterator::DataModelIterator(TranslationType type, DataModel *model, int group, int message)
+DataModelIterator::DataModelIterator(TranslationType type, const DataModel *model, int group,
+ int message)
: DataIndex(type, group, message), m_model(model)
{
}
diff --git a/src/linguist/linguist/messagemodel.h b/src/linguist/linguist/messagemodel.h
index 4f28298e5..d75650001 100644
--- a/src/linguist/linguist/messagemodel.h
+++ b/src/linguist/linguist/messagemodel.h
@@ -140,14 +140,14 @@ protected:
class DataModelIterator : public DataIndex
{
public:
- DataModelIterator(TranslationType type, DataModel *model = 0, int groupNo = 0,
+ DataModelIterator(TranslationType type, const DataModel *model = 0, int groupNo = 0,
int messageNo = 0);
MessageItem *current() const;
bool isValid() const;
void operator++();
private:
- DataModel *m_model; // not owned
+ const DataModel *m_model; // not owned
};
class DataModel : public QObject
diff --git a/src/linguist/linguist/qmlformpreviewview.cpp b/src/linguist/linguist/qmlformpreviewview.cpp
index 649880b4c..7b43c7882 100644
--- a/src/linguist/linguist/qmlformpreviewview.cpp
+++ b/src/linguist/linguist/qmlformpreviewview.cpp
@@ -40,8 +40,9 @@ QHash<QString, QList<QObject *>> extractSources(DataModel *m, const QString &con
{
QHash<QString, QList<QObject *>> t;
GroupItem *ctx = m->findGroup(contextName, TEXTBASED);
- for (int j = 0; j < ctx->messageCount(); j++)
- t[ctx->messageItem(j)->text()] = {};
+ if (ctx)
+ for (int j = 0; j < ctx->messageCount(); j++)
+ t[ctx->messageItem(j)->text()] = {};
for (DataModelIterator it(IDBASED, m); it.isValid(); ++it)
t[it.current()->text()] = {};
diff --git a/src/linguist/shared/auto-translation/machinetranslator.cpp b/src/linguist/shared/auto-translation/machinetranslator.cpp
new file mode 100644
index 000000000..e091352de
--- /dev/null
+++ b/src/linguist/shared/auto-translation/machinetranslator.cpp
@@ -0,0 +1,103 @@
+// Copyright (C) 2025 The Qt Company Ltd.
+// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0
+
+#include "machinetranslator.h"
+#include "ollama.h"
+#include "translatormessage.h"
+
+#include <QNetworkRequest>
+#include <QNetworkReply>
+#include <QNetworkAccessManager>
+
+using namespace Qt::Literals::StringLiterals;
+
+QT_BEGIN_NAMESPACE
+
+MachineTranslator::MachineTranslator()
+ : m_request(std::make_unique<QNetworkRequest>()),
+ m_manager(std::make_unique<QNetworkAccessManager>()),
+ m_translator(std::make_unique<Ollama>())
+{
+ m_request->setHeader(QNetworkRequest::ContentTypeHeader, "application/json"_L1);
+}
+
+MachineTranslator::~MachineTranslator() = default;
+
+void MachineTranslator::translate(const Messages &messages)
+{
+ auto batches = m_translator->makeBatches(messages);
+ for (auto &b : batches)
+ translateBatch(std::move(b), 0);
+}
+
+void MachineTranslator::setUrl(const QString &url)
+{
+ m_translator->setUrl(url);
+ m_request->setUrl(m_translator->translationEndpoint());
+}
+
+void MachineTranslator::setTranslationModel(const QString &modelName)
+{
+ m_translator->setTranslationModel(modelName);
+}
+
+void MachineTranslator::requestModels()
+{
+ QNetworkRequest req(m_translator->discoveryEndpoint());
+ QNetworkReply *reply = m_manager->get(req);
+ connect(reply, &QNetworkReply::finished, this, [this, reply]() {
+ reply->deleteLater();
+ QStringList models;
+ if (reply->error() == QNetworkReply::NoError) {
+ const QByteArray response = reply->readAll();
+ models = m_translator->extractModels(response);
+ }
+ emit modelsReceived(std::move(models));
+ });
+}
+
+void MachineTranslator::translateBatch(Batch b, int tries)
+{
+ if (m_stopped)
+ return;
+ const QByteArray body = m_translator->payload(b);
+ QNetworkReply *reply = m_manager->post(*m_request, body);
+ connect(reply, &QNetworkReply::finished, this,
+ [this, reply, batch = std::move(b), session = m_session.load(), tries] {
+ translationReceived(reply, std::move(batch), tries, session);
+ });
+}
+
+void MachineTranslator::translationReceived(QNetworkReply *reply, Batch b, int tries, int session)
+{
+ reply->deleteLater();
+ if (m_stopped || session != m_session.load() || reply->error() != QNetworkReply::NoError)
+ return;
+ const QByteArray response = reply->readAll();
+ QList<Item> items = std::move(b.items);
+ QHash<const TranslatorMessage *, QString> out;
+ const QHash<QString, QString> translations = m_translator->extractTranslations(response);
+ for (Item &i : items) {
+ if (i.msg->translation().isEmpty()) {
+ if (QString translation = translations[i.msg->sourceText()]; !translation.isEmpty())
+ out[i.msg] = std::move(translation);
+ else
+ b.items.append(std::move(i));
+ }
+ }
+
+ if (out.empty() && tries == s_maxTries) {
+ QList<const TranslatorMessage *> failed;
+ for (const auto &i : std::as_const(b.items))
+ failed.append(i.msg);
+ emit translationFailed(std::move(failed));
+ } else if (out.empty() && tries < s_maxTries) {
+ translateBatch(std::move(b), tries + 1);
+ } else {
+ emit batchTranslated(std::move(out));
+ if (!b.items.empty())
+ translateBatch(std::move(b), tries);
+ }
+}
+
+QT_END_NAMESPACE
diff --git a/src/linguist/shared/auto-translation/machinetranslator.h b/src/linguist/shared/auto-translation/machinetranslator.h
new file mode 100644
index 000000000..191419a63
--- /dev/null
+++ b/src/linguist/shared/auto-translation/machinetranslator.h
@@ -0,0 +1,55 @@
+#ifndef MACHINETRANSLATOR_H
+// Copyright (C) 2025 The Qt Company Ltd.
+// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0
+
+#define MACHINETRANSLATOR_H
+
+#include "translationprotocol.h"
+
+#include <QString>
+#include <QObject>
+
+QT_BEGIN_NAMESPACE
+
+class QNetworkRequest;
+class QNetworkAccessManager;
+class QNetworkReply;
+
+class MachineTranslator : public QObject
+{
+ Q_OBJECT
+public:
+ MachineTranslator();
+ ~MachineTranslator();
+
+ void translate(const Messages &messages);
+ void stop() noexcept { m_stopped = true; }
+ void start() noexcept
+ {
+ m_session++;
+ m_stopped = false;
+ }
+ void setUrl(const QString &url);
+ void setTranslationModel(const QString &modelName);
+ void requestModels();
+
+signals:
+ void batchTranslated(QHash<const TranslatorMessage *, QString> translations);
+ void translationFailed(QList<const TranslatorMessage *>);
+ void modelsReceived(QStringList models);
+
+private:
+ std::atomic_bool m_stopped = false;
+ std::atomic_int m_session = 0;
+ std::unique_ptr<QNetworkRequest> m_request;
+ std::unique_ptr<QNetworkAccessManager> m_manager;
+ std::unique_ptr<TranslationProtocol> m_translator;
+ void translateBatch(Batch b, int tries);
+ void translationReceived(QNetworkReply *reply, Batch b, int tries, int session);
+
+ static constexpr int s_maxTries = 3;
+};
+
+QT_END_NAMESPACE
+
+#endif // MACHINETRANSLATOR_H
diff --git a/src/linguist/shared/auto-translation/ollama.cpp b/src/linguist/shared/auto-translation/ollama.cpp
new file mode 100644
index 000000000..7776af791
--- /dev/null
+++ b/src/linguist/shared/auto-translation/ollama.cpp
@@ -0,0 +1,234 @@
+// Copyright (C) 2025 The Qt Company Ltd.
+// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0
+
+#include "ollama.h"
+#include "translatormessage.h"
+
+#include <QJsonObject>
+#include <QJsonArray>
+
+using namespace Qt::Literals::StringLiterals;
+
+namespace {
+static std::optional<QJsonArray> recursiveFind(const QJsonValue &jval, const QString &key)
+{
+ if (jval.isObject()) {
+ const QJsonObject obj = jval.toObject();
+ auto it = obj.find(key);
+ if (it != obj.end() && it->isArray())
+ return it->toArray();
+ for (it = obj.constBegin(); it != obj.constEnd(); ++it) {
+ if (it.key().trimmed() == key && it.value().isArray())
+ return it.value().toArray();
+ if (const auto r = recursiveFind(it.value(), key); r)
+ return r;
+ }
+ } else if (jval.isArray()) {
+ const QJsonArray arr = jval.toArray();
+ for (const QJsonValue &element : arr)
+ if (const auto r = recursiveFind(element, key); r)
+ return r;
+ } else if (jval.isString()) {
+ QJsonParseError err;
+ auto inner = QJsonDocument::fromJson(jval.toString().toUtf8(), &err);
+ if (err.error != QJsonParseError::NoError || !inner.isObject())
+ return {};
+ const auto obj = inner.object();
+ if (auto it = obj.find(key); it != obj.end()) {
+ if (it.value().isArray())
+ return it.value().toArray();
+ }
+ }
+ return {};
+}
+} // namespace
+
+QT_BEGIN_NAMESPACE
+
+Ollama::Ollama()
+ : m_payloadBase(std::make_unique<QJsonObject>()),
+ m_systemMessage(std::make_unique<QJsonObject>())
+{
+ m_payloadBase->insert("stream"_L1, false);
+ m_payloadBase->insert("format"_L1, "json"_L1);
+
+ QJsonObject opts;
+ opts.insert("temperature"_L1, 0);
+ m_payloadBase->insert("options"_L1, opts);
+
+ m_systemMessage->insert("role"_L1, "system"_L1);
+ m_systemMessage->insert("content"_L1, makeSystemPrompt());
+}
+
+Ollama::~Ollama() = default;
+
+QList<Batch> Ollama::makeBatches(const Messages &messages) const
+{
+ QHash<QString, QList<const TranslatorMessage *>> groups;
+
+ for (const auto &item : messages.items)
+ groups[item->context() + item->label()].append(item);
+
+ QList<Batch> out;
+ out.reserve(groups.size());
+ for (auto it = groups.cbegin(); it != groups.cend(); ++it) {
+ auto msgIt = it.value().cbegin();
+ while (msgIt != it.value().cend()) {
+ Batch b;
+ b.srcLang = messages.srcLang;
+ b.tgtLang = messages.tgtLang;
+ b.context = it.key();
+ b.items.reserve(it.value().size());
+ while (msgIt != it.value().cend() && b.items.size() < s_maxBatchSize) {
+ Item item;
+ item.msg = *msgIt;
+ item.translation = item.msg->translation();
+ b.items.append(std::move(item));
+ msgIt++;
+ }
+ out.append(std::move(b));
+ }
+ }
+ return out;
+}
+
+QHash<QString, QString> Ollama::extractTranslations(const QByteArray &response) const
+{
+ QJsonParseError err;
+ QJsonDocument doc = QJsonDocument::fromJson(response, &err);
+ if (err.error != QJsonParseError::NoError)
+ return {};
+
+ auto translations = recursiveFind(doc.object(), "Translations"_L1);
+ QHash<QString, QString> out;
+ if (!translations)
+ return out;
+ out.reserve(translations->size());
+ for (const QJsonValue &v : std::as_const(*translations)) {
+ if (v.isObject()) {
+ const QJsonObject obj = v.toObject();
+ const QString key = obj.keys().first();
+ if (QJsonValue val = obj.value(key); val.isString())
+ out[key] = val.toString();
+ }
+ }
+ return out;
+}
+
+QStringList Ollama::extractModels(const QByteArray &response) const
+{
+ QJsonParseError err;
+ QJsonDocument doc = QJsonDocument::fromJson(response, &err);
+ if (err.error != QJsonParseError::NoError)
+ return {};
+ const QJsonObject obj = doc.object();
+ const QJsonArray arr = obj.value("models"_L1).toArray();
+ QStringList models;
+ for (const QJsonValue &v : arr)
+ models.append(v.toObject().value("name"_L1).toString());
+ return models;
+}
+
+QByteArray Ollama::payload(const Batch &b) const
+{
+ QJsonObject userMessage;
+ userMessage.insert("role"_L1, "user"_L1);
+ userMessage.insert("content"_L1, makePrompt(b));
+
+ QJsonArray messages;
+ messages.append(*m_systemMessage);
+ messages.append(userMessage);
+
+ QJsonObject req = *m_payloadBase;
+ req.insert("messages"_L1, messages);
+ return QJsonDocument(req).toJson();
+}
+
+void Ollama::setTranslationModel(const QString &modelName)
+{
+ m_payloadBase->insert("model"_L1, modelName);
+}
+
+void Ollama::setUrl(const QString &url)
+{
+ m_url = url;
+}
+
+QUrl Ollama::translationEndpoint() const
+{
+ return QUrl(m_url).resolved(QUrl("/api/chat"_L1));
+}
+
+QUrl Ollama::discoveryEndpoint() const
+{
+ return QUrl(m_url).resolved(QUrl("/api/tags"_L1));
+}
+
+QString Ollama::makePrompt(const Batch &b) const
+{
+ QStringList lines;
+ lines.reserve(b.items.size() + 32);
+ lines << "Context: %1"_L1.arg(b.context);
+ lines << "Target: %1"_L1.arg(b.tgtLang);
+ lines << "Items:"_L1;
+ for (const Item &it : b.items) {
+ QString line = "- source: '%1'"_L1.arg(it.msg->sourceText());
+ if (const QString comment = it.msg->comment(); !comment.isEmpty())
+ line += ", comment: '%1'"_L1.arg(comment);
+ lines << line;
+ }
+
+ return lines.join(QLatin1Char('\n'));
+}
+
+QString Ollama::makeSystemPrompt() const
+{
+ static QString systemPrompt = uR"(
+You are a professional software translator specialized in Qt UI strings.
+
+When given a list of items of the given 'Context', each may include:
+- source: the original text to translate
+- comment: an optional developer note for more context
+
+Translate the items into the **target language** specified by the user,
+preserving keyboard accelerators (e.g. “&File”) and placeholders (e.g. “%1”).
+
+RESULT FORMAT (MUST FOLLOW):
+A single JSON object with one key, "Translations",
+whose value is an array of objects.
+Each object maps the original source string to translated string:
+
+Two examples:
+
+Input:
+Context: MainWindow
+Target: German
+Items:
+ - source: "File"
+ - source: "Exit"
+ - source: "&Open", comment: "opens a document"
+
+Output:
+{"Translations":[{"File":"Datei"},{"Exit":"Beenden"},{"&Open":"&Öffnen"}]}
+
+Input:
+Context: MainWindow
+Target: French
+Items:
+– source: "File"
+– source: "Exit"
+Output:
+{"Translations":[{"File":"Fichier"},{"Exit":"Quitter"}]}
+
+Return **only** valid JSON, no code fences, no extra text.
+After generating and before returning, verify:
+1. Every string is in the target language; if any aren’t, correct them before returning.
+2. Every JSON key exactly matches one of the input source strings.
+3. No key equals its value.
+4. Every string is translated
+)"_s;
+
+ return systemPrompt;
+}
+
+QT_END_NAMESPACE
diff --git a/src/linguist/shared/auto-translation/ollama.h b/src/linguist/shared/auto-translation/ollama.h
new file mode 100644
index 000000000..cff90bf9a
--- /dev/null
+++ b/src/linguist/shared/auto-translation/ollama.h
@@ -0,0 +1,41 @@
+// Copyright (C) 2025 The Qt Company Ltd.
+// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0
+
+#ifndef OLLAMA_H
+#define OLLAMA_H
+
+#include "translationprotocol.h"
+
+QT_BEGIN_NAMESPACE
+
+class QJsonObject;
+class QJsonArray;
+
+class Ollama : public TranslationProtocol
+{
+public:
+ Ollama();
+ ~Ollama() override;
+ QList<Batch> makeBatches(const Messages &messages) const override;
+ QByteArray payload(const Batch &b) const override;
+ QHash<QString, QString> extractTranslations(const QByteArray &response) const override;
+ QStringList extractModels(const QByteArray &data) const override;
+
+ void setTranslationModel(const QString &modelName) override;
+ void setUrl(const QString &url) override;
+ QUrl translationEndpoint() const override;
+ QUrl discoveryEndpoint() const override;
+
+private:
+ QString makePrompt(const Batch &b) const;
+ QString makeSystemPrompt() const;
+
+ std::unique_ptr<QJsonObject> m_payloadBase;
+ std::unique_ptr<QJsonObject> m_systemMessage;
+ QString m_url;
+ static constexpr int s_maxBatchSize = 20;
+};
+
+QT_END_NAMESPACE
+
+#endif // OLLAMA_H
diff --git a/src/linguist/shared/auto-translation/translationprotocol.h b/src/linguist/shared/auto-translation/translationprotocol.h
new file mode 100644
index 000000000..f255a143a
--- /dev/null
+++ b/src/linguist/shared/auto-translation/translationprotocol.h
@@ -0,0 +1,53 @@
+// Copyright (C) 2025 The Qt Company Ltd.
+// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0
+
+#ifndef TRANSLATIONPROTOCOL_H
+#define TRANSLATIONPROTOCOL_H
+
+#include <QList>
+
+QT_BEGIN_NAMESPACE
+
+class TranslatorMessage;
+class QUrl;
+
+struct Messages
+{
+ QList<const TranslatorMessage *> items;
+ QString srcLang;
+ QString tgtLang;
+};
+
+struct Item
+{
+ QString translation;
+ const TranslatorMessage *msg;
+};
+
+class Batch
+{
+public:
+ QString srcLang;
+ QString tgtLang;
+ QString context;
+ QList<Item> items;
+ std::shared_ptr<std::atomic_int> counter;
+};
+
+class TranslationProtocol
+{
+public:
+ virtual QList<Batch> makeBatches(const Messages &messages) const = 0;
+ virtual QByteArray payload(const Batch &b) const = 0;
+ virtual QHash<QString, QString> extractTranslations(const QByteArray &data) const = 0;
+ virtual QStringList extractModels(const QByteArray &data) const = 0;
+ virtual void setTranslationModel(const QString &modelName) = 0;
+ virtual void setUrl(const QString &url) = 0;
+ virtual QUrl translationEndpoint() const = 0;
+ virtual QUrl discoveryEndpoint() const = 0;
+ virtual ~TranslationProtocol() = default;
+};
+
+QT_END_NAMESPACE
+
+#endif // TRANSLATIONPROTOCOL_H