mirror of
https://github.com/qingjian-team/qingjian.git
synced 2026-11-15 00:14:54 +00:00
* feat(linux): Server 接本地整句模型重排 - 照 Windows Server 的 dispatch/rescore 平移:后台加载、停键 80 ms 防抖、20 ms 收结果、2 s 兜底,只换整句候选 - 模型在后台接上时用户正在组句,补查一次攒下整句路径再起防抖,这一轮不错过重排 - 主循环改按 next_tick 的绝对到点时间等消息,防抖与轮询才到得了点;Poll 先 tick 再回帧 - 前文用本会话历史,不读应用光标前文;[model] 只在启动时读,Linux 没有配置热加载 * feat(linux): Poll 回帧内容没变时沿用展示身份 - 插件组句期间每 80 ms Poll 一次取重排结果;若每次都推进版本号,插件就得每拍重画、每拍回报曝光,面板闪且词汇统计被刷高 - 会话记下上次发出的帧,Poll 回的帧与之相同就当没有新展示:身份不变、不清曝光记录;重排换了顺序才推进 - KeyResult 的行为不变,每次按键仍是新的展示 * feat(linux): 插件组句期间轮询取本地整句模型的重排结果 - 插件是一问一答,Server 推不了;照 Windows DLL 挂一个 80 ms 定时器,组句期间发已有的 Poll 消息 - 只在 revision 推进时重画并回报;同版本什么都不做,面板不闪、曝光不重复记 - 帧为空、Reset、失焦、停用、断线时停表;定时器建一次反复用 - 回归:mock 全模式应答 Poll,rescore 模式第一条回换序帧,断言重画一次、回报一次 * build(linux): 安装清单加本地整句模型文件 - files.py 只装 data/generated,data-fetch.sh 取回的 data/model/model.qjm 没进清单,装完 Server 找不到模型 - 非样例安装把它按 data.lock 的校验值装到 resources/data/model/,缺文件提示先跑 data-fetch.sh - 用户文档:Linux 页去掉「暂不提供神经模型」,本地整句模型页补 Linux 的开关与自有模型路径 --------- Co-authored-by: Li <clwillsoncl@gmail.com>
201 lines
6.8 KiB
Rust
201 lines
6.8 KiB
Rust
//! 真正上屏触发曝光;Linux 回报按连接代次/上下文/帧验证,不能越过隐私边界。
|
|
mod events;
|
|
use qingjian_core::{Engine, Language, Sense, Translation, Translator, VocabularyTracker};
|
|
use qingjian_dictionary::Dictionary;
|
|
use qingjian_linux_server::{Router, RouterConfig};
|
|
use qingjian_platform::protocol::{
|
|
ClientMessage, KeyEvent, KeyModifiers, PROTOCOL_VERSION, SessionId,
|
|
};
|
|
use serde_json::{Value, json};
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
struct Glosses;
|
|
impl Translator for Glosses {
|
|
fn language(&self) -> Language {
|
|
Language::English
|
|
}
|
|
fn translate(&self, text: &str) -> Option<Translation> {
|
|
(text == "你好").then(|| {
|
|
Translation::new(
|
|
Language::English,
|
|
["hello", "greetings"]
|
|
.into_iter()
|
|
.map(|text| Sense {
|
|
part_of_speech: None,
|
|
text: text.into(),
|
|
reading: None,
|
|
fresh: false,
|
|
})
|
|
.collect(),
|
|
)
|
|
})
|
|
}
|
|
}
|
|
struct Book(Arc<Mutex<Vec<String>>>);
|
|
impl VocabularyTracker for Book {
|
|
fn exposures(&self, _: Language, _: &str) -> u32 {
|
|
0
|
|
}
|
|
fn record_exposure(&mut self, _: Language, word: &str) {
|
|
self.0.lock().unwrap().push(word.into());
|
|
}
|
|
fn record_commit(&mut self, _: Language, _: &str, _: bool) {}
|
|
}
|
|
fn router() -> (Router, Arc<Mutex<Vec<String>>>) {
|
|
let book = Arc::new(Mutex::new(Vec::new()));
|
|
let engine = Engine::new(Dictionary::parse("你好\tni hao\t100\n").unwrap())
|
|
.with_translator(Box::new(Glosses))
|
|
.with_vocabulary_tracker(Box::new(Book(book.clone())));
|
|
let mut router = Router::new(engine, RouterConfig::default());
|
|
router.handle(ClientMessage::OpenSession {
|
|
session: SessionId(1),
|
|
app: None,
|
|
protocol: PROTOCOL_VERSION,
|
|
});
|
|
router.handle(ClientMessage::Privacy {
|
|
session: SessionId(1),
|
|
private: false,
|
|
});
|
|
router.handle_linux(json!({"DisplayReporting": {"session": 1, "identity": {"generation": 2, "context": "first", "revision": 0}}}));
|
|
(router, book)
|
|
}
|
|
fn key(router: &mut Router, c: char) -> Value {
|
|
router
|
|
.handle_linux(
|
|
serde_json::to_value(ClientMessage::Key {
|
|
session: SessionId(1),
|
|
event: KeyEvent::new(c as u32, Some(c), KeyModifiers::default()),
|
|
})
|
|
.unwrap(),
|
|
)
|
|
.unwrap()
|
|
}
|
|
fn compose(router: &mut Router) -> Value {
|
|
let mut last = Value::Null;
|
|
for c in "nihao".chars() {
|
|
last = key(router, c);
|
|
}
|
|
assert_eq!(
|
|
last["KeyResult"]["frame"]["candidates"]["items"][0]["text"],
|
|
"你好"
|
|
);
|
|
last["KeyResult"]["identity"].clone()
|
|
}
|
|
#[test]
|
|
fn display_ack_replaces_page_and_deduplicates_senses() {
|
|
let (mut router, book) = router();
|
|
let identity = compose(&mut router);
|
|
router.handle_linux(json!({"DisplayAcknowledged": {"session": 1, "identity": identity, "senses": [[0, 1], [0, 1]]}}));
|
|
key(&mut router, ' ');
|
|
assert_eq!(*book.lock().unwrap(), ["greetings"]);
|
|
}
|
|
#[test]
|
|
fn wrong_identity_or_indices_cannot_create_exposures() {
|
|
for defect in ["generation", "context", "revision", "sense", "row"] {
|
|
let (mut router, book) = router();
|
|
let mut identity = compose(&mut router);
|
|
let mut senses = json!([[0, 0]]);
|
|
match defect {
|
|
"context" => identity[defect] = json!("other"),
|
|
"sense" => senses = json!([[0, 0], [0, 99]]),
|
|
"row" => senses = json!([[99, 0]]),
|
|
_ => identity[defect] = json!(999),
|
|
}
|
|
router.handle_linux(
|
|
json!({"DisplayAcknowledged": {"session": 1, "identity": identity, "senses": senses}}),
|
|
);
|
|
key(&mut router, ' ');
|
|
assert!(book.lock().unwrap().is_empty(), "{defect}");
|
|
}
|
|
}
|
|
#[test]
|
|
fn private_and_old_frame_reports_cannot_be_replayed() {
|
|
let (mut router, book) = router();
|
|
let old = compose(&mut router);
|
|
router.handle(ClientMessage::Privacy {
|
|
session: SessionId(1),
|
|
private: true,
|
|
});
|
|
let private = compose(&mut router);
|
|
for identity in [old, private] {
|
|
router.handle_linux(json!({"DisplayAcknowledged": {"session": 1, "identity": identity, "senses": [[0, 0]]}}));
|
|
}
|
|
key(&mut router, ' ');
|
|
router.handle(ClientMessage::Privacy {
|
|
session: SessionId(1),
|
|
private: false,
|
|
});
|
|
compose(&mut router);
|
|
key(&mut router, ' ');
|
|
assert!(book.lock().unwrap().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn same_frame_fallback_replaces_exposure_without_double_counting() {
|
|
let (mut router, book) = router();
|
|
let identity = compose(&mut router);
|
|
for senses in [
|
|
json!([[0, 0], [0, 1]]),
|
|
json!([]),
|
|
json!([[0, 0]]),
|
|
json!([[0, 0]]),
|
|
] {
|
|
router.handle_linux(
|
|
json!({"DisplayAcknowledged": {"session": 1, "identity": identity, "senses": senses}}),
|
|
);
|
|
}
|
|
key(&mut router, ' ');
|
|
assert_eq!(*book.lock().unwrap(), ["hello"]);
|
|
}
|
|
|
|
#[test]
|
|
fn poll_with_an_unchanged_frame_keeps_the_acknowledged_display() {
|
|
// 插件组句期间定时 Poll 取重排结果;帧没变就不是新的展示,已回报的曝光照算
|
|
let (mut router, book) = router();
|
|
let identity = compose(&mut router);
|
|
router.handle_linux(
|
|
json!({"DisplayAcknowledged": {"session": 1, "identity": identity, "senses": [[0, 0]]}}),
|
|
);
|
|
let update = router
|
|
.handle_linux(json!({"Poll": {"session": 1}}))
|
|
.unwrap();
|
|
assert_eq!(identity, update["Update"]["identity"]);
|
|
key(&mut router, ' ');
|
|
assert_eq!(*book.lock().unwrap(), ["hello"]);
|
|
}
|
|
|
|
#[test]
|
|
fn unfocused_or_resumed_session_requires_a_new_display_report() {
|
|
let (mut router, book) = router();
|
|
let identity = compose(&mut router);
|
|
router.handle_linux(
|
|
json!({"DisplayAcknowledged": {"session": 1, "identity": identity, "senses": [[0, 0]]}}),
|
|
);
|
|
router.handle_linux(
|
|
json!({"LinuxEvent": {"session": 1, "event": {"Focus": {"focused": false}}}}),
|
|
);
|
|
router.handle_linux(
|
|
json!({"DisplayAcknowledged": {"session": 1, "identity": identity, "senses": [[0, 0]]}}),
|
|
);
|
|
key(&mut router, ' ');
|
|
assert!(book.lock().unwrap().is_empty());
|
|
|
|
router
|
|
.handle_linux(json!({"LinuxEvent": {"session": 1, "event": {"Focus": {"focused": true}}}}));
|
|
let identity = compose(&mut router);
|
|
router.handle_linux(
|
|
json!({"DisplayAcknowledged": {"session": 1, "identity": identity, "senses": [[0, 0]]}}),
|
|
);
|
|
router.handle(ClientMessage::OpenSession {
|
|
session: SessionId(2),
|
|
app: None,
|
|
protocol: PROTOCOL_VERSION,
|
|
});
|
|
router.handle(ClientMessage::Poll {
|
|
session: SessionId(2),
|
|
});
|
|
// 会话恢复不能把失焦前的显示记录带回来。
|
|
key(&mut router, ' ');
|
|
assert!(book.lock().unwrap().is_empty());
|
|
}
|