中文模式下英文候选可以限两件事:[general] english_max_in_chinese 限数量(1–4,缺省 4 即 1 条精确词 + 3 条补全),english_min_position 限有中文候选时英文最高排第几(1–9,缺省 1 不限制;没有中文候选时英文照旧第一)。缺省值下行为不变;完全关闭仍用「打拼音时也给英文词」。三个平台的 Server 与共用设置页都接上了,Linux 改配置后也会重新读 english_in_chinese。 Closes #768
22 KiB
Core 与显示层拆解
architecture.md 写的是「为什么这么定」,这一页写「现在长什么样」:
Engine 的字段与线程归属、一次按键从键到候选走过的每一层、三条异步旁路、上屏时的写回、几处缓存,
以及显示层在三个平台上各画了什么、哪些地方还是各写一份。
读代码前先看这页,读完直接进 crates/qingjian-core/src/engine/。
2026-09-23 起稿,2026-10-14 对着 main(2b96d75,0.2.1 之后)核过一遍。实现改了要回来改这里;文中的常数以代码为准。
一、Engine:75 个字段,分六组
Engine(engine/mod.rs)是 Core 对外的唯一门面。字段按「谁改、多久改一次」分六组看:
| 组 | 字段 | 性质 |
|---|---|---|
| 数据 | dictionary、extra_dictionaries、companion_words(编进 Core 的符号 / emoji 名字,词库里没有)、english、emoji、kaomoji、symbols、custom_phrases、code(五笔码表)、aux_codes(辅码表)、opencc |
装配时给,热加载时整张换 |
| 注入口 | translator、english_translator、learner、logger、predictor、language_model、sentence_scorer、rescorer、meter、vocabulary、gloss_filler |
trait 对象,全有 No* 空实现;learner / logger 外面各罩一层 MutedLearner / MutedLogger,私密输入时吞掉写入 |
| 配置 | modes、fuzzy、shuangpin、shuangpin_raw_preedit、zhuyin、phonetic、full_width_punctuation、chinese_first、english_in_chinese、english_max_in_chinese / english_min_position、auto_space、emoji_limit / kaomoji_limit / symbol_limit、shift_letter_compose、interpolation、typo_costs、neural_weight / neural_margin / neural_context、aux_enabled / aux_show / aux_code_key / aux_keep_empty、traditional |
壳用 set_*(engine/setup.rs)推进来;影响排序的顺手清缓存 |
| 会话状态 | composition、english_mode、punctuation、history、chain、recent_commits、recording、retype_snapshot、passthrough_pending、page_turns、composition_started、application、committed_since_break、displayed |
每键变;正好是 EngineSession 的 14 个字段,可以整组换出(见下) |
| 引擎级状态 | private、aux_code(码段)、panel(打开着的符号面板)、rescoring_before、log_sequence、prediction_sequence、pending_predictions(在飞的请求按序号记种类与作用域) |
每键变,但不随会话换 |
| 内部可变 | span_cache、correction_cache、neural_cache、last_rescored、last_query、traditional_map |
RefCell / Cell:query(&self) 只读借用也要能往里写 |
sentence_scorer 是同步打分器(CLI 评测用),rescorer 是把它挪到后台线程的 RescoreWorker(壳用,set_async_sentence_scorer 装)。
三条对移植与并发都要紧的性质
-
注入的 trait 全部
Send,缺省实现全是空操作(Translator/Learner/Predictor/LanguageModel/SentenceScorer/InputLogger/UsageMeter/VocabularyTracker/GlossFiller;辅码表AuxCodeLookup要多处共享,是Send + Sync)。 所以 CLI 和单元测试不需要真实词库、不联网、不落盘也能跑完整条查询路径——这是整个架构最值钱的一处设计。 -
Engine是Send、不是Sync。 内部可变那组让&Engine不能在两条线程上同时用,但整个 Engine 可以交给另一条线程。 三个壳都是「一条线程独占,别的线程发消息过来」:壳 谁持有 Engine 别的线程怎么够到它 macOS 主线程的 thread_local!单例,host::with取IMK 回调本来就在主线程;后台结果由主线程上的 NSTimer轮询Windows Server 进程的 Router 线程 每条管道连接一条线程读消息,经 mpsc交给 Router;画窗口的是另一条 UI 线程Linux Server 主线程上的 Router 每条 socket 连接一条线程,经 mpsc::sync_channel(128)交给 Router任何新壳都照这个形状来:别想着给 Engine 加锁多线程共享,查询路径上的缓存都假定只有一个人在用。
-
会话状态可以整组换出。
EngineSession(engine/session.rs)就是会话状态那 14 个字段;swap_session与它整组互换, 顺手取消在飞的联想、清掉查询缓存,词库、学习数据与配置始终只有一份。Linux Server 靠它给每个输入上下文一份独立的会话状态 (组句、中英模式、标点配对、上屏前文互不串);macOS 与 Windows 目前不用它。discard_input用在隐私边界:清掉组句,不写日志、不学习。 缺口:辅码码段aux_code不在EngineSession里,切会话不跟着走、discard_input也不清它。 Linux Server 还没接辅码所以暂时碰不到,接之前先把它挪进会话。
二、一次按键的数据流
壳: push(c) / backspace / move_cursor_* / push_aux_code …
│
▼
Composition 拼音缓冲 + 光标;scope = 光标前那段(光标停中间时只按它出候选)
│
▼
Engine::query() engine/query/mod.rs,最热的一条路
│
├─ query_inner:按顺序分派,先命中先走
│ english_mode → query_english 英文词表:精确词、前缀补全、拼错纠正
│ 表达式键开头(v) → query_expression 算式结果、中文数字、中文日期
│ 问字键开头(u / ?) → query_question 本地没有候选,答案等云端;u4e00 这类码点直接给字
│ is_raw(含 - . 这类) → query_raw 英文直输段,唯一候选就是原文
│ 有码表、拼音关 → query_code 五笔:码表前缀查,没有切分 / 纠错 / 整句
│ 有码表、拼音开 → query_mixed 混输:形码「编码打全」的排最前,其余与拼音合并
│ 其余 → query_phonetic ↓
│
├─ decode 双拼 / 注音先解成全拼(Decoded),之后与全拼同路
├─ split_english_tail 末尾是英文词(…xuehaorust):与整段读成拼音比分(mixed_beats_plain),赢了才拆开
├─ parser::segment trie 切音节;全拼 / 简拼混排 / 末尾残缺音节;整段切不动就取最长可切前缀;
│ 同音节数时不带生僻音节(fiao / biang)的切法在前:biangeng 是 bian geng
├─ active_correction 拼音「不像话」(unlikely_pinyin)才试一处编辑的变体;按噪声信道,
│ 纠后得分扣掉编辑代价仍高于原样才纠;结果按 scope 记进 correction_cache
│
├─【词级】fuzzy.expand → lookup_all 每个位置扩成多种写法(z/zh、an/ang…),主词库、附加词库、用户词逐个查
│ 输入前缀 → lookup_exact_all kaifazhe 也出 开发、开;同一次查询里同一个模式只查一遍(memo)
│ ranking::rank log P(词 | 上一个上屏词) + 封顶的选择次数加分,截到 500 条
│
├─【辅码】aux_filter 码段非空时反向过滤:没有码的词藏掉,命中的按 完全匹配 > 码长 > 原序 重排
│ (下面几种附加候选都没有码,辅码筛词时一律不出)
│
├─【整句】insert_sentence → sentence/
│ 词图:每格 lookup_exact,每格留前 6(含简拼的格子留 20),SpanCache 跨按键复用
│ 词频 0 的生僻字不进图(用户选过的除外),免得 keneng 按 ken eng 读出 肯鞥
│ correction::typo 敲错边:每个完整音节的一处敲错变体带代价入图
│ Viterbi + 束搜索(束宽 8)
│ 打分 = (1−μ)·静态 bigram + μ·个人 n-gram,μ = c/(c+8) 封顶 0.5
│ 个人侧先算二元(0.8 / 0.2 插值),上文见过再套绝对折扣三元(D = 0.75)
│ rescore_paths:装了神经打分器时前 6 条路径按字级 Transformer 重排;
│ 分数从 neural_cache 取,缺分的攒着等壳送后台,有一条缺分就不动顺序;
│ 任一条带打分器字表外的字也不动(那个字只能当 <unk> 算,整条会被压到底)
│
├─ insert_english 与整句谁先进看 chinese_first
├─ insert_shortcuts / insert_emoji emoji、颜文字、符号三张表按各自名额配在对应的词后面
│
▼
query() 收尾 aux_segment;insert_custom_phrases(辅码态不出);写 last_query 摘要给输入日志;
繁体开着时 Chinese / Sentence / Cloud 候选过 OpenCC,繁→简记进 traditional_map
│
▼
Query{candidates, segmentations, tail, correction, aux, …}
│
▼
Engine::annotate() Translator 查本地释义 → Translation{senses}
│ VocabularyTracker 标 Sense::fresh(看到的轮次不到 FRESH_UNTIL = 3 即生词)
▼
壳: 画一帧
形码那条路不经过切分、纠错与整句(编码不需要切,没有简拼与模糊音,一处编辑的纠错也不适用), 但译词标注、生词记录、输入日志、选择学习与个人 n-gram 都按上屏的词工作,与拼音共用;取舍见 plan/wubi.md。 辅码的触发、过滤与学习语义见 aux-code.md。 各层常数为什么是这些值,见 architecture.md「Core 的关键技术决定」与 notes/constant-sweep.md。
三、三条异步旁路
三条都遵守同一条铁律:不阻塞候选生成——结果没到就先按本地的出,到了壳再重画一次。
云端词只进 CandidateLayout 留的固定槽位,不挪本地候选;神经分只在整句那几条路径之间调顺序。
Linux Server 只接了神经重排(#214):Server 里同样停键 80 ms 送去、每 20 ms 收、最多等 2 s;
Linux 协议是一问一答、Server 不推送,重排结果靠 fcitx5 插件组句期间每 80 ms 发一次 Poll 取回。云联想与释义兜底没接。
| 旁路 | trait | 后台线程 | 壳怎么取结果 | 丢不丢得起 |
|---|---|---|---|---|
| 云联想 / 问字 | Predictor |
qingjian-predict 自开,防抖 300 ms、缓存 64、超时 5 s |
poll_prediction() |
丢得起:组句联想与整句译文各只认最新一条,可同时在飞(pending_predictions 按序号记着),清空输入时连还没发的一起撤掉 |
| 神经重排 | SentenceScorer |
rescoring::RescoreWorker |
停键 80 ms 后 request_rescoring(),每 20 ms poll_rescoring(),最多等 2 s |
丢得起:没到的分数就按静态模型排 |
| 释义兜底 | GlossFiller |
qingjian-predict 独立线程,攒 1.5 s 或 8 个词 |
poll_glosses() |
丢不起:每个词都要问到,慢点没关系 |
同样是「异步调云」,因为丢得起和丢不起,Predictor 与 GlossFiller 是两个 trait、两条线程、两套策略,
没有合并成一个通用的异步任务队列。这条分界是有意的,见 architecture.md「翻译数据本地化」。
私密输入时两条云端旁路都不发(联想请求前查 private,释义兜底在上屏时跳过),set_private(true) 还把 prediction_sequence 加一,
在飞的联想回来也作废;神经重排在本地照跑,但光标前文清掉、不带进模型。
四、上屏时的写回
Engine::commit() → commit_with()(engine/commit/mod.rs),按代码里的顺序:
commit(candidate)
├─ 关符号面板;开了自动空格(set_auto_space)时先看上文要不要补一个前导空格
├─ forget_span_cache 先清格子缓存:下面每一路都可能改排序
├─ Learner,按候选种类分路
│ Chinese / Code record(词频)+ record_choice(这段输入串下选了它)
│ Cloud 同上;词库里没有的先 learn_word 记成用户词
│ English record + learn_english(进个人英文词表)
│ Sentence 不记词频,路径上的词在下面逐条记转移
│ Emoji / Kaomoji / Symbol 按对应词的音节吃拼音,不学习
│ Generated 模型直接生成的整句,没有音节对齐,吃掉整段作用域,不学习
│ Shortcut / Custom 不学习
├─ apply_retraction 删光重打换了个词:上一次记的词频、选择、转移、敲错全部退回
├─ record_typo 接受的 (敲的, 要的) 音节对 ──► user-typos.tsv
├─ log_commit input-log.jsonl 一行(键按原样记,辅码态把触发键与码段接在后面)
├─ meter_commit 汉字 / 词数、首选、键数与打字时长 ──► usage-hourly.tsv
├─ vocabulary.record_commit 带译词的中文候选记「上屏过」──► user-vocab.tsv
├─ gloss_filler 词库有、释义表没有的词交给后台问云端,回来写进个人释义表
├─ record_word 个人 n-gram:用户点选的词记 2 份,整句路径顺带的记 1 份
│ └─ try_auto_word 连选两词、合起来 ≤ 4 字且哪里都没有:同一段拼音里选出 2 次、分两段打 3 次就成用户词
└─ finish_buffer 一段拼音分几次才选完:整段合起来记一次选择,够次数也造词
两层包装罩在外面:
- 私密输入:
set_private(true)让MutedLearner/MutedLogger生效——写吞掉、读照常、排序不变; 词汇记录与释义兜底在commit_with里跳过,输入统计照记(只有数字、没有文本)。 判定在壳:Windows 看 TSF 的输入范围,Linux 看前端报来的能力位(密码 / 敏感); macOS 在 Secure Input 下不组句,所以壳不调set_private,只额外挡掉云联想、神经重排的前文与翻译快捷键。 - 退格撤销:
recent_commits留最近 4 次上屏,壳把组句外的退格用note_backspace()告诉 Engine, 删光再重打同一段拼音换了个词,就走上面的apply_retraction。目前只有 macOS 调了note_backspace, Windows 与 Linux 的 Server 都没调,这两个平台上撤销不会发生。
五、缓存与内部可变状态
性能主要来源于前三处,动它们要用 qingjian-cli --typing 的 release 构建重量每键耗时:
| 名字 | 键 → 值 | 什么时候清 |
|---|---|---|
span_cache(sentence::SpanCache) |
格子模式(含模糊写法)→ 排好截好的格子候选 | 上屏、删词、换学习数据 / 词库 / 模糊音这类配置、换会话;超过 8192 格整个清 |
correction_cache |
scope → 纠错结果(含「不用纠」) | scope 变了自然重算;take_raw 原样上屏(记成不再纠)、删词、改配置时清 |
| 词库前缀记忆化 | 模式 → 命中 | 单次查询内的局部 HashMap |
neural_cache(rescoring::NeuralCache) |
前文 + 整句文本 → 神经分;外加「等着送后台」的文本 | 前文变了整张清 |
last_rescored |
这次查询有没有动用神经分 | 每次查询开头复位 |
last_query(QuerySnapshot) |
上一次查询的摘要:scope、切分、前 32 个候选 | 每次查询覆盖;给输入日志接上「看到了什么」与「选了什么」 |
traditional_map |
繁体候选文本 → 原简体 | 组句清空 / 原样上屏 / discard_input 时清;上屏与删词靠它把学习记回简体 |
lm 的 CSR bigram |
— | 只读,随 .qj mmap |
历次优化的起因、定位方法与前后数字在 notes/performance.md。
六、显示层
Core 交出什么
壳拿到的是已经排好序、已经带好 annotation 的数据,一行排序逻辑都不在壳里:
Query{ candidates: CandidateList, segmentations, tail, correction, aux: Option<AuxSegment>, … }
.marked_segments() -> Vec<MarkedSegment{text, kind}> kind: Typed | Rest | Corrected | AuxCode
CandidateList{ items: Vec<Candidate> }
Candidate{ text, kind, syllables, reading, translation: Option<Translation>, aux_code }
kind: Chinese | Code | English | Cloud | Shortcut | Custom(i) | Emoji | Kaomoji | Symbol | Sentence | Generated
Translation{ senses } ─► Sense{ part_of_speech, text, reading, fresh }
.furigana() -> Vec<FuriganaSegment{text, reading}> 日文汉字段注平假名
CandidateLayout / Cell 分页排布:本地候选 + 云端固定槽位([predict] slots)
Engine::raw_preedit() -> RawPreedit{text, cursor_bytes} 不查询、不学习地读原样键串(失焦原样上屏这类场合)
连「云端词补进第一页末尾几格、前面的本地候选一格不挪」这种排布规则都在 Core 的 CandidateLayout 里。
Windows 与 Linux 的 Engine 在 Server 进程里,与画面之间隔着一层协议。Windows 是 qingjian_platform::protocol 的 Frame
(拼音行分段、光标、当前页候选、高亮、页码、排布与主题),释义晚到的用后续的 Update 补;
Linux 在 apps/linux/server/src/protocol/ 另有一套 JSON 消息,候选连译文一起发给 fcitx5 插件。
两边开会话时带的是同一个 PROTOCOL_VERSION,Linux Server 版本不等就断开:Windows 那边加消息升版本,Linux 插件也得跟着变
(插件在 C++ 里写死这个数,apps/linux/server/tests/protocol.rs 读插件源码核对,忘了改 cargo test 就过不了)。
候选窗只有四个原语
若干行 (序号, 候选词, 译文) / 一行高亮 / 跟随光标定位 / 异步补画译文。
技术选型与被否掉的方案(WebView、egui / iced / GPUI)见 candidate-ui.md;
自绘渲染器 crates/qingjian-render 的设计与验收见 rendering.md。
三个平台各画了什么
| macOS | Windows | Linux(fcitx5) | |
|---|---|---|---|
| 在哪画 | 输入法进程内,主线程 | Server 进程的一条 UI 线程(HWND 只在该线程碰,Router 经通道 + PostThreadMessageW 交过去) |
fcitx5 进程内的 C++ 插件;Server 只出数据 |
| 谁画 | qingjian-render 出位图,drawRect: 里贴;[general] renderer = "system" 退回旧的 AppKit 自绘 |
qingjian-render 出位图,UpdateLayeredWindow 贴;system 退回 GDI |
fcitx5 自己的输入面板(setCandidateList / setPreedit),外观跟 fcitx5 主题走 |
| 窗口 | 非激活 NSPanel,level 101(与系统候选框同级) |
分层窗,圆角与阴影在 ui/layered/ 合成 |
fcitx5 管 |
| 置顶 | CanJoinAllSpaces + FullScreenAuxiliary |
exe 的 uiAccess manifest + 代码签名 + 装 Program Files |
fcitx5 管 |
| 定位 | IMK attributesForCharacterIndex:lineHeightRectangle: |
DLL 量 GetTextExt ──► PositionCandidates{rect} |
fcitx5 管 |
| 译词 | 全部义项、词性、假名注音,生词用强调色 | 同 macOS,另显示辅码 [码] |
只显示第一条义项,生词后缀「 · 生」;实际显示了哪几条回报给 Server(DisplayReporting),Server 只对这些调 note_displayed |
| 重排结果回到面板 | 主线程 NSTimer 轮询 Engine(20 ms 一次,最多 2 s) |
DLL 组句期间每 80 ms 发 Poll 拉异步结果(重排、云端候选都走它),Server 回新帧 |
插件组句期间每 80 ms 发 Poll;帧没变时 Server 沿用展示版本号,插件看版本号变新才重画 |
| 踩过的坑 | macOS 26 把面板绑死在创建时已有的 Space,全屏应用里看不见;每次 orderFront 后查 isOnActiveSpace,不在就把内容视图搬到新建面板 |
普通置顶窗被微软商店 / 任务栏搜索的高 z-band 盖住;整个渲染层从 DLL 搬进 Server 进程 | 面板更新是异步的,点选、翻页都带 revision / 显示标识回来,对不上的丢掉 |
还是各写一份的地方
这几处重复都已经开始分叉,是下一步收口的对象:
-
候选 → 行。 macOS
apps/macos/src/candidates/row.rs(自己的Row/Tone,再经bitmap/convert.rs转成渲染器的类型)、 Windowsapps/windows/server/src/ui/candidates/row.rs(直接产渲染器的Row)、Linuxapps/linux/fcitx5/src/qingjian.cpp(C++ 拼 comment)。 现状:Windows 在行里显示辅码而 macOS 没有(macOS 也还没接辅码),macOS 把自定义短语截成 60 字预览而 Windows 不截, 云端标记 macOS 在host/presenting里另设、Windows 在转换时就设,Linux 只取第一条义项。 收口方向:from_candidate写一份放进共享 crate,macOS 删掉自己的Frame/Row直接用qingjian_render的(convert.rs文件头已经这么写)。 -
按键分流。 macOS
apps/macos/src/imk/controller/{text,command}.rs、Windowsapps/windows/server/src/dispatch/key/、 Linuxapps/linux/server/src/dispatch/key/。后两份文件结构一模一样,Linux 那份是从 Windows 复制的,已经分叉: Windows 有辅码的四处分支,Linux 有 Delete 向后删。两个 Server 吃的都是qingjian_platform::protocol::KeyEvent, 最现实的一步是先把这两份合成一份放进共享 crate,macOS 的再看。 -
Engine 装配与设置下发。 三个壳各自决定装哪些 Translator / Learner、调哪些
set_*,已经不一样了:macOS Windows Linux Server 神经重排( set_async_sentence_scorer)✓ ✓ ✓ 云联想 / 释义兜底 ✓ ✓ — 形码( set_code_scheme)✓ ✓ — 辅码( set_aux_codes)— ✓ — 繁体( set_traditional_mode)✓ ✓ — 退格撤销( note_backspace)✓ — — 符号面板( open_symbol_panel)✓ ✓ — 中西文自动空格( set_auto_space)✓ — — 多会话( swap_session)— — ✓ 这里只是记下现状,不是说每个壳都该补齐;但新加一项 Core 能力时,要在这张表里写清楚哪些壳接了。