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 /src/linguist/shared | |
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]>
Diffstat (limited to 'src/linguist/shared')
-rw-r--r-- | src/linguist/shared/auto-translation/machinetranslator.cpp | 103 | ||||
-rw-r--r-- | src/linguist/shared/auto-translation/machinetranslator.h | 55 | ||||
-rw-r--r-- | src/linguist/shared/auto-translation/ollama.cpp | 234 | ||||
-rw-r--r-- | src/linguist/shared/auto-translation/ollama.h | 41 | ||||
-rw-r--r-- | src/linguist/shared/auto-translation/translationprotocol.h | 53 |
5 files changed, 486 insertions, 0 deletions
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 |