← 所有文章

为 Rime 提供非阻塞的云拼音支持

文章目录

起因:想给小企鹅加个云输入

这次折腾的起因很简单:我想给 macOS 上的 Fcitx5-Mac 加上云输入。

本地词库、用户词典,再配上语法模型,已经能解决很多输入需求。不过遇到一些本地没有收录的词,或者想看看云端能不能给出更合适的整句时,还是会惦记一下搜狗那种云候选的体验。

这里说的“云联想”,具体是根据当前还没上屏的拼音,向云端查询候选,再把结果补进 Rime 的候选框。

之前在 GitHub 上看到过 Rime 云输入相关的插件,所以这也不算一个凭空冒出来的想法。既然已经有人探索过,而我手边又有 Codex,那不如自己 vibe 一个出来?

于是就有了这个项目:

rime-cloud-pinyin-async

后面继续折腾,又补上了 Windows 小狼毫的支持。至于实现过程,真正值得聊的地方,倒不只是“怎么调一个云输入接口”。

云端可以慢,本地打字不能跟着等

如果只考虑拿到云候选,事情看起来并不复杂:读取拼音、发 HTTP 请求、解析结果,然后塞进候选列表。

但这些步骤放在哪里执行,直接决定了这个功能能不能日常使用。

输入法几乎每次按键都会参与工作。如果在 Lua 的输入处理或候选生成过程中同步等网络,正常联网时可能还凑合,一旦遇到 DNS 卡住、接口超时,本地输入也会跟着受影响。为了多几个候选,把打字本身搞卡了,显然有点本末倒置。

所以我给这套实现定下的要求是:本地候选照常出来,云候选晚一点补进来;网络出了问题,本地输入仍然能继续。

最后的结构拆成了三部分:

输入、选词
    ↓
Rime Lua 组件 ──请求文件──→ 独立 helper
    ↑                         ├─ 搜狗
    │                         └─ Google Input Tools
    │                              ↓
    └── 校验状态、更新候选 ←──响应文件 + 刷新信号

Lua 负责和 Rime 打交道:现在输入了什么、选到了哪一段、候选应该怎么排,以及选中的词如何学习。

helper 是输入法进程之外的独立程序,负责防抖、网络请求、结果合并和超时处理。macOS 这边用 Swift,Windows 用 C#。

两边通过 Rime 用户目录里的小文件交换状态。协议里带着请求编号、输入内容和响应版本,候选文本则用 Base64 包装,避免中文、制表符和换行把字段格式弄乱。

用文件通信并不是什么特别炫酷的方案,但调试直观,Lua 侧也容易接入。这里仍有少量本地文件 I/O;所谓“非阻塞”,重点是把网络等待移出输入路径,而不是宣称每一次按键都完全没有 I/O。

不用每敲一个字母就联网

连上云端以后,接着要处理的是请求频率。

假设正在输入一串拼音,中间每多敲一个字母都发请求,大部分响应返回时,用户早就输入到下一步了。这些请求既没有多少用处,还会给后面的状态判断添乱。

因此 helper 会先等一小会儿。当前仓库的示例配置里,macOS 的防抖时间是 300 ms,Windows 是 500 ms;期间如果来了更新的输入,就换成最新请求重新计时。这两个数只是可调整的配置,不是什么必须遵守的标准。

防抖结束后,搜狗和 Google 同时查询。但显示结果时,不需要等两边一起结束:先回来的一路先显示,另一路回来后再合并。

比如第一次发布的是搜狗结果,对应 revision 1;稍后 Google 返回了新的候选,就再发布 revision 2。如果候选集合和来源都没变化,也就没必要再刷新一次。

候选旁边会带上来源标记:☁搜、☁谷,或者两个来源都有的 ☁搜谷。哪一路暂时不可用,也不会拖住另外一路。

结果回来了,候选框为什么还没变

异步查询有个很容易忽略的问题:结果已经写进文件,不代表输入法知道自己该重新生成候选了。

尤其是用户已经停下手,正在等候选的时候。如果必须再敲一下键才能看到云结果,这个“云输入”用起来就会有点别扭。

