summaryrefslogtreecommitdiffstats
path: root/src/linguist/shared
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 /src/linguist/shared
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]>
Diffstat (limited to 'src/linguist/shared')
-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
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