From e350a94509d476edfacd5e3ba56fa47714b4c2fa Mon Sep 17 00:00:00 2001 From: fanlusky <1730976608@qq.com> Date: Sun, 13 Sep 2026 20:49:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(translation):=20=E5=80=99=E9=80=89?= =?UTF-8?q?=E8=AF=8D=E7=BF=BB=E8=AF=91=E6=94=AF=E6=8C=81=E5=B0=8F=E7=89=9B?= =?UTF-8?q?=E7=BF=BB=E8=AF=91=EF=BC=88NiuTrans=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在线翻译服务由「腾讯云 / 自定义 DeepLX」扩展为三选一,新增小牛翻译。 - 新增 NiuTrans provider(/v2/text/translate):按官方规则对参数加 apikey 排序拼接后取 MD5 作为 authStr(BCrypt 实现,无新增依赖),表单编码逐条 翻译,解析 tgtText,errorCode/errorMsg 视为失败 - cloud_translation 调度改为 Provider 枚举,优先级 niutrans > custom > tencent, 缓存 key 带 provider 区分 - 配置层新增 [niutrans](enabled/app_id/apikey)及读取与读写接口 - 设置界面新增「小牛翻译」下拉项与 APP ID / API Key 输入框,切换时保持 provider 互斥 - 新增 test_niutrans_translation(凭据校验、鉴权 MD5、响应解析) --- installer/default_config/config.default.toml | 9 + server/CMakeLists.txt | 2 + server/assets/config/config.toml | 9 + server/src/cloud/cloud_translation.cpp | 79 ++++++-- server/src/cloud/niutrans_translation.cpp | 174 ++++++++++++++++++ server/src/cloud/niutrans_translation.h | 30 +++ server/src/config/ime_config.cpp | 30 +++ server/src/config/ime_config.h | 11 ++ server/src/settings/settings_app.cpp | 10 + server/tests/CMakeLists.txt | 2 + .../tests/src/test_niutrans_translation.cpp | 29 +++ .../ime-settings/src/modules/config-sync.ts | 1 + .../ime-settings/src/modules/input.ts | 67 +++++-- .../ime-settings/src/partials/input.html | 33 +++- 14 files changed, 461 insertions(+), 25 deletions(-) create mode 100644 server/src/cloud/niutrans_translation.cpp create mode 100644 server/src/cloud/niutrans_translation.h create mode 100644 server/tests/src/test_niutrans_translation.cpp diff --git a/installer/default_config/config.default.toml b/installer/default_config/config.default.toml index 16af26b1..80c941ab 100644 --- a/installer/default_config/config.default.toml +++ b/installer/default_config/config.default.toml @@ -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" diff --git a/server/CMakeLists.txt b/server/CMakeLists.txt index b34b3d80..f1db1bf7 100644 --- a/server/CMakeLists.txt +++ b/server/CMakeLists.txt @@ -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" diff --git a/server/assets/config/config.toml b/server/assets/config/config.toml index 53e6dbfb..8f4b60b6 100644 --- a/server/assets/config/config.toml +++ b/server/assets/config/config.toml @@ -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" diff --git a/server/src/cloud/cloud_translation.cpp b/server/src/cloud/cloud_translation.cpp index 1942b2f9..29d292be 100644 --- a/server/src/cloud/cloud_translation.cpp +++ b/server/src/cloud/cloud_translation.cpp @@ -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 &group } } -void TranslateGroup(const TencentTmt::Credentials &credentials, const CustomTranslation::Config &custom, - bool use_custom, const std::vector &group, const std::string &source, +std::vector TranslateWithProvider(Provider provider, const TencentTmt::Credentials &credentials, + const CustomTranslation::Config &custom, + const NiuTransTranslation::Config &niutrans, + const std::vector &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 &group, const std::string &source, const std::string &target, const std::string &target_language, const std::string &provider_scope, std::vector &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) diff --git a/server/src/cloud/niutrans_translation.cpp b/server/src/cloud/niutrans_translation.cpp new file mode 100644 index 00000000..9e44f9f0 --- /dev/null +++ b/server/src/cloud/niutrans_translation.cpp @@ -0,0 +1,174 @@ +#include "niutrans_translation.h" + +#include "translation_gloss.h" +#include +#include +#include +#include +#include +#include +#include + +#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(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(const_cast(data.data())), + static_cast(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(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(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(); + } + catch (...) + { + } + return {}; +} + +std::vector TextTranslateBatch(const Config &config, const std::vector &texts, + const std::string &source, const std::string &target) +{ + std::vector 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(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 diff --git a/server/src/cloud/niutrans_translation.h b/server/src/cloud/niutrans_translation.h new file mode 100644 index 00000000..0fe2aaa2 --- /dev/null +++ b/server/src/cloud/niutrans_translation.h @@ -0,0 +1,30 @@ +#pragma once + +#include +#include + +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 TextTranslateBatch(const Config &config, const std::vector &texts, + const std::string &source, const std::string &target); +} // namespace NiuTransTranslation diff --git a/server/src/config/ime_config.cpp b/server/src/config/ime_config.cpp index f0d5af69..683be92c 100644 --- a/server/src/config/ime_config.cpp +++ b/server/src/config/ime_config.cpp @@ -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 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; diff --git a/server/src/config/ime_config.h b/server/src/config/ime_config.h index 92ae9435..b9ebd143 100644 --- a/server/src/config/ime_config.h +++ b/server/src/config/ime_config.h @@ -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); diff --git a/server/src/settings/settings_app.cpp b/server/src/settings/settings_app.cpp index 06177a39..e21ab43c 100644 --- a/server/src/settings/settings_app.cpp +++ b/server/src/settings/settings_app.cpp @@ -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(value)); } + if (path == "niutrans.enabled") + return SetConfiguredNiuTransBool("enabled", json::value_to(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(value)); + } if (path == "helpcode.show_sp_helpcode_in_candidate_window") return SetConfiguredShowShuangpinHelpcodeInCandidateWindow(json::value_to(data.at("value"))); if (path == "helpcode.shuangpin_helpcode") diff --git a/server/tests/CMakeLists.txt b/server/tests/CMakeLists.txt index 2a29f383..fab56842 100644 --- a/server/tests/CMakeLists.txt +++ b/server/tests/CMakeLists.txt @@ -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" diff --git a/server/tests/src/test_niutrans_translation.cpp b/server/tests/src/test_niutrans_translation.cpp new file mode 100644 index 00000000..3e2c7585 --- /dev/null +++ b/server/tests/src/test_niutrans_translation.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({"", ""})); +} + +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()); +} diff --git a/ui-html/webview2/settings/ime-settings/src/modules/config-sync.ts b/ui-html/webview2/settings/ime-settings/src/modules/config-sync.ts index e7fa1a08..571d005d 100644 --- a/ui-html/webview2/settings/ime-settings/src/modules/config-sync.ts +++ b/ui-html/webview2/settings/ime-settings/src/modules/config-sync.ts @@ -293,6 +293,7 @@ function applyConfigData(data: Record, 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); }); } diff --git a/ui-html/webview2/settings/ime-settings/src/modules/input.ts b/ui-html/webview2/settings/ime-settings/src/modules/input.ts index 8aae72cb..66bc5ee2 100644 --- a/ui-html/webview2/settings/ime-settings/src/modules/input.ts +++ b/ui-html/webview2/settings/ime-settings/src/modules/input.ts @@ -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 | 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 | undefine } export function applyCustomTranslationConfig(config: Record | 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 | 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('.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 = { 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', diff --git a/ui-html/webview2/settings/ime-settings/src/partials/input.html b/ui-html/webview2/settings/ime-settings/src/partials/input.html index a577d4b7..6e5b673c 100644 --- a/ui-html/webview2/settings/ime-settings/src/partials/input.html +++ b/ui-html/webview2/settings/ime-settings/src/partials/input.html @@ -208,7 +208,7 @@
在线翻译服务
-
可使用腾讯云,或填写自己部署的 DeepLX 兼容接口
+
可使用腾讯云、小牛翻译,或填写自己部署的 DeepLX 兼容接口
@@ -272,6 +273,36 @@
+