Fcitx5-Mac 这边,我加了一个很小的刷新 addon。它运行在 Fcitx5 进程内,每 25 ms 检查一次响应文件的快照。发现变化后,先确认当前输入上下文仍有焦点,而且正在使用 Rime,再直接向这个输入法引擎投递一个私有的 F24 事件。

这里的 F24 只是一个刷新信号,不会进入 macOS 的系统按键流,也不会发给前台应用,因此这条路径不需要辅助功能权限。

Lua processor 收到它以后,先做状态检查,通过后才调用:

context:refresh_non_confirmed_composition()

这样就能重新生成尚未确认部分的候选。这个私有事件也会被 Lua 吞掉,不参与正常输入。

整个刷新 addon 不做联网。它的任务很单纯:发现新结果,然后在合适的输入上下文里通知 Rime。这个边界也让 Fcitx5-Mac 的适配不用去修改官方应用源码,不过 addon 和 Fcitx5 的 ABI 有关系,升级前端后仍然要注意匹配版本、重新构建。

异步输入里最怕的是“结果没错,时机错了”

假设为输入 A 发出了请求,用户马上又把拼音改成 B。偏偏 A 的响应这时候才回来。

如果只看“接口成功了”,就把它放进当前候选框,显示出来的可能是一组完全不相干的词。

所以这套实现会做两层判断:helper 发布结果前确认请求仍然有效,Lua 激活结果时再确认当前 Rime 状态与请求一致。请求编号、完整输入、当前 segment 等信息,都参与判断。

用户已经切走输入窗口、不再组词,或者把候选选择移到了别的位置,也不能贸然刷新。否则云端迟到的结果可能改变用户正在选择的菜单。

这里还有一个比“继续敲字”更隐蔽的情况:分段选词。

例如输入一段类似 nihaoshijie 的拼音,先确认前面的“你好”,接下来还要为剩余的 shijie 提供候选。Rime 里的完整 context.input 不一定因此改变,普通的输入更新通知也不一定触发。

只盯着整串输入,就可能把这次变化漏掉。

因此协议把输入拆成了两个概念:context_input 保留完整输入,用于状态校验;query_input 则是当前尚未确认的那一段,用来查询云端。Lua 除了监听输入更新,还会在 select_notifier 里重新调度剩余拼音。

这也是我觉得这类功能需要实际打字去试的原因。单独输入一整串拼音再按空格,是一条路径;选一半、改一半、切窗口,又是另外几条路径。

云候选补进来,也别挤乱本地顺序

拿到云候选以后,我不希望它们一股脑挤到最前面。

本地词库和用户词典积累下来的习惯,应该继续发挥作用。当前示例会先保留两个本地候选,再从第三位开始插入云候选,双源合并后最多显示五个。

这个需求写成一句话很简单,放进 Rime 的候选处理链里,却还要考虑 filter 的执行顺序。

项目里就修过这样一个问题:输入 xian,原本排在前面的“先、线”,可能被云端返回的“西安、锡安”挤下去。原因不只是云候选自身的排序,而是它们的高权重影响了后续长词过滤器的判断。

所以 cloud filter 要放在其他改序 filter 和 uniquifier 之前,先保住本地原始顺序,再插入云候选。过滤器只预读一个有界的本地候选窗口,不会为了找重复项把整个候选流遍历完。

重复项也要处理。如果本地词典、用户词或者万象模型已经给出了某个词,就保留非云版本,不再额外显示一份带云标记的相同文本。

去重以后,可能剩不了几个真正新增的云候选。对此我加了一次补查:等首轮两个来源都结束后,用稍大的候选池再查一轮,最终显示数量仍然受原来的上限约束。补不满就算了,不会为了凑数一直联网。

用户真正选中并上屏的云候选,还可以通过 Memory:update_userdict 写入当前方案的用户词典。这个动作留在 Lua 里做,helper 不直接打开 Rime 数据库;仅仅出现在候选框里的词,也不会因此被当作用户选择记下来。

到 Windows 上,继续补“非阻塞”的细节

后面我也把这套思路适配到了 Windows 小狼毫。

Lua 与 helper 之间继续使用相同的文件协议,helper 换成 C#,无需修改小狼毫源码。刷新方式与 macOS 不完全相同:Windows helper 在确认前台窗口仍匹配后发送私有 F24,再由 Lua 消费它并检查当前输入状态。

