Skip to content

查词弹窗打磨包 —— 熟词入口 / 义项一致性 / 存词语义澄清

物理仓库:/Users/larry/reading-browser(RB 桌面端) 创建:2026-08-11 · 状态:已落地(2026-08-12) —— 实施中有 6 处偏离,见文末 §4 as-built 缘起:外部 LLM 截图评审(~/Downloads/readbrowser-ux-backlog.md)+ 用户补充需求(2026-08-11) 姊妹计划:data-hygiene-identity-plan.md · module-state-preservation-plan.md

共同特征:全部集中在 content-script/features/popup.js + styles.js + settings.js 三个文件,打成一包一次改完、一次 pnpm build:cs、一次实机验证,避免反复重编译 content-script。


0. 前置更正 —— auto_save_words 从来没生效过

这条推翻了本轮讨论早期的一个错误判断(当时据 init_data.sql 的种子值 'true' 推断 "双击瞬间就已存词"),必须先说清楚,因为后面三条设计都建立在它之上

js
// core/state.js:8
autoSave: false,                                    // ← 初始值

// features/settings.js:99
if (batch.auto_save_words === 'false') state.settings.autoSave = false;
//                          ^^^^^^^ 只能置 false,永远置不了 true

DB 里 auto_save_words = 'true'(实测),但加载器只有关闭分支、没有开启分支state.settings.autoSave 恒为 falseresolveSaveState()popup.js:356永不返回 'auto'

结论:

  1. 双击查词从来不会自动存词,用户看到的「+ 加入生词」手动按钮才是真实路径 ✅
  2. runSaveDispatch 里的 kind === 'auto' 整个分支 + showSavedBadge 的部分调用 = 死代码
  3. 用户提的「去掉自动保存这个选项、默认双击不保存」——已经是现状,本包只做显式化清理

产品侧确认(用户 2026-08-11 裁定,与现状一致):不做自动保存。理由——自动保存把 「我查了一下」和「我要学它」混为一谈,生词本会被一次性好奇心填满、稀释复习队列; 查词是低成本动作,收藏应当是有意识的。与「不制造学习负债」的产品定位一致。


1. 任务清单

T1 — 弹窗加「已认识」出口(本包最高价值

问题:把词标为已认识的唯一入口是右键已高亮的词highlight.js:173, 只对 .rb-highlight / .rb-discovery-highlight 生效)。而「我早就会这个词」这个念头 产生的真实时刻,恰恰是双击了一个词、看完释义的那一瞬——那一刻没有出口。

设计.rb-popup-saved 槽位从「单一保存按钮」改为两个并列出口

[ + 加入生词本 ]   [ 已认识 ]
  • 因 §0 已确认无自动保存,这是两个干净的平行动作,不涉及"撤销刚才的自动收藏"
  • 已在生词本的词(kind === 'existing'):左侧退为「✓ 已在生词本」状态徽标, 右侧「已认识」保留——它此时的语义是移出生词本add_known_word 已级联软删 learning_entries,见 known_words.rs:80,零后端改动)
  • 文案走行为型口径(roadmap 2-3 已定基调):「阅读时不用再提示」 而非「我认识」, 把用户要做的判断从"自我评价"降为"要不要继续被提醒"

落库后的即时反馈(照抄 highlight.js:210-218 已验证的三步,别自创):

js
await invoke('add_known_word', { word });
state.vocab.excludedWordsSet.add(word);
removeHighlightForWord(word);                                   // 页面上该词的高亮立即消失
invoke('report_word_saved', { word, isNew: false, sourceUrl: '' }).catch(()=>{});  // 主 webview 刷新

⚠️ 顺带修的既有缺陷highlight.js:203 的右键菜单文案是硬编码英文Exclude "${word}" from highlights,中文界面下直接露英文。本次统一到 content-script 的 __t()(i18n 已有 core/i18n.js)。


