Merge pull request #376 from fanlusky/feat/niutrans-candidate-translation
feat(translation): 候选词翻译支持小牛翻译(NiuTrans)
This commit is contained in:
14 files changed
+461
-25
No files matched your search
@@ -75,6 +75,15 @@ enabled = false
|
||||
endpoint = ""
|
||||
api_key = ""
|
||||
|
||||
#
|
||||
# 小牛翻译(NiuTrans)机器翻译。app_id 与 apikey 在“控制台->API应用”中查看。
|
||||
# enabled = true 时优先于腾讯云与自定义 DeepLX。
|
||||
#
|
||||
[niutrans]
|
||||
enabled = false
|
||||
app_id = ""
|
||||
apikey = ""
|
||||
|
||||
[frequency_adjustment]
|
||||
# disabled / pin / halve / linear / promote
|
||||
mode = "promote"
|
||||
|
||||
@@ -155,6 +155,8 @@ set(SRC_FILES
|
||||
"./src/cloud/cloud_translation.h"
|
||||
"./src/cloud/custom_translation.cpp"
|
||||
"./src/cloud/custom_translation.h"
|
||||
"./src/cloud/niutrans_translation.cpp"
|
||||
"./src/cloud/niutrans_translation.h"
|
||||
"./src/cloud/tencent_tmt.cpp"
|
||||
"./src/cloud/tencent_tmt.h"
|
||||
"./src/cloud/translation_gloss.cpp"
|
||||
|
||||
@@ -74,6 +74,15 @@ enabled = false
|
||||
endpoint = ""
|
||||
api_key = ""
|
||||
|
||||
#
|
||||
# 小牛翻译(NiuTrans)机器翻译。app_id 与 apikey 在“控制台->API应用”中查看。
|
||||
# enabled = true 时优先于腾讯云与自定义 DeepLX。
|
||||
#
|
||||
[niutrans]
|
||||
enabled = false
|
||||
app_id = ""
|
||||
apikey = ""
|
||||
|
||||
[frequency_adjustment]
|
||||
# disabled / pin / halve / linear / promote
|
||||
mode = "promote"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include "config/ime_config.h"
|
||||
#include "custom_translation.h"
|
||||
#include "niutrans_translation.h"
|
||||
#include "tencent_tmt.h"
|
||||
#include "translation_gloss.h"
|
||||
#include "engine/english/english_dictionary.h"
|
||||
@@ -41,10 +42,36 @@ std::string TargetLanguage()
|
||||
return language.empty() ? "en" : language;
|
||||
}
|
||||
|
||||
enum class Provider
|
||||
{
|
||||
Tencent,
|
||||
Custom,
|
||||
NiuTrans,
|
||||
};
|
||||
|
||||
// Only one online provider is active at a time. NiuTrans and the custom DeepLX
|
||||
// endpoint override the Tencent default when enabled; NiuTrans wins if both are.
|
||||
Provider ActiveProvider()
|
||||
{
|
||||
if (GetConfiguredNiuTrans().enabled)
|
||||
return Provider::NiuTrans;
|
||||
if (GetConfiguredCustomTranslation().enabled)
|
||||
return Provider::Custom;
|
||||
return Provider::Tencent;
|
||||
}
|
||||
|
||||
std::string ProviderScope()
|
||||
{
|
||||
const auto &custom = GetConfiguredCustomTranslation();
|
||||
return custom.enabled ? "custom:" + custom.endpoint : "tencent";
|
||||
switch (ActiveProvider())
|
||||
{
|
||||
case Provider::NiuTrans:
|
||||
return "niutrans:" + GetConfiguredNiuTrans().app_id;
|
||||
case Provider::Custom:
|
||||
return "custom:" + GetConfiguredCustomTranslation().endpoint;
|
||||
case Provider::Tencent:
|
||||
default:
|
||||
return "tencent";
|
||||
}
|
||||
}
|
||||
|
||||
std::string Identity(const EnglishIme::TranslationQuery &query, const std::string &target_language,
|
||||
@@ -112,6 +139,14 @@ CustomTranslation::Config ResolveCustomConfig()
|
||||
return {CloudTranslation::TrimSecret(configured.endpoint), CloudTranslation::TrimSecret(configured.api_key)};
|
||||
}
|
||||
|
||||
NiuTransTranslation::Config ResolveNiuTransConfig()
|
||||
{
|
||||
const auto &configured = GetConfiguredNiuTrans();
|
||||
if (!configured.enabled)
|
||||
return {};
|
||||
return {CloudTranslation::TrimSecret(configured.app_id), CloudTranslation::TrimSecret(configured.apikey)};
|
||||
}
|
||||
|
||||
void PersistGloss(const EnglishIme::TranslationQuery &query, const std::string &gloss,
|
||||
const std::string &target_language)
|
||||
{
|
||||
@@ -143,8 +178,27 @@ void ApplyTranslatedGroup(const std::vector<EnglishIme::TranslationQuery> &group
|
||||
}
|
||||
}
|
||||
|
||||
void TranslateGroup(const TencentTmt::Credentials &credentials, const CustomTranslation::Config &custom,
|
||||
bool use_custom, const std::vector<EnglishIme::TranslationQuery> &group, const std::string &source,
|
||||
std::vector<std::string> TranslateWithProvider(Provider provider, const TencentTmt::Credentials &credentials,
|
||||
const CustomTranslation::Config &custom,
|
||||
const NiuTransTranslation::Config &niutrans,
|
||||
const std::vector<std::string> &texts, const std::string &source,
|
||||
const std::string &target)
|
||||
{
|
||||
switch (provider)
|
||||
{
|
||||
case Provider::NiuTrans:
|
||||
return NiuTransTranslation::TextTranslateBatch(niutrans, texts, source, target);
|
||||
case Provider::Custom:
|
||||
return CustomTranslation::TextTranslateBatch(custom, texts, source, target);
|
||||
case Provider::Tencent:
|
||||
default:
|
||||
return TencentTmt::TextTranslateBatch(credentials, texts, source, target);
|
||||
}
|
||||
}
|
||||
|
||||
void TranslateGroup(Provider provider, const TencentTmt::Credentials &credentials,
|
||||
const CustomTranslation::Config &custom, const NiuTransTranslation::Config &niutrans,
|
||||
const std::vector<EnglishIme::TranslationQuery> &group, const std::string &source,
|
||||
const std::string &target, const std::string &target_language, const std::string &provider_scope,
|
||||
std::vector<EnglishIme::TranslationResult> &out)
|
||||
{
|
||||
@@ -152,8 +206,7 @@ void TranslateGroup(const TencentTmt::Credentials &credentials, const CustomTran
|
||||
texts.reserve(group.size());
|
||||
for (const auto &query : group)
|
||||
texts.push_back(query.key);
|
||||
const auto translated = use_custom ? CustomTranslation::TextTranslateBatch(custom, texts, source, target)
|
||||
: TencentTmt::TextTranslateBatch(credentials, texts, source, target);
|
||||
const auto translated = TranslateWithProvider(provider, credentials, custom, niutrans, texts, source, target);
|
||||
ApplyTranslatedGroup(group, translated, target_language, provider_scope, out);
|
||||
}
|
||||
|
||||
@@ -188,11 +241,13 @@ void WorkerLoop()
|
||||
if (!g_running || queries.empty() || !EnglishIme::IsTranslationCurrent(generation))
|
||||
continue;
|
||||
|
||||
const bool use_custom = GetConfiguredCustomTranslation().enabled;
|
||||
const Provider provider = ActiveProvider();
|
||||
const auto custom = ResolveCustomConfig();
|
||||
const auto credentials = use_custom ? TencentTmt::Credentials{} : ResolveCredentials();
|
||||
if ((use_custom && !CustomTranslation::IsSupportedEndpoint(custom.endpoint)) ||
|
||||
(!use_custom && !CloudTranslation::IsUsableSecret(credentials.secret_id)))
|
||||
const auto niutrans = ResolveNiuTransConfig();
|
||||
const auto credentials = provider == Provider::Tencent ? ResolveCredentials() : TencentTmt::Credentials{};
|
||||
if ((provider == Provider::Custom && !CustomTranslation::IsSupportedEndpoint(custom.endpoint)) ||
|
||||
(provider == Provider::NiuTrans && !NiuTransTranslation::IsUsableConfig(niutrans)) ||
|
||||
(provider == Provider::Tencent && !CloudTranslation::IsUsableSecret(credentials.secret_id)))
|
||||
continue;
|
||||
|
||||
const std::string target_language = TargetLanguage();
|
||||
@@ -229,10 +284,10 @@ void WorkerLoop()
|
||||
}
|
||||
|
||||
if (!en_zh.empty())
|
||||
TranslateGroup(credentials, custom, use_custom, en_zh, "en", "zh", target_language, provider_scope,
|
||||
TranslateGroup(provider, credentials, custom, niutrans, en_zh, "en", "zh", target_language, provider_scope,
|
||||
results);
|
||||
if (!zh_target.empty())
|
||||
TranslateGroup(credentials, custom, use_custom, zh_target, "zh", target_language, target_language,
|
||||
TranslateGroup(provider, credentials, custom, niutrans, zh_target, "zh", target_language, target_language,
|
||||
provider_scope, results);
|
||||
if (results.empty() || !g_running || g_job.load() != observed_job ||
|
||||
!EnglishIme::IsTranslationCurrent(generation) || !g_callback)
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
#include "niutrans_translation.h"
|
||||
|
||||
#include "translation_gloss.h"
|
||||
#include <Windows.h>
|
||||
#include <bcrypt.h>
|
||||
#include <curl/curl.h>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <string_view>
|
||||
|
||||
#pragma comment(lib, "bcrypt.lib")
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr const char *kUrl = "https://api.niutrans.com/v2/text/translate";
|
||||
constexpr long kTimeoutMs = 2500;
|
||||
constexpr size_t kMaxResponseBytes = 1024 * 1024;
|
||||
constexpr auto kBatchBudget = std::chrono::seconds(6);
|
||||
|
||||
struct ResponseBuffer
|
||||
{
|
||||
std::string text;
|
||||
bool overflow = false;
|
||||
};
|
||||
|
||||
size_t WriteResponse(char *data, size_t size, size_t count, void *user)
|
||||
{
|
||||
const size_t bytes = size * count;
|
||||
auto *buffer = static_cast<ResponseBuffer *>(user);
|
||||
if (bytes > kMaxResponseBytes - (std::min)(buffer->text.size(), kMaxResponseBytes))
|
||||
{
|
||||
buffer->overflow = true;
|
||||
return 0;
|
||||
}
|
||||
buffer->text.append(data, bytes);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
std::string HexEncodeLower(const unsigned char *data, size_t size)
|
||||
{
|
||||
static const char *kHex = "0123456789abcdef";
|
||||
std::string out;
|
||||
out.resize(size * 2);
|
||||
for (size_t i = 0; i < size; ++i)
|
||||
{
|
||||
out[i * 2] = kHex[(data[i] >> 4) & 0x0F];
|
||||
out[i * 2 + 1] = kHex[data[i] & 0x0F];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string Md5Hex(std::string_view data)
|
||||
{
|
||||
BCRYPT_ALG_HANDLE alg = nullptr;
|
||||
BCRYPT_HASH_HANDLE hash = nullptr;
|
||||
if (BCryptOpenAlgorithmProvider(&alg, BCRYPT_MD5_ALGORITHM, nullptr, 0) != 0)
|
||||
return {};
|
||||
const NTSTATUS created = BCryptCreateHash(alg, &hash, nullptr, 0, nullptr, 0, 0);
|
||||
const NTSTATUS hashed = created == 0
|
||||
? BCryptHashData(hash, reinterpret_cast<PUCHAR>(const_cast<char *>(data.data())),
|
||||
static_cast<ULONG>(data.size()), 0)
|
||||
: created;
|
||||
unsigned char digest[16]{};
|
||||
const NTSTATUS finished = hashed == 0 ? BCryptFinishHash(hash, digest, sizeof(digest), 0) : hashed;
|
||||
if (hash)
|
||||
BCryptDestroyHash(hash);
|
||||
if (alg)
|
||||
BCryptCloseAlgorithmProvider(alg, 0);
|
||||
return finished == 0 ? HexEncodeLower(digest, sizeof(digest)) : std::string{};
|
||||
}
|
||||
|
||||
std::string UrlEncode(CURL *curl, const std::string &value)
|
||||
{
|
||||
char *escaped = curl_easy_escape(curl, value.c_str(), static_cast<int>(value.size()));
|
||||
if (!escaped)
|
||||
return {};
|
||||
std::string out(escaped);
|
||||
curl_free(escaped);
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string MillisecondTimestamp()
|
||||
{
|
||||
const auto now = std::chrono::system_clock::now().time_since_epoch();
|
||||
return std::to_string(std::chrono::duration_cast<std::chrono::milliseconds>(now).count());
|
||||
}
|
||||
} // namespace
|
||||
|
||||
namespace NiuTransTranslation
|
||||
{
|
||||
bool IsUsableConfig(const Config &config)
|
||||
{
|
||||
return CloudTranslation::IsUsableSecret(config.app_id) && CloudTranslation::IsUsableSecret(config.apikey);
|
||||
}
|
||||
|
||||
std::string BuildAuthString(const Config &config, const std::string &from, const std::string &to,
|
||||
const std::string ×tamp, const std::string &src_text)
|
||||
{
|
||||
// NiuTrans sorts the request parameters plus apikey by key name and joins
|
||||
// them as "key=value&...". For the fixed field set the ASCII order is:
|
||||
// apikey, appId, from, srcText, timestamp, to.
|
||||
const std::string param_str = "apikey=" + config.apikey + "&appId=" + config.app_id + "&from=" + from +
|
||||
"&srcText=" + src_text + "×tamp=" + timestamp + "&to=" + to;
|
||||
return Md5Hex(param_str);
|
||||
}
|
||||
|
||||
std::string ParseTranslationResponse(const std::string &response)
|
||||
{
|
||||
try
|
||||
{
|
||||
const auto root = nlohmann::json::parse(response);
|
||||
const auto tgt = root.find("tgtText");
|
||||
if (tgt != root.end() && tgt->is_string())
|
||||
return tgt->get<std::string>();
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
std::vector<std::string> TextTranslateBatch(const Config &config, const std::vector<std::string> &texts,
|
||||
const std::string &source, const std::string &target)
|
||||
{
|
||||
std::vector<std::string> results(texts.size());
|
||||
if (texts.empty() || !IsUsableConfig(config) || source.empty() || target.empty())
|
||||
return results;
|
||||
|
||||
CURL *curl = curl_easy_init();
|
||||
if (!curl)
|
||||
return results;
|
||||
|
||||
curl_slist *headers = nullptr;
|
||||
headers = curl_slist_append(headers, "Content-Type: application/x-www-form-urlencoded; charset=utf-8");
|
||||
|
||||
curl_easy_setopt(curl, CURLOPT_URL, kUrl);
|
||||
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, WriteResponse);
|
||||
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, kTimeoutMs);
|
||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, kTimeoutMs);
|
||||
curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);
|
||||
|
||||
const auto started = std::chrono::steady_clock::now();
|
||||
for (size_t i = 0; i < texts.size(); ++i)
|
||||
{
|
||||
if (std::chrono::steady_clock::now() - started >= kBatchBudget)
|
||||
break;
|
||||
const std::string timestamp = MillisecondTimestamp();
|
||||
// authStr is computed over the raw (unencoded) parameter values; the POST
|
||||
// body sends the same values url-encoded.
|
||||
const std::string auth = BuildAuthString(config, source, target, timestamp, texts[i]);
|
||||
if (auth.empty())
|
||||
continue;
|
||||
const std::string payload = "from=" + UrlEncode(curl, source) + "&to=" + UrlEncode(curl, target) +
|
||||
"&appId=" + UrlEncode(curl, config.app_id) + "×tamp=" + timestamp +
|
||||
"&srcText=" + UrlEncode(curl, texts[i]) + "&authStr=" + auth;
|
||||
ResponseBuffer response;
|
||||
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, payload.c_str());
|
||||
curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, static_cast<long>(payload.size()));
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response);
|
||||
|
||||
const CURLcode performed = curl_easy_perform(curl);
|
||||
long status = 0;
|
||||
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status);
|
||||
if (performed == CURLE_OK && !response.overflow && status >= 200 && status < 300)
|
||||
results[i] = ParseTranslationResponse(response.text);
|
||||
}
|
||||
|
||||
curl_slist_free_all(headers);
|
||||
curl_easy_cleanup(curl);
|
||||
return results;
|
||||
}
|
||||
} // namespace NiuTransTranslation
|
||||
@@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace NiuTransTranslation
|
||||
{
|
||||
struct Config
|
||||
{
|
||||
std::string app_id;
|
||||
std::string apikey;
|
||||
};
|
||||
|
||||
// Both credentials must be present and non-placeholder before requests are sent.
|
||||
bool IsUsableConfig(const Config &config);
|
||||
|
||||
// MD5 permission string per the NiuTrans v2 spec: md5 of the request parameters
|
||||
// (plus apikey) sorted by key and joined as "key=value&...". Exposed for tests.
|
||||
std::string BuildAuthString(const Config &config, const std::string &from, const std::string &to,
|
||||
const std::string ×tamp, const std::string &src_text);
|
||||
|
||||
// Extracts tgtText from a /v2/text/translate response. Empty on error responses
|
||||
// (errorCode/errorMsg) or malformed JSON. Exposed for tests.
|
||||
std::string ParseTranslationResponse(const std::string &response);
|
||||
|
||||
// Calls the NiuTrans /v2/text/translate endpoint once per source string. Empty
|
||||
// entries mean that item failed.
|
||||
std::vector<std::string> TextTranslateBatch(const Config &config, const std::vector<std::string> &texts,
|
||||
const std::string &source, const std::string &target);
|
||||
} // namespace NiuTransTranslation
|
||||
@@ -167,6 +167,7 @@ bool g_persist_polish_token_slot = false;
|
||||
AiAssistantConfig g_ai_assistant;
|
||||
TencentTmtConfig g_tencent_tmt;
|
||||
CustomTranslationConfig g_custom_translation;
|
||||
NiuTransConfig g_niutrans;
|
||||
FrequencyAdjustmentConfig g_frequency_adjustment;
|
||||
std::filesystem::path g_config_path;
|
||||
std::optional<std::filesystem::file_time_type> g_config_last_write_time;
|
||||
@@ -1214,6 +1215,9 @@ bool LoadImeConfig()
|
||||
g_custom_translation.enabled = tbl["custom_translation"]["enabled"].value_or(false);
|
||||
g_custom_translation.endpoint = tbl["custom_translation"]["endpoint"].value_or(std::string());
|
||||
g_custom_translation.api_key = tbl["custom_translation"]["api_key"].value_or(std::string());
|
||||
g_niutrans.enabled = tbl["niutrans"]["enabled"].value_or(false);
|
||||
g_niutrans.app_id = tbl["niutrans"]["app_id"].value_or(std::string());
|
||||
g_niutrans.apikey = tbl["niutrans"]["apikey"].value_or(std::string());
|
||||
RememberConfigWriteTime();
|
||||
return true;
|
||||
}
|
||||
@@ -3434,6 +3438,32 @@ bool SetConfiguredCustomTranslationString(const std::string &key, const std::str
|
||||
return true;
|
||||
}
|
||||
|
||||
const NiuTransConfig &GetConfiguredNiuTrans()
|
||||
{
|
||||
return g_niutrans;
|
||||
}
|
||||
|
||||
bool SetConfiguredNiuTransBool(const std::string &key, bool value)
|
||||
{
|
||||
if (key != "enabled" || !WriteConfiguredValue("niutrans", key, value ? "true" : "false"))
|
||||
return false;
|
||||
g_niutrans.enabled = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool SetConfiguredNiuTransString(const std::string &key, const std::string &value)
|
||||
{
|
||||
std::string *target = nullptr;
|
||||
if (key == "app_id")
|
||||
target = &g_niutrans.app_id;
|
||||
else if (key == "apikey")
|
||||
target = &g_niutrans.apikey;
|
||||
if (!target || !WriteConfiguredValue("niutrans", key, EscapeTomlBasicString(value)))
|
||||
return false;
|
||||
*target = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
const FrequencyAdjustmentConfig &GetConfiguredFrequencyAdjustment()
|
||||
{
|
||||
return g_frequency_adjustment;
|
||||
|
||||
@@ -116,6 +116,14 @@ struct CustomTranslationConfig
|
||||
std::string api_key;
|
||||
};
|
||||
|
||||
struct NiuTransConfig
|
||||
{
|
||||
bool enabled = false;
|
||||
// 控制台->API应用 中的应用唯一标识与 apikey。
|
||||
std::string app_id;
|
||||
std::string apikey;
|
||||
};
|
||||
|
||||
struct FrequencyAdjustmentConfig
|
||||
{
|
||||
std::string mode = "promote"; // disabled | pin | halve | linear | promote
|
||||
@@ -363,6 +371,9 @@ bool SetConfiguredTencentTmtString(const std::string &key, const std::string &va
|
||||
const CustomTranslationConfig &GetConfiguredCustomTranslation();
|
||||
bool SetConfiguredCustomTranslationBool(const std::string &key, bool value);
|
||||
bool SetConfiguredCustomTranslationString(const std::string &key, const std::string &value);
|
||||
const NiuTransConfig &GetConfiguredNiuTrans();
|
||||
bool SetConfiguredNiuTransBool(const std::string &key, bool value);
|
||||
bool SetConfiguredNiuTransString(const std::string &key, const std::string &value);
|
||||
const FrequencyAdjustmentConfig &GetConfiguredFrequencyAdjustment();
|
||||
bool SetConfiguredFrequencyAdjustmentString(const std::string &key, const std::string &value);
|
||||
bool SetConfiguredFrequencyAdjustmentInt(const std::string &key, int value);
|
||||
|
||||
@@ -261,6 +261,7 @@ std::wstring BuildConfigMessage(bool refresh_skin_catalog)
|
||||
const AiAssistantConfig &ai = GetConfiguredAiAssistant();
|
||||
const TencentTmtConfig &tencent_tmt = GetConfiguredTencentTmt();
|
||||
const CustomTranslationConfig &custom_translation = GetConfiguredCustomTranslation();
|
||||
const NiuTransConfig &niutrans = GetConfiguredNiuTrans();
|
||||
const FrequencyAdjustmentConfig &frequency = GetConfiguredFrequencyAdjustment();
|
||||
const FloatingToolbarItemsConfig &toolbar = GetConfiguredFloatingToolbarItems();
|
||||
const std::filesystem::path skins_root =
|
||||
@@ -480,6 +481,7 @@ std::wstring BuildConfigMessage(bool refresh_skin_catalog)
|
||||
{{"enabled", custom_translation.enabled},
|
||||
{"endpoint", custom_translation.endpoint},
|
||||
{"api_key", custom_translation.api_key}}},
|
||||
{"niutrans", {{"enabled", niutrans.enabled}, {"app_id", niutrans.app_id}, {"apikey", niutrans.apikey}}},
|
||||
{"helpcode",
|
||||
{{"shuangpin_helpcode", GetConfiguredShuangpinHelpcodeEnabled()},
|
||||
{"shuangpin_helpcode_schema", GetConfiguredShuangpinHelpcodeSchema()},
|
||||
@@ -751,6 +753,14 @@ bool ApplyConfigUpdate(const json::object &data)
|
||||
SetConfiguredCustomTranslationString(path.substr(std::string("custom_translation.").size()),
|
||||
json::value_to<std::string>(value));
|
||||
}
|
||||
if (path == "niutrans.enabled")
|
||||
return SetConfiguredNiuTransBool("enabled", json::value_to<bool>(data.at("value")));
|
||||
if (path.rfind("niutrans.", 0) == 0)
|
||||
{
|
||||
const json::value &value = data.at("value");
|
||||
return value.is_string() && SetConfiguredNiuTransString(path.substr(std::string("niutrans.").size()),
|
||||
json::value_to<std::string>(value));
|
||||
}
|
||||
if (path == "helpcode.show_sp_helpcode_in_candidate_window")
|
||||
return SetConfiguredShowShuangpinHelpcodeInCandidateWindow(json::value_to<bool>(data.at("value")));
|
||||
if (path == "helpcode.shuangpin_helpcode")
|
||||
|
||||
@@ -20,6 +20,8 @@ set(TEST_SOURCE_FILES
|
||||
"${CMAKE_SOURCE_DIR}/src/cloud/translation_gloss.cpp"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/src/test_custom_translation.cpp"
|
||||
"${CMAKE_SOURCE_DIR}/src/cloud/custom_translation.cpp"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/src/test_niutrans_translation.cpp"
|
||||
"${CMAKE_SOURCE_DIR}/src/cloud/niutrans_translation.cpp"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/src/test_active_client_state.cpp"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/src/test_outbound_session_state.cpp"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/src/test_pipe_write_policy.cpp"
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
#include "tests/includes/test_framework.h"
|
||||
|
||||
#include "cloud/niutrans_translation.h"
|
||||
|
||||
TEST_CASE(NiuTransRequiresBothCredentials)
|
||||
{
|
||||
REQUIRE(NiuTransTranslation::IsUsableConfig({"app123", "key456"}));
|
||||
REQUIRE(!NiuTransTranslation::IsUsableConfig({"", "key456"}));
|
||||
REQUIRE(!NiuTransTranslation::IsUsableConfig({"app123", ""}));
|
||||
// Placeholder-shaped credentials from config.default.toml must not count as usable.
|
||||
REQUIRE(!NiuTransTranslation::IsUsableConfig({"<YOUR_APP_ID>", "<YOUR_APIKEY>"}));
|
||||
}
|
||||
|
||||
TEST_CASE(NiuTransBuildsSortedAuthString)
|
||||
{
|
||||
// md5 of "apikey=key456&appId=app123&from=zh&srcText=你好×tamp=1700000000000&to=en"
|
||||
const std::string auth =
|
||||
NiuTransTranslation::BuildAuthString({"app123", "key456"}, "zh", "en", "1700000000000", "你好");
|
||||
REQUIRE_EQ(auth, std::string("7b63ea8c434003c0dfb6e0ef4e0f6a46"));
|
||||
}
|
||||
|
||||
TEST_CASE(NiuTransParsesResponses)
|
||||
{
|
||||
REQUIRE_EQ(NiuTransTranslation::ParseTranslationResponse(R"({"from":"zh","to":"en","tgtText":"hello"})"),
|
||||
std::string("hello"));
|
||||
REQUIRE(
|
||||
NiuTransTranslation::ParseTranslationResponse(R"({"errorCode":"20003","errorMsg":"参数校验异常"})").empty());
|
||||
REQUIRE(NiuTransTranslation::ParseTranslationResponse("not json").empty());
|
||||
}
|
||||
@@ -293,6 +293,7 @@ function applyConfigData(data: Record<string, any>, target?: string): void {
|
||||
data?.general?.cn_en_mixed_input_min_chars
|
||||
);
|
||||
module.applyTencentTmtConfig(data?.tencent_tmt);
|
||||
module.applyNiuTransConfig(data?.niutrans);
|
||||
module.applyCustomTranslationConfig(data?.custom_translation);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,8 +6,11 @@ import { updateCandidatePreviewHelpcode } from './appearance';
|
||||
type InputScheme = 'quanpin' | 'shuangpin' | 'wubi';
|
||||
type InputMode = 'chinese' | 'japanese';
|
||||
|
||||
type TranslationProvider = 'tencent' | 'niutrans' | 'custom';
|
||||
|
||||
let applyingInputConfig = false;
|
||||
let usingCustomTranslation = false;
|
||||
let customTranslationEnabled = false;
|
||||
let niutransTranslationEnabled = false;
|
||||
|
||||
function updateInputConfig(path: string, value: string): void {
|
||||
window.chrome?.webview?.postMessage(serializeHostMessage({
|
||||
@@ -89,31 +92,52 @@ function syncCandidateTranslationOptions(enabled: boolean): void {
|
||||
document.getElementById('candidateTranslationApiOptions')?.classList.toggle('is-disabled', !enabled);
|
||||
}
|
||||
|
||||
function activeTranslationProvider(): TranslationProvider {
|
||||
if (niutransTranslationEnabled) return 'niutrans';
|
||||
if (customTranslationEnabled) return 'custom';
|
||||
return 'tencent';
|
||||
}
|
||||
|
||||
function syncCandidateTranslationWarning(): void {
|
||||
const warning = document.getElementById('candidateTranslationApiWarning');
|
||||
if (!warning) return;
|
||||
if (usingCustomTranslation) {
|
||||
const provider = activeTranslationProvider();
|
||||
if (provider === 'custom') {
|
||||
const endpoint = (document.getElementById('customTranslationEndpoint') as HTMLInputElement | null)?.value.trim();
|
||||
const valid = /^https?:\/\/\S+$/i.test(endpoint ?? '');
|
||||
warning.textContent = '请填写以 http:// 或 https:// 开头的完整接口地址';
|
||||
warning.classList.toggle('is-hidden', valid);
|
||||
return;
|
||||
}
|
||||
if (provider === 'niutrans') {
|
||||
const appId = (document.getElementById('niutransAppId') as HTMLInputElement | null)?.value.trim();
|
||||
const apiKey = (document.getElementById('niutransApiKey') as HTMLInputElement | null)?.value.trim();
|
||||
warning.textContent = '请填写 APP ID 和 API Key 后使用小牛翻译';
|
||||
warning.classList.toggle('is-hidden', Boolean(appId && apiKey));
|
||||
return;
|
||||
}
|
||||
const secretId = (document.getElementById('tencentTmtSecretId') as HTMLInputElement | null)?.value.trim();
|
||||
const secretKey = (document.getElementById('tencentTmtSecretKey') as HTMLInputElement | null)?.value.trim();
|
||||
warning.textContent = '请填写 SecretId 和 SecretKey 后使用云端翻译';
|
||||
warning.classList.toggle('is-hidden', Boolean(secretId && secretKey));
|
||||
}
|
||||
|
||||
function syncTranslationProviderView(useCustom: boolean): void {
|
||||
usingCustomTranslation = useCustom;
|
||||
function syncTranslationProviderView(provider: TranslationProvider): void {
|
||||
const tencentFields = document.getElementById('tencentTranslationFields');
|
||||
const niutransFields = document.getElementById('niutransTranslationFields');
|
||||
const customFields = document.getElementById('customTranslationFields');
|
||||
if (tencentFields) tencentFields.hidden = useCustom;
|
||||
if (customFields) customFields.hidden = !useCustom;
|
||||
if (tencentFields) tencentFields.hidden = provider !== 'tencent';
|
||||
if (niutransFields) niutransFields.hidden = provider !== 'niutrans';
|
||||
if (customFields) customFields.hidden = provider !== 'custom';
|
||||
syncCandidateTranslationWarning();
|
||||
}
|
||||
|
||||
function refreshTranslationProvider(): void {
|
||||
const provider = activeTranslationProvider();
|
||||
applyDropdownValue('translationProviderBtn', 'translationProviderMenu', provider);
|
||||
syncTranslationProviderView(provider);
|
||||
}
|
||||
|
||||
export function applyTencentTmtConfig(config: Record<string, unknown> | undefined): void {
|
||||
const secretId = document.getElementById('tencentTmtSecretId') as HTMLInputElement | null;
|
||||
const secretKey = document.getElementById('tencentTmtSecretKey') as HTMLInputElement | null;
|
||||
@@ -128,13 +152,21 @@ export function applyTencentTmtConfig(config: Record<string, unknown> | undefine
|
||||
}
|
||||
|
||||
export function applyCustomTranslationConfig(config: Record<string, unknown> | undefined): void {
|
||||
const useCustom = config?.enabled === true;
|
||||
applyDropdownValue('translationProviderBtn', 'translationProviderMenu', useCustom ? 'custom' : 'tencent');
|
||||
customTranslationEnabled = config?.enabled === true;
|
||||
const endpoint = document.getElementById('customTranslationEndpoint') as HTMLInputElement | null;
|
||||
const apiKey = document.getElementById('customTranslationApiKey') as HTMLInputElement | null;
|
||||
if (endpoint && typeof config?.endpoint === 'string') endpoint.value = config.endpoint;
|
||||
if (apiKey && typeof config?.api_key === 'string') apiKey.value = config.api_key;
|
||||
syncTranslationProviderView(useCustom);
|
||||
refreshTranslationProvider();
|
||||
}
|
||||
|
||||
export function applyNiuTransConfig(config: Record<string, unknown> | undefined): void {
|
||||
niutransTranslationEnabled = config?.enabled === true;
|
||||
const appId = document.getElementById('niutransAppId') as HTMLInputElement | null;
|
||||
const apiKey = document.getElementById('niutransApiKey') as HTMLInputElement | null;
|
||||
if (appId && typeof config?.app_id === 'string') appId.value = config.app_id;
|
||||
if (apiKey && typeof config?.apikey === 'string') apiKey.value = config.apikey;
|
||||
refreshTranslationProvider();
|
||||
}
|
||||
|
||||
function setupSecretVisibility(inputId: string, buttonId: string, name: string): void {
|
||||
@@ -246,13 +278,23 @@ export function setupInput(): void {
|
||||
document.getElementById('translationProviderMenu')?.addEventListener('click', (event: Event) => {
|
||||
const item = (event.target as HTMLElement | null)?.closest<HTMLElement>('.dropdown-item');
|
||||
if (!item) return;
|
||||
const useCustom = item.dataset.value === 'custom';
|
||||
syncTranslationProviderView(useCustom);
|
||||
updateConfig('custom_translation.enabled', useCustom);
|
||||
const provider: TranslationProvider = item.dataset.value === 'niutrans'
|
||||
? 'niutrans'
|
||||
: item.dataset.value === 'custom'
|
||||
? 'custom'
|
||||
: 'tencent';
|
||||
niutransTranslationEnabled = provider === 'niutrans';
|
||||
customTranslationEnabled = provider === 'custom';
|
||||
syncTranslationProviderView(provider);
|
||||
// Only one provider is active; keep the mutually-exclusive flags in sync.
|
||||
updateConfig('niutrans.enabled', niutransTranslationEnabled);
|
||||
updateConfig('custom_translation.enabled', customTranslationEnabled);
|
||||
});
|
||||
const translationFields: Record<string, string> = {
|
||||
tencentTmtSecretId: 'tencent_tmt.secret_id',
|
||||
tencentTmtSecretKey: 'tencent_tmt.secret_key',
|
||||
niutransAppId: 'niutrans.app_id',
|
||||
niutransApiKey: 'niutrans.apikey',
|
||||
customTranslationEndpoint: 'custom_translation.endpoint',
|
||||
customTranslationApiKey: 'custom_translation.api_key'
|
||||
};
|
||||
@@ -265,6 +307,7 @@ export function setupInput(): void {
|
||||
});
|
||||
});
|
||||
setupSecretVisibility('tencentTmtSecretKey', 'tencentTmtSecretKeyVisibility', 'SecretKey');
|
||||
setupSecretVisibility('niutransApiKey', 'niutransApiKeyVisibility', 'API Key');
|
||||
setupSecretVisibility('customTranslationApiKey', 'customTranslationApiKeyVisibility', 'API Key');
|
||||
setupDropdownMenu(
|
||||
'zhEnTriggerLengthBtn',
|
||||
|
||||
@@ -208,7 +208,7 @@
|
||||
<div class="section-header translation-language-row">
|
||||
<div>
|
||||
<div class="section-title">在线翻译服务</div>
|
||||
<div class="input-setting-description">可使用腾讯云,或填写自己部署的 DeepLX 兼容接口</div>
|
||||
<div class="input-setting-description">可使用腾讯云、小牛翻译,或填写自己部署的 DeepLX 兼容接口</div>
|
||||
</div>
|
||||
<div class="dropdown">
|
||||
<button class="dropdown-toggle translation-language-toggle" id="translationProviderBtn">
|
||||
@@ -216,6 +216,7 @@
|
||||
</button>
|
||||
<div class="dropdown-menu translation-language-menu" id="translationProviderMenu">
|
||||
<div class="dropdown-item" data-value="tencent">腾讯云</div>
|
||||
<div class="dropdown-item" data-value="niutrans">小牛翻译</div>
|
||||
<div class="dropdown-item" data-value="custom">自定义 DeepLX</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -272,6 +273,36 @@
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="niutransTranslationFields" hidden>
|
||||
<div class="translation-api-heading">
|
||||
<div class="section-title">小牛翻译 API 凭据</div>
|
||||
<div class="input-setting-description">在“控制台—>API应用”中查看,凭据仅保存在本机配置文件中</div>
|
||||
</div>
|
||||
<div class="translation-api-grid">
|
||||
<label class="translation-api-field">
|
||||
<span>APP ID</span>
|
||||
<input id="niutransAppId" type="text" autocomplete="off" spellcheck="false" placeholder="应用唯一标识">
|
||||
</label>
|
||||
<label class="translation-api-field">
|
||||
<span>API Key</span>
|
||||
<span class="translation-secret-input">
|
||||
<input id="niutransApiKey" type="password" autocomplete="new-password" spellcheck="false" placeholder="apikey">
|
||||
<button id="niutransApiKeyVisibility" class="translation-secret-toggle" type="button"
|
||||
aria-label="显示 API Key" aria-pressed="false" title="显示 API Key">
|
||||
<svg class="translation-secret-icon translation-secret-icon-show" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z"></path>
|
||||
<circle cx="12" cy="12" r="2.6"></circle>
|
||||
</svg>
|
||||
<svg class="translation-secret-icon translation-secret-icon-hide" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="m3 3 18 18"></path>
|
||||
<path d="M10.6 6.1A9.6 9.6 0 0 1 12 6c6 0 9.5 6 9.5 6a16 16 0 0 1-2.2 3"></path>
|
||||
<path d="M6.2 6.3C3.8 8.1 2.5 12 2.5 12s3.5 6 9.5 6a9 9 0 0 0 3-.5"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="customTranslationFields" hidden>
|
||||
<div class="translation-api-heading">
|
||||
<div class="section-title">自定义 DeepLX 服务</div>
|
||||
|
||||
Reference in new issue
Block a user