所以不能把 macOS 的“进程内投递、不进入系统按键流”直接套到 Windows 上。平台相关的刷新方式,还是得分别处理。

Windows 这边后来还有一轮弱网改造。把 HTTP 放到 helper,并不代表 helper 内部就不会被拖住;如果它自己等网络等到不动,心跳和另一来源的处理也可能受影响。

现在的 Windows 实现让搜狗和 Google 各自拥有一个长期 worker,每个来源只保留一个可被覆盖的最新待处理请求。某一路卡在 DNS 或网络调用里,就只占住自己的 worker,不让任务无限堆积,也不阻塞协调主循环和另一路查询。

这里还需要区分两件事:请求超过截止时间,不代表操作系统里的 DNS 调用一定已经结束。 能保证的是,迟到结果不再参与当前候选;不能靠一个超时参数,就假定所有底层工作都被立刻终止了。

仓库里也加了对应的弱网回归:让 Google worker 模拟阻塞 10 秒,把协调器的硬截止设成 500 ms,检查搜狗结果、后续请求和心跳是否仍能推进。测试模式不访问真实云接口,也不向系统发送 F24。

另一个后来收紧的地方,是 helper 的启动时机。即使网络已经挪走,如果普通输入回调里还在用 os.execute 拉起进程,按键路径里依然藏着额外等待。

现在两端都只在方案初始化时尝试启动 helper。它在会话中退出了,就暂时退回本地输入,等下次切换方案、重新部署或重新初始化时再尝试启动。

回头看,用 Codex 做这件事,初版代码只是开始。后面这些修改,才逐渐把“能查出几个词”变成了一套更适合放在输入法里的实现。把期望说具体——断网时怎么办、结果迟到了怎么办、选了一半怎么办——比一句“帮我优化一下”有用得多。

Linux 想用,也可以继续改

这次主要围绕 Fcitx5-Mac 和 Windows 小狼毫展开。仓库里另外有鼠须管适配,不过它需要修改、构建和签名前端,维护成本更高,Mac 上我还是更推荐 Fcitx5-Mac 这条路线。

Linux 上如果也想给 Fcitx5 的 Rime 加这个功能,我觉得很适合把仓库扔给 agent,让它参考现有实现做一次移植。

但需要移植的不只是“能发 HTTP 的 helper”。还要调整平台相关代码、用户目录与构建方式,并把刷新 addon 接进当地的 Fcitx5 环境。尤其要保留请求编号、分段输入校验、迟到结果丢弃和本地候选优先这些行为。

可以直接从仓库的架构文档和文件协议入手,把它们作为 agent 的实现约束,再在自己的前端里验证快打、分段选词、切窗口和弱网场景。

目前仓库没有现成验证过的 Linux 适配,也只验证了全拼,不能直接把 macOS 的构建产物搬过去就算支持了。

另外,云输入终究需要联网:启用后,当前尚未上屏、用于查询的全拼编码会发送给搜狗和 Google Input Tools。它不会主动上传本地词库、用户词典或剪贴板,但输入编码本身也可能包含敏感内容。这一点要接受,再决定要不要开。

顺手把自己的输入方案也整理了一下

云拼音之外,我也一直在基于白霜拼音、雾凇拼音维护自己的配置:

skykeyjoker/rime-ice

准确一点说,当前这份配置以雾凇拼音为主方案,保留了来自白霜的迁移词库和细胞词库,再整合万象 LTS、这套异步云拼音,以及拆字、颜文字等自己会用到的功能。

日常输入仍然由本地词库、用户词和本地模型打底,云端提供额外候选。没有网络,也照常打字。

仓库里区分了 Mac 和 Windows 配置分支,main 用来同步雾凇上游。感兴趣可以拿去参考,再按自己的输入习惯改。部署前记得保留用户词典和私人配置,别为了尝鲜把自己积累的词给覆盖了。

最后也感谢 Rime、librime-lua、白霜和雾凇这些项目。云输入相关实现还参考了 rime-wenyun 和 librime-cloud 的思路与协议研究。能借着这些现成的基础,再和 Codex 一起把自己想要的功能补上,这次折腾还是挺有意思的。