T2 — 单义项也标绿底 + ✓(不只是外观,还修了复习卡的漏

现状popup.js:502

js
if (candidates.length === 0 || totalSenses < 2) return;   // 单义词不消歧(D3)

全词只有 1 个义项时直接跳过消歧 → 无绿底、无 ✓ 徽标。

这不是纯外观问题——它在复习卡上也漏了:单义词走不到 persistContextSenserecord_cloze_sense 从不调用 → word_cloze_contexts.sense_gloss 永远 NULL → 复习卡背面也没有「贴合语境义项」绿底。 实测:58 条 cloze 语境中 23 条 sense_gloss 为空(40%)。

设计totalSenses === 1 时走一条纯本地分支(零 LLM、零成本、零延迟): ① 直接对那唯一义项调 revealMatchedSense;② 本地构造 {pos, sense_index:1, gloss} 种进 disambigCachepersistContextSense 自然生效,sense_gloss 落库。

⚠️ 诚实性边界(必须遵守):绿底的既有语义是「AI 判定这条义项贴合你的句子」。 单义项时我们只知道"没有别的候选",并未验证这唯一义项是否真的适配(例如该词 在句中被当专有名词用)。因此:颜色统一,但 tooltip / aria-label 文案必须区分 ——如「本词仅一个义项」而非「AI 判定贴合语境」。 这条呼应项目此前否决本地 Lesk 方案的同一条理由:不要制造自信的错误徽标 (见 memory project_word_sense_disambiguation)。


T3 — ✓ 徽标位置移到翻译图标之前

用户观察正确,且根因比"顺序不对"更具体

revealMatchedSensepopup.js:623)用 def.appendChild(badge),而翻译图标是渲染时 就写进 .rb-popup-def 的(popup.js:129)→ 实际顺序 释义文字 → 翻译图标 → ✓

关键在于翻译图标是 opacity: 0 但仍占位styles.js:339):

  • 静止时释义文字 [一段莫名的空隙] ✓
  • hover 时:翻译图标从空隙里浮现,把 ✓ 往右推

这正是用户说的"不稳重"。

改法(一行):

js
def.insertBefore(badge, def.querySelector('.rb-popup-trans-toggle'));  // 找不到则 append

关于 hover 效果:核查确认 .rb-popup-ctx-badge没有任何 :hover 规则, 只有 cursor: helpstyles.js:284)——那是"此处有解释"的标准约定,配合 title 原生提示。 所以用户感知到的"hover 效果"大概率就是上面那个布局抖动纪律:先只改位置,改完实机再判断是否还刺眼;若仍想去掉,改 cursor: default 即可,但代价是 tooltip 可发现性下降。不要在本包里预先改掉它。


T4 — 清理 auto_save_words 死逻辑

按 §0,做显式化清理(行为零变化):

删除项位置
auto_save_words 种子行assets/sql/init_data.sql:17 ⚠️ 只删种子、不删 DB 里已有的键(schema.sql 已冻结,init_data.sql 是 v2 迁移,同样已应用——见下方警告)
if (batch.auto_save_words === 'false') …features/settings.js:99
'auto_save_words' 批量取数键features/settings.js:21
state.settings.autoSave 字段core/state.js:8
resolveSaveStateif (autoSave) return 'auto'popup.js:356
runSaveDispatchkind === 'auto' 整个分支popup.js:358-376

⚠️🔴 init_data.sql 是 v2 迁移的 SQL 本体,同样受 sqlx checksum 校验 —— 但 CI 守不住它

已核实(migrations.rs:44-48):v2 的 sql 就是 include_str!("../../assets/sql/init_data.sql"), 且 migrations.rs:56 的注释明写「禁止再改 v1 schema.sql / v2 / v3」。 动它 = 改 v2 指纹 = 存量库 VersionMismatch → 整条迁移链中止(红线 #11 同一机制)。

审核时新发现的守门缺口scripts/check-schema-frozen.mjs 只比对 schema.sql 一个文件SCHEMA_PATH 单一常量 + 单一 FROZEN_SHA256)——init_data.sql(v2) 与 seed_reference_words.sql(v3) 没有任何守门。也就是说,误改这两个文件会静默通过 CI, 到存量用户机器上才炸。

