From 435d0885bf9f564bfd5754433069b58194d62691 Mon Sep 17 00:00:00 2001 From: omni624562 Date: Sun, 31 May 2026 08:23:56 +0800 Subject: [PATCH 1/2] Improve backend stability and config tooling --- .gitignore | 4 + PIMELauncher/src/backend_manager.rs | 43 +++-- PIMELauncher/src/main.rs | 1 + PIMELauncher/src/protocol.rs | 12 +- PIMETextService/PIMEClient.cpp | 29 +-- python/cinbase/__init__.py | 165 ++++++++++-------- python/cinbase/config.py | 21 ++- python/cinbase/configtool.py | 49 ++++-- python/input_methods/chewing/config_tool.html | 1 + python/input_methods/chewing/config_tool.py | 22 ++- python/input_methods/chewing/js/config.js | 68 ++++++++ python/server.py | 38 ++-- tests/test_backend_resilience.py | 65 +++++++ 13 files changed, 369 insertions(+), 149 deletions(-) create mode 100644 tests/test_backend_resilience.py diff --git a/.gitignore b/.gitignore index f1b3b6243..d24faabe5 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,7 @@ build64 build_arm64/ node/input_methods/McBopomofo installer/*.exe +PIMELauncher/target/ +.claude/ +cleanup_x64.ps1 +replace_x64_dll.ps1 diff --git a/PIMELauncher/src/backend_manager.rs b/PIMELauncher/src/backend_manager.rs index ae5381546..4319ef71f 100644 --- a/PIMELauncher/src/backend_manager.rs +++ b/PIMELauncher/src/backend_manager.rs @@ -72,17 +72,31 @@ impl BackendManager { /// Retrieves a channel to send messages directly to the backend. pub async fn get_backend_input(&self, backend_name: &str) -> Option> { - let mut state = self.state.lock().await; - if !state.backends.contains_key(backend_name) { - // Dynamically look up the backend configuration - if let Some(config) = self.registry.get_backend(backend_name) { - let backend = self.spawn_backend_process(config).await; - state.backends.insert(backend_name.to_string(), backend); - } else { + // Fast path: backend already running — take and release the lock immediately. + { + let state = self.state.lock().await; + if let Some(b) = state.backends.get(backend_name) { + return Some(b.stdin_tx.clone()); + } + } + + // Slow path: spawn the backend without holding the lock, so other clients + // are not blocked during the (potentially multi-second) process startup. + let config = match self.registry.get_backend(backend_name) { + Some(c) => c.clone(), + None => { error!("Unknown backend requested: {}", backend_name); return None; } - } + }; + let backend = self.spawn_backend_process(&config).await; + + // Re-acquire lock to insert; use entry() so a concurrent spawn doesn't overwrite. + let mut state = self.state.lock().await; + state + .backends + .entry(backend_name.to_string()) + .or_insert(backend); state.backends.get(backend_name).map(|b| b.stdin_tx.clone()) } @@ -213,6 +227,7 @@ impl BackendManager { .current_dir(&working_dir) .creation_flags(CREATE_NO_WINDOW) .env("PYTHONIOENCODING", "utf-8:ignore") + .env("PYTHONUNBUFFERED", "1") .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) @@ -289,14 +304,14 @@ impl BackendManager { loop { tokio::select! { msg = stdin_rx.recv() => { - let Some(data) = msg else { + let Some(data) = msg else { info!("Backend {} stdin channel closed. Exiting input loop.", backend_name); - break; + break; }; let now = Self::current_ms(); last_request_time = Some(now); info!("Backend {} received request from channel. Data len: {}. req_t={}", backend_name, data.len(), now); - + // LinesCodec expects data without the newline, it will add it for us. let write_res = tokio::time::timeout(Duration::from_secs(5), stdin_writer.send(data)).await; if let Err(_) = write_res { @@ -315,12 +330,12 @@ impl BackendManager { if let Some(req_t) = last_request_time { let last_out = last_output_time.load(Ordering::SeqCst); // Log tick status occasionally or at least for debugging - info!("Watchdog tick for {}: last_out={}, req_t={}, now={}, delta={}", + debug!("Watchdog tick for {}: last_out={}, req_t={}, now={}, delta={}", backend_name, last_out, req_t, now, now as i64 - req_t as i64); - + // If no output has been received since the last request and it's been more than 15 seconds if last_out < req_t && (now - req_t) > 15000 { - error!("Backend {} seems to be hung (no output for 15s after request). last_out={}, req_t={}, now={}. Forcing restart.", + error!("Backend {} seems to be hung (no output for 15s after request). last_out={}, req_t={}, now={}. Forcing restart.", backend_name, last_out, req_t, now); let _ = child_process.kill().await; break; diff --git a/PIMELauncher/src/main.rs b/PIMELauncher/src/main.rs index 12186bf11..1ce5ca8ae 100644 --- a/PIMELauncher/src/main.rs +++ b/PIMELauncher/src/main.rs @@ -117,6 +117,7 @@ async fn main() { // Writing to a non-existent stdout in a GUI subsystem app can cause hangs. tracing_subscriber::fmt() .with_ansi(false) + .with_max_level(tracing::Level::WARN) .with_writer(std::io::sink) .init(); } diff --git a/PIMELauncher/src/protocol.rs b/PIMELauncher/src/protocol.rs index 7b2d1d5b8..eeef7b56d 100644 --- a/PIMELauncher/src/protocol.rs +++ b/PIMELauncher/src/protocol.rs @@ -26,15 +26,9 @@ pub fn parse_client_handshake(first_line: &str) -> Result { /// Expects the format `PIME_MSG||`. /// Returns `Some((client_id, payload))` if valid. pub fn parse_backend_output(line: &str) -> Option<(String, String)> { - if line.starts_with("PIME_MSG|") { - let parts: Vec<&str> = line.splitn(3, '|').collect(); - if parts.len() == 3 { - let client_id = parts[1].to_string(); - let payload = parts[2].to_string(); - return Some((client_id, payload)); - } - } - None + let rest = line.strip_prefix("PIME_MSG|")?; + let sep = rest.find('|')?; + Some((rest[..sep].to_string(), rest[sep + 1..].to_string())) } /// Formats a message to be sent to a backend process. diff --git a/PIMETextService/PIMEClient.cpp b/PIMETextService/PIMEClient.cpp index 902b63436..f9a08e549 100644 --- a/PIMETextService/PIMEClient.cpp +++ b/PIMETextService/PIMEClient.cpp @@ -369,7 +369,7 @@ void Client::updateCandidateList(json& msg, Ime::EditSession* session) { candidates.emplace_back(utf8ToUtf16(candidate.get().c_str())); } textService_->updateCandidates(session); - if (!showCandidatesVal.get()) { + if (showCandidatesVal.is_boolean() && !showCandidatesVal.get()) { textService_->hideCandidates(); } } @@ -412,7 +412,7 @@ bool Client::filterKeyDown(Ime::KeyEvent& keyEvent) { json ret; callRpcMethod(req, ret); if (handleRpcResponse(ret)) { - return ret["return"].get(); + return ret.value("return", false); } return false; } @@ -424,7 +424,7 @@ bool Client::onKeyDown(Ime::KeyEvent& keyEvent, Ime::EditSession* session) { json ret; callRpcMethod(req, ret); if (handleRpcResponse(ret, session)) { - return ret["return"].get(); + return ret.value("return", false); } return false; } @@ -436,7 +436,7 @@ bool Client::filterKeyUp(Ime::KeyEvent& keyEvent) { json ret; callRpcMethod(req, ret); if (handleRpcResponse(ret)) { - return ret["return"].get(); + return ret.value("return", false); } return false; } @@ -448,7 +448,7 @@ bool Client::onKeyUp(Ime::KeyEvent& keyEvent, Ime::EditSession* session) { json ret; callRpcMethod(req, ret); if (handleRpcResponse(ret, session)) { - return ret["return"].get(); + return ret.value("return", false); } return false; } @@ -462,7 +462,7 @@ bool Client::onPreservedKey(const GUID& guid) { json ret; callRpcMethod(req, ret); if (handleRpcResponse(ret)) { - return ret["return"].get(); + return ret.value("return", false); } } return false; @@ -476,7 +476,7 @@ bool Client::onCommand(UINT id, Ime::TextService::CommandType type) { json ret; callRpcMethod(req, ret); if (handleRpcResponse(ret)) { - return ret["return"].get(); + return ret.value("return", false); } return false; } @@ -634,7 +634,10 @@ json Client::createRpcRequest(const char* methodName) { } bool Client::callPipeIO(bool isRead, void *buffer, DWORD size, DWORD *rlen, int timeoutMs) { - if (!ioEvent_) { + if (!ioEvent_ || ioEvent_ == INVALID_HANDLE_VALUE) { + ioEvent_ = CreateEvent(NULL, TRUE, FALSE, NULL); + } + if (!ioEvent_ || ioEvent_ == INVALID_HANDLE_VALUE) { return false; } @@ -677,7 +680,7 @@ bool Client::callRpcPipe(HANDLE pipe, const std::string& serializedRequest, std: return false; } - char buf[1024]; + char buf[8192]; DWORD rlen = 0; while (true) { // Check if we already have a full line in the buffer @@ -771,9 +774,9 @@ bool Client::waitForRpcConnection() { } wstring serverPipeName = getPipeName(L"Launcher"); - for (int attempt = 0; pipe_ == INVALID_HANDLE_VALUE && attempt < 5; ++attempt) { + for (int attempt = 0; pipe_ == INVALID_HANDLE_VALUE && attempt < 3; ++attempt) { // try to connect to the server - pipe_ = connectPipe(serverPipeName.c_str(), 30000); + pipe_ = connectPipe(serverPipeName.c_str(), 3000); } if (pipe_ != INVALID_HANDLE_VALUE) { @@ -818,9 +821,9 @@ void Client::closeRpcConnection() { CloseHandle(pipe_); pipe_ = INVALID_HANDLE_VALUE; } - if (ioEvent_ != INVALID_HANDLE_VALUE) { + if (ioEvent_ && ioEvent_ != INVALID_HANDLE_VALUE) { CloseHandle(ioEvent_); - ioEvent_ = INVALID_HANDLE_VALUE; + ioEvent_ = NULL; } readBuffer_.clear(); } diff --git a/python/cinbase/__init__.py b/python/cinbase/__init__.py index 69c985ef1..a579fc9fb 100644 --- a/python/cinbase/__init__.py +++ b/python/cinbase/__init__.py @@ -311,7 +311,7 @@ def filterKeyDown(self, cbTS, keyEvent, CinTable, RCinTable, HCinTable): if cbTS.lastKeyDownTime == 0.0: cbTS.lastKeyDownTime = time.time() - if CinTable.loading: + if CinTable.loading or not getattr(cbTS, 'cin', None): return True # 使用者開始輸入,還沒送出前的編輯區內容稱 composition string @@ -425,7 +425,7 @@ def onKeyDown(self, cbTS, keyEvent, CinTable, RCinTable, HCinTable): charStr = chr(charCode) charStrLow = charStr.lower() - if CinTable.loading: + if CinTable.loading or not getattr(cbTS, 'cin', None): if not cbTS.client.isUiLess: messagestr = '正在載入輸入法碼表,請稍候...' cbTS.isShowMessage = True @@ -3278,18 +3278,22 @@ def __init__(self, cbTS, PhraseData): def run(self): self.PhraseData.loading = True - cfg = self.cbTS.cfg - datadirs = (cfg.getConfigDir(), cfg.getDataDir()) + try: + cfg = self.cbTS.cfg + datadirs = (cfg.getConfigDir(), cfg.getDataDir()) - if hasattr(self.PhraseData.phrase, '__del__'): - self.PhraseData.phrase.__del__() + if hasattr(self.PhraseData.phrase, '__del__'): + self.PhraseData.phrase.__del__() - self.PhraseData.phrase = None + self.PhraseData.phrase = None - phrasePath = cfg.findFile(datadirs, "phrase.json") - with io.open(phrasePath, 'r', encoding='utf8') as fs: - self.PhraseData.phrase = phrase(fs) - self.PhraseData.loading = False + phrasePath = cfg.findFile(datadirs, "phrase.json") + with io.open(phrasePath, 'r', encoding='utf8') as fs: + self.PhraseData.phrase = phrase(fs) + except Exception: + pass + finally: + self.PhraseData.loading = False class LoadCinTable(threading.Thread): @@ -3303,42 +3307,47 @@ def run(self): self.cbTS.debug.setStartTimer("LoadCinTable") self.CinTable.loading = True - if self.cbTS.cfg.selCinType >= len(self.cbTS.cinFileList): - self.cbTS.cfg.selCinType = 0 - selCinFile = self.cbTS.cinFileList[self.cbTS.cfg.selCinType] - jsonPath = os.path.join(self.cbTS.jsondir, selCinFile) - - if self.cbTS.reLoadCinTable or not hasattr(self.cbTS, 'cin'): - self.cbTS.reLoadCinTable = False - - if hasattr(self.cbTS, 'cin'): - self.cbTS.cin.__del__() - if hasattr(self.CinTable.cin, '__del__'): - self.CinTable.cin.__del__() - - self.cbTS.cin = None - self.CinTable.cin = None - - with io.open(jsonPath, 'r', encoding='utf8') as fs: - self.cbTS.cin = Cin(fs, self.cbTS.imeDirName, self.cbTS.ignorePrivateUseArea) - self.CinTable.cin = self.cbTS.cin - self.CinTable.curCinType = self.cbTS.cfg.selCinType - - if not hasattr(self.cbTS, 'extendtable'): - if self.cbTS.cfg.userExtendTable: - datadirs = (self.cbTS.cfg.getConfigDir(), self.cbTS.cfg.getDataDir()) - extendtablePath = self.cbTS.cfg.findFile(datadirs, "extendtable.dat") - with io.open(extendtablePath, encoding='utf-8') as fs: - self.cbTS.extendtable = extendtable(fs) - else: - self.cbTS.extendtable = {} - self.cbTS.cin.updateCinTable(self.cbTS.cfg.userExtendTable, self.cbTS.cfg.priorityExtendTable, self.cbTS.extendtable, self.cbTS.cfg.ignorePrivateUseArea) - self.CinTable.userExtendTable = self.cbTS.cfg.userExtendTable - self.CinTable.priorityExtendTable = self.cbTS.cfg.priorityExtendTable - self.CinTable.ignorePrivateUseArea = self.cbTS.cfg.ignorePrivateUseArea - self.CinTable.loading = False - - if DEBUG_MODE: + selCinFile = None + try: + if self.cbTS.cfg.selCinType >= len(self.cbTS.cinFileList): + self.cbTS.cfg.selCinType = 0 + selCinFile = self.cbTS.cinFileList[self.cbTS.cfg.selCinType] + jsonPath = os.path.join(self.cbTS.jsondir, selCinFile) + + if self.cbTS.reLoadCinTable or not hasattr(self.cbTS, 'cin'): + self.cbTS.reLoadCinTable = False + + if hasattr(self.cbTS, 'cin'): + self.cbTS.cin.__del__() + if hasattr(self.CinTable.cin, '__del__'): + self.CinTable.cin.__del__() + + self.cbTS.cin = None + self.CinTable.cin = None + + with io.open(jsonPath, 'r', encoding='utf8') as fs: + self.cbTS.cin = Cin(fs, self.cbTS.imeDirName, self.cbTS.ignorePrivateUseArea) + self.CinTable.cin = self.cbTS.cin + self.CinTable.curCinType = self.cbTS.cfg.selCinType + + if not hasattr(self.cbTS, 'extendtable'): + if self.cbTS.cfg.userExtendTable: + datadirs = (self.cbTS.cfg.getConfigDir(), self.cbTS.cfg.getDataDir()) + extendtablePath = self.cbTS.cfg.findFile(datadirs, "extendtable.dat") + with io.open(extendtablePath, encoding='utf-8') as fs: + self.cbTS.extendtable = extendtable(fs) + else: + self.cbTS.extendtable = {} + self.cbTS.cin.updateCinTable(self.cbTS.cfg.userExtendTable, self.cbTS.cfg.priorityExtendTable, self.cbTS.extendtable, self.cbTS.cfg.ignorePrivateUseArea) + self.CinTable.userExtendTable = self.cbTS.cfg.userExtendTable + self.CinTable.priorityExtendTable = self.cbTS.cfg.priorityExtendTable + self.CinTable.ignorePrivateUseArea = self.cbTS.cfg.ignorePrivateUseArea + except Exception: + pass + finally: + self.CinTable.loading = False + + if DEBUG_MODE and selCinFile: self.cbTS.debug.setEndTimer("LoadCinTable") self.cbTS.debugLog[time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) + " [C]"] = self.cbTS.debug.info['brand'] + ":「" + self.cbTS.debug.jsonNameDict[selCinFile] + "」碼表載入時間約為 " + self.cbTS.debug.getDurationTime("LoadCinTable") + " 秒" @@ -3364,25 +3373,30 @@ def run(self): self.cbTS.debug.setStartTimer("LoadRCinTable") self.RCinTable.loading = True - selCinFile = self.rcinFileList[self.cbTS.cfg.selRCinType] - jsonPath = os.path.join(self.cbTS.jsondir, selCinFile) + selCinFile = None + try: + selCinFile = self.rcinFileList[self.cbTS.cfg.selRCinType] + jsonPath = os.path.join(self.cbTS.jsondir, selCinFile) - if self.RCinTable.cin is not None and hasattr(self.RCinTable.cin, '__del__'): - self.RCinTable.cin.__del__() + if self.RCinTable.cin is not None and hasattr(self.RCinTable.cin, '__del__'): + self.RCinTable.cin.__del__() - self.RCinTable.cin = None + self.RCinTable.cin = None - if os.path.exists(jsonPath): - self.cbTS.RCinFileNotExist = False - with io.open(jsonPath, 'r', encoding='utf8') as fs: - self.RCinTable.cin = RCin(fs, self.cbTS.imeDirName) - else: - self.cbTS.RCinFileNotExist = True - - self.RCinTable.curCinType = self.cbTS.cfg.selRCinType - self.RCinTable.loading = False + if os.path.exists(jsonPath): + self.cbTS.RCinFileNotExist = False + with io.open(jsonPath, 'r', encoding='utf8') as fs: + self.RCinTable.cin = RCin(fs, self.cbTS.imeDirName) + else: + self.cbTS.RCinFileNotExist = True - if DEBUG_MODE: + self.RCinTable.curCinType = self.cbTS.cfg.selRCinType + except Exception: + pass + finally: + self.RCinTable.loading = False + + if DEBUG_MODE and selCinFile: self.cbTS.debug.setEndTimer("LoadRCinTable") self.cbTS.debugLog[time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) + " [R]"] = self.cbTS.debug.info['brand'] + ":「" + self.cbTS.debug.jsonNameDict[selCinFile] + "」反查碼表載入時間約為 " + self.cbTS.debug.getDurationTime("LoadRCinTable") + " 秒" @@ -3398,19 +3412,24 @@ def run(self): self.cbTS.debug.setStartTimer("LoadHCinTable") self.HCinTable.loading = True - selCinFile = CinBase.hcinFileList[self.cbTS.cfg.selHCinType] - jsonPath = os.path.join(self.cbTS.jsondir, selCinFile) - - if self.HCinTable.cin is not None and hasattr(self.HCinTable.cin, '__del__'): - self.HCinTable.cin.__del__() + selCinFile = None + try: + selCinFile = CinBase.hcinFileList[self.cbTS.cfg.selHCinType] + jsonPath = os.path.join(self.cbTS.jsondir, selCinFile) - self.HCinTable.cin = None + if self.HCinTable.cin is not None and hasattr(self.HCinTable.cin, '__del__'): + self.HCinTable.cin.__del__() - with io.open(jsonPath, 'r', encoding='utf8') as fs: - self.HCinTable.cin = HCin(fs, self.cbTS.imeDirName) - self.HCinTable.curCinType = self.cbTS.cfg.selHCinType - self.HCinTable.loading = False + self.HCinTable.cin = None - if DEBUG_MODE: + with io.open(jsonPath, 'r', encoding='utf8') as fs: + self.HCinTable.cin = HCin(fs, self.cbTS.imeDirName) + self.HCinTable.curCinType = self.cbTS.cfg.selHCinType + except Exception: + pass + finally: + self.HCinTable.loading = False + + if DEBUG_MODE and selCinFile: self.cbTS.debug.setEndTimer("LoadHCinTable") self.cbTS.debugLog[time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) + " [H]"] = self.cbTS.debug.info['brand'] + ":「" + self.cbTS.debug.jsonNameDict[selCinFile] + "」同音字碼表載入時間約為 " + self.cbTS.debug.getDurationTime("LoadHCinTable") + " 秒" diff --git a/python/cinbase/config.py b/python/cinbase/config.py index 3b3bc0ac0..a8d30db4c 100644 --- a/python/cinbase/config.py +++ b/python/cinbase/config.py @@ -103,21 +103,33 @@ def getLastTime(self): return self._lastTime def load(self): + # Layer 1: apply shipped defaults so new keys reach all users regardless of + # whether they already have an APPDATA config from a previous install. + default_config = os.path.join(self.getDefaultConfigDir(), "config.json") + try: + if os.path.exists(default_config) and os.stat(default_config).st_size > 0: + with open(default_config, "r") as f: + self.__dict__.update(json.load(f)) + except Exception: + pass + + # Layer 2: overlay with the user's personal config (APPDATA or legacy home-dir path). filename = self.getConfigFile() try: if not os.path.exists(filename) or os.stat(filename).st_size == 0: filename = os.path.join(os.path.expanduser("~"), "PIME", self.imeDirName, "config.json") if not os.path.exists(filename) or os.stat(filename).st_size == 0: - filename = os.path.join(self.getDefaultConfigDir(), "config.json") + filename = None else: src_dir = os.path.join(os.path.expanduser("~"), "PIME", self.imeDirName) dst_dir = self.getConfigDir() self.copytree(src_dir, dst_dir) filename = self.getConfigFile() - with open(filename, "r") as f: - self.__dict__.update(json.load(f)) + if filename: + with open(filename, "r") as f: + self.__dict__.update(json.load(f)) except Exception: self.save() self.update() @@ -129,8 +141,7 @@ def save(self): filename = self.getConfigFile() try: with open(filename, "w") as f: - jsondata = {key: value for key, value in self.__dict__.items() if not key in self.ignoreSaveList} - js = json.dump(jsondata, f, sort_keys=True, indent=4) + json.dump(self.toJson(), f, sort_keys=True, indent=4) self.update() except Exception: pass # FIXME: handle I/O errors? diff --git a/python/cinbase/configtool.py b/python/cinbase/configtool.py index fc66dfee3..74bd0ef1d 100644 --- a/python/cinbase/configtool.py +++ b/python/cinbase/configtool.py @@ -62,6 +62,14 @@ def prepare(self): # called before every request self.application.reset_timeout() # reset the quit server timeout +class NoCacheStaticFileHandler(tornado.web.StaticFileHandler): + + def set_extra_headers(self, path): + self.set_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") + self.set_header("Pragma", "no-cache") + self.set_header("Expires", "0") + + class KeepAliveHandler(BaseHandler): @tornado.web.authenticated @@ -98,36 +106,33 @@ def post(self): # save config os.makedirs(config_dir, exist_ok=True) # write the config to files config = data.get("config", None) - if config: + if config is not None: self.save_file("config.json", json.dumps(config, sort_keys=True, indent=4)) symbols = data.get("symbols", None) - if symbols: + if symbols is not None: self.save_file("symbols.dat", symbols) swkb = data.get("swkb", None) - if swkb: + if swkb is not None: self.save_file("swkb.dat", swkb) - self.write('{"return":true}') fsymbols = data.get("fsymbols", None) - if fsymbols: + if fsymbols is not None: self.save_file("fsymbols.dat", fsymbols) - self.write('{"return":true}') phrase = data.get("phrase", None) - if phrase: + if phrase is not None: self.save_file("userphrase.dat", phrase) - self.write('{"return":true}') flangs = data.get("flangs", None) - if flangs: + if flangs is not None: self.save_file("flangs.dat", flangs) - self.write('{"return":true}') extendtable = data.get("extendtable", None) - if extendtable: + if extendtable is not None: self.save_file("extendtable.dat", extendtable) + self.write('{"return":true}') def load_config(self): @@ -169,10 +174,18 @@ def load_data(self, name): return "" def save_file(self, filename, data): + target = os.path.join(config_dir, filename) + tmp_target = target + ".tmp" try: - with open(os.path.join(config_dir, filename), "w", encoding="UTF-8") as f: + with open(tmp_target, "w", encoding="UTF-8") as f: f.write(data) + os.replace(tmp_target, target) except Exception: + try: + if os.path.exists(tmp_target): + os.remove(tmp_target) + except Exception: + pass pass @@ -185,7 +198,7 @@ def post(self, page_name): self.set_cookie(COOKIE_ID, token) if page_name != "user_phrase_editor": page_name = "config" - self.redirect("/{}.html".format(page_name)) + self.redirect("/{}.html?v={}".format(page_name, self.settings["access_token"][:8])) @@ -200,11 +213,11 @@ def __init__(self): "debug": True } handlers = [ - (r"/(.*\.html|config.js)", tornado.web.StaticFileHandler, {"path": current_ime_config_dir}), - (r"/(.*\.htm)", tornado.web.StaticFileHandler, {"path": os.path.join(current_dir, "config")}), - (r"/((css|fonts|images|js)/.*)", tornado.web.StaticFileHandler, {"path": os.path.join(current_dir, "config")}), - (r"/(icon.ico)", tornado.web.StaticFileHandler, {"path": current_ime_dir}), - (r"/(version.txt)", tornado.web.StaticFileHandler, {"path": os.path.join(current_dir, "../../")}), + (r"/(.*\.html|config.js)", NoCacheStaticFileHandler, {"path": current_ime_config_dir}), + (r"/(.*\.htm)", NoCacheStaticFileHandler, {"path": os.path.join(current_dir, "config")}), + (r"/((css|fonts|images|js)/.*)", NoCacheStaticFileHandler, {"path": os.path.join(current_dir, "config")}), + (r"/(icon.ico)", NoCacheStaticFileHandler, {"path": current_ime_dir}), + (r"/(version.txt)", NoCacheStaticFileHandler, {"path": os.path.join(current_dir, "../../")}), (r"/config", ConfigHandler), # main configuration handler (r"/keep_alive", KeepAliveHandler), # keep the api server alive (r"/login/(.*)", LoginHandler) # authentication diff --git a/python/input_methods/chewing/config_tool.html b/python/input_methods/chewing/config_tool.html index 3c6331278..7958ed8af 100644 --- a/python/input_methods/chewing/config_tool.html +++ b/python/input_methods/chewing/config_tool.html @@ -3,6 +3,7 @@ + 設定新酷音輸入法 diff --git a/python/input_methods/chewing/config_tool.py b/python/input_methods/chewing/config_tool.py index 1acfd6e46..249aec90a 100644 --- a/python/input_methods/chewing/config_tool.py +++ b/python/input_methods/chewing/config_tool.py @@ -59,6 +59,14 @@ def prepare(self): # called before every request self.application.reset_timeout() # reset the quit server timeout +class NoCacheStaticFileHandler(tornado.web.StaticFileHandler): + + def set_extra_headers(self, path): + self.set_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") + self.set_header("Pragma", "no-cache") + self.set_header("Expires", "0") + + class KeepAliveHandler(BaseHandler): @tornado.web.authenticated @@ -89,13 +97,13 @@ def post(self): # save config os.makedirs(config_dir, exist_ok=True) # write the config to files config = data.get("config", None) - if config: + if config is not None: self.save_file("config.json", json.dumps(config, indent=2)) symbols = data.get("symbols", None) - if symbols: + if symbols is not None: self.save_file("symbols.dat", symbols) swkb = data.get("swkb", None) - if swkb: + if swkb is not None: self.save_file("swkb.dat", swkb) self.write('{"return":true}') @@ -230,7 +238,7 @@ def post(self, page_name): self.set_cookie(COOKIE_ID, token) if page_name != "user_phrase_editor": page_name = "config_tool" - self.redirect("/{}.html".format(page_name)) + self.redirect("/{}.html?v={}".format(page_name, token[:8])) class ConfigApp(tornado.web.Application): @@ -244,9 +252,9 @@ def __init__(self): "debug": True } handlers = [ - (r"/(.*\.html)", tornado.web.StaticFileHandler, {"path": current_dir}), - (r"/((css|images|js|fonts)/.*)", tornado.web.StaticFileHandler, {"path": current_dir}), - (r"/(version.txt)", tornado.web.StaticFileHandler, {"path": os.path.join(current_dir, "../../../")}), + (r"/(.*\.html)", NoCacheStaticFileHandler, {"path": current_dir}), + (r"/((css|images|js|fonts)/.*)", NoCacheStaticFileHandler, {"path": current_dir}), + (r"/(version.txt)", NoCacheStaticFileHandler, {"path": os.path.join(current_dir, "../../../")}), (r"/config", ConfigHandler), # main configuration handler (r"/user_phrases", UserPhraseHandler), # user phrase editor (r"/user_phrase_file", UserPhraseFileHandler), # export user phrase diff --git a/python/input_methods/chewing/js/config.js b/python/input_methods/chewing/js/config.js index 73981caa1..51e59c44a 100644 --- a/python/input_methods/chewing/js/config.js +++ b/python/input_methods/chewing/js/config.js @@ -27,6 +27,71 @@ $(function () { ); } + function bindTabsFallback() { + $('.nav-tabs a[data-toggle="tab"]').on("click", function (event) { + var target = $(this).attr("href"); + if (!target || target.charAt(0) !== "#") { + return; + } + + event.preventDefault(); + event.stopImmediatePropagation(); + + var nav = $(this).closest(".nav-tabs"); + nav.find(".nav-link").removeClass("active").attr("aria-selected", "false"); + $(this).addClass("active").attr("aria-selected", "true"); + + $(".tab-content .tab-pane").removeClass("active show in"); + $(target).addClass("active show in"); + }); + } + + function installNativeTabsFallback() { + var links = document.querySelectorAll(".nav-tabs a[data-toggle='tab']"); + for (var i = 0; i < links.length; ++i) { + links[i].onclick = function (event) { + event = event || window.event; + var target = this.getAttribute("href"); + if (!target || target.charAt(0) !== "#") { + return true; + } + + if (event.preventDefault) { + event.preventDefault(); + } + event.returnValue = false; + event.cancelBubble = true; + if (event.stopPropagation) { + event.stopPropagation(); + } + + var navLinks = document.querySelectorAll(".nav-tabs .nav-link"); + for (var navIndex = 0; navIndex < navLinks.length; ++navIndex) { + navLinks[navIndex].className = navLinks[navIndex].className.replace(/\s*active/g, ""); + navLinks[navIndex].setAttribute("aria-selected", "false"); + } + + this.className += this.className.indexOf("active") === -1 ? " active" : ""; + this.setAttribute("aria-selected", "true"); + + var panes = document.querySelectorAll(".tab-content .tab-pane"); + for (var paneIndex = 0; paneIndex < panes.length; ++paneIndex) { + panes[paneIndex].className = panes[paneIndex].className + .replace(/\s*active/g, "") + .replace(/\s*show/g, "") + .replace(/\s*in/g, ""); + } + + var pane = document.getElementById(target.substr(1)); + if (pane) { + pane.className += " active show in"; + } + + return false; + } + } + } + function saveConfig(callbackFunc) { // Check easy symbols format let ez_symbols_array = $("#ez_symbols").val().split("\n"); @@ -319,5 +384,8 @@ $(function () { $("#test_input_text").val("").select(); }); + installNativeTabsFallback(); + bindTabsFallback(); + return false; }); diff --git a/python/server.py b/python/server.py index c64175b09..c3214782b 100644 --- a/python/server.py +++ b/python/server.py @@ -29,6 +29,18 @@ from serviceManager import textServiceMgr +def append_error_log(message): + try: + log_dir = os.path.join(os.path.expandvars("%LOCALAPPDATA%"), "PIME", "Logs") + os.makedirs(log_dir, mode=0o700, exist_ok=True) + with open(os.path.join(log_dir, "python_backend.log"), "a", encoding="utf-8") as log_file: + log_file.write(message) + if not message.endswith("\n"): + log_file.write("\n") + except Exception: + pass + + class Client(object): def __init__(self, server): self.server = server @@ -39,7 +51,7 @@ def init(self, msg): self.isWindows8Above = msg["isWindows8Above"] self.isMetroApp = msg["isMetroApp"] self.isUiLess = msg["isUiLess"] - self.isUiLess = msg["isConsole"] + self.isConsole = msg["isConsole"] # create the text service self.service = textServiceMgr.createService(self, self.guid) return (self.service is not None) @@ -77,14 +89,19 @@ def run(self): # parse PIME requests (one request per line): # request format: "|\n" # response format: "PIME_MSG||\n" - client_id, msg_text = line.split('|', maxsplit=1) + parts = line.split('|', maxsplit=1) + if len(parts) != 2: + print("ERROR: malformed request:", line, file=sys.stderr) + append_error_log("ERROR: malformed request: {0}\n".format(line)) + continue + client_id, msg_text = parts msg = json.loads(msg_text) client = self.clients.get(client_id) if not client: # create a Client instance for the client client = Client(self) self.clients[client_id] = client - print("new client:", client_id) + print("new client:", client_id, file=sys.stderr) if msg.get("method") == "close": # special handling for closing a client self.remove_client(client_id) else: @@ -92,23 +109,24 @@ def run(self): # Send the response to the client via stdout # one response per line in the format "PIME_MSG||" reply_line = '|'.join(["PIME_MSG", client_id, json.dumps(ret, ensure_ascii=False)]) - print(reply_line) + print(reply_line, flush=True) except EOFError: # stop the server break except Exception as e: - print("ERROR:", e, line) + print("ERROR:", e, line, file=sys.stderr) # print the exception traceback for ease of debugging traceback.print_exc() + append_error_log("ERROR: {0}\nREQUEST: {1}\n{2}\n".format(e, line, traceback.format_exc())) # generate an empty output containing {success: False} to prevent the client from being blocked reply_line = '|'.join(["PIME_MSG", client_id, '{"success":false}']) - print(reply_line) - # Just terminate the python server process if any unknown error happens. - # The python server will be restarted later by PIMELauncher. - sys.exit(1) + print(reply_line, flush=True) + # Keep the backend alive after one bad request; tearing down an + # active TSF session can destabilize the foreground application. + continue def remove_client(self, client_id): - print("client disconnected:", client_id) + print("client disconnected:", client_id, file=sys.stderr) try: del self.clients[client_id] except KeyError: diff --git a/tests/test_backend_resilience.py b/tests/test_backend_resilience.py new file mode 100644 index 000000000..15d34c455 --- /dev/null +++ b/tests/test_backend_resilience.py @@ -0,0 +1,65 @@ +import contextlib +import importlib +import io +import os +import sys +import unittest +from unittest import mock + + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), os.path.pardir)) +PYTHON_DIR = os.path.join(ROOT, "python") +if PYTHON_DIR not in sys.path: + sys.path.insert(0, PYTHON_DIR) + + +class ServerResilienceTests(unittest.TestCase): + def import_server(self): + with contextlib.redirect_stdout(io.StringIO()): + return importlib.import_module("server") + + def input_then_eof(self, lines): + iterator = iter(lines) + + def fake_input(): + try: + return next(iterator) + except StopIteration: + raise EOFError + + return fake_input + + def test_server_continues_after_client_exception(self): + server_mod = self.import_server() + server = server_mod.Server() + + class FailingClient: + def handleRequest(self, msg): + raise RuntimeError("boom") + + server.clients["client-1"] = FailingClient() + + with mock.patch("builtins.input", side_effect=self.input_then_eof(['client-1|{"method":"onKeyDown"}'])), \ + mock.patch.object(server_mod, "append_error_log"), \ + contextlib.redirect_stdout(io.StringIO()) as stdout, \ + contextlib.redirect_stderr(io.StringIO()): + server.run() + + self.assertIn('PIME_MSG|client-1|{"success":false}', stdout.getvalue()) + + def test_server_ignores_malformed_request_line(self): + server_mod = self.import_server() + server = server_mod.Server() + + with mock.patch("builtins.input", side_effect=self.input_then_eof(["malformed-request"])), \ + mock.patch.object(server_mod, "append_error_log") as append_error_log, \ + contextlib.redirect_stdout(io.StringIO()) as stdout, \ + contextlib.redirect_stderr(io.StringIO()): + server.run() + + self.assertEqual(stdout.getvalue(), "") + append_error_log.assert_called_once() + + +if __name__ == "__main__": + unittest.main() From fb72650c4307d805459dabc44833479b65f8d77d Mon Sep 17 00:00:00 2001 From: omni624562 Date: Sun, 24 May 2026 14:44:10 +0800 Subject: [PATCH 2/2] Add Dayi smart candidate selection --- python/cinbase/__init__.py | 60 +++++- python/cinbase/cin.py | 178 ++++++++++++++++-- python/cinbase/config.py | 3 + python/cinbase/config/config.htm | 11 ++ python/cinbase/config/js/config.js | 13 ++ .../input_methods/chedayi/config/config.json | 5 +- tests/test_backend_resilience.py | 92 +++++++++ 7 files changed, 339 insertions(+), 23 deletions(-) diff --git a/python/cinbase/__init__.py b/python/cinbase/__init__.py index a579fc9fb..589c79c6a 100644 --- a/python/cinbase/__init__.py +++ b/python/cinbase/__init__.py @@ -110,6 +110,9 @@ def initTextService(self, cbTS, TextService): cbTS.easySymbolsWithShift = False cbTS.showPhrase = False cbTS.sortByPhrase = False + cbTS.intelligentSelect = False + cbTS.intelligentSelectRecent = False + cbTS.intelligentSelectContext = False cbTS.compositionBufferMode = False cbTS.autoMoveCursorInBrackets = False cbTS.imeReverseLookup = False @@ -297,6 +300,9 @@ def onDeactivate(self, cbTS): if hasattr(cbTS, 'dsymbols'): del cbTS.dsymbols + if hasattr(cbTS, 'cin'): + cbTS.cin.saveCountFile(force=True) + # 使用者按下按鍵,在 app 收到前先過濾那些鍵是輸入法需要的。 # return True,系統會呼叫 onKeyDown() 進一步處理這個按鍵 @@ -691,6 +697,9 @@ def onKeyDown(self, cbTS, keyEvent, CinTable, RCinTable, HCinTable): menu_playSoundWhenNonCand = "☑ 拆錯字碼時發出警告嗶聲提示" if cbTS.playSoundWhenNonCand else "☐ 拆錯字碼時發出警告嗶聲提示" menu_showPhrase = "☑ 輸出字串後顯示聯想字詞" if cbTS.showPhrase else "☐ 輸出字串後顯示聯想字詞" menu_sortByPhrase = "☑ 優先以聯想字詞排序候選清單" if cbTS.sortByPhrase else "☐ 優先以聯想字詞排序候選清單" + menu_intelligentSelect = "☑ 智慧選字" if cbTS.intelligentSelect else "☐ 智慧選字" + menu_intelligentSelectRecent = "☑ 智慧選字:近期選字優先" if cbTS.intelligentSelectRecent else "☐ 智慧選字:近期選字優先" + menu_intelligentSelectContext = "☑ 智慧選字:前一字上下文" if cbTS.intelligentSelectContext else "☐ 智慧選字:前一字上下文" menu_supportWildcard = "☑ 萬用字元查詢" if cbTS.supportWildcard else "☐ 萬用字元查詢" menu_imeReverseLookup = "☑ 反查輸入字根" if cbTS.imeReverseLookup else "☐ 反查輸入字根" menu_homophoneQuery = "☑ 同音字查詢" if cbTS.homophoneQuery else "☐ 同音字查詢" @@ -701,9 +710,9 @@ def onKeyDown(self, cbTS, keyEvent, CinTable, RCinTable, HCinTable): elif cbTS.imeDirName == "cheez": cbTS.smenucandidates = [menu_autoClearCompositionChar, menu_playSoundWhenNonCand, menu_showPhrase, menu_sortByPhrase, menu_supportWildcard, menu_imeReverseLookup] cbTS.smenuitems = ["autoClearCompositionChar", "playSoundWhenNonCand", "showPhrase", "sortByPhrase", "supportWildcard", "imeReverseLookup"] - elif cbTS.imeDirName == "chearray" or cbTS.imeDirName == "chedayi": - cbTS.smenucandidates = [menu_fullShapeSymbols, menu_easySymbolsWithShift, menu_autoClearCompositionChar, menu_playSoundWhenNonCand, menu_showPhrase, menu_sortByPhrase, menu_supportWildcard, menu_imeReverseLookup, menu_homophoneQuery] - cbTS.smenuitems = ["fullShapeSymbols", "easySymbolsWithShift", "autoClearCompositionChar", "playSoundWhenNonCand", "showPhrase", "sortByPhrase", "supportWildcard", "imeReverseLookup", "homophoneQuery"] + elif cbTS.imeDirName == "chedayi": + cbTS.smenucandidates = [menu_fullShapeSymbols, menu_easySymbolsWithShift, menu_autoClearCompositionChar, menu_playSoundWhenNonCand, menu_showPhrase, menu_sortByPhrase, menu_intelligentSelect, menu_intelligentSelectRecent, menu_intelligentSelectContext, menu_supportWildcard, menu_imeReverseLookup, menu_homophoneQuery] + cbTS.smenuitems = ["fullShapeSymbols", "easySymbolsWithShift", "autoClearCompositionChar", "playSoundWhenNonCand", "showPhrase", "sortByPhrase", "intelligentSelect", "intelligentSelectRecent", "intelligentSelectContext", "supportWildcard", "imeReverseLookup", "homophoneQuery"] else: cbTS.smenucandidates = [menu_fullShapeSymbols, menu_easySymbolsWithShift, menu_autoClearCompositionChar, menu_playSoundWhenNonCand, menu_showPhrase, menu_sortByPhrase, menu_supportWildcard, menu_imeReverseLookup, menu_homophoneQuery] cbTS.smenuitems = ["fullShapeSymbols", "easySymbolsWithShift", "autoClearCompositionChar", "playSoundWhenNonCand", "showPhrase", "sortByPhrase", "supportWildcard", "imeReverseLookup", "homophoneQuery"] @@ -1445,6 +1454,7 @@ def onKeyDown(self, cbTS, keyEvent, CinTable, RCinTable, HCinTable): candidates = cbTS.cin.getCharDef(cbTS.compositionChar) if cbTS.sortByPhrase and candidates: candidates = self.sortByPhrase(cbTS, copy.deepcopy(candidates)) + candidates = self.sortByIntelligentSelect(cbTS, cbTS.compositionChar, candidates) if cbTS.compositionBufferMode and not cbTS.selcandmode: cbTS.compositionBufferType = "default" elif cbTS.imeDirName == "chepinyin" and cbTS.cinFileList[cbTS.cfg.selCinType] == "thpinyin.json" and not cbTS.ctrlsymbolsmode: @@ -1452,6 +1462,7 @@ def onKeyDown(self, cbTS, keyEvent, CinTable, RCinTable, HCinTable): candidates = cbTS.cin.getCharDef(cbTS.compositionChar + '1') if cbTS.sortByPhrase and candidates: candidates = self.sortByPhrase(cbTS, copy.deepcopy(candidates)) + candidates = self.sortByIntelligentSelect(cbTS, cbTS.compositionChar + "1", candidates) if cbTS.compositionBufferMode and not cbTS.selcandmode: cbTS.compositionBufferType = "default" elif cbTS.fullShapeSymbols and cbTS.fsymbols.isInCharDef(cbTS.compositionChar) and cbTS.closemenu: @@ -1488,6 +1499,7 @@ def onKeyDown(self, cbTS, keyEvent, CinTable, RCinTable, HCinTable): cbTS.isWildcardChardefs = True if cbTS.sortByPhrase and candidates: candidates = self.sortByPhrase(cbTS, copy.deepcopy(candidates)) + candidates = self.sortByIntelligentSelect(cbTS, cbTS.compositionChar, candidates) # 組字編輯模式 if cbTS.compositionBufferMode and cbTS.isComposing() and cbTS.compositionChar == "" and cbTS.closemenu and not cbTS.multifunctionmode and not cbTS.phrasemode and not cbTS.selcandmode: @@ -1590,6 +1602,7 @@ def onKeyDown(self, cbTS, keyEvent, CinTable, RCinTable, HCinTable): candidates = cbTS.cin.getCharDef(sellist[1]) if cbTS.sortByPhrase and candidates: candidates = self.sortByPhrase(cbTS, copy.deepcopy(candidates)) + candidates = self.sortByIntelligentSelect(cbTS, cbTS.compositionChar, candidates) cbTS.selcandmode = True else: if cbTS.cin.isHaveKey(cbTS.compositionBufferString[cbTS.compositionBufferCursor]): @@ -1597,6 +1610,7 @@ def onKeyDown(self, cbTS, keyEvent, CinTable, RCinTable, HCinTable): candidates = cbTS.cin.getCharDef(cbTS.compositionChar) if cbTS.sortByPhrase and candidates: candidates = self.sortByPhrase(cbTS, copy.deepcopy(candidates)) + candidates = self.sortByIntelligentSelect(cbTS, cbTS.compositionChar, candidates) cbTS.selcandmode = True else: cbTS.selcandmode = False @@ -1641,6 +1655,7 @@ def onKeyDown(self, cbTS, keyEvent, CinTable, RCinTable, HCinTable): candidates = cbTS.cin.getCharDef(cbTS.compositionChar) if cbTS.sortByPhrase and candidates: candidates = self.sortByPhrase(cbTS, copy.deepcopy(candidates)) + candidates = self.sortByIntelligentSelect(cbTS, cbTS.compositionChar, candidates) # 如果是碼表標點 if cbTS.cin.isInKeyName(cbTS.compositionChar[0]): if cbTS.cin.getKeyName(cbTS.compositionChar[0]) in cbTS.directCommitSymbolList: @@ -1666,6 +1681,7 @@ def onKeyDown(self, cbTS, keyEvent, CinTable, RCinTable, HCinTable): candidates = cbTS.cin.getCharDef(cbTS.compositionChar) if cbTS.sortByPhrase and candidates: candidates = self.sortByPhrase(cbTS, copy.deepcopy(candidates)) + candidates = self.sortByIntelligentSelect(cbTS, cbTS.compositionChar, candidates) if cbTS.langMode == CHINESE_MODE and cbTS.dayisymbolsmode and len(cbTS.compositionChar) == 1 and (keyCode == VK_SPACE or keyCode == VK_RETURN): candidates = cbTS.cin.getCharDef(cbTS.compositionChar) @@ -1846,6 +1862,7 @@ def onKeyDown(self, cbTS, keyEvent, CinTable, RCinTable, HCinTable): i = cbTS.selKeys.index(charStr) if i < cbTS.candPerPage and i < len(cbTS.candidateList): commitStr = cbTS.candidateList[i] + self.addIntelligentSelectCount(cbTS, cbTS.compositionChar, commitStr) cbTS.lastCommitString = commitStr self.setOutputString(cbTS, RCinTable, commitStr) if cbTS.showPhrase and not cbTS.selcandmode: @@ -1935,6 +1952,7 @@ def onKeyDown(self, cbTS, keyEvent, CinTable, RCinTable, HCinTable): if not cbTS.homophoneselpinyinmode: # 找出目前游標位置的選字鍵 (1234..., asdf...等等) commitStr = cbTS.candidateList[candCursor] + self.addIntelligentSelectCount(cbTS, cbTS.compositionChar, commitStr) cbTS.lastCommitString = commitStr self.setOutputString(cbTS, RCinTable, commitStr) if cbTS.showPhrase and not cbTS.selcandmode: @@ -2200,6 +2218,7 @@ def onKeyDown(self, cbTS, keyEvent, CinTable, RCinTable, HCinTable): candidates = cbTS.cin.getCharDef(cbTS.compositionChar) if cbTS.sortByPhrase and candidates: candidates = self.sortByPhrase(cbTS, copy.deepcopy(candidates)) + candidates = self.sortByIntelligentSelect(cbTS, cbTS.compositionChar, candidates) if candidates: pagecandidates = list(self.chunks(candidates, cbTS.candPerPage)) cbTS.setCandidateList(pagecandidates[currentCandPage]) @@ -2603,6 +2622,12 @@ def onMenuCommand(self, cbTS, commandId, commandType): cbTS.showPhrase = not cbTS.showPhrase elif commandItem == "sortByPhrase": cbTS.sortByPhrase = not cbTS.sortByPhrase + elif commandItem == "intelligentSelect": + cbTS.intelligentSelect = not cbTS.intelligentSelect + elif commandItem == "intelligentSelectRecent": + cbTS.intelligentSelectRecent = not cbTS.intelligentSelectRecent + elif commandItem == "intelligentSelectContext": + cbTS.intelligentSelectContext = not cbTS.intelligentSelectContext elif commandItem == "imeReverseLookup": cbTS.imeReverseLookup = not cbTS.imeReverseLookup elif commandItem == "homophoneQuery": @@ -2769,6 +2794,27 @@ def SymbolscharCodeToFullshape(self, charCode): charStr = chr(charCode) return charStr + def getIntelligentSelectPreviousChar(self, cbTS): + lastCommitString = getattr(cbTS, 'lastCommitString', '') + if not isinstance(lastCommitString, str) or not lastCommitString: + return '' + return lastCommitString[-1:] + + def sortByIntelligentSelect(self, cbTS, key, candidates): + if cbTS.imeDirName != "chedayi" or not getattr(cbTS, 'intelligentSelect', False) or not candidates: + return candidates + return cbTS.cin.sortByCount( + key, + candidates, + self.getIntelligentSelectPreviousChar(cbTS), + getattr(cbTS, 'intelligentSelectRecent', True), + getattr(cbTS, 'intelligentSelectContext', True) + ) + + def addIntelligentSelectCount(self, cbTS, key, commitStr): + if cbTS.imeDirName == "chedayi" and getattr(cbTS, 'intelligentSelect', False) and key: + cbTS.cin.addCount(key, commitStr, self.getIntelligentSelectPreviousChar(cbTS)) + def sortByPhrase(self, cbTS, candidates): sortbyphraselist = [] if cbTS.userphrase.isInCharDef(cbTS.lastCommitString): @@ -3127,6 +3173,11 @@ def applyConfig(self, cbTS): # 優先以聯想字詞排序候選清單? cbTS.sortByPhrase = cfg.sortByPhrase + # 智慧選字 (依使用者選字頻率自動排序候選清單)? + cbTS.intelligentSelect = getattr(cfg, 'intelligentSelect', True) + cbTS.intelligentSelectRecent = getattr(cfg, 'intelligentSelectRecent', True) + cbTS.intelligentSelectContext = getattr(cfg, 'intelligentSelectContext', True) + # 拆錯字碼時自動清除輸入字串? cbTS.autoClearCompositionChar = cfg.autoClearCompositionChar @@ -3192,8 +3243,7 @@ def checkConfigChange(self, cbTS, CinTable, RCinTable, HCinTable): if hasattr(cbTS, 'cin'): if hasattr(cbTS.cin, 'cincount'): - if not os.path.exists(cbTS.cin.getCountFile()): - cbTS.cin.saveCountFile() + cbTS.cin.saveCountFile() # 如果有更換輸入法碼表,就重新載入碼表資料 if not CinTable.loading: diff --git a/python/cinbase/cin.py b/python/cinbase/cin.py index 074924aaf..3c644ae0b 100644 --- a/python/cinbase/cin.py +++ b/python/cinbase/cin.py @@ -4,12 +4,15 @@ import re import json import copy +import time class Cin(object): # TODO check the possiblility if the encoding is not utf-8 encoding = 'utf-8' + MAX_CONTEXT_ENTRIES = 32 + COUNT_SAVE_INTERVAL_SECONDS = 60.0 def __init__(self, fs, imeDirName, ignorePrivateUseArea): self.imeDirName = imeDirName @@ -21,6 +24,8 @@ def __init__(self, fs, imeDirName, ignorePrivateUseArea): self.selkey = "" self.keynames = {} self.cincount = {} + self._count_dirty = False + self._last_count_save_time = 0.0 self.chardefs = {} self.privateuse = {} self.dupchardefs = {} @@ -57,15 +62,17 @@ def __init__(self, fs, imeDirName, ignorePrivateUseArea): newvalue.remove(value) self.chardefs[key] = newvalue - self.saveCountFile() + self.loadCountFile() def __del__(self): - del self.keynames - del self.cincount - del self.chardefs - del self.privateuse - del self.dupchardefs + try: + self.saveCountFile(force=True) + except Exception: + pass + for name in ("keynames", "cincount", "chardefs", "privateuse", "dupchardefs"): + if hasattr(self, name): + delattr(self, name) self.keynames = {} self.cincount = {} @@ -211,20 +218,157 @@ def updateCinTable(self, userExtendTable, priorityExtendTable, extendtable, igno self.chardefs[key.lower()] = [root] - def saveCountFile(self): + def loadCountFile(self): filename = self.getCountFile() - tempcincount = {} - - if os.path.exists(filename) and not os.stat(filename).st_size == 0: - with open(filename, "r") as f: - tempcincount.update(json.load(f)) - - if not tempcincount == self.cincount: + if os.path.exists(filename) and os.stat(filename).st_size > 0: try: - with open(filename, "w") as f: - js = json.dump(self.cincount, f, sort_keys=True, indent=4) + with open(filename, "r", encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, dict): + normalized = {} + changed = False + for key, value in data.items(): + if not isinstance(key, str) or not isinstance(value, dict): + changed = True + continue + normalized[key] = {} + for char, entry in value.items(): + if not isinstance(char, str): + changed = True + continue + normalizedEntry = self._normalizeCountEntry(entry) + normalized[key][char] = normalizedEntry + if normalizedEntry != entry: + changed = True + self.cincount.update(normalized) + if changed: + self._count_dirty = True except Exception: - pass # FIXME: handle I/O errors? + pass + + def saveCountFile(self, force=False): + if not self._count_dirty: + return + now = time.time() + if ( + not force and + self._last_count_save_time > 0 and + now - self._last_count_save_time < self.COUNT_SAVE_INTERVAL_SECONDS + ): + return + filename = self.getCountFile() + try: + with open(filename, "w", encoding="utf-8") as f: + json.dump(self.cincount, f, ensure_ascii=False, separators=(",", ":")) + self._count_dirty = False + self._last_count_save_time = now + except Exception: + pass + + def _trimContextCounts(self, prev, keepKey=""): + if len(prev) <= self.MAX_CONTEXT_ENTRIES: + return prev + + items = sorted(prev.items(), key=lambda item: (-item[1], item[0])) + trimmed = dict(items[:self.MAX_CONTEXT_ENTRIES]) + if keepKey and keepKey in prev and keepKey not in trimmed: + removable = [key for key in trimmed if key != keepKey] + if removable: + dropKey = min(removable, key=lambda key: (trimmed[key], key)) + del trimmed[dropKey] + trimmed[keepKey] = prev[keepKey] + return trimmed + + def _normalizeCountEntry(self, value): + if isinstance(value, dict): + count = value.get("count", 0) + try: + count = int(count) + except (TypeError, ValueError): + count = 0 + last = value.get("last", 0) + try: + last = float(last) + except (TypeError, ValueError): + last = 0 + prev = value.get("prev", {}) + if not isinstance(prev, dict): + prev = {} + normalized_prev = {} + for k, v in prev.items(): + if not isinstance(k, str): + continue + try: + normalized_prev[k] = int(v) + except (TypeError, ValueError): + pass + prev = self._trimContextCounts(normalized_prev) + return {"count": count, "last": last, "prev": prev} + try: + count = int(value) + except (TypeError, ValueError): + count = 0 + return {"count": count, "last": 0, "prev": {}} + + def _countEntryScoreParts(self, value, previousChar=""): + if isinstance(value, dict): + try: + count = int(value.get("count", 0)) + except (TypeError, ValueError): + count = 0 + try: + last = float(value.get("last", 0)) + except (TypeError, ValueError): + last = 0 + prevCount = 0 + prev = value.get("prev", {}) + if isinstance(previousChar, str) and previousChar and isinstance(prev, dict): + try: + prevCount = int(prev.get(previousChar, 0)) + except (TypeError, ValueError): + prevCount = 0 + return count, last, prevCount + + try: + count = int(value) + except (TypeError, ValueError): + count = 0 + return count, 0, 0 + + def addCount(self, key, char, previousChar=""): + if not isinstance(key, str) or not isinstance(char, str): + return + if key not in self.cincount or not isinstance(self.cincount[key], dict): + self.cincount[key] = {} + entry = self._normalizeCountEntry(self.cincount[key].get(char, 0)) + entry["count"] += 1 + entry["last"] = time.time() + if isinstance(previousChar, str) and previousChar: + entry["prev"][previousChar] = entry["prev"].get(previousChar, 0) + 1 + entry["prev"] = self._trimContextCounts(entry["prev"], previousChar) + self.cincount[key][char] = entry + self._count_dirty = True + + def sortByCount(self, key, candidates, previousChar="", useRecent=True, useContext=True): + if key not in self.cincount or not isinstance(self.cincount[key], dict): + return candidates + counts = self.cincount[key] + now = time.time() + + def score(candidate): + count, last, prevCount = self._countEntryScoreParts(counts.get(candidate, 0), previousChar) + value = float(count) + if useContext and isinstance(previousChar, str) and previousChar: + value += prevCount * 2.0 + if useRecent and last > 0: + age_days = max(0.0, (now - last) / 86400.0) + value += 3.0 / (1.0 + age_days / 7.0) + return value + + return [candidate for _, candidate in sorted( + enumerate(candidates), + key=lambda item: (-score(item[1]), item[0]) + )] def getCountDir(self): diff --git a/python/cinbase/config.py b/python/cinbase/config.py index a8d30db4c..901d815b1 100644 --- a/python/cinbase/config.py +++ b/python/cinbase/config.py @@ -77,6 +77,9 @@ def __init__(self): self.messageDurationTime = 3 self.keyboardType = 0 self.selDayiSymbolCharType = 0 + self.intelligentSelect = True + self.intelligentSelectRecent = True + self.intelligentSelectContext = True self.ignoreSaveList = ["ignoreSaveList", "curdir", "cinFileList", "selCinFile", "imeDirName", "_version", "_lastUpdateTime"] self.curdir = os.path.abspath(os.path.dirname(__file__)) diff --git a/python/cinbase/config/config.htm b/python/cinbase/config/config.htm index 4d0684325..4e239c0c4 100644 --- a/python/cinbase/config/config.htm +++ b/python/cinbase/config/config.htm @@ -132,6 +132,17 @@

候選清單


+
+ +
+ + +
+ + +
+
+
diff --git a/python/cinbase/config/js/config.js b/python/cinbase/config/js/config.js index c7adefb63..fcc1b3e42 100644 --- a/python/cinbase/config/js/config.js +++ b/python/cinbase/config/js/config.js @@ -129,6 +129,15 @@ if (!Date.now) { function loadConfig() { $.get(CONFIG_URL, function(data, status) { checjConfig = data.config; + if (imeFolderName == "chedayi" && typeof checjConfig.intelligentSelect === "undefined") { + checjConfig.intelligentSelect = true; + } + if (imeFolderName == "chedayi" && typeof checjConfig.intelligentSelectRecent === "undefined") { + checjConfig.intelligentSelectRecent = true; + } + if (imeFolderName == "chedayi" && typeof checjConfig.intelligentSelectContext === "undefined") { + checjConfig.intelligentSelectContext = true; + } cinCount = data.cincount; symbolsData = data.symbols; swkbData = data.swkb; @@ -448,6 +457,10 @@ function pageReady() { $("#candPerRow").TouchSpin({min:1, max:10}); $("#candPerPage").TouchSpin({min:1, max:10}); } + if (imeFolderName != "chedayi") { + $("#dayiSmartSelectionOptions").hide(); + $("#dayiSmartSelectionOptions input").attr("name", ""); + } $("#candMaxItems").TouchSpin({min:100, max:10000}); $("#fontSize").TouchSpin({min:6, max:200}); diff --git a/python/input_methods/chedayi/config/config.json b/python/input_methods/chedayi/config/config.json index 93691c925..c553c82db 100644 --- a/python/input_methods/chedayi/config/config.json +++ b/python/input_methods/chedayi/config/config.json @@ -29,9 +29,12 @@ "candMaxItems": 100, "showPhrase": false, "sortByPhrase": true, + "intelligentSelect": true, + "intelligentSelectRecent": true, + "intelligentSelectContext": true, "compositionBufferMode": false, "autoMoveCursorInBrackets": false, "imeReverseLookup": false, "userExtendTable": false, "reLoadTable": false -} \ No newline at end of file +} diff --git a/tests/test_backend_resilience.py b/tests/test_backend_resilience.py index 15d34c455..68d77d8e3 100644 --- a/tests/test_backend_resilience.py +++ b/tests/test_backend_resilience.py @@ -1,8 +1,10 @@ import contextlib import importlib import io +import json import os import sys +import tempfile import unittest from unittest import mock @@ -13,6 +15,96 @@ sys.path.insert(0, PYTHON_DIR) +class CinCountTests(unittest.TestCase): + def make_cin(self, temp_dir, count_data): + spec = importlib.util.spec_from_file_location("cin_module_for_test", os.path.join(PYTHON_DIR, "cinbase", "cin.py")) + cin_module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(cin_module) + Cin = cin_module.Cin + + cin = Cin.__new__(Cin) + cin.keynames = {} + cin.cincount = {} + cin.chardefs = {} + cin.privateuse = {} + cin.dupchardefs = {} + cin._count_dirty = False + cin._last_count_save_time = 0.0 + cin.getCountFile = lambda name="cincount.json": os.path.join(temp_dir, name) + with open(cin.getCountFile(), "w", encoding="utf-8") as f: + json.dump(count_data, f) + return cin + + def test_load_count_file_ignores_malformed_entries(self): + with tempfile.TemporaryDirectory() as temp_dir: + cin = self.make_cin(temp_dir, { + "abc": {"A": 3}, + "bad": ["not", "a", "dict"], + "also_bad": 2, + }) + + cin.loadCountFile() + + self.assertEqual(cin.cincount, { + "abc": {"A": {"count": 3, "last": 0, "prev": {}}} + }) + + def test_add_and_sort_count_tolerate_bad_state(self): + with tempfile.TemporaryDirectory() as temp_dir: + cin = self.make_cin(temp_dir, {}) + cin.cincount = {"abc": "bad"} + + cin.addCount("abc", "A") + cin.addCount(None, "B") + + self.assertEqual(cin.cincount["abc"]["A"]["count"], 1) + self.assertEqual(cin.cincount["abc"]["A"]["prev"], {}) + self.assertEqual(cin.sortByCount("abc", ["B", "A"]), ["A", "B"]) + self.assertEqual(cin.sortByCount("missing", ["B", "A"]), ["B", "A"]) + + def test_recent_and_context_scores_can_be_disabled_independently(self): + with tempfile.TemporaryDirectory() as temp_dir: + cin = self.make_cin(temp_dir, { + "abc": { + "A": {"count": 1, "last": 100.0, "prev": {"文": 4}}, + "B": {"count": 2, "last": 200.0, "prev": {}}, + } + }) + cin.loadCountFile() + + with mock.patch("time.time", return_value=200.0): + self.assertEqual(cin.sortByCount("abc", ["B", "A"], "文"), ["A", "B"]) + self.assertEqual(cin.sortByCount("abc", ["B", "A"], "文", useContext=False), ["B", "A"]) + + def test_context_counts_are_capped(self): + with tempfile.TemporaryDirectory() as temp_dir: + cin = self.make_cin(temp_dir, {}) + + for i in range(40): + cin.addCount("abc", "A", "prev{0:02d}".format(i)) + + prev = cin.cincount["abc"]["A"]["prev"] + self.assertEqual(len(prev), cin.MAX_CONTEXT_ENTRIES) + self.assertIn("prev39", prev) + + def test_save_count_file_is_throttled(self): + with tempfile.TemporaryDirectory() as temp_dir: + cin = self.make_cin(temp_dir, {}) + cin.cincount = {"abc": {"A": {"count": 1, "last": 0, "prev": {}}}} + cin._count_dirty = True + cin._last_count_save_time = 100.0 + + with mock.patch("time.time", return_value=110.0): + cin.saveCountFile() + with open(cin.getCountFile(), "r", encoding="utf-8") as f: + self.assertEqual(json.load(f), {}) + + with mock.patch("time.time", return_value=111.0): + cin.saveCountFile(force=True) + with open(cin.getCountFile(), "r", encoding="utf-8") as f: + self.assertEqual(json.load(f), cin.cincount) + + class ServerResilienceTests(unittest.TestCase): def import_server(self): with contextlib.redirect_stdout(io.StringIO()):