dsh-plugin-term-dictionary
Manifest valid术语词典(dsh-plugin-term-dictionary)
在 DSH 对话里自动识别专业术语、建立词典条目,并在对话中直接查看解释的插件。
它做什么
- 自动收录:agent 回复渲染完成时,插件扫描其中的英文技术词汇、缩写、代码标识符风格
的命名(
camelCase、snake_case)以及内置词库里的中文行业术语,为其中置信度最高的 若干条建立词条。 - 左侧插件区入口:左侧边栏的插件行出现「术语词典」,点击后中间主区域显示词典面板, 可搜索、按「全部 / 待补充 / 已钉选 / 已删除」四个视图查看,编辑、钉选、删除、撤回删除、 导入 / 导出;面板内另有两个二级页:设置与导入 / 导出。
- 选中即建条:在 agent 回复里用鼠标选中一个词或短语,选区旁会出现「添加词条」按钮, 点击后词典面板打开并预填该词条。
- 收录即标注:已收录的词在回复中被划出(虚线下划线 + 轻微底色),提示「这个词在词典里」。
三个鼠标手势(对已收录的词)
| 手势 | 行为 |
|---|---|
| 鼠标靠近(指针停在词上 110ms,设置页可调 0–200ms) | 在指针旁浮出简短解释:术语、中文译名、最多三行的解释,以及一行提示。气泡不接收指针事件,不会挡住下面的文字。 |
| 点击 | 进入词典对应词条:切换到词典面板并把那条词条滚动到视野中,短暂高亮。 |
| 选中词句 | 出现「添加词条」按钮,点击后打开编辑器并预填。 |
判断「这个词在词典里」用的是检测器给出的来源:source === "dictionary" 是你自己的词条,
source === "glossary" 是内置词库。所以:
- 内置词库就能解释的词(
quorum、idempotent)靠近会有解释,但点击弹出解释气泡 而不是进入面板——它没有对应的词条页可进; - 词典里还没有的词,点击弹出气泡并提供「创建词条 / 用模型生成解释」。
术语从哪里来
插件按证据强弱分四层判断,全部在浏览器本地完成,不发网络请求:
| 证据 | 说明 | 置信度 |
|---|---|---|
| 你自己的词条 | data 里的词典条目与别名命中 | 1.00 |
| 内置词库 | lib/lexicon.en.js、lib/lexicon.zh.js,开箱即用 | 0.80 |
| 命名形式 | camelCase / snake_case / ALL_CAPS 等标识符写法 | 0.50–0.55 |
| 构词特征 | -ization、-ology、meta-、poly- 等技术构词前后缀 | 0.45 |
lib/stopwords.js 里的常用词永远不会被单独当作术语,因此正常英文散文不会被误标。
什么时候主动建词条(自动收录的门槛)
重点是对话里真正生僻的那个词,不是把 agent 说过的一切都搬进词典。自动收录因此有两道门槛:
- 必须有术语证据:缩写(
DSN、CRDT)或标识符写法(WriteAheadLog、snake_case)。 普通单词、常用词、英文散文都不会自动建条——即使它们看起来「像个词」。 - 内置词库已经能解释的,不收录。
quorum、idempotent、durability、Kubernetes这类词插件本来就能解释,再抄一份进你的个人词典只会把面板塞满、把真正生僻的词埋掉。 它们仍然可以点击查看解释(读的是内置词库),也仍然可以用鼠标选中后手动加入。 - 你删掉过的词,不再自动收录。删除是一次明确的操作,而「删掉的词」不等于「没见过的词」: 把带释义的 agent 回复重新变成词条,等于让你的删除自己撤销自己。删掉的词只有在 你手动加回来(选中它、或在面板里编辑)时才会回来 —— 自动通道永远不碰它。
实测一段技术叙述的效果:
| 词 | 自动建条? | 为什么 |
|---|---|---|
WriteAheadLog | ✅ | 标识符写法,内置词库没有 |
DSN | ✅ | 缩写 |
quorum / idempotent / durability / Kubernetes | ❌ | 内置词库已能解释(可点击、可手动加) |
the / happy / 普通散文 | ❌ | 常用词表命中 |
所以「生僻就主动建,其他不建,用户也可以拉选添加」这条规则,是门槛 1 + 门槛 2 + 手动通道三者一起实现的。
标注是怎么画上去的(为什么不是包一层 span)
「把已收录的词变成词条块」最直觉的做法是把匹配到的文字包进 <span>。这里没有这么做,
原因是那段 DOM 属于宿主的渲染器:
- React 拥有那棵树,下一次 re-render 会把包进去的节点丢掉,而流式回复一直在 re-render;
- 往虚拟化列表里插节点会干扰它自己的测量。
所以标注走 CSS Custom Highlight API:插件只创建 Range 并把它交给
CSS.highlights(键名 term-dictionary-entry),由样式层着色。宿主 DOM 一个节点都没被动过,
插件卸载时 clear() 掉注册项,什么也不残留。样式规则由 overlay 组件渲染成一个 <style>
元素(::highlight() 只能写在样式表里),颜色只用 --dsw-alias-* 主题令牌。
只标注 source === "dictionary" 的词,也就是你自己词典里的词条。内置词库能解释的词不标注:
它们没有词条页可进,而且给一篇技术文章里每个能查到的词都划线,等于把整篇划满——这和采集器
「别把词典堆肥」是同一条规则用在页面上。
运行时没有这个 API(老版本)时,supported 为 false,标注整层静默降级:不画线、不报错,
靠近解释与点击进词条照常工作。
解释从哪里来
- 内置词库:约 860 条英文技术词与 160 条中文行业词,直接给出中文译名、领域和一句解释。
- 模型生成:点「用模型生成解释」时,host 半侧调用当前 profile 的
llm服务, 要求模型返回一个 JSON 对象,再逐字段校验后写入词典。没有可用模型时,界面提示手动填写。
用哪条模型路由(这里踩过一次坑)
选择顺序是三条,顺序本身就是修复:
- 插件自己的
config.provider/config.model(写了就用); - profile 自己的默认模型选择(
agentDefaultModel.currentSelection())——也就是这个窗口里 agent 正在用的那条路由,因此它一定是本部署里已配置、已授权的那条; - 最后才是「注册表里的第一个 provider + 它广告的第一个模型」。
第 3 条单独用会选错。llm.listProviders() 返回的是注册顺序,本 profile 里官方 API key 路由
(deepseek-official)注册在已登录账号路由(deepseek-account)之前,于是「用模型生成解释」报:
llm-deepseek: no API key for provider route "deepseek-official";
store DEEPSEEK_API_KEY through the credentials service ...
而窗口自己正好好地走 deepseek-account。问部署它自己在用什么,是唯一能确定「哪条路由真能应答」
的办法。选择逻辑由 host: the model route prefers the profile's own selection 测试守住
(含「选择里写的 provider 本部署没注册就回退」「不跨 provider 借模型 id」「选择服务抛异常也能生成」)。
样式只用平台真的定义了的令牌
所有颜色走 --dsw-alias-* / --dsw-specific-*,但名字必须真实存在。踩过的坑:
primaryButton 原本写的是
color: "var(--dsw-alias-label-inverse, #fff)",
background: "var(--dsw-alias-brand-primary, #4d6bfe)",
两个名字里 --dsw-alias-label-inverse 在整套设计平台里根本不存在,于是标签颜色落到
硬编码的 #fff;而暗色主题下 --dsw-alias-brand-primary 解析到
--dsw-static-neutral-bluish-50——近白色,和主题给 --dsw-alias-label-primary 的是同一个值。
结果就是白底白字:保存按钮渲染成一个空白矩形。
现在用平台为这件事定义的那一对令牌(取值均从随包发布的主题 CSS 实测):
| 用途 | 令牌 | 亮色 | 暗色 |
|---|---|---|---|
| 主按钮填充 | --dsw-alias-button-primary-fill | bluish-1000 近黑 | bluish-50 近白 |
| 主按钮文字 | --dsw-alias-label-primary-inverted | bluish-00 白 | bluish-800 深 |
| 输入框底色 | --dsw-specific-input-major | bluish-00 | bluish-850 |
同类问题还有:--dsw-alias-bg-l1(不存在,且 var() 没有回退会让整条声明失效,
输入框因此丢了底色)、--dsw-alias-state-warning-primary(真名是 state-warn-primary)。
标注的下划线也从 brand-primary 换成 label-secondary——暗色下 brand 与正文同色,
等于没画。
审计方式(可复现):从 app.asar 抽出 @deepseek-ai/dsh-client-ui-theme/lib/client.js,
列出全部 --dsw-(alias|specific)-*: 定义共 118 个,再核对插件引用的每一个名字。
一个词条的「上下文」有多大,以及从哪里取
上下文(存进词条、显示在编辑器标题下、弹窗里那一段)统一由 core.contextAround(text, start, end, 240)
产生:向句子边界扩展,但硬性封顶 240 字符。三条路径(自动收录 / 点击 / 选中)都走它,
所以插件里只有一条取上下文的规则。
这里出过一次很严重的错,值得记下来:
- 选中路径原来写的是
readBlock(regionFor(node)),而regionFor返回的是区域的直接子元素。 真实 DOM 里那个直接子元素是装载全部消息的滚动容器,于是readBlock取到的是 整段会话(innerText连工具调用的小标签一起算进去)。 - 编辑器把上下文直接渲染在标题下面,所以「添加词条」一按,面板就被整段会话填满, 下面的字段和「保存」按钮全被顶到看不见——编辑器直接不可用。
- 实测:修复前该测试测得 2434 字符,修复后 ≤ 240 且不含其他句。
- 保险起见,编辑器/弹窗的上下文另有 3 行 clamp:即使将来有历史数据或导入的词条带着超长上下文, 也再不会把面板顶爆。
regionFor 已删除。
代码块不参与收录,也不参与标注
SKIP_SELECTOR(pre, code, a, [contenteditable=true], [data-term-dictionary])过去只用在
点击 / 悬停 / 选中三条路径上,采集器没有用它——于是它扫描了 readRegion 产出的每一个块,
而转录里的代码块和工具输出也是块。结果是一次会话就把 Invoke-WebRequest、StatusCode、
StartTime、data-chat-flow-key 之类 25 个 shell/工具输出来标识符收进了词典,
正是这个插件本该避免的「词典臃肿」。现在 isProseBlock(block) 同时把关:
- 块本身在 skip 容器里 → 跳过;
- 块里所有文本 run 都在 skip 容器里(整块就是代码)→ 跳过;
- 段落在正文里夹了一段行内代码(技术回复的常态)→ 仍然收录。
标注层用同一个判定,所以整块代码不会被划线(行内代码仍会被划——见下一节,那个差别本身就是个 bug)。
测试:collect: a code block contributes no terms 与 collect: prose that merely contains inline code is still collected
(两边都测,才说明这扇门是「是不是代码块」而不是「有没有提到代码」)。
行内代码:标注和交互必须对同一件事表态
用户看到的现象是「有下划线,但悬停没解释、点击不跳词条,什么都做不了」。 根因是标注与命中对行内代码的判断不一致:
行内代码里的词(runInTransaction、elementFromPoint) | |
|---|---|
| 采集 | 收录(它所在的块是正文段落,isProseBlock 通过) |
| 标注 | 划线(同一个块被扫描,range 覆盖到代码 span) |
| 命中测试 | 拒绝(SKIP_SELECTOR 里有 code,isSkipped 直接返回 null) |
于是一篇技术回复里最显眼、读者最想去点的那些词——恰恰是行内代码里的标识符—— 被划了线却完全点不动。这不是「某个函数有 bug」,而是同一件事有两份互相矛盾的定义。
修法是让两边对同一件事表态,但只在「已知词条」这一档放宽:
| 容器 | 已知词条(词典/内置词库) | 未知词(可建条) |
|---|---|---|
| 正文 | 悬停解释 + 点击进词条 | 点击弹「创建词条」 |
| 行内代码 / 代码块 | 悬停解释 + 点击进词条 | 什么都不做 |
| 链接 / 输入区 / 插件自己的 UI | 不介入 | 不介入 |
选择器因此拆成两个:BLOCKED_SELECTOR(链接、编辑器、插件 UI——永不介入)与
DATA_SELECTOR(pre, code——只禁掉建条那条路)。代码里的词仍然进不了词典
(collect: an uncollected identifier inside inline code never offers to create),
但已经收进来的词在任何地方都能读、都能跳(click: a collected term inside inline code is explained, not refused)。
为什么「已经添加的词条」以前不显示为词条块
scan 是按 keys 里的每个 key 去文本里搜来发现命中的,而 keys 只由内置词库填充过。
用户自己的词条只被塞进了 known 这张查找表,从来没有进 keys。于是:
- 一个不在词库里的已收录词(例如
WriteAheadLog)会被报成未知的identifier候选 (known: false),而不是词典命中; - 后果正好是看到的三条:对话里不划线、悬停没有解释、点击时还劝你「创建」一个已经存在的词条;
- 只有恰好也是词库词的词条(
quorum这类)是好的——所以测试全绿:测试用的都是词库词。
修复:用户词条与其别名的 key 一并加入匹配表(仍按长度从长到短排序,短语优先)。
测试 terms: the user's own entry is matched, not merely remembered 守住这一点,
含别名命中与「词库词仍然报 glossary」两侧。
钉选为什么以前取消不掉
mergeEntry 里写的是 pinned: current.pinned || incoming.pinned。OR 只能把钉子加上,永远去不掉:
取消钉选后本地写 false,host 那一份还是 true,合并回 true,而页面采用合并后的文档,
于是每次点击都「弹回去」。
改成「后一次决定胜出」还撞上一个更隐蔽的问题:钉选没有自己的时钟。
合并只在「到达的内容胜出」时才推进 updatedAt,而单纯改 pinned 不改任何解释文本,
所以时间戳根本不会变——即使把 OR 换成比较 updatedAt,取消钉选仍然会被丢掉
(这正是第一次修复时测试报出的 actual: true)。
所以记录里多了一个字段 pinnedAt,仅在调用方明确给出 pinned 时盖章;合并按它比较,
并对时间戳取 max,结果与合并顺序无关(两边都会跑这个函数,必须收敛)。
三个功能开关
面板搜索框下方三个开关,状态就写在按钮上(role="switch" + aria-checked):
| 开关 | 关掉之后 |
|---|---|
| 自动收录 | 不再建词条;且不把消息标记为已读,所以再打开时这条会话仍然会被看到 |
| 划出术语 | 清掉对话里的标注(词条块) |
| 自动解释 | 收录后不再调模型,词条停在「待补充」 |
开关存在自己的 localStorage 键(dsh-plugin-term-dictionary:settings:v1),不进词典文档:
文档会被合并、会往返 host,而偏好只有一个用户、一个页面,没有东西需要对账;
把开关塞进合并文档等于把它拖进墓碑/复活那套机制里。存储不可用时退回默认值,面板照常工作。
自动解释
收录到新词条后,shell 把它们排队交给模型(onCollected → /explain),成功则按与
「用模型生成解释」完全相同的路径写入(source: "llm"),因此享有同样的复活权限。约束是刻意的:
- 同时最多 2 个请求在飞、队列最多 6 条——一次 settle 可能新建好几个词条, 不排队就会堆起用户没要求的请求;
- 一次失败就整个会话停止再试:常见原因是那条路由没有凭据,下一个词条会一模一样地失败。
收录频次的两道闸
| 闸 | 数值 | 挡住的是 |
|---|---|---|
| 每条消息 | 3 | 一条消息里一堆术语时灌满面板 |
| 每分钟(全会话) | 8 | 长会话里「每来一条回复收一个」,即「收录过程太频繁」 |
每分钟预算会过期(窗口 60 秒)。这条测试顺带挖出一个旧 bug:MAX_AUTO_PER_MESSAGE
的守卫用的是整趟 pass 的总数 created,所以名字写着「每条消息」的常量实际上把一趟 settle
卡在 3 条,五条消息的一趟会静默丢掉两条消息的术语。现在按每个块计数。
反馈:一条留在本机,一条带得出去
标记和修正走的是同一次访问:编辑器里就有「反馈」那一块,所以说着"这条解释不对"的人,正看着他要改的那个字段。
词条上的两个字段,两个不同的意思
| 字段 | 意思 | 谁听它的 |
|---|---|---|
feedback: { kind, note } | 你要说的话:类型 + 自己的措辞 | 「已标记」视图与反馈报告 |
untrusted: true | 别再拿这条解释当准 | 自动解释器——它不会再覆盖这条词条 |
两者各有自己的时间戳(feedbackAt / untrustedAt),跟 pinnedAt 同一个理由:它们都不改定义,
updatedAt 带不动它们,而"撤回"必须能跟"没说"区分开——否则另一台机器上那份还带着标记的副本
会在下次同步里把它带回来。撤回标记({ feedback: null, untrusted: false })会把时间戳推到
严格更晚,所以同一毫秒里点两下也不会丢。
质量回路:recordSighting 有一条硬规则——被标为不可信的词条,自动写入不能填它的解释。
规则放在数据层而不是解释队列里,因为每一条自动写入(页面的队列、将来的后台刷新、宿主自己的
record 动作)都从那里过;sighting.overridesUntrusted 是唯一的例外,给"用户按了按钮"的生成用。
解释队列另有一份同样的判断,只是为了不花掉一次注定被丢弃的模型调用。
重新生成时,页面发的是 retry: true(不是一个字符串),宿主据此在提示词里追加一句固定的话——
不把用户自己的措辞发给模型,那是把备注变成指令。
反馈报告(导入 / 导出页):把标记导出成一个文件,可以下载或复制。
{ "kind": "dsh-term-dictionary-feedback", "version": 1, "exportedAt": 0, "count": 1,
"entries": [{ "term": "Quorum", "key": "quorum", "untrusted": true, "untrustedAt": 0,
"kind": "wrong-gloss", "note": "太笼统", "at": 0 }] }
两件事是刻意的:报告里永远没有会话原文——词条记着术语出现的那句话,而那是私人对话的一部分; 这不是一个可以勾选的选项,是构造上就没有这个字段。当前解释要另外勾选才附上(默认不附),因为 它是模型写的、往往正是被抱怨的东西,但它仍然不是用户的话。
另外还有一条走的不是这个页面:宿主自己的反馈通道。它记在这台机器的会话日志里、不进模型上下文,所以它到不了作者手里——内容级反馈必须靠报告带出去。这条通道是宿主服务(sessionFeedback),不是客户端的 remote:页面的 remote.sessionFeedback 需要写进客户端 inject,而一个永远不来的服务会把整个包挂起(面板、下划线、所有手势一起消失)。实测这台机器的桌面端根本没有 remote 服务,所以那样写就等于在自己写代码的机器上把插件杀死。宿主半边用的是运行时给出的可选写法——ctx.inject(["sessionFeedback"], …),和 llm、agentDefaultModel 同一个模式:服务在就接上,不在就由页面如实报告"这个组合没有反馈通道"(报告照样能导出)。
POST /dsh-term-dictionary/feedback { sessionId, text, category }
→ { ok: true }
→ { ok: false, error: "unavailable" } 这个组合没有反馈通道
→ { ok: false, error: "session-not-found" } 宿主已经没有这个会话了
页面对这几种结果分别给不同的话,不合并成一句"失败了":unavailable 是"这个版本做不到",session-not-found 是"那个会话没了",no-session 是"还没看到过会话,先回对话里点一下"。
面板的五个视图与两个二级页
标题栏右侧依次是「设置」「导入 / 导出」、新建词条、批量选择;下面是搜索框与四个视图标签。
| 视图 / 页面 | 内容 |
|---|---|
| 全部 / 待补充 / 已钉选 | 同一份词条列表的三种看法:全部、还没有解释的、你钉选的。 |
| 已标记 | 你标过的词条:写了什么问题、有没有判为不可信,一行一个「撤回标记」。见「反馈」一节。 |
| 已删除 | 删掉的词条(墓碑),可以撤回。见下一节。 |
| 设置(二级页) | 11 项偏好,每项一行:名字(不随状态变化)+ 它做什么 + 控件。 |
| 导入 / 导出(二级页) | 按全部或按分类导出成文件;从文件导入;导出反馈报告。见「导入 / 导出」一节。 |
二级页开在面板内部而不是弹窗里:面板占着主区域,弹窗会盖住它正在处理的东西。
复制词条是按钮,不是"选中再按 Ctrl+C"
每一行有一个复制图标,编辑器里也有一个复制词条按钮:一次点击就把词条按可读文本写进剪贴板。
Quorum(多数派确认)
写入需要多少个副本确认。
例:reached quorum
别名与分类不进这段文本——那是用来在面板里检索的元数据,不是往对话里粘的东西。
为什么不用右键菜单:右键需要宿主提供 context menu 扩展点(它没有),而且不可发现、不可键盘操作、和浏览器自带菜单抢位置。一个按钮解决了同一件事,还多一个好处——不需要选中。
顺带修掉的两个同类 bug:整行的点击会打开编辑器、标记词的点击会切进词典,而结束一次选中的那个 click 也是 click——所以"选中一句话想复制"(选区正好结束在标记词上)会被换成词条页,"双击词条里的词想复制"会被换成编辑器,选中的文字随之消失。现在的规则统一在一处(lib/core/selection.js):
- 有非空选区(去掉空白后)→ 这一下算选中手势,不打开编辑器、不切面板、不弹未收录词的卡片;
- 空白选区、折叠成光标的选区 → 仍然算普通点击(拖过句尾的空格不该吃掉激活);
- 面板那一路还多一层:选区必须在那一行里才拦(刚选中一段回复再点这一行,仍然是点这一行)。
三条路(面板行、标记词、未收录词)现在问的是同一个函数,所以这类"补了一条路、漏了另一条"的差异不会再发生——interact.js 的未收录词那条从写下起就有这个守卫,而 hover.js 的标记词那条是后来搬进来的,漏了,你踩到的就是它。
控件形状由值的形状决定
| 值的形状 | 控件 | 出现在哪些偏好上 |
|---|---|---|
| 两态 | 开关(role="switch") | 自动收录、划出术语、自动解释、参考段落、收代码术语、收中文术语 |
| 三个以上固定选项 | 选项条(role="radiogroup",所有取值都摆在上面) | 解释语言、详细程度、最短词长 |
| 一个范围内的数 | 滑杆 + 数字框(两者绑同一个值) | 悬停读入延迟、悬停读出延迟(0–200ms) |
三件事是刻意的:
- 行的名字不随状态变化。早先每个开关是一枚 chip,标签自己带状态(「收代码术语」/「不收代码术语」), 于是一行里再也说不出「这一项是干什么的」——名字会随着你点它而改变。现在状态在控件上,名字恒定。
- 多值的用选项条,不用循环 chip。循环 chip 只能告诉你当前值,要选别的值得盲点若干次; 选项条把全部取值摆出来,一次点到。
- 数值用滑杆 + 数字框。滑杆管「差不多」,数字框用来精确输入同一个值,两者都夹在 0–200ms。 默认读入 110ms、读出 0ms:读出延迟一旦不为 0,指针离开后卡片还挂着,读起来像卡顿而不是从容。
批量选择与删除
面板标题右侧的方框图标进入选择模式:每行变复选框,工具条给出已选数量、全选/取消全选、删除所选。
删除走 store.deleteEntries(ids) 一次写入,而不是循环单条删除:每次删除都会持久化整份文档
并推给 host,20 行一条一条删就是 20 次往返。每条仍然各留一个墓碑——否则 host 那份副本会把它们全部搬回来。
已删除:黑名单要看得见,也要撤得回
「已删除」是第四个视图,列出墓碑(词条删掉后留下的记录),按删除时间倒序,每行给出 「删除于 …」和一个「撤回」按钮。
为什么需要一个视图,而不是把删除做成彻底的抹除:
- 词典同时是黑名单——删掉的词不会被自动重新收录,导入默认也跳过它——而一份读不到的黑名单
就是一份改不了的黑名单。删错一个词(或点错一行)此前没有任何补救路径:墓碑在
backing里, 对所有普通读者不可见,listEntries永远不会返回它,面板自然也就列不出来。 - 所以删除视图不是「第四个筛选器」,而是另一份列表:
store.list(query, "deleted")走dictionary.listDeleted(state, query),读的是backing——对entries做任何谓词都不可能显示一条墓碑。 - 撤回是
store.reviveEntry(id)→dictionary.reviveEntry。它只把墓碑变回活词条,不接受任何补丁, 所以词条按原样回来,而不是按编辑器里碰巧有的内容回来。撤回后那一行从「已删除」消失、回到词条列表, 并弹一条提示说明它去了哪:行消失是唯一的其他迹象,没有提示就等于「点了一下,行没了」。 - 撤回必须满足合并的那条规则(用户要过的记录、且严格晚于删除,见下一节):
reviveEntry把source记成user、时间戳取max(now, 墓碑时间 + 1),并把墓碑从backing里摘掉。三条缺一条, 按钮看起来有反应,下次同步又没了——这正是它需要单测而不能只靠手点的原因。 - 删除视图里没有批量选择:选择模式的复选框只挂在活词条行上,对墓碑没有意义。
- 删除后
clearAll也看不见它(墓碑不算「还有的词条」),但已删除视图能:清空词典之后 想找回某一条,路仍然在。
导入 / 导出
面板标题栏的「导入 / 导出」进入二级页。
- 导出:范围是全部或按分类(分类就是词条的
domain;清单由domainsIn从数据里统计, 按条数从多到少)。一个分类都不选 = 导出空文件,而不是「那就全导」——不选是一个表态。 - 文件是
{kind:"dsh-term-dictionary", version:1, exportedAt, scope, domains, entries}。每条只带走 可移植的部分:术语、解释、领域、别名,以及pinned(那是用户的判断);不带seen/lastSeenAt/createdAt——那是本机的历史,带过去等于替这个词声称它在这里出现过。 - 导入接受本插件写的信封、裸数组,以及只有
entries的对象。逐条判定归宿,并报告各自条数: 「导入 12 条」和「导入 12 条、跳过 3 条」是两个不同的事实,只有后者是真的。
| 归宿 | 处理 |
|---|---|
| 词典里没有 | 建条 |
| 词典里已有 | 原样保留:本机的解释可能被编辑过、钉选过,或者就是比文件里的好 |
| 被用户删过 | 默认跳过(墓碑就是用户的决定,一个文件不是推翻它的理由)。勾选「导入我删除过的词条」才导入,并从已删除列表里撤回 |
- 导入不读文件里的
source:导入的词条由保存路径记成user,那是唯一被合并当作「意图」的来源。 一个声称source: "auto"的文件会产出下次同步就被静默丢掉的词条。 - 「已存在」按
key判定,文件内部重复的键也只算一条(与 store 的合并规则一致)。
悬停与点击:两条只有真实浏览器才会走到的路径
有下划线、但悬停不出解释、点击不跳词条——两个原因都在 termAtPoint,而且都在
真实页面才会执行、测试夹具从来不覆盖的分支里(夹具的假 DOM 没有 elementFromPoint,
每个块也只有单个文本节点)。
一、指针覆盖在消息之上。 原判断要求指针下最上层的元素必须是该块的子孙:
if (!containsNode(hit.block.element, over)) return null; // 宿主在消息上画悬停工具条 → 直接返回 null
宿主会在消息之上画悬停工具条、吸顶行、选择层,于是那个元素是块的兄弟而不是子孙, 这一条就把该消息里的每一次悬停和点击都判成「什么都没点到」。它真正想问的问题 (指针是不是在转录上)由紧随其后的 caret 检查精确回答。现在只在指针被区域之外的东西 盖住时(例如插件自己的气泡)才拒绝。
二、精确偏移被丢掉,换成了几何估算。 caret 给出的字符偏移来自 textRuns 的走查,
而被扫描的文本是块的 innerText(block.text):
const live = collapse(runs.text), stale = collapse(blockText);
const offset = exact === null || live !== stale ? hit.offset : exact; // 两者不等就丢掉 exact
单个文本节点时两者必然相等——所以测试里永远相等;真实回复里有嵌套元素与不可见子节点,
两者合法地不同,于是代码丢掉 caret 的精确偏移,改用 hit.offset:由指针到块左边缘的
距离按比例估算字符位置。这对多行段落不是「不够准」,而是系统性错误——
12 行里第 5 行、横向 20% 处的点,其实是全文约 40% 的位置,不是 20%。落点因此跑到无关的词上:
悬停找不到术语,点击要么没反应,要么劝你创建一个词典里已有的词。
修法是消除分歧而不是绕开它:caret 有精确偏移时,就扫描 runs.text 并使用 runs.text 的偏移,
两者来自同一次走查。block.text 只在 caret API 真的帮不上忙(caret 所在节点没有对应的 run)时兜底。
两条都有回归测试,且用变异验证过:把任一条改回旧行为,对应测试立刻失败。
现状(2026-10-09 两个插件合并之后)
上面两条是历史,记的是当时怎么修的。合并之后指针层搬进了 hover.js,悬停不再走几何:
matchAt 直接在标注层留下的 (节点, 起点, 终点) 区间表里查 caret 的 (节点, 偏移),既不读包围盒,
也不拼整条消息的文本。所以这一节里的 termAtPoint / hit.offset / block.text:
悬停半边已无对应物,点击半边仍然在 interact.js 里(blockAtPoint → elementFromPoint 复核
→ offsetFromCaret 优先取 caret 精确偏移),它服务的是「词典里没有这个词」的弹窗与选区入口,
而词典里有的词的点击由 hover.js 的 onActivate 直接接管。
数据落在哪
| 位置 | 内容 |
|---|---|
$DSH_HOME/dsh-plugin-term-dictionary/dictionary.json | host 半侧的权威词典文件(原子写入) |
浏览器 localStorage | 页面副本,host 不可用时依然可用 |
host 路由 /dsh-term-dictionary/* | 页面与 host 之间的读写通道 |
浏览器先写本地副本,再异步推给 host;host 按 term 合并并把结果回给页面,两边因此收敛。 用户自己写的解释永远不会被自动收录或模型结果覆盖。
$DSH_HOME 不可写时(受保护的安装目录、沙箱进程等),host 会依次尝试候选目录并选用第一个
可写的,面板底部会显示实际使用的路径;全都不行时面板会明确提示「词典无法写入磁盘」,
而不是静默丢弃每次保存。
删除是怎么同步的
合并天然只能做并集,所以「删除」如果只从文档里抹掉一条记录,下次合并就会把另一侧的旧副本 搬回来。文档因此分成三个字段:
| 字段 | 内容 | 谁能看到 |
|---|---|---|
entries | 只有活词条 | 所有读者(面板、弹窗、检测器、导出、HTTP) |
deletedKeys | 已删除术语的键 | 合并(以及需要知道「这个词被删过」的调用方) |
backing | 墓碑本体(含删除时间) | 只有合并 |
规则(删除和复活都由 dictionary.js 的 mergeRecords 一个函数裁决):
- 裁决比较两件事:删除时间,以及用户真正要过这份内容的时间。
- 只有用户要过的内容能复活词条。这就是为什么光比时间不够:一个「不知道这个词被删过」的
窗口会自动收录它并盖上「现在」的时间戳,而「现在」永远比删除新——如果只比时间,插件的
自动检测就能撤销用户的删除。授权复活的是来源(
source,本来就在网线上、上盘,不会像 临时标记一样丢):user(用户在编辑器里写的)和llm(用户让模型解释的)。glossary/heuristic/auto都只是插件自己注意到了这个词,不能复活任何东西。 - 重新观察(sighting)永远不复活:它不推进
updatedAt;对已删除术语的观察直接 不写入(连计数都不加),因为墓碑本身就是那道闸。 - 编辑器里的保存一定复活:用户正在为这个词条输入解释。复活时间戳取
max(now, 墓碑时间 + 1)——删除那台机器时钟走得快时,否则用户的文字会在下次同步被丢掉。 复活必须「严格更新」才能站得住:临时标记过不了序列化。 - 删除时间戳取
max(now, 内容时间 + 1):一次删除必须严格新于它删掉的内容,否则一台时钟 落后的客户端会出现「点了删除却没反应」。 - 同一时刻算删除(不是「不早于」——严格更新才复活)。
observed这个临时标记不上盘、不上网:它只表示「这次到达是一次观察,计一次数」,serializeState会剥掉它,否则每次加载都会重复计数。- 只有键、没有时间的通知(
deletedKeys)不能压过一条整理过的词条:「整理过」= 用户写过释义, 所以自动收录(哪怕释义来自内置词库)仍然可以被它删掉。 - 文件名保持不变,墓碑随文件一起保存,所以删除能跨重启。
路由
| 方法 | 路径 | 作用 |
|---|---|---|
| GET | /dsh-term-dictionary/state | 读取整份词典(含数据目录与模型可用性) |
| POST | /dsh-term-dictionary/entries | replace / record / update / remove |
| POST | /dsh-term-dictionary/explain | 调用模型生成一条解释并入库 |
配置
cordis.patch.yml 的 config 三项都可留空:
- insert:
- id: term-dictionary
name: 'dsh-plugin-term-dictionary'
config:
provider: deepseek-account # 留空则用 profile 的默认模型路由(agentDefaultModel)
model: deepseek-flash # 留空则用该路由自己的默认模型
dataDir: '' # 留空则用 $DSH_HOME/dsh-plugin-term-dictionary
本插件刻意不导出 Config。 Cordis 用
runtime.Config["~standard"].validate(config) 校验行配置,也就是要求一个 Standard Schema;
而 @deepseek-ai/schemastery(DSH 自己声明 schema 用的库)是宿主包,从 link 进 profile 的
插件里解析不到——本机已安装的第三方插件没有一个引用它。
这里曾经导出过一份 JSON Schema,后果不是「配置不校验」,而是整个插件无法激活:
TypeError: Cannot read properties of undefined (reading 'validate')
at resolveConfig (.../cordis/lib/index.js:958:45)
看起来像插件坏了,实际是 schema 形状不对。不导出 Config 时 resolveConfig 原样返回行配置,
而上面三个字段在 lib/index.js 里都被防御性读取(类型不对就用回退值),所以空配置、错类型、
缺字段都不会让插件挂掉。代价是插件管理器里没有这张配置表单,改配置要写 cordis.patch.yml。
测试 manifest: the host half exports no activation-blocking Config 守住了这一点。
安装与启用
从插件市场装(推荐):设置 → 插件市场 → 搜「术语词典」→ 一键安装。也可以直接装包:
dsh plugin --profile <你的 profile> add dsh-plugin-term-dictionary
装完刷新一次页面即可(host 半边与浏览器半边都会在运行中的进程里生效)。包不带任何 npm 依赖, 也不需要执行构建脚本。
开发时:link: 安装与何时重启
下面这些是改代码时才需要知道的。包已用 link: 装进 desktop profile。启用要走插件管理器(不要手改 profile 文件):
-
plugin_managerinstall_bundle(或dsh plugin --profile desktop add)——装包 + 选中 bundle; -
set_bundle/set_plugin两处都要开:bundle 被选中不等于行被启用。 本插件曾在「bundle 已装、行enabled: false」的状态下静默什么都不做。 -
改过
lib/index.js之后要重启应用:宿主已把上一代模块留在进程里, 重新 enable 不会重新 import。这一点实测过两次(去掉Config、修模型路由), 两次set_plugin都返回applied,但跑的仍是旧代码——POST /explain报的还是修复前的错。 平台行为:替换已安装的包需要重启才能加载新的 JS 模块代。 重启后C:\Users\user\.dsh\profiles\desktop\package.json的dsh.profile.bundles与cordis.patch.yml里的term-dictionary: disabled: false会让它自动激活。浏览器半边通常不用重启:
lib/client.js是按产物 stat 重算 rev 后由 host 提供的, 重新构建后 host 立刻就在供新字节(node tools/verify-plugin-row.mjs dsh-plugin-term-dictionary <新特征串>能证明:实测 rev 从16ff8ae5c296变为2d9aa8179993,取回的正文与磁盘产物逐字节相同, 只差 host 追加的一行sourceMappingURL)。已经打开的页面是否换到新字节,取决于它启动时拿到的那份 启动图:刷新一次即可;若刷新后仍是旧行为,说明启动图还指着旧 rev,重启一次就对了。 所以 改样式/交互 → 先刷新;改 host 半边 → 重启应用。
怎么确认真的活着(两种都实测过):
# host 半边:路由是否在
(Invoke-WebRequest http://127.0.0.1:19387/dsh-term-dictionary/state -UseBasicParsing).StatusCode # 期望 200
# 浏览器半边:host 是否在提供本插件的 client 模块(rev 由产物 stat 重算)
node tools/verify-plugin-row.mjs dsh-plugin-term-dictionary
目录结构
| 路径 | 作用 |
|---|---|
lib/index.js | host 半侧(ESM):词典文件、HTTP 路由、可选的模型调用 |
lib/client.js | 构建产物,浏览器半侧(由 tools/build-client.mjs 生成,不要手改) |
lib/client.template.js | 浏览器 bundle 的外壳模板 |
lib/core/shell.js | 浏览器半侧的装配:store、检测器、交互层、标注层、三个 slot |
lib/core/core.js | 分词、术语形状判断、上下文截取(无环境依赖) |
lib/core/terms.js | 术语检测器 |
lib/core/entries.js | 词条模型与合并规则 |
lib/core/dictionary.js | 词典文档操作与两种存储后端 |
lib/core/store.js | 页面侧可订阅状态与持久化编排 |
lib/core/settings.js | 三个功能开关(独立 localStorage 键,不进合并文档) |
lib/core/interact.js | 对话区交互:自动收录、点击命中、悬停解释、选中建条 |
lib/core/highlight.js | 标注层:把已收录的词算成 Range 并交给 CSS Custom Highlight |
lib/core/bus.js | 会话区 → 面板的单槽命令总线(建条 / 定位词条) |
lib/core/views.js · overlay.js · styles.js | 面板、气泡、样式 |
lib/core/lexicon.*.js · stopwords.js | 内置数据 |
lib/core/package.json | 把 lib/core/ 标记为 CommonJS(不是包) |
tools/ | 构建、用例、检查、变异 harness、宿主探针(tools/paths.mjs 按 dsh.bundle 向上定位本包,所以 tools/ 放在包里还是包外都能跑) |
LICENSE · CHANGELOG.md | MIT 正文;每个发布出去的版本记一条 |
为什么要构建
两个约束共同决定了这个形状:
- DSH 的客户端模块表交给插件 bundle 的同步
require只能解析平台种子模块 (react等)和已注册的包工厂,不能解析同目录文件;require.async也只按client.<name>.js的分块约定取文件。 - 本包是 ESM,但浏览器 bundle 需要可调用的
module.exports。
因此可测试的 CommonJS 模块放在 lib/core/(有自己的 "type": "commonjs" 作用域标记),
由 tools/build-client.mjs 在构建时内联进唯一的 lib/client.js,并生成一个本地模块注册表
让源码里的 require("./x.js") 原样可用。lib/index.js 保持 ESM,通过 createRequire
读取同一份核心逻辑,两半因此共用完全一致的合并规则。
node tools/build-client.mjs # 重新生成 lib/client.js
node tools/build-client.mjs --check # 校验产物是否最新
node tools/test-term-dictionary.mjs # 核心、词典、检测器、settings、bundle、清单、命令总线、主题令牌、模型路由、导入导出页(91 项)
node tools/test-interaction.mjs # 点击 / 悬停 / 选中 / 行内代码 / 自动收录 / 代码块 / 开关 / 预算 / 命中阶段 / 标注(55 项)
node tools/test-host-routes.mjs # host 半边:路由、状态序列化、解释请求的形状(15 项)
node tools/check-pointer-guards.mjs # 19 个变异:把每条指针链的断言逐条改坏,看对应守卫是否真的变红
node tools/check-revive-guards.mjs # 16 个变异:撤回 / 已删除视图 / 导入选项 / 控制台报告那几组守卫
node tools/check-display-meta.mjs # 宿主读到的名称/简介/图标(含 `exports` 与 `en` 回退层)
node tools/check-release-ready.mjs # 能不能发布:清单字段 + 真的打包 + 装进干净目录再 import
node tools/verify-plugin-row.mjs dsh-plugin-term-dictionary "术语词典" # 运行中的 host 是否在提供本插件的 client 行
这些命令都在本仓库根目录下跑(也就是插件包目录)。如果你是从上一层的开发工作区敲,
路径加前缀即可:node dsh-plugin-term-dictionary/tools/test-term-dictionary.mjs。
两个 check-*-guards.mjs 都在 .tmp/<名字>/ 里各放一份整树副本,只在副本里改坏、重建、跑相应的用例,
最后比对源码校验和——源码不动,退出码非 0 就说明有变异没咬动。两者都把结果分三类:BIT/咬动
(点名的守卫真的红了)、STALE(点名的检查已经不存在了,说明变异清单该重定向或删除这一条)、
ESCAPED(检查还在但没咬动,那才是插件的问题)。变异条目对不上代码时不要顺手删:先看那句
代码的行为还在不在(合并通常只是换了文件),确实随功能消失的就在原处留一条 RETIRED Mn 注释。
verify-plugin-row.mjs 按宿主自己的算法重算 row rev(sha1("plugin-artifact" + NUL + framed(mtimeMs, ctimeMs, size))),再把它取回来;取得到就说明这条 row 已经在运行中的
host 的 client 图里,而不是「磁盘上有文件」。实测它复现了 dshmarket 的 rev
bb4970215ee3,与本机安装记录一致。
还没做的(本次范围之外)
按「先做词典和手势功能」的分期,下面这一项尚未实现:
- 词典分享市场:尚未开始。
已经可用的:搜索、四个视图(含已删除与撤回)、钉选、编辑、单条与批量删除、
设置页的 11 项偏好、导入 / 导出(含「按分类导出」与「导入我删除过的词条」)、
面板底部的「清空词典」(跳过钉选的)与「复制 JSON」、用模型生成解释。
已知限制
- 点击与悬停的命中都是同一次走查的查表:caret 给出
(节点, 偏移),标注层把每段标记的(节点, 起点, 终点)记在segments()里,命中就是在这个区间表里查一次,不做几何估算, 也不拼整条消息的文本。仍然存在的限制是时序而不是精度:标注要等宿主渲染完(DOM 变化后 约 700ms 的 settle 节流)才画上去,指针在那之前到达同一个词是命不中的;另外它依赖document.caretRangeFromPoint/caretPositionFromPoint,两者都没有时命中层不工作。 - 悬停读入延迟默认 110ms(
hover.js的HOVER_DELAY_MS,设置页可调 0–200ms)。这是刻意的: 跟着每个pointermove出气泡会在鼠标移动时闪个不停。停留在同一个词上不会重复弹。 读出延迟默认 0:给「离开」加延迟读起来像卡顿,所以它默认关闭,需要时才在设置页打开。 - 标注只在宿主渲染完文本后生效,且在滚动或 DOM 变化时重新计算(沿用采集器那套 700ms 的 settle 节流),所以流式回复进行中标注会滞后一点,稳定后补齐。
- 术语只在渲染后的对话文本上识别,代码块、链接、输入区(composer)内的文本会被跳过。
- 消息块的划分是结构式的(下钻到「文本直接落在自己文本节点上」的元素),没有可依赖的 逐消息属性。这会在布局异常时过度切分而不是漏切——每条消息仍是一个块,但极端布局下 一条消息可能被切成多块。方向是刻意选择的:切多了每块的语义和指针几何仍然成立, 而把整份会话当成一块则不成立。
- 自动收录每条消息最多新建 3 个词条,避免一次刷屏。
- 未收录的词汇只有「看起来像术语」时才会弹出建条入口(命名形式、构词特征、缩写,或 中文 2–8 字词);在普通英文词、以及句首的 The/We 之类上点击不会弹窗。
- 模型解释依赖 profile 里挂载了
llm服务和至少一个 provider adapter;没有时 「用模型生成解释」会明确报告不可用,其余功能不受影响。 - 配色只在「令牌是否真实存在」这一层被验证过,没有在真实页面里用眼睛确认。 令牌审计(见上文)
能证明每个名字都被主题定义、以及亮/暗两套取值分别是什么,但「看起来对不对」需要在页面里看。
标注的
::highlight()与主按钮的填充色都属于这一类:单测覆盖了 range 计算与令牌正确性, 观感要靠刷新页面确认。 - host 半侧路由没有鉴权。 见下一条。
- 复活词条的授权来自
source字段,而source是发送方自己声明的。 这是设计选择, 不是疏漏:插件要靠某个已经上盘、已经过网线的字段区分「用户要的内容」和「插件的自动检测」, 临时标记过不了序列化,时间戳又表达不了意图。代价是任何能写/dsh-term-dictionary/entries的进程都可以声称source: "user"来复活一个已删除的词条。该路由只监听本机回环地址, 且不经过任何鉴权中间件——如果将来要多用户或远程访问,这一条必须先收紧。 - 一个 payload 如果让某条记录的
deletedAt和它所在的通道(entries/backing)自相矛盾, 插件按通道解释它:backing里的记录一律当墓碑。这样「把活记录塞进墓碑通道」无法绕过删除规则, 代价是一个畸形 payload 可以借此删掉一个正常词条(同一件事的两面)。
Comments
Loading…