因此本项:

  1. 只删代码侧读取,init_data.sql 一个字都不改——那行残留的种子是无害的死键 (删掉读取后无人再读),留着比断链便宜得多
  2. 顺手补守门(建议一并做,成本 ~10 行):把 check-schema-frozen.mjs 从单文件 扩成三文件白名单(schema.sql / init_data.sql / seed_reference_words.sql 各一个冻结 SHA)。 这是本次审核捞出的独立价值项,与 T4 无关也该做

T5 — AI 语境助手默认开(用户裁定:整开关默认开

改法features/settings.js:113):

js
state.settings.contextDisambiguation = batch.context_disambiguation !== 'false';

即从 opt-in(只认显式 'true')翻转为 opt-out(只认显式 'false')。 同步 ReadingLearningSettings.tsx:44 的读取语义 + state.js:21 初值。

用户裁定:整个开关默认开(成本此前评估过、可控,后续观察再决定是否拆分)。 以下两条是该裁定的必须配套项,不是可选项

T5a 🔴 隐私政策文案必须同步改(否则对外承诺与实际行为不符

landing/lib/legal.ts:58 现文:

「AI 文本处理:当你主动使用查词消歧、翻译、句子分析、例句生成等功能时, 你选中的文本或其所在句子会被发送到 AI 服务以生成结果。」

默认开之后,双击查词(最基础的阅读动作)就会自动把句子发给 DeepSeek——这不属于 "主动使用"。同样问题在 EN 版 legal.ts:231("when you actively use…")。

必须:改成如实描述(默认开启、可随时在设置中关闭),中英双语同改, 并随 landing 一起重新部署。这是法务文本,不能只改 app 不改站点。

T5b 🔴 llm_calls_passive_limit 列在 quota_config根本没建 → 目前无上限

同一开关还门控整页短语高亮settings.js:123state.phrase.highlightEnabled = state.settings.contextDisambiguation)——那是 每打开一个页面就发一批 judge_phrases_batch(走 llm_calls_passive 计量)。

quota.ts:147-148 的注释写得很清楚:该列尚未建 → loadConfig 取到 undefined!limit 判为"未配 = 关闭该闸"。即:这条最贵的被动链路目前没有任何配额上限。

默认开 = 从「只有极少数手动开启的用户」变成「所有用户每次开页面」。 必须在 Supabase Dashboard 给 quota_configllm_calls_passive_limit 列并配值, 先于默认开的客户端发版。

📌 保留的备选(本轮未采纳,记录以备观察后回头):把合并的开关拆回两个—— 「单词双击消歧」默认开(用户主动双击才触发、一次一句)+「整页短语高亮」保持关 (被动触发、按页批量、成本不可控)。两者本是独立开关,2026-07-15 为减少设置项才合并。 若 T5b 的用量观察显示成本失控,这是第一顺位的回退动作。


2. 明确不做

  • 让「+ 加入生词」等待消歧完成:核查确认现在就不等——popup.js:673maybeDisambiguate 不返回 Promise,await runSaveDispatch 只等一次本地 get_vocab_status(毫秒级)。而且这个解耦是刻意的:persistContextSense 在 "结果到达"和"save 成功"两处都调用,正是为覆盖两种时序(popup.js:468-470)。 若实机观察到保存按钮出现慢,先查 lookup_word 本身或 runBackfill(词库未命中 → 走 Edge Function),不要动这里的时序。
  • cursor: help:见 T3 纪律,先改位置再判断。
  • init_data.sql:见 T4 警告。

