DSH Plugins Marketplace

DSH Plugins

Plugins

/

dsh-plugin-term-dictionary

l

dsh-plugin-term-dictionary

Manifest valid
UI (client)hasBundlePatch

术语词典(dsh-plugin-term-dictionary)

在 DSH 对话里自动识别专业术语、建立词典条目,并在对话中直接查看解释的插件。

它做什么

  1. 自动收录:agent 回复渲染完成时,插件扫描其中的英文技术词汇、缩写、代码标识符风格 的命名(camelCase、snake_case)以及内置词库里的中文行业术语,为其中置信度最高的 若干条建立词条。
  2. 左侧插件区入口:左侧边栏的插件行出现「术语词典」,点击后中间主区域显示词典面板, 可搜索、按「全部 / 待补充 / 已钉选 / 已删除」四个视图查看,编辑、钉选、删除、撤回删除、 导入 / 导出;面板内另有两个二级页:设置与导入 / 导出。
  3. 选中即建条:在 agent 回复里用鼠标选中一个词或短语,选区旁会出现「添加词条」按钮, 点击后词典面板打开并预填该词条。
  4. 收录即标注:已收录的词在回复中被划出(虚线下划线 + 轻微底色),提示「这个词在词典里」。

三个鼠标手势(对已收录的词)

手势行为
鼠标靠近(指针停在词上 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 说过的一切都搬进词典。自动收录因此有两道门槛:

  1. 必须有术语证据:缩写(DSN、CRDT)或标识符写法(WriteAheadLog、snake_case)。 普通单词、常用词、英文散文都不会自动建条——即使它们看起来「像个词」。
  2. 内置词库已经能解释的,不收录。quorum、idempotent、durability、Kubernetes 这类词插件本来就能解释,再抄一份进你的个人词典只会把面板塞满、把真正生僻的词埋掉。 它们仍然可以点击查看解释(读的是内置词库),也仍然可以用鼠标选中后手动加入。
  3. 你删掉过的词,不再自动收录。删除是一次明确的操作,而「删掉的词」不等于「没见过的词」: 把带释义的 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 对象,再逐字段校验后写入词典。没有可用模型时,界面提示手动填写。

用哪条模型路由(这里踩过一次坑)

选择顺序是三条,顺序本身就是修复:

  1. 插件自己的 config.provider / config.model(写了就用);
  2. profile 自己的默认模型选择(agentDefaultModel.currentSelection())——也就是这个窗口里 agent 正在用的那条路由,因此它一定是本部署里已配置、已授权的那条;
  3. 最后才是「注册表里的第一个 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-fillbluish-1000 近黑bluish-50 近白
主按钮文字--dsw-alias-label-primary-invertedbluish-00 白bluish-800 深
输入框底色--dsw-specific-input-majorbluish-00bluish-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.jsonhost 半侧的权威词典文件(原子写入)
浏览器 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/entriesreplace / 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 文件):

  1. plugin_manager install_bundle(或 dsh plugin --profile desktop add)——装包 + 选中 bundle;

  2. set_bundle / set_plugin 两处都要开:bundle 被选中不等于行被启用。 本插件曾在「bundle 已装、行 enabled: false」的状态下静默什么都不做。

  3. 改过 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.jshost 半侧(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.mdMIT 正文;每个发布出去的版本记一条

为什么要构建

两个约束共同决定了这个形状:

  1. DSH 的客户端模块表交给插件 bundle 的同步 require 只能解析平台种子模块 (react 等)和已注册的包工厂,不能解析同目录文件;require.async 也只按 client.<name>.js 的分块约定取文件。
  2. 本包是 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…