主题
查词弹窗打磨包 —— 熟词入口 / 义项一致性 / 存词语义澄清
物理仓库:
/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,永远置不了 trueDB 里 auto_save_words = 'true'(实测),但加载器只有关闭分支、没有开启分支 → state.settings.autoSave 恒为 false → resolveSaveState()(popup.js:356) 永不返回 'auto'。
结论:
- 双击查词从来不会自动存词,用户看到的「+ 加入生词」手动按钮才是真实路径 ✅
runSaveDispatch里的kind === 'auto'整个分支 +showSavedBadge的部分调用 = 死代码- 用户提的「去掉自动保存这个选项、默认双击不保存」——已经是现状,本包只做显式化清理
产品侧确认(用户 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 个义项时直接跳过消歧 → 无绿底、无 ✓ 徽标。
这不是纯外观问题——它在复习卡上也漏了:单义词走不到 persistContextSense → record_cloze_sense 从不调用 → word_cloze_contexts.sense_gloss 永远 NULL → 复习卡背面也没有「贴合语境义项」绿底。 实测:58 条 cloze 语境中 23 条 sense_gloss 为空(40%)。
设计:totalSenses === 1 时走一条纯本地分支(零 LLM、零成本、零延迟): ① 直接对那唯一义项调 revealMatchedSense;② 本地构造 {pos, sense_index:1, gloss} 种进 disambigCache → persistContextSense 自然生效,sense_gloss 落库。
⚠️ 诚实性边界(必须遵守):绿底的既有语义是「AI 判定这条义项贴合你的句子」。 单义项时我们只知道"没有别的候选",并未验证这唯一义项是否真的适配(例如该词 在句中被当专有名词用)。因此:颜色统一,但 tooltip / aria-label 文案必须区分 ——如「本词仅一个义项」而非「AI 判定贴合语境」。 这条呼应项目此前否决本地 Lesk 方案的同一条理由:不要制造自信的错误徽标 (见 memory project_word_sense_disambiguation)。
T3 — ✓ 徽标位置移到翻译图标之前
用户观察正确,且根因比"顺序不对"更具体:
revealMatchedSense(popup.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: help(styles.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 |
resolveSaveState 的 if (autoSave) return 'auto' | popup.js:356 |
runSaveDispatch 的 kind === '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, 到存量用户机器上才炸。
因此本项:
- 只删代码侧读取,
init_data.sql一个字都不改——那行残留的种子是无害的死键 (删掉读取后无人再读),留着比断链便宜得多 - 顺手补守门(建议一并做,成本 ~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_config 建 llm_calls_passive_limit 列并配值, 先于默认开的客户端发版。
📌 保留的备选(本轮未采纳,记录以备观察后回头):把合并的开关拆回两个—— 「单词双击消歧」默认开(用户主动双击才触发、一次一句)+「整页短语高亮」保持关 (被动触发、按页批量、成本不可控)。两者本是独立开关,2026-07-15 为减少设置项才合并。 若 T5b 的用量观察显示成本失控,这是第一顺位的回退动作。
2. 明确不做
- ❌ 让「+ 加入生词」等待消歧完成:核查确认现在就不等——
popup.js:673的maybeDisambiguate不返回 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. 验收
- 双击任意词 → 弹窗底部出现两个出口;点「已认识」→ 页面高亮立即消失 + 该词从生词本移除(Library 刷新可见)
- 双击一个单义项词(先在库里挑一个
totalSenses===1的)→ 该义项有绿底 + ✓, tooltip 文案为"仅一个义项"版本;存词后查word_cloze_contexts.sense_gloss非空 - ✓ 徽标紧贴释义文字;hover 义项时翻译图标出现在 ✓ 右侧,✓ 不位移
- 中文界面下右键高亮词 → 菜单文案为中文
- 全新库(或清掉
context_disambiguation键)启动 → AI 语境助手默认开 grep -rn "auto_save\|autoSave" src-tauri/src/content-script/→ 0 命中
质量闸:/arch-check → pnpm 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:820 按 mastery_level >= level4 统计)。用户表达"我学会了",得到的是 "这条学习记录不该存在"。二者在数据层本就该分开落点:
| 场景 | 动作 | 落点 |
|---|---|---|
| 待发现词 / 普通词 | 「不用再提示」 | known_words + 级联软删 |
| 已在生词本 | 「标记已掌握」 | mastery_level = 'level5' |
| 已是熟词 | 静态说明「已设为熟词」 | 无动作(左侧仍可「+ 加入生词」) |
| 刚存下 / 已 level5 | 无次要出口 | — |
新增命令 mark_word_mastered(word)(已有的 mark_entry_mastered 要 entry_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_cefr 的 NOT IN known_words 把它滤掉 → 该词在任何页面上永不高亮;get_vocabulary_list 同时给出 in_notebook=1 与 is_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触发),部署56z7mw22iReady。 实测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实测已建列且已配值 60000(updated_at2026-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 档位开关的叠加语义。留待实际阅读体感 积累后再定「改默认」还是「加开关」。