Files
qingjian/apps/linux/server/tests/display/main.rs
T
li-chenaandLi b12218557f feat(linux): Server 接本地整句模型重排 (#214)
* 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>
2026-09-23 08:50:45 +08:00

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());
}