feat(translate): add translation presets and custom instructions

This commit is contained in:
tri committed 2026-11-07 13:28:32 +07:00
1 parent cebce0915f
commit 18ba92a6e3
9 files changed
+172 -8

No files matched your search

+2
View File
@@ -38,6 +38,8 @@ add_executable(qtpotato
translate/llmclient.h translate/llmclient.h
translate/translationservice.cpp translate/translationservice.cpp
translate/translationservice.h translate/translationservice.h
translate/translationpresets.cpp
translate/translationpresets.h
) )
kconfig_target_kcfg_file(qtpotato kconfig_target_kcfg_file(qtpotato
+45
View File
@@ -145,6 +145,51 @@ SettingsBasePage {
onEditingFinished: QtPotatoSettings.save() 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 { Label {
text: KI18n.i18nc("@label:spinbox", "Lines per request") text: KI18n.i18nc("@label:spinbox", "Lines per request")
Layout.alignment: Qt.AlignRight Layout.alignment: Qt.AlignRight
+8
View File
@@ -122,6 +122,14 @@
<label>Language to translate subtitles into, written for the model</label> <label>Language to translate subtitles into, written for the model</label>
<default>Vietnamese</default> <default>Vietnamese</default>
</entry> </entry>
<entry name="TranslationPreset" type="String">
<label>Style preset used by the subtitle translation prompt</label>
<default>general</default>
</entry>
<entry name="CustomInstructions" type="String">
<label>Additional instructions for subtitle translation</label>
<default></default>
</entry>
<entry name="BatchSize" type="Int"> <entry name="BatchSize" type="Int">
<label>Subtitle lines sent per request</label> <label>Subtitle lines sent per request</label>
<default>40</default> <default>40</default>
+18 -6
View File
@@ -1,5 +1,7 @@
#include "llmclient.h" #include "llmclient.h"
#include "translationpresets.h"
#include <KLocalizedString> #include <KLocalizedString>
#include <QCoroNetworkReply> #include <QCoroNetworkReply>
@@ -28,19 +30,29 @@ struct ReplyDeleter {
}; };
using ReplyPtr = std::unique_ptr<QNetworkReply, ReplyDeleter>; using ReplyPtr = std::unique_ptr<QNetworkReply, ReplyDeleter>;
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: The user sends a JSON object:
- "title": the video title, for context. - "title": the video title, for context.
- "context": the subtitle lines right before this batch, for context only. Do not translate them. - "context": the subtitle lines right before this batch, for context only. Do not translate them.
- "lines": an object that maps ids to subtitle lines. - "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 Style guidance:
.arg(language); %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. // Pulls the error message out of an OpenAI-style error body, falling back to the raw text.
@@ -137,7 +149,7 @@ QCoro::Task<LlmClient::BatchResult> LlmClient::translate(QStringList lines, QStr
{"model"_L1, m_config.model}, {"model"_L1, m_config.model},
{"messages"_L1, {"messages"_L1,
QJsonArray{ 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))}}, QJsonObject{{"role"_L1, "user"_L1}, {"content"_L1, QString::fromUtf8(QJsonDocument(userContent).toJson(QJsonDocument::Compact))}},
}}, }},
}; };
+2
View File
@@ -19,6 +19,8 @@ public:
QString apiKey; QString apiKey;
QString model; QString model;
QString targetLanguage; QString targetLanguage;
QString translationPreset;
QString customInstructions;
}; };
struct BatchResult { struct BatchResult {
+59
View File
@@ -0,0 +1,59 @@
// SPDX-FileCopyrightText: 2026 Tri Vo Khac
// SPDX-License-Identifier: GPL-3.0-or-later
#include "translationpresets.h"
#include <KLocalizedString>
using namespace Qt::Literals::StringLiterals;
namespace TranslationPresets
{
const QList<Preset> &all()
{
static const QList<Preset> 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();
}
}
+19
View File
@@ -0,0 +1,19 @@
// SPDX-FileCopyrightText: 2026 Tri Vo Khac
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QList>
#include <QString>
namespace TranslationPresets
{
struct Preset {
QString id;
QString name;
QString guidance;
};
const QList<Preset> &all();
const Preset &find(const QString &id);
}
+16 -2
View File
@@ -2,6 +2,7 @@
#include "apikeystore.h" #include "apikeystore.h"
#include "qtpotatosettings.h" #include "qtpotatosettings.h"
#include "translationpresets.h"
#include <KLocalizedString> #include <KLocalizedString>
@@ -34,7 +35,7 @@ constexpr int MaxAttempts = 3;
// depend on each other. // depend on each other.
constexpr int ParallelRequests = 3; constexpr int ParallelRequests = 3;
// Bump when the prompt changes so old cached translations are not reused. // 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. // "Vietnamese" -> "vi", so the file ends in ".vi.srt" like other players expect.
QString languageSuffix(const QString &language) QString languageSuffix(const QString &language)
@@ -112,6 +113,15 @@ QStringList TranslationService::availableModels() const
return m_availableModels; 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 bool TranslationService::isFetchingModels() const
{ {
return m_fetchingModels; return m_fetchingModels;
@@ -160,6 +170,8 @@ QCoro::Task<std::pair<LlmClient::Config, QString>> TranslationService::loadConfi
{}, {},
settings->model().trimmed(), settings->model().trimmed(),
settings->targetLanguage().trimmed(), settings->targetLanguage().trimmed(),
settings->translationPreset().trimmed(),
settings->customInstructions(),
}; };
if (!config.baseUrl.isValid() || config.baseUrl.scheme().isEmpty() || config.baseUrl.host().isEmpty()) { 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; 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); QCryptographicHash hash(QCryptographicHash::Sha1);
hash.addData(CacheVersion); hash.addData(CacheVersion);
hash.addData(config.model.toUtf8()); hash.addData(config.model.toUtf8());
hash.addData(config.targetLanguage.toUtf8()); hash.addData(config.targetLanguage.toUtf8());
hash.addData(config.translationPreset.toUtf8());
hash.addData(config.customInstructions.toUtf8());
hash.addData(Subtitles::toSrt(cues).toUtf8()); hash.addData(Subtitles::toSrt(cues).toUtf8());
const QString outputPath = cachePath(videoUrl, QString::fromLatin1(hash.result().toHex().left(12)), config.targetLanguage); const QString outputPath = cachePath(videoUrl, QString::fromLatin1(hash.result().toHex().left(12)), config.targetLanguage);
+3
View File
@@ -8,6 +8,7 @@
#include <QObject> #include <QObject>
#include <QStringList> #include <QStringList>
#include <QUrl> #include <QUrl>
#include <QVariantList>
#include <QVariantMap> #include <QVariantMap>
#include <QtQml/qqmlregistration.h> #include <QtQml/qqmlregistration.h>
@@ -28,6 +29,7 @@ class TranslationService : public QObject
Q_PROPERTY(QString statusText READ statusText NOTIFY statusTextChanged) Q_PROPERTY(QString statusText READ statusText NOTIFY statusTextChanged)
Q_PROPERTY(QString errorText READ errorText NOTIFY errorTextChanged) Q_PROPERTY(QString errorText READ errorText NOTIFY errorTextChanged)
Q_PROPERTY(QStringList availableModels READ availableModels NOTIFY availableModelsChanged) Q_PROPERTY(QStringList availableModels READ availableModels NOTIFY availableModelsChanged)
Q_PROPERTY(QVariantList translationPresets READ translationPresets CONSTANT)
Q_PROPERTY(bool fetchingModels READ isFetchingModels NOTIFY fetchingModelsChanged) Q_PROPERTY(bool fetchingModels READ isFetchingModels NOTIFY fetchingModelsChanged)
public: public:
@@ -39,6 +41,7 @@ public:
QString statusText() const; QString statusText() const;
QString errorText() const; QString errorText() const;
QStringList availableModels() const; QStringList availableModels() const;
QVariantList translationPresets() const;
bool isFetchingModels() const; bool isFetchingModels() const;
// \a track is an entry of MpvItem::subtitleTracks. // \a track is an entry of MpvItem::subtitleTracks.