From 18ba92a6e35e7dbfbec8679c8b8a5da78cd5f109 Mon Sep 17 00:00:00 2001 From: Tri Vo Khac Date: Sat, 7 Nov 2026 12:51:41 +0700 Subject: [PATCH] feat(translate): add translation presets and custom instructions --- src/CMakeLists.txt | 2 + src/qml/TranslationSettings.qml | 45 +++++++++++++++++++++ src/settings/qtpotatosettings.kcfg | 8 ++++ src/translate/llmclient.cpp | 24 ++++++++--- src/translate/llmclient.h | 2 + src/translate/translationpresets.cpp | 59 ++++++++++++++++++++++++++++ src/translate/translationpresets.h | 19 +++++++++ src/translate/translationservice.cpp | 18 ++++++++- src/translate/translationservice.h | 3 ++ 9 files changed, 172 insertions(+), 8 deletions(-) create mode 100644 src/translate/translationpresets.cpp create mode 100644 src/translate/translationpresets.h diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 23899cf..1db6325 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -38,6 +38,8 @@ add_executable(qtpotato translate/llmclient.h translate/translationservice.cpp translate/translationservice.h + translate/translationpresets.cpp + translate/translationpresets.h ) kconfig_target_kcfg_file(qtpotato diff --git a/src/qml/TranslationSettings.qml b/src/qml/TranslationSettings.qml index e78c2e6..874cc8b 100644 --- a/src/qml/TranslationSettings.qml +++ b/src/qml/TranslationSettings.qml @@ -145,6 +145,51 @@ SettingsBasePage { onEditingFinished: QtPotatoSettings.save() } + Label { + text: KI18n.i18nc("@label:listbox", "Preset") + Layout.alignment: Qt.AlignRight | Qt.AlignTop + } + + ColumnLayout { + Layout.fillWidth: true + + ComboBox { + id: presetCombo + Layout.fillWidth: true + model: root.translator.translationPresets + textRole: "name" + valueRole: "id" + Component.onCompleted: currentIndex = Math.max(0, indexOfValue(QtPotatoSettings.translationPreset)) + onActivated: { + QtPotatoSettings.translationPreset = currentValue + QtPotatoSettings.save() + } + } + + Label { + text: presetCombo.currentIndex >= 0 ? presetCombo.model[presetCombo.currentIndex].guidance : "" + wrapMode: Text.WordWrap + opacity: 0.7 + Layout.fillWidth: true + } + } + + Label { + text: KI18n.i18nc("@label:textbox", "Additional instructions") + Layout.alignment: Qt.AlignRight | Qt.AlignTop + } + + TextArea { + text: QtPotatoSettings.customInstructions + placeholderText: KI18n.i18nc("@info:placeholder", "e.g. Translate 'Young Master' as 'Thiếu gia'. Use 'ta/ngươi' between enemies.") + wrapMode: TextArea.Wrap + Layout.fillWidth: true + Layout.preferredHeight: Kirigami.Units.gridUnit * 4 + // TextArea has no textEdited before Qt 6.9. + onTextChanged: QtPotatoSettings.customInstructions = text + onEditingFinished: QtPotatoSettings.save() + } + Label { text: KI18n.i18nc("@label:spinbox", "Lines per request") Layout.alignment: Qt.AlignRight diff --git a/src/settings/qtpotatosettings.kcfg b/src/settings/qtpotatosettings.kcfg index 0ee2dbb..0e4d1bd 100644 --- a/src/settings/qtpotatosettings.kcfg +++ b/src/settings/qtpotatosettings.kcfg @@ -122,6 +122,14 @@ Vietnamese + + + general + + + + + 40 diff --git a/src/translate/llmclient.cpp b/src/translate/llmclient.cpp index c1557bb..0085a9d 100644 --- a/src/translate/llmclient.cpp +++ b/src/translate/llmclient.cpp @@ -1,5 +1,7 @@ #include "llmclient.h" +#include "translationpresets.h" + #include #include @@ -28,19 +30,29 @@ struct ReplyDeleter { }; using ReplyPtr = std::unique_ptr; -QString systemPrompt(const QString &language) +QString systemPrompt(const QString &language, const QString &presetId, const QString &customInstructions) { - return uR"(You are a professional subtitle translator. Translate subtitles into %1. + const auto &preset = TranslationPresets::find(presetId); + QString prompt = uR"(You are a professional subtitle translator. Translate subtitles into %1. The user sends a JSON object: - "title": the video title, for context. - "context": the subtitle lines right before this batch, for context only. Do not translate them. - "lines": an object that maps ids to subtitle lines. -Translate every entry of "lines" into natural, spoken %1 that fits the scene and the speaker. Keep names and the tone. Translate each entry on its own; never merge, split, skip or reorder entries. +Translate every entry of "lines" into natural, spoken %1 that fits the scene and the speaker. Translate each entry on its own; never merge, split, skip or reorder entries. -Reply with only a JSON object that maps every id to its translation, for example {"1": "...", "2": "..."}. No explanations, no Markdown.)"_s - .arg(language); +Style guidance: +%2 + +)"_s + .arg(language, preset.guidance); + if (!customInstructions.trimmed().isEmpty()) { + prompt += u"Additional instructions from the user (follow them unless they conflict with the reply format):\n"_s; + prompt += customInstructions.trimmed(); + } + prompt += u"\n\nReply with only a JSON object that maps every id to its translation, for example {\"1\": \"...\", \"2\": \"...\"}. No explanations, no Markdown."_s; + return prompt; } // Pulls the error message out of an OpenAI-style error body, falling back to the raw text. @@ -137,7 +149,7 @@ QCoro::Task LlmClient::translate(QStringList lines, QStr {"model"_L1, m_config.model}, {"messages"_L1, QJsonArray{ - QJsonObject{{"role"_L1, "system"_L1}, {"content"_L1, systemPrompt(m_config.targetLanguage)}}, + QJsonObject{{"role"_L1, "system"_L1}, {"content"_L1, systemPrompt(m_config.targetLanguage, m_config.translationPreset, m_config.customInstructions)}}, QJsonObject{{"role"_L1, "user"_L1}, {"content"_L1, QString::fromUtf8(QJsonDocument(userContent).toJson(QJsonDocument::Compact))}}, }}, }; diff --git a/src/translate/llmclient.h b/src/translate/llmclient.h index d860578..bf50817 100644 --- a/src/translate/llmclient.h +++ b/src/translate/llmclient.h @@ -19,6 +19,8 @@ public: QString apiKey; QString model; QString targetLanguage; + QString translationPreset; + QString customInstructions; }; struct BatchResult { diff --git a/src/translate/translationpresets.cpp b/src/translate/translationpresets.cpp new file mode 100644 index 0000000..1a56ac9 --- /dev/null +++ b/src/translate/translationpresets.cpp @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: 2026 Tri Vo Khac +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "translationpresets.h" + +#include + +using namespace Qt::Literals::StringLiterals; + +namespace TranslationPresets +{ +const QList &all() +{ + static const QList presets{ + { + u"general"_s, + i18nc("@item:inlistbox translation preset", "General"), + u"Keep names as they are conventionally rendered and use a natural, conversational tone appropriate to the scene."_s, + }, + { + u"chinese-donghua"_s, + i18nc("@item:inlistbox translation preset", "Chinese animation / wuxia / xianxia"), + u"For Chinese animation, wuxia, xianxia, and historical settings, the source is often an English subtitle with pinyin names. When translating into Vietnamese, convert Chinese personal names, place names, sect and clan names, techniques, and cultivation terms into their Sino-Vietnamese (Hán-Việt) forms (for example, Xiao Yan → Tiêu Viêm, Dou Qi → Đấu Khí, Sect Master → Tông chủ). For other target languages, use that language's conventional rendering. Be consistent and prefer well-known established renderings from famous works. When translating into Vietnamese, use period-appropriate forms of address such as ta/ngươi, huynh/đệ, sư phụ, and tiền bối."_s, + }, + { + u"japanese-anime"_s, + i18nc("@item:inlistbox translation preset", "Japanese anime"), + u"Keep Japanese names in romaji. Keep honorifics such as -san, -kun, and -senpai as-is. Use a natural, casual tone."_s, + }, + { + u"korean-drama"_s, + i18nc("@item:inlistbox translation preset", "Korean drama"), + u"Keep Korean names. Use modern forms of address that fit the relationships; when translating into Vietnamese, use forms such as anh/em, and oppa may be kept when appropriate."_s, + }, + { + u"western"_s, + i18nc("@item:inlistbox translation preset", "Western"), + u"Keep names. Use a neutral modern tone and adapt idioms naturally for the target language."_s, + }, + { + u"documentary"_s, + i18nc("@item:inlistbox translation preset", "Documentary"), + u"Keep names. Use a formal, precise tone and translate terminology accurately."_s, + }, + }; + return presets; +} + +const Preset &find(const QString &id) +{ + const auto &presets = all(); + for (const Preset &preset : presets) { + if (preset.id == id) { + return preset; + } + } + return presets.constFirst(); +} +} diff --git a/src/translate/translationpresets.h b/src/translate/translationpresets.h new file mode 100644 index 0000000..7ac9ae6 --- /dev/null +++ b/src/translate/translationpresets.h @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2026 Tri Vo Khac +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include + +namespace TranslationPresets +{ +struct Preset { + QString id; + QString name; + QString guidance; +}; + +const QList &all(); +const Preset &find(const QString &id); +} diff --git a/src/translate/translationservice.cpp b/src/translate/translationservice.cpp index 1a976e7..b592725 100644 --- a/src/translate/translationservice.cpp +++ b/src/translate/translationservice.cpp @@ -2,6 +2,7 @@ #include "apikeystore.h" #include "qtpotatosettings.h" +#include "translationpresets.h" #include @@ -34,7 +35,7 @@ constexpr int MaxAttempts = 3; // depend on each other. constexpr int ParallelRequests = 3; // Bump when the prompt changes so old cached translations are not reused. -constexpr auto CacheVersion = "llm-v1"; +constexpr auto CacheVersion = "llm-v2"; // "Vietnamese" -> "vi", so the file ends in ".vi.srt" like other players expect. QString languageSuffix(const QString &language) @@ -112,6 +113,15 @@ QStringList TranslationService::availableModels() const return m_availableModels; } +QVariantList TranslationService::translationPresets() const +{ + QVariantList result; + for (const auto &preset : TranslationPresets::all()) { + result.append(QVariantMap{{u"id"_s, preset.id}, {u"name"_s, preset.name}, {u"guidance"_s, preset.guidance}}); + } + return result; +} + bool TranslationService::isFetchingModels() const { return m_fetchingModels; @@ -160,6 +170,8 @@ QCoro::Task> TranslationService::loadConfi {}, settings->model().trimmed(), settings->targetLanguage().trimmed(), + settings->translationPreset().trimmed(), + settings->customInstructions(), }; if (!config.baseUrl.isValid() || config.baseUrl.scheme().isEmpty() || config.baseUrl.host().isEmpty()) { @@ -235,11 +247,13 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track) co_return; } - // Same subtitles, model and language give the same file, so it is translated once. + // Same subtitles, model, language, and prompt settings give the same file, so it is translated once. QCryptographicHash hash(QCryptographicHash::Sha1); hash.addData(CacheVersion); hash.addData(config.model.toUtf8()); hash.addData(config.targetLanguage.toUtf8()); + hash.addData(config.translationPreset.toUtf8()); + hash.addData(config.customInstructions.toUtf8()); hash.addData(Subtitles::toSrt(cues).toUtf8()); const QString outputPath = cachePath(videoUrl, QString::fromLatin1(hash.result().toHex().left(12)), config.targetLanguage); diff --git a/src/translate/translationservice.h b/src/translate/translationservice.h index 0b0795a..72b1b56 100644 --- a/src/translate/translationservice.h +++ b/src/translate/translationservice.h @@ -8,6 +8,7 @@ #include #include #include +#include #include #include @@ -28,6 +29,7 @@ class TranslationService : public QObject Q_PROPERTY(QString statusText READ statusText NOTIFY statusTextChanged) Q_PROPERTY(QString errorText READ errorText NOTIFY errorTextChanged) Q_PROPERTY(QStringList availableModels READ availableModels NOTIFY availableModelsChanged) + Q_PROPERTY(QVariantList translationPresets READ translationPresets CONSTANT) Q_PROPERTY(bool fetchingModels READ isFetchingModels NOTIFY fetchingModelsChanged) public: @@ -39,6 +41,7 @@ public: QString statusText() const; QString errorText() const; QStringList availableModels() const; + QVariantList translationPresets() const; bool isFetchingModels() const; // \a track is an entry of MpvItem::subtitleTracks.