3. 验收

  1. 双击任意词 → 弹窗底部出现两个出口;点「已认识」→ 页面高亮立即消失 + 该词从生词本移除(Library 刷新可见)
  2. 双击一个单义项词(先在库里挑一个 totalSenses===1 的)→ 该义项有绿底 + ✓, tooltip 文案为"仅一个义项"版本;存词后查 word_cloze_contexts.sense_gloss 非空
  3. ✓ 徽标紧贴释义文字;hover 义项时翻译图标出现在 ✓ 右侧,✓ 不位移
  4. 中文界面下右键高亮词 → 菜单文案为中文
  5. 全新库(或清掉 context_disambiguation 键)启动 → AI 语境助手默认开
  6. grep -rn "auto_save\|autoSave" src-tauri/src/content-script/ → 0 命中

质量闸/arch-checkpnpm run build:cs(⚠️ content-script 是打包产物, tauri dev 不跑打包器,见 memory project_content_script_bundle_step)→ /build-check/code-review

跨端:T1-T4 零跨端影响。T5 有——context_disambiguation 是 RB 本地设置 (不在同步矩阵),但隐私政策是双端共用的对外文本,T5a 改完需知会 RVH 会话。 零 migration、零 schema 变更。


4. As-built(2026-08-12 实施记录)

计划本身的 T1-T5 全部落地,但实施与实机验证中暴露 6 处偏离,逐条记录:

4.1 T5b 的前提是错的(计划写错,非实施变更)

llm_calls_passive_limit 早已建列——supabase/sql/quota-and-attribution.sql:119 (2026-08-02 WP3 增补,default 60000 + 127 行显式赋值)。quota.ts:147-148 那条 「该列尚未建」注释挂的是 dict_lookups_limit,计划把两者混为一谈。 遗留动作降级为「Dashboard 实测确认该 SQL 已执行」(文件里有 ≠ 线上已建)。

4.2 add_known_word 违反红线 #9(计划外必修,否则 T1 不成立)

它用 word.to_lowercase() 而非 lemmatizer::normalize(),而调用方传的是页面原始 surface。 双击 "cats" → 存 "cats",但 vocabulary PK 是 "cat":高亮过滤 NOT IN known_words 比不上、 级联软删 learning_entries WHERE word=? 也比不上。客户端 excludedWordsSet 同样是拿去跟 词元集合做差集的(discovery.js:76 / highlight.js:269)。 症状最难发现:当场高亮消失(客户端按 surface 删 span,成功),换页才复发。 → 改用与 save_word 相同的 lemmatizer::normalize。存量脏行不回填(惰性无害,按原值仍可删)。

4.3 .rb-discovery-highlight 是不存在的 class(长期潜伏 bug)

全仓仅出现在 highlight.js 两处 querySelector 里,从无任何地方设置它;真名是 .rb-discover。 后果:① removeHighlightForWord 删不掉待发现词点线(标熟词后要换页才消失); ② 右键菜单对待发现词从来不弹——即计划开头「唯一入口是右键已高亮的词」这句话本身不成立, 对最该被标熟词的那批词,那个入口是死的。→ 两处改 .rb-discover

4.4 次要出口按「在不在生词本」二分(用户裁定,推翻计划 T1 的单一动作设计)

计划让「已认识」对已存词复用 add_known_word("语义天然就是移出生词本,零后端改动")。 这是错的add_known_word 会级联软删 learning_entries,对一个已复习 8 次的词点它, repetitions/easy_factor/复习历史一起作废,且它从「已掌握词数」统计里消失 (reading.rs:820mastery_level >= level4 统计)。用户表达"我学会了",得到的是 "这条学习记录不该存在"。二者在数据层本就该分开落点:

场景动作落点
待发现词 / 普通词「不用再提示」known_words + 级联软删
已在生词本「标记已掌握」mastery_level = 'level5'
已是熟词静态说明「已设为熟词」无动作(左侧仍可「+ 加入生词」)
刚存下 / 已 level5无次要出口

新增命令 mark_word_mastered(word)(已有的 mark_entry_masteredentry_id,弹窗只有词)。 判据复用 get_vocab_status 已有的 status(0..5 = mastery level,-1 = 不在生词本),未加查询。 熟词判定要用归一形,故 runSaveDispatch 增加 lemma 入参(取 entries[0].word)。

4.5 save_word 不清 known_words = 无出口的死状态(计划外必修)

熟词重新「+ 加入生词」时:learning_entries 建了、复习队列收了(get_due_cards 不看 known_words),但 get_learning_words_with_cefrNOT IN known_words 把它滤掉 → 该词在任何页面上永不高亮get_vocabulary_list 同时给出 in_notebook=1is_excluded=1 两个自相矛盾的标志。→ save_word 补软删 known_words 一步 (红线 #7 的镜像:那条要求复活软删的 learning_entries,这条要求清掉相反的标记)。

4.6 右键「标为熟词」菜单整块下线(用户裁定)

弹窗出口落地后它成了重复入口,且命中面残缺(只在已高亮词上触发,普通正文词从不弹—— 即用户实测「大部分时候弹不出来」的直接原因)。叠加 4.3 的证据(坏了很久没人报 = 没人用) 与它自带的硬编码配色(isDarkMode + hex 字面量绕开设计系统),判定为负债。 → 删除 highlight.js:166-243 整块 + 无主的 __t import + 两条 i18n key。 删除理由留在原位注释,回退见 git history(该块自包含)。

4.7 其它实施细节

  • context_disambiguation 实际有 5 处读取(计划只列 3 处):另有 supabase.rs:719 (Rust 兜底闸)与 useContentTools.ts:116(短语面板默认态),不同步翻转会出现 「弹窗消歧开着、个性化例句和面板却是关的」。
  • runSaveDispatch 加弹窗身份守卫:resolveSaveState 那次 IPC 期间用户可能已换词, 出口会绑到上一个词——存错词可撤,把错词移出生词本不对称。
  • init_data.sql 一字未动(v2 指纹)。其中两处注释因本次改动过时但不能就地改, 更正写在 migrations.rs 的 v2 条目上(不受 checksum 约束)。
  • 守门 check-schema-frozen.mjs 从单文件扩为三文件白名单(schema.sql / init_data.sql / seed_reference_words.sql)——后两者此前无任何守门,误改会静默过 CI。

4.8 收尾状态(2026-08-12 全部了结)

实机验收 6 项全绿(用户 2026-08-12 逐项确认)。验收项 4 因右键菜单整块下线(§4.6) 而失效,不再适用。

已完成的收尾动作:

  • landing 已部署并公网复验:Vercel Git 集成(推 main 触发),部署 56z7mw22i Ready。 实测 lampio.app/{zh,en}/privacy —— 新文「默认开启 / on by default」命中、 旧文「主动使用 / actively use」消失、LAST_UPDATED 已翻到 2026-08-12。 顺序是对的:政策先上线,客户端尚未经 updater 发出,无窗口期。
  • T5b 阻塞项作废(见 §4.1):quota_config.llm_calls_passive_limit 实测已建列且已配值 60000updated_at 2026-08-02,即 WP3 增补那次 SQL 确实执行过)。 顺带查了实际用量:本月 llm_calls_passive = 1186 units,而该配额是 per-user 的 → 约 50 倍余量,默认开无需调阈值。
  • RVH 两条已持久化到 backlog.md(红线 #9 对称核对 + 隐私政策文案对齐), 均标注「RVH 新会话」。

唯一未决:

  • 已掌握(level5)的词仍然高亮get_learning_words_with_cefr 不按 mastery 过滤。 当前判定为正确默认(实线高亮 = "这是你的词汇",复现即强化;待发现词点线才是提示层)。 用户提议加面板开关,本轮未做——理由:用户手上没有做此判断所需的信息,且真加要动 SQL + 设置项 + content-script 三处并处理与 CEFR 档位开关的叠加语义。留待实际阅读体感 积累后再定「改默认」还是「加开关」。