diff options
author | Masoud Jami <[email protected]> | 2025-06-23 14:09:46 +0200 |
---|---|---|
committer | Masoud Jami <[email protected]> | 2025-07-11 13:53:20 +0200 |
commit | 1f7b381787184d1a903e4cb3e8a487d445535eee (patch) | |
tree | 42f118141d11d82ddc81be25abbd99ff4f3808fb | |
parent | d0db4581ec8232641a25c0c6685b40aa4d877c4a (diff) |
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]>
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 Binary files differnew file mode 100644 index 000000000..5c641086e --- /dev/null +++ b/src/linguist/linguist/doc/images/linguist-ai-translation-dialog.webp 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><!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> +<html><head><meta name="qrichtext" content="1" /><meta charset="utf-8" /><style type="text/css"> +p, li { white-space: pre-wrap; } +hr { height: 1px; border-width: 0; } +li.unchecked::marker { content: "\2610"; } +li.checked::marker { content: "\2612"; } +</style></head><body style=" font-family:'.AppleSystemUIFont'; font-size:13pt; font-weight:400; font-style:normal;"> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Translation Log</p></body></html></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>&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 &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 |