Merge pull request #376 from fanlusky/feat/niutrans-candidate-translation

feat(translation): 候选词翻译支持小牛翻译(NiuTrans)
This commit is contained in:
Lu Fan(SonnyCalcr) authored and GitHub committed 2026-09-13 21:02:54 +08:00
commit 2bc1e47653
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"
+2
View File
@@ -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"
+9
View File
@@ -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"
+67 -12
View File
@@ -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)
+174
View File
@@ -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 &timestamp, 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 + "&timestamp=" + 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) + "&timestamp=" + 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
+30
View File
@@ -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 &timestamp, 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
+30
View File
@@ -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;
+11
View File
@@ -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);
+10
View File
@@ -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")
+2
View File
@@ -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=你好&timestamp=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>