From 69092b9d9ad02b4a55a4d52973ccba4f4d15aeac Mon Sep 17 00:00:00 2001 From: Jia Sui Date: Fri, 2 Oct 2026 20:15:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(server):=20=E4=B8=87=E8=B1=A1=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E7=9A=84=E9=85=8D=E7=BD=AE=E9=93=BE=E8=B7=AF=E3=80=81?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E5=8C=85=E8=A7=A3=E6=9E=90=E4=B8=8E=E6=8C=89?= =?UTF-8?q?=E9=9C=80=E4=B8=8B=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [association] 新增五键:sentence_collocation_additive/rerank(设置页开关,默认 关)、sentence_collocation_model(模型包 id,下载器写回)、两个权重(配置文件 高级项,不进设置页);出厂模板与工作配置同步更新,通过五件套映射下发 - 模型包确定性布局 /models//.gram,解析只是一次 stat;开关开 着但文件缺席即视为关闭,词格静默降级 - collocation_model.{h,cpp}:WinHTTP 按需下载(重定向放行、进度回报)+ BCrypt SHA256 校验 + 同卷原子落位 + CC-BY-4.0 NOTICE。SHA256/字节数在常量里钉死: 上游 LTS release 会被原地重传,换字节必须显式更新并复跑离线评测 - CandidateSource::Collocation 落库/学习判定与神经整句同类,候选徽标〔万象〕 - 下载器编译进 Server 与独立设置进程两个宿主,状态以磁盘为准 --- .gitignore | 7 + installer/default_config/config.default.toml | 12 +- server/CMakeLists.txt | 4 + server/assets/config/config.toml | 12 +- server/src/config/ime_config.cpp | 13 + server/src/config/ime_config.h | 9 + server/src/config/ime_config_general.cpp | 43 +++ server/src/config/ime_config_internal.h | 5 + server/src/ipc/candidate_selection_policy.h | 15 +- server/src/ipc/event_listener_candidates.cpp | 2 + server/src/session/engine_input_session.cpp | 36 +++ server/src/settings/collocation_model.cpp | 292 ++++++++++++++++++ server/src/settings/collocation_model.h | 33 ++ server/src/settings/settings_app.cpp | 23 +- .../webview2/windows_webview2_settings.cpp | 36 ++- 15 files changed, 532 insertions(+), 10 deletions(-) create mode 100644 server/src/settings/collocation_model.cpp create mode 100644 server/src/settings/collocation_model.h diff --git a/.gitignore b/.gitignore index 9cd27b2b..412984b5 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,10 @@ log.txt # 由 scripts/fetch-neural-model.ps1 按 lock.json 从 release 重新取到并校验 sha256。 /neural-model/*.safetensors __pycache__/ + +# 外部模型包:models/ 下是按需下载的大文件(如万象 .gram 400MB),来源与摘要 +# 记在调研文档与锁文件里,不进仓库。 +/models/ + +# 本地测试数据暂存(照 scripts/ci/test-server.ps1 的布局手工准备的) +/stage/ diff --git a/installer/default_config/config.default.toml b/installer/default_config/config.default.toml index bbf48153..947de8b5 100644 --- a/installer/default_config/config.default.toml +++ b/installer/default_config/config.default.toml @@ -81,8 +81,18 @@ sentence_neural_desktop = false sentence_neural_keyboard = true # 某个模型的首选与已有候选重复时,改为显示该模型排名最高的下一条不同结果 sentence_show_next_on_duplicate = false -# 在候选窗的整句候选后显示来源标签(〔Trigram〕〔Unigram〕〔神经D〕〔神经K〕) +# 在候选窗的整句候选后显示来源标签(〔Trigram〕〔Unigram〕〔神经D〕〔神经K〕〔万象〕) sentence_source_badge = false +# 万象语法模型(octagram .gram,约 390MB,设置页下载):字级搭配强度表,与词表零耦合。 +# 两个机制共用一份模型包(models//.gram),默认全关;模型未下载时开关不生效。 +# additive 在词格解码里逐边叠加搭配分,rerank 在 n-best 上按整句搭配分重排(〔万象〕行)。 +sentence_collocation_additive = false +sentence_collocation_rerank = false +# 模型包 id,由设置页下载器写回;也可手动放置后填 id(布局 models//.gram) +sentence_collocation_model = "" +# 搭配项的线性权重(log10 域)。取值依据 docs/research 的离线评测扫描;不进设置页 +sentence_collocation_weight = 0.1 +sentence_collocation_rerank_weight = 0.05 # # 腾讯云机器翻译(竖排候选项本地词库未命中时的补全) diff --git a/server/CMakeLists.txt b/server/CMakeLists.txt index 911a86b2..7e77c352 100644 --- a/server/CMakeLists.txt +++ b/server/CMakeLists.txt @@ -210,6 +210,8 @@ set(SRC_FILES "./src/settings/settings_launcher.h" "./src/settings/api_credential_test.cpp" "./src/settings/api_credential_test.h" + "./src/settings/collocation_model.cpp" + "./src/settings/collocation_model.h" "./src/skin/candidate_skin_catalog.cpp" "./src/skin/candidate_skin_catalog.h" "./src/session/engine_input_session.cpp" @@ -432,6 +434,8 @@ add_executable(${SETTINGS_EXECUTABLE_NAME} WIN32 "./src/settings/settings_app.cpp" "./src/settings/api_credential_test.cpp" "./src/settings/api_credential_test.h" + "./src/settings/collocation_model.cpp" + "./src/settings/collocation_model.h" "./src/settings/settings_splash.cpp" "./src/settings/settings_splash.h" "./src/settings/settings_launcher.cpp" diff --git a/server/assets/config/config.toml b/server/assets/config/config.toml index ed7f2d12..d955deb2 100644 --- a/server/assets/config/config.toml +++ b/server/assets/config/config.toml @@ -81,8 +81,18 @@ sentence_neural_desktop = false sentence_neural_keyboard = true # 某个模型的首选与已有候选重复时,改为显示该模型排名最高的下一条不同结果 sentence_show_next_on_duplicate = false -# 在候选窗的整句候选后显示来源标签(〔Trigram〕〔Unigram〕〔神经D〕〔神经K〕) +# 在候选窗的整句候选后显示来源标签(〔Trigram〕〔Unigram〕〔神经D〕〔神经K〕〔万象〕) sentence_source_badge = false +# 万象语法模型(octagram .gram,约 390MB,设置页下载):字级搭配强度表,与词表零耦合。 +# 两个机制共用一份模型包(models//.gram),默认全关;模型未下载时开关不生效。 +# additive 在词格解码里逐边叠加搭配分,rerank 在 n-best 上按整句搭配分重排(〔万象〕行)。 +sentence_collocation_additive = false +sentence_collocation_rerank = false +# 模型包 id,由设置页下载器写回;也可手动放置后填 id(布局 models//.gram) +sentence_collocation_model = "" +# 搭配项的线性权重(log10 域)。取值依据 docs/research 的离线评测扫描;不进设置页 +sentence_collocation_weight = 0.1 +sentence_collocation_rerank_weight = 0.05 # # 腾讯云机器翻译(候选项本地词库未命中时的补全) diff --git a/server/src/config/ime_config.cpp b/server/src/config/ime_config.cpp index bc409f98..bb07ed9f 100644 --- a/server/src/config/ime_config.cpp +++ b/server/src/config/ime_config.cpp @@ -120,6 +120,13 @@ bool g_assoc_sentence_google = true; bool g_assoc_sentence_neural_desktop = false; bool g_assoc_sentence_neural_keyboard = true; bool g_assoc_sentence_show_next_on_duplicate = false; +// 万象语法模型(octagram .gram):两个机制默认全关,模型 id 默认空(未下载/未选)。 +// 权重不进设置页,供配置文件高级调参;取值依据 docs/research 的离线评测扫描。 +bool g_assoc_sentence_collocation_additive = false; +bool g_assoc_sentence_collocation_rerank = false; +std::string g_assoc_sentence_collocation_model; +double g_assoc_sentence_collocation_weight = 0.1; +double g_assoc_sentence_collocation_rerank_weight = 0.05; // 候选窗整句候选后的来源标签(〔Trigram〕〔神经K〕等),默认不显示。 bool g_assoc_sentence_source_badge = false; bool g_emoji_mixed_input_enabled = false; @@ -491,6 +498,12 @@ bool LoadImeConfig() g_assoc_sentence_neural_desktop = tbl["association"]["sentence_neural_desktop"].value_or(false); g_assoc_sentence_neural_keyboard = tbl["association"]["sentence_neural_keyboard"].value_or(true); g_assoc_sentence_show_next_on_duplicate = tbl["association"]["sentence_show_next_on_duplicate"].value_or(false); + g_assoc_sentence_collocation_additive = tbl["association"]["sentence_collocation_additive"].value_or(false); + g_assoc_sentence_collocation_rerank = tbl["association"]["sentence_collocation_rerank"].value_or(false); + g_assoc_sentence_collocation_model = tbl["association"]["sentence_collocation_model"].value_or(std::string()); + g_assoc_sentence_collocation_weight = tbl["association"]["sentence_collocation_weight"].value_or(0.1); + g_assoc_sentence_collocation_rerank_weight = + tbl["association"]["sentence_collocation_rerank_weight"].value_or(0.05); g_assoc_sentence_source_badge = tbl["association"]["sentence_source_badge"].value_or(false); g_emoji_mixed_input_enabled = tbl["general"]["emoji_mixed_input"].value_or(false); g_kaomoji_mixed_input_enabled = tbl["general"]["kaomoji_mixed_input"].value_or(false); diff --git a/server/src/config/ime_config.h b/server/src/config/ime_config.h index 688e0bf5..07335e25 100644 --- a/server/src/config/ime_config.h +++ b/server/src/config/ime_config.h @@ -361,6 +361,15 @@ bool GetConfiguredAssocSentenceShowNextOnDuplicate(); bool SetConfiguredAssocSentenceShowNextOnDuplicate(bool enabled); bool GetConfiguredAssocSentenceSourceBadge(); bool SetConfiguredAssocSentenceSourceBadge(bool enabled); +// 万象语法模型([association] 段):两个机制开关进设置页,模型 id 由下载器写回, +// 权重是配置文件里的高级调参项,不进设置页。 +bool GetConfiguredAssocSentenceCollocationAdditive(); +bool SetConfiguredAssocSentenceCollocationAdditive(bool enabled); +bool GetConfiguredAssocSentenceCollocationRerank(); +bool SetConfiguredAssocSentenceCollocationRerank(bool enabled); +bool GetConfiguredAssocSentenceCollocationRerankWeight(); +std::string GetConfiguredAssocSentenceCollocationModel(); +double GetConfiguredAssocSentenceCollocationWeight(); bool GetConfiguredUnicodeModeEnabled(); bool SetConfiguredUnicodeModeEnabled(bool enabled); // Shift+K 快捷短语模式。 diff --git a/server/src/config/ime_config_general.cpp b/server/src/config/ime_config_general.cpp index 1b47c85d..a0c30fcf 100644 --- a/server/src/config/ime_config_general.cpp +++ b/server/src/config/ime_config_general.cpp @@ -208,6 +208,49 @@ bool SetConfiguredAssocSentenceNeuralKeyboard(bool enabled) return true; } +bool GetConfiguredAssocSentenceCollocationAdditive() +{ + return g_assoc_sentence_collocation_additive; +} +bool SetConfiguredAssocSentenceCollocationAdditive(bool enabled) +{ + if (!WriteConfiguredValue("association", "sentence_collocation_additive", enabled ? "true" : "false")) + { + return false; + } + g_assoc_sentence_collocation_additive = enabled; + return true; +} + +bool GetConfiguredAssocSentenceCollocationRerank() +{ + return g_assoc_sentence_collocation_rerank; +} +bool SetConfiguredAssocSentenceCollocationRerank(bool enabled) +{ + if (!WriteConfiguredValue("association", "sentence_collocation_rerank", enabled ? "true" : "false")) + { + return false; + } + g_assoc_sentence_collocation_rerank = enabled; + return true; +} + +bool GetConfiguredAssocSentenceCollocationRerankWeight() +{ + return g_assoc_sentence_collocation_rerank_weight; +} + +std::string GetConfiguredAssocSentenceCollocationModel() +{ + return g_assoc_sentence_collocation_model; +} + +double GetConfiguredAssocSentenceCollocationWeight() +{ + return g_assoc_sentence_collocation_weight; +} + bool GetConfiguredAssocSentenceShowNextOnDuplicate() { return g_assoc_sentence_show_next_on_duplicate; diff --git a/server/src/config/ime_config_internal.h b/server/src/config/ime_config_internal.h index 375ac620..3b6339e6 100644 --- a/server/src/config/ime_config_internal.h +++ b/server/src/config/ime_config_internal.h @@ -164,6 +164,11 @@ extern bool g_assoc_sentence_neural_desktop; extern bool g_assoc_sentence_neural_keyboard; extern bool g_assoc_sentence_show_next_on_duplicate; extern bool g_assoc_sentence_source_badge; +extern bool g_assoc_sentence_collocation_additive; +extern bool g_assoc_sentence_collocation_rerank; +extern std::string g_assoc_sentence_collocation_model; +extern double g_assoc_sentence_collocation_weight; +extern double g_assoc_sentence_collocation_rerank_weight; extern bool g_emoji_mixed_input_enabled; extern bool g_kaomoji_mixed_input_enabled; extern bool g_unicode_mode_enabled; diff --git a/server/src/ipc/candidate_selection_policy.h b/server/src/ipc/candidate_selection_policy.h index e50275ec..81b2f9f6 100644 --- a/server/src/ipc/candidate_selection_policy.h +++ b/server/src/ipc/candidate_selection_policy.h @@ -31,12 +31,14 @@ inline size_t CountCanonicalSyllables(const std::string &canonical_pinyin) noexc return 1 + static_cast(std::count(canonical_pinyin.begin(), canonical_pinyin.end(), '\'')); } -// 猜出来的整句来源:词格 Generated、Google 解码器 Fallback,以及神经整句 -// NeuralDesktop / NeuralKeyboard。它们都不是词库里已有的行,落库/学习判定同类处理。 +// 猜出来的整句来源:词格 Generated、Google 解码器 Fallback,神经整句 +// NeuralDesktop / NeuralKeyboard,以及万象语法模型重排的 Collocation。 +// 它们都不是词库里已有的行,落库/学习判定同类处理。 constexpr bool IsGuessedSentenceSource(CandidateSource source) noexcept { return source == CandidateSource::Generated || source == CandidateSource::Fallback || - source == CandidateSource::NeuralDesktop || source == CandidateSource::NeuralKeyboard; + source == CandidateSource::NeuralDesktop || source == CandidateSource::NeuralKeyboard || + source == CandidateSource::Collocation; } // 整句候选独立上屏——不接在造词前缀后面、自己就是整条输入——时是否该落库。该落:整句是 @@ -64,10 +66,11 @@ inline bool ShouldStoreEarlyReturnPhrase(CandidateSource source, bool creating_w const std::string &prefix_canonical_pinyin, const std::string &candidate_canonical_pinyin) noexcept { - // 与词格 Generated 同一支:神经整句同样带 canonical quanpin,可以结束一段造词或独立 - // 上屏。Google Fallback 沿用旧语义仍不走这条造词落库路径。 + // 与词格 Generated 同一支:神经整句与万象重排句同样带 canonical quanpin,可以结束 + // 一段造词或独立上屏。Google Fallback 沿用旧语义仍不走这条造词落库路径。 const bool early_return_source = source == CandidateSource::Generated || source == CandidateSource::NeuralDesktop || - source == CandidateSource::NeuralKeyboard; + source == CandidateSource::NeuralKeyboard || + source == CandidateSource::Collocation; if (!early_return_source || candidate_canonical_pinyin.empty()) { return false; diff --git a/server/src/ipc/event_listener_candidates.cpp b/server/src/ipc/event_listener_candidates.cpp index ac32c9aa..ff4ccd08 100644 --- a/server/src/ipc/event_listener_candidates.cpp +++ b/server/src/ipc/event_listener_candidates.cpp @@ -302,6 +302,8 @@ std::string BuildCurrentCandidatePage() view.badge = " 〔神经D〕"; else if (item.source == CandidateSource::NeuralKeyboard) view.badge = " 〔神经K〕"; + else if (item.source == CandidateSource::Collocation) + view.badge = " 〔万象〕"; } view.fixed_position = item.fixed_position > 0; ApplyFixedPositionBadge(view, show_fixed_badge, fixed_badge_style); diff --git a/server/src/session/engine_input_session.cpp b/server/src/session/engine_input_session.cpp index cd87f4f4..c6f2066b 100644 --- a/server/src/session/engine_input_session.cpp +++ b/server/src/session/engine_input_session.cpp @@ -4,6 +4,30 @@ #include "engine/core/sentence_association_options.h" #include "engine/quanpin/quanpin_utils.h" +#include + +namespace +{ +// 模型包的确定性布局:/models//.gram。下载器与手动放置都 +// 遵守它,解析因此只是一次 stat,不需要目录扫描,也不需要 model.toml。 +std::string ResolveCollocationModelPath(const std::string &model_id) +{ + if (model_id.empty()) + { + return {}; + } + // 会话持有的是 legacy() 布局:资源根就是数据根,模型包随安装数据走。 + const std::filesystem::path file = + metasequoia::RuntimePaths::legacy().resources / "models" / model_id / (model_id + ".gram"); + std::error_code error; + if (!std::filesystem::is_regular_file(file, error) || error) + { + return {}; + } + return file.u8string(); +} +} // namespace + EngineInputSession::EngineInputSession(SchemeType scheme, const ShuangpinProfile &profile) : paths_(metasequoia::RuntimePaths::legacy()), session_(scheme, profile, paths_) { @@ -47,6 +71,18 @@ void EngineInputSession::ApplyConfiguration() association.neural_desktop = GetConfiguredAssocSentenceNeuralDesktop(); association.neural_keyboard = GetConfiguredAssocSentenceNeuralKeyboard(); association.show_next_on_duplicate = GetConfiguredAssocSentenceShowNextOnDuplicate(); + // 万象语法模型:两个机制共用一份模型包。模型 id 按 /models//.gram + // 解析成路径(下载器与手动放置都遵守这个命名);开关开着但文件不在就等于关,词格 + // 静默降级。解析是一次 stat,按键路径上可忽略。 + association.collocation_model = ResolveCollocationModelPath(GetConfiguredAssocSentenceCollocationModel()); + association.collocation_weight = GetConfiguredAssocSentenceCollocationWeight(); + association.collocation_rerank = GetConfiguredAssocSentenceCollocationRerank(); + association.collocation_rerank_weight = GetConfiguredAssocSentenceCollocationRerankWeight(); + if (association.collocation_model.empty()) + { + // 模型缺席时连开关一起置空,避免词典层拿空路径做无谓解析。 + association.collocation_rerank = false; + } session_.set_sentence_association(association); session_.set_shuangpin_preedit_uses_raw(GetConfiguredShuangpinPreeditMode() == "shuangpin"); // 五笔拼音混输与 z 键角色是两个独立设置:混输是「同时给五笔和拼音候选」,z 键角色只管 diff --git a/server/src/settings/collocation_model.cpp b/server/src/settings/collocation_model.cpp new file mode 100644 index 00000000..8818eee8 --- /dev/null +++ b/server/src/settings/collocation_model.cpp @@ -0,0 +1,292 @@ +#include "collocation_model.h" + +#include "engine/core/data_path.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#pragma comment(lib, "winhttp.lib") +#pragma comment(lib, "bcrypt.lib") + +namespace collocation +{ +namespace +{ + +// 模型包的钉死输入。bytes 与 sha256 来自 +// https://github.com/amzxyz/RIME-LMDG/releases/tag/LTS 的资产清单, +// 与 docs/research/2026-10-01-wanxiang-octagram-model.md 的评测基线同一份字节。 +constexpr const wchar_t *kModelHost = L"github.com"; +constexpr const wchar_t *kModelPath = L"/amzxyz/RIME-LMDG/releases/download/LTS/wanxiang-lts-zh-hans.gram"; +constexpr const char *kModelSha256 = "ae43724a9aa02b4c493c603025549d2cba6fa6a67ae3ae040e55efdfeeaa1183"; +constexpr long long kModelBytes = 409719852LL; +constexpr const char *kModelId = "wanxiang-lts-zh-hans"; + +std::filesystem::path ModelDirectory() +{ + return metasequoia::data_directory() / "models" / kModelId; +} + +std::filesystem::path ModelFile() +{ + return ModelDirectory() / (std::string(kModelId) + ".gram"); +} + +std::filesystem::path PartFile() +{ + // 同一卷上的临时名,下载完成后原子 rename 到位。 + return metasequoia::data_directory() / "models" / (std::string(kModelId) + ".gram.part"); +} + +std::string Sha256Hex(const std::filesystem::path &file) +{ + BCRYPT_ALG_HANDLE algorithm = nullptr; + BCRYPT_HASH_HANDLE hash = nullptr; + std::string result; + DWORD digest_length = 0, object_length = 0, callback = 0; + if (BCryptOpenAlgorithmProvider(&algorithm, BCRYPT_SHA256_ALGORITHM, nullptr, 0) != 0 || + BCryptGetProperty(algorithm, BCRYPT_HASH_LENGTH, reinterpret_cast(&digest_length), + sizeof(digest_length), &callback, 0) != 0 || + BCryptGetProperty(algorithm, BCRYPT_OBJECT_LENGTH, reinterpret_cast(&object_length), + sizeof(object_length), &callback, 0) != 0) + { + if (algorithm) + BCryptCloseAlgorithmProvider(algorithm, 0); + return {}; + } + std::vector hash_object(object_length); + if (BCryptCreateHash(algorithm, &hash, hash_object.data(), object_length, nullptr, 0, 0) == 0) + { + std::ifstream in(file, std::ios::binary); + std::vector buffer(1 << 20); + bool ok = static_cast(in); + while (ok && in) + { + in.read(buffer.data(), static_cast(buffer.size())); + const auto read = in.gcount(); + if (read > 0) + ok = BCryptHashData(hash, reinterpret_cast(buffer.data()), static_cast(read), 0) == 0; + } + if (ok) + { + std::vector digest(digest_length); + ok = BCryptFinishHash(hash, digest.data(), digest_length, 0) == 0; + static const char *kHex = "0123456789abcdef"; + for (unsigned char byte : digest) + { + result.push_back(kHex[byte >> 4]); + result.push_back(kHex[byte & 0x0F]); + } + } + BCryptDestroyHash(hash); + } + BCryptCloseAlgorithmProvider(algorithm, 0); + return result; +} + +enum class DownloadState +{ + Idle, + Downloading, + Error, +}; + +std::mutex g_mutex; +DownloadState g_state = DownloadState::Idle; +int g_progress = 0; +std::string g_error; + +void SetState(DownloadState state, int progress, std::string error) +{ + std::lock_guard lock(g_mutex); + g_state = state; + g_progress = progress; + g_error = std::move(error); +} + +// 后台线程:下载 -> 校验 -> 原子落位。全程只碰 PartFile,失败时不留半个模型 +// 在正式位置上。GitHub 的 release 资产经 302 跳转到 CDN,显式放开自动重定向。 +void DownloadThread() +{ + SetState(DownloadState::Downloading, 0, {}); + + std::error_code fs_error; + const std::filesystem::path directory = ModelDirectory(); + std::filesystem::create_directories(directory, fs_error); + if (fs_error) + { + SetState(DownloadState::Error, 0, "无法创建模型目录"); + return; + } + + HINTERNET session = WinHttpOpen(L"MetasequoiaImeServer/1.0", WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, + WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0); + if (session == nullptr) + { + SetState(DownloadState::Error, 0, "无法初始化网络"); + return; + } + // 400MB 不是快操作:连接阶段给足超时,收发阶段不设上限靠进度与失败兜底。 + WinHttpSetTimeouts(session, 30000, 30000, 30000, 0); + HINTERNET connection = WinHttpConnect(session, kModelHost, INTERNET_DEFAULT_HTTPS_PORT, 0); + HINTERNET request = nullptr; + if (connection != nullptr) + { + request = WinHttpOpenRequest(connection, L"GET", kModelPath, nullptr, WINHTTP_NO_REFERER, + WINHTTP_DEFAULT_ACCEPT_TYPES, WINHTTP_FLAG_SECURE); + if (request != nullptr) + { + DWORD policy = WINHTTP_OPTION_REDIRECT_POLICY_ALWAYS; + WinHttpSetOption(request, WINHTTP_OPTION_REDIRECT_POLICY, &policy, sizeof(policy)); + } + } + bool ok = connection != nullptr && request != nullptr && + WinHttpSendRequest(request, WINHTTP_NO_ADDITIONAL_HEADERS, 0, WINHTTP_NO_REQUEST_DATA, 0, 0, 0) && + WinHttpReceiveResponse(request, nullptr); + DWORD status = 0; + DWORD status_size = sizeof(status); + if (ok) + { + WinHttpQueryHeaders(request, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, + WINHTTP_HEADER_NAME_BY_INDEX, &status, &status_size, WINHTTP_NO_HEADER_INDEX); + ok = status == HTTP_STATUS_OK; + } + + std::ofstream out; + if (ok) + { + out.open(PartFile(), std::ios::binary | std::ios::trunc); + ok = static_cast(out); + } + + long long received = 0; + while (ok) + { + DWORD available = 0; + if (!WinHttpQueryDataAvailable(request, &available)) + { + ok = false; + break; + } + if (available == 0) + break; + std::vector buffer(available); + DWORD read = 0; + if (!WinHttpReadData(request, buffer.data(), available, &read) || read == 0) + { + ok = false; + break; + } + out.write(buffer.data(), static_cast(read)); + if (!out) + { + ok = false; + break; + } + received += read; + const long long total = kModelBytes; + if (total > 0) + { + const int progress = static_cast((std::min)(100LL, received * 100LL / total)); + SetState(DownloadState::Downloading, progress, {}); + } + } + if (request != nullptr) + WinHttpCloseHandle(request); + if (connection != nullptr) + WinHttpCloseHandle(connection); + WinHttpCloseHandle(session); + out.close(); + + if (!ok) + { + SetState(DownloadState::Error, 0, status != 0 && status != HTTP_STATUS_OK ? "下载响应异常" : "下载中断"); + std::error_code cleanup; + std::filesystem::remove(PartFile(), cleanup); + return; + } + + // 字节级校验:LTS tag 会被上游原地重传,这一步是防漂移的唯一闸门。 + if (std::filesystem::file_size(PartFile()) != static_cast(kModelBytes)) + { + SetState(DownloadState::Error, 0, "下载字节数与锁定值不符"); + std::error_code cleanup; + std::filesystem::remove(PartFile(), cleanup); + return; + } + const std::string digest = Sha256Hex(PartFile()); + if (digest != kModelSha256) + { + SetState(DownloadState::Error, 0, "SHA256 与锁定值不符,模型可能已被上游更换"); + std::error_code cleanup; + std::filesystem::remove(PartFile(), cleanup); + return; + } + + std::filesystem::rename(PartFile(), ModelFile(), fs_error); + if (fs_error) + { + SetState(DownloadState::Error, 0, "模型落位失败"); + return; + } + + // CC-BY-4.0 的署名义务随模型走:NOTICE 与模型同目录,注明来源与锁定摘要。 + std::ofstream notice(ModelDirectory() / "NOTICE.md", std::ios::binary | std::ios::trunc); + if (notice) + { + notice << "# 万象语法模型(" << kModelId << ")\n\n" + << "- 来源:" << "https://github.com/amzxyz/RIME-LMDG/releases/tag/LTS\n" + << "- 许可:CC-BY-4.0(© amzxyz / RIME-LMDG 项目)\n" + << "- 文件:" << kModelId << ".gram(" << kModelBytes << " 字节)\n" + << "- SHA256:" << kModelSha256 << "\n"; + } + SetState(DownloadState::Idle, 100, {}); +} + +} // namespace + +ModelStatus GetModelStatus() +{ + std::error_code fs_error; + if (std::filesystem::exists(ModelFile(), fs_error) && !fs_error && + static_cast(std::filesystem::file_size(ModelFile(), fs_error)) == kModelBytes && !fs_error) + { + return {"ready", 100, {}}; + } + std::lock_guard lock(g_mutex); + switch (g_state) + { + case DownloadState::Downloading: + return {"downloading", g_progress, {}}; + case DownloadState::Error: + return {"error", 0, g_error}; + default: + return {"absent", 0, {}}; + } +} + +bool StartDownload() +{ + { + std::lock_guard lock(g_mutex); + if (g_state == DownloadState::Downloading) + return true; + g_state = DownloadState::Downloading; + g_progress = 0; + g_error.clear(); + } + std::thread(DownloadThread).detach(); + return true; +} + +} // namespace collocation diff --git a/server/src/settings/collocation_model.h b/server/src/settings/collocation_model.h new file mode 100644 index 00000000..bf4ffbc0 --- /dev/null +++ b/server/src/settings/collocation_model.h @@ -0,0 +1,33 @@ +#pragma once + +// 万象语法模型(octagram .gram)的按需下载与状态查询。 +// +// 390MB 的模型不进安装包:设置页提供下载按钮,落到 +// /models/wanxiang-lts-zh-hans/wanxiang-lts-zh-hans.gram, +// SHA256 与字节数在本文件钉死。上游 LTS release 会被原地重传(tag 不变、字节变), +// 所以这里的摘要就是防漂移的锁;上游更新模型时必须显式改这里的常量并复跑 +// docs/research 的离线评测,而不是让旧字节静默失效。 +// +// 状态以磁盘为准(存在且字节数吻合 = ready),下载态是各进程内存里的 +// runtime 状态:独立设置进程与 Server 各自能看到自己的下载进度。 + +#include + +namespace collocation +{ + +struct ModelStatus +{ + std::string state; // "absent" | "downloading" | "ready" | "error" + int progress = 0; // 下载中 0-100;其余状态无意义 + std::string error; // state == "error" 时的人类可读原因 +}; + +// 磁盘现状 + 本进程下载态。轻量,可随配置快照频繁调用。 +ModelStatus GetModelStatus(); + +// 幂等启动后台下载:已在下载或已就绪时返回 true 不重复启动;磁盘空间不足、 +// 无法创建目录等同步失败返回 false 并把原因记入状态。完成/失败经状态查询可见。 +bool StartDownload(); + +} // namespace collocation diff --git a/server/src/settings/settings_app.cpp b/server/src/settings/settings_app.cpp index d60b939c..30e099f4 100644 --- a/server/src/settings/settings_app.cpp +++ b/server/src/settings/settings_app.cpp @@ -5,6 +5,7 @@ #include "resource/resource.h" #include "settings/settings_launcher.h" #include "settings/api_credential_test.h" +#include "settings/collocation_model.h" #include "settings/settings_splash.h" #include "settings/dictionary_manager.h" #include "settings/serial_task_queue.h" @@ -506,7 +507,13 @@ std::wstring BuildConfigMessage(bool refresh_skin_catalog) {"sentence_neural_desktop", GetConfiguredAssocSentenceNeuralDesktop()}, {"sentence_neural_keyboard", GetConfiguredAssocSentenceNeuralKeyboard()}, {"sentence_show_next_on_duplicate", GetConfiguredAssocSentenceShowNextOnDuplicate()}, - {"sentence_source_badge", GetConfiguredAssocSentenceSourceBadge()}}}, + {"sentence_source_badge", GetConfiguredAssocSentenceSourceBadge()}, + {"sentence_collocation_additive", GetConfiguredAssocSentenceCollocationAdditive()}, + {"sentence_collocation_rerank", GetConfiguredAssocSentenceCollocationRerank()}, + {"sentence_collocation_model_status", [] { + const auto status = collocation::GetModelStatus(); + return nlohmann::json{{"state", status.state}, {"progress", status.progress}, {"error", status.error}}; + }()}}}, {"keybindings", {{"switch_language_shift", GetConfiguredSwitchLanguageShiftEnabled()}, {"switch_language_ctrl", GetConfiguredSwitchLanguageCtrlEnabled()}, @@ -880,6 +887,10 @@ bool ApplyConfigUpdate(const json::object &data) return SetConfiguredAssocSentenceShowNextOnDuplicate(json::value_to(data.at("value"))); if (path == "association.sentence_source_badge") return SetConfiguredAssocSentenceSourceBadge(json::value_to(data.at("value"))); + if (path == "association.sentence_collocation_additive") + return SetConfiguredAssocSentenceCollocationAdditive(json::value_to(data.at("value"))); + if (path == "association.sentence_collocation_rerank") + return SetConfiguredAssocSentenceCollocationRerank(json::value_to(data.at("value"))); if (path == "utility.unicode_mode") return SetConfiguredUnicodeModeEnabled(json::value_to(data.at("value"))); if (path == "utility.quick_phrase") @@ -1327,6 +1338,16 @@ void HandleWebMessage(HWND hwnd, ICoreWebView2WebMessageReceivedEventArgs *args) if (!ec) ShellExecuteW(hwnd, L"open", directory.c_str(), nullptr, nullptr, SW_SHOWNORMAL); } + else if (type == "collocationModelDownload") + { + // 后台线程下载,进度随下一次配置快照回给页面;页面在下载态轮询 configRequest。 + collocation::StartDownload(); + PostConfig(false); + } + else if (type == "collocationModelStatusRequest") + { + PostConfig(false); + } else if (type == "configUpdate") { const auto data = value.at("data").as_object(); diff --git a/server/src/webview2/windows_webview2_settings.cpp b/server/src/webview2/windows_webview2_settings.cpp index 0d088c6f..5266f18c 100644 --- a/server/src/webview2/windows_webview2_settings.cpp +++ b/server/src/webview2/windows_webview2_settings.cpp @@ -10,6 +10,7 @@ #include "ipc/ipc.h" #include "ipc/event_listener.h" #include "skin/candidate_skin_catalog.h" +#include "settings/collocation_model.h" #include "utils/common_utils.h" #include #include @@ -375,6 +376,16 @@ HRESULT OnControllerCreatedSettingsWnd( // if (!ec) ShellExecuteW(hwnd, L"open", directory.c_str(), nullptr, nullptr, SW_SHOWNORMAL); } + else if (type == "collocationModelDownload") + { + // 后台线程下载,进度随下一次配置快照回给页面;页面在下载态轮询 configRequest。 + collocation::StartDownload(); + PostSettingsConfig(); + } + else if (type == "collocationModelStatusRequest") + { + PostSettingsConfig(); + } else if (type == "configUpdate") { try @@ -1052,6 +1063,22 @@ HRESULT OnControllerCreatedSettingsWnd( // PostSettingsConfig(); } } + else if (path == "association.sentence_collocation_additive") + { + const bool value = json::value_to(data.at("value")); + if (SetConfiguredAssocSentenceCollocationAdditive(value)) + { + PostSettingsConfig(); + } + } + else if (path == "association.sentence_collocation_rerank") + { + const bool value = json::value_to(data.at("value")); + if (SetConfiguredAssocSentenceCollocationRerank(value)) + { + PostSettingsConfig(); + } + } else if (path == "utility.unicode_mode") { const bool value = json::value_to(data.at("value")); @@ -1474,7 +1501,14 @@ void PostSettingsConfig() {"sentence_neural_desktop", GetConfiguredAssocSentenceNeuralDesktop()}, {"sentence_neural_keyboard", GetConfiguredAssocSentenceNeuralKeyboard()}, {"sentence_show_next_on_duplicate", GetConfiguredAssocSentenceShowNextOnDuplicate()}, - {"sentence_source_badge", GetConfiguredAssocSentenceSourceBadge()}}}, + {"sentence_source_badge", GetConfiguredAssocSentenceSourceBadge()}, + {"sentence_collocation_additive", GetConfiguredAssocSentenceCollocationAdditive()}, + {"sentence_collocation_rerank", GetConfiguredAssocSentenceCollocationRerank()}, + {"sentence_collocation_model_status", + [] { + const auto status = collocation::GetModelStatus(); + return nlohmann::json{{"state", status.state}, {"progress", status.progress}, {"error", status.error}}; + }()}}}, {"keybindings", {{"switch_language_shift", GetConfiguredSwitchLanguageShiftEnabled()}, {"switch_language_ctrl", GetConfiguredSwitchLanguageCtrlEnabled()},