Skip to content
dsh.fish
Bundle

dsh-plugin-term-dictionary

术语词典:自动识别对话中的生僻术语并建立词条,左侧插件区提供词典面板。已收录的词在回复中被划出,鼠标靠近显示简短解释,点击进入词典对应词条;选中词句可一键添加词条。

Source
lakerian
License
MIT
Updated
Updated 6 days ago

Readme

# 术语词典(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 条英文技术词与 240 条中文行业词,直接给出中文译名、领域和一句解释。
- **模型生成**:点「用模型生成解释」时,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` 原本写的是

```js
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, [data-lexical-editor], [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`(不是一个字符串),宿主据此在提示词里**追加一句固定的话**——
不把用户自己的措辞发给模型,那是把备注变成指令。

**反馈报告**(导入 / 导出页):把标记导出成一个文件,可以下载或复制。

```json
{ "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` 是"还没看到过会话,先回对话里点一下"。

## 面板的五个视图与三个二级页

标题栏右侧依次是「设置」「导入 / 导出」「词条包」、新建词条、批量选择;下面是搜索框与五个视图标签。

| 视图 / 页面 | 内容 |
|---|---|
| **全部 / 待补充 / 已钉选** | 同一份词条列表的三种看法:全部、还没有解释的、你钉选的。 |
| **已标记** | 你标过的词条:写了什么问题、有没有判为不可信,一行一个「撤回标记」。见「反馈」一节。 |
| **已删除** | 删掉的词条(墓碑),可以撤回。见下一节。 |
| **分组**(组行) | 词条可以放进分组;组是列表里的一行,点进去是一层同样的词条界面(含搜索与五个标签),
顶部有面包屑回去;组行上有「改名 / 移动」与「删除」。见「词条分组」一节。 |
| **设置**(二级页) | 13 项偏好,每项一行:名字(不随状态变化)+ 它做什么 + 控件。 |
| **导入 / 导出**(二级页) | 按全部或按分类导出成文件;从文件导入;导出反馈报告。见「导入 / 导出」一节。 |

二级页开在面板内部而不是弹窗里:面板占着主区域,弹窗会盖住它正在处理的东西。

### 复制词条是按钮,不是"选中再按 Ctrl+C"

每一行有一个**复制**图标,编辑器里也有一个**复制词条**按钮:一次点击就把词条按可读文本写进剪贴板。

```
Quorum(多数派确认)
写入需要多少个副本确认。
例:reached quorum
```

别名与分类不进这段文本——那是用来在面板里检索的元数据,不是往对话里粘的东西。

**为什么不用右键菜单**:右键需要宿主提供 context menu 扩展点(它没有),而且不可发现、不可键盘操作、和浏览器自带菜单抢位置。一个按钮解决了同一件事,还多一个好处——**不需要选中**。

**顺带修掉的两个同类 bug**:整行的点击会打开编辑器、标记词的点击会切进词典,而**结束一次选中的那个 click 也是 click**——所以"选中一句话想复制"(选区正好结束在标记词上)会被换成词条页,"双击词条里的词想复制"会被换成编辑器,选中的文字随之消失。现在的规则统一在一处(`lib/core/selection.js`):

- 有非空选区(去掉空白后)→ **这一下算选中手势**,不打开编辑器、不切面板、不弹未收录词的卡片;
- 空白选区、折叠成光标的选区 → 仍然算普通点击(拖过句尾的空格不该吃掉激活);
- 面板那一路还多一层:选区必须**在那一行里**才拦(刚选中一段回复再点这一行,仍然是点这一行)。

三条路(面板行、标记词、未收录词)现在问的是同一个函数,所以这类"补了一条路、漏了另一条"的差异不会再发生——`interact.js` 的未收录词那条从写下起就有这个守卫,而 `hover.js` 的标记词那条是后来搬进来的,**漏了**,你踩到的就是它。

## 词条包与分享码

一个**词条包(pack)**是一份可以递给别人的词典:某个领域的术语表、一个团队的黑话、某类项目的词汇。

**包不是导出文件**。导出是"这台机器的一份快照",包里只有读的人需要的东西——这条界线是构造出来的,不是靠某处判断:

| 字段 | 会不会进包 | 为什么 |
|---|---|---|
| `term` `key` `definition.{zh,gloss,usage}` `domain` `aliases` | ✅ | 用这个词需要的内容 |
| `context`(术语出现的那句话) | ❌ | 那是一段私人对话 |
| `notes`(你自己写的备注) | ❌ | 私人笔记 |
| `feedback` / `untrusted` | ❌ | 别人的判断当成你的判断,比没有判断更糟;而且合并会把它们当"决定"处理 |
| `pinned` `source` `seen` `lastSeenAt` `createdAt` | ❌ | 本机偏好与历史;包自己钉选自己的词条会打乱读者的列表 |
| 墓碑(已删除的词条) | ❌ | 发布别人的删除记录 |
| 包自己的 `id/name/description/author/license/homepage/build/createdAt/scope/count` | ✅ | 包自己的元信息 |

用例里断言的是**允许键的精确集合**(`["aliases","definition","domain","key","term"]`),不是"这些字段不在"——以后加字段必须是一次有意的决定,而不是测试忘了禁。

**分享码**是把包压成一个可粘贴的字符串:`dshpack1:<base64url(raw-deflate(pack JSON))>`。

- 编码/解码只在**宿主半边**(`lib/pack-code.js`,`node:zlib` + `node:crypto`)——页面两样都没有,而在浏览器里再写一份 `CompressionStream` 实现就是第二份要同步的代码。页面通过自己的路由请宿主代劳。
- 解码先去掉所有空白:码很长,凡是它真正走过的地方(聊天窗口、终端、编辑器)都会把它折行。
- 失败分得很细,因为不同的失败要去不同的地方查:`not-a-code`(这压根不是分享码)、`corrupt`(粘贴丢了字符)、`too-large`(解出来的东西超过 4 MiB 上限)。
- **解压有上限**:deflate 能把几 KB 压成几字节,没有上限的话,陌生人粘来的一串字就是对粘贴它的应用的内存攻击。

### 源(index)

没有服务器。一个「源」就是**别人托管的一个静态 JSON**,页面从它列出可用的包:

```json
{ "kind": "dsh-term-dictionary-index", "version": 1, "updatedAt": 0, "packs": [
  { "id": "lakerian/backend", "name": "后端黑话", "description": "…", "author": "lakerian",
    "license": "CC-BY-4.0", "domains": ["分布式"], "count": 128, "sha256": "…", "url": "https://…/backend.json" } ] }
```

三条规则,都在宿主半边:

1. **只取 https**(`refuseUrl`):`http://`、`file://`、`data:`、URL 里带账号密码,一律在**发起任何请求之前**拒绝。规则检查的是 URL **字符串**,所以缓存里的副本和测试注入的传输方式都绕不过它。
2. **有磁盘缓存**:索引 1 小时、包 24 小时(写在插件的 dataDir,`sources.json`)。源暂时连不上而缓存还在,就**返回上次的内容并标 `stale`**——"昨天那份列表"比一句报错有用。索引里的行不可用就**丢掉并计数**(`dropped`),一行坏数据不该让另外四十个包消失。
3. **校验和**:索引里带 `sha256`,取到包之后核对;不符就拒绝、并把实际摘要报出来,让"不符"可诊断。校验和覆盖的是**下载到的那些字节**,所以不需要规范化 JSON。

推荐用 jsDelivr 托管(`https://cdn.jsdelivr.net/gh/<owner>/<repo>@<tag>/index.json`):它是静态、可缓存、对国内网络也通的那条路。

**新装自带一个源**:本仓库的 `packs/index.json`(`@main`,在「源」列表里标着**内置**,一键可移除)。打开词条包页时会自动取一次——没有这一步,新装用户看到的是一个源加一片空白,而「刷新」是个没人猜得出目的的按钮。取的是**你自己配置的那些源**,宿主负责取并缓存一小时。

设置层面有一处必须分清:**缺这个键**(从没设过)用内置默认,**存着空数组**(我全删了)就是空——少了这个区分,清空源列表的人每次打开都会被内置源"复活"一次。

### 自己发一个源(四步)

```
1. 面板「词条包」→「生成文件」→ 下载 term-pack-<id>.json
2. 放进仓库的 packs/(文件名随意),提交
3. node tools/make-pack-index.mjs --base https://cdn.jsdelivr.net/gh/<owner>/<repo>@main/packs/
     读每个包、算各自的 sha256、写 packs/index.json,并打印要粘进面板的地址
4. 提交 index.json 并推送 → 把那个地址加进面板的「源」→ 刷新
```

两条硬规则:**`--base` 必须是 https**(用的就是读者取回前那条 `refuseUrl`,所以生成不出读者会拒绝的索引),**`sha256` 覆盖文件本身的字节**(改了包就必须重新生成,否则读者看到 `checksum-mismatch`)。

索引是**确定性**的——`updatedAt` 取目录里最新那个包的时间,而不是"什么时候跑的脚本"——所以它还能当检查用:

```
node tools/make-pack-index.mjs --base <同一个 base> --check     # 不写文件,不一致就非零退出
```

**为什么必须是确定性的**:第一版不是,于是"写完之后立刻 `--check`"永远失败(每次跑 `updatedAt` 都变)。这个 bug 是端到端那条用例逼出来的——它先跑真正的生成器,再把生成出来的索引与包喂给真实路由。

**重发一个包**:同一个地址换了内容、索引里的 `sha256` 也随之改变。宿主取回时若发现**缓存那份对不上新摘要**,会**重新取一次**再判定——摘要才是权威,缓存不是。没有这一步,重发包之后所有读者会在约一天里看到 `checksum-mismatch`,看起来就像包坏了。这条有正反两个方向:同 URL 新字节新摘要必须通过(用例),以及一个专门朝它去的变异。

**界面**:面板里的第三个二级页「词条包」——生成分享码 / 下载包文件、粘贴分享码并**先预览再导入**、源的增删与刷新(每个包显示名称、作者、许可、条数、分类、校验和,点「预览」才去取;取回来仍然先预览)。

**一份包进来时走的是同一条导入路径**:`importEntries()` 由导入 / 导出页与词条包页共用,所以"包里带了我删过的词怎么办"这类决定只有一个实现——重复项报告、已删除词条的勾选、以及"精心构造的复活"(让撤回的词条在下次合并里活下来)自动一致。这次共用是被变异 harness 逼出来的:它指出分类勾选行**有了两份**,那份也一并提成了共享组件(`CategoryChecklist`)。

源的地址与"能不能取"用的是**同一个规则**(`pack.js` 的 `refuseUrl`):设置存储校验它、页面添加源时校验它、宿主在发起任何请求前再校验一次——三处一个实现,所以页面不可能接受一个传输层会拒绝的地址。

### 控件形状由值的形状决定

| 值的形状 | 控件 | 出现在哪些偏好上 |
|---|---|---|
| 两态 | 开关(`role="switch"`) | 自动收录、划出术语、自动解释、参考段落、收代码术语、收中文术语 |
| 三个以上固定选项 | 选项条(`role="radiogroup"`,所有取值都摆在上面) | 解释语言、详细程度、最短词长 |
| 一个范围内的数 | 滑杆 + 数字框(两者绑同一个值) | 悬停读入延迟、悬停读出延迟(0–200ms) |

三件事是刻意的:

- **行的名字不随状态变化**。早先每个开关是一枚 chip,标签自己带状态(「收代码术语」/「不收代码术语」),
  于是一行里再也说不出「这一项是干什么的」——名字会随着你点它而改变。现在状态在控件上,名字恒定。
- **多值的用选项条,不用循环 chip**。循环 chip 只能告诉你当前值,要选别的值得盲点若干次;
  选项条把全部取值摆出来,一次点到。
- **数值用滑杆 + 数字框**。滑杆管「差不多」,数字框用来精确输入同一个值,两者都夹在 0–200ms。
  默认读入 110ms、读出 0ms:读出延迟一旦不为 0,指针离开后卡片还挂着,读起来像卡顿而不是从容。

## 批量选择与删除

面板标题右侧的方框图标进入选择模式:每行变复选框,工具条给出已选数量、全选/取消全选、删除所选。
删除走 **`store.deleteEntries(ids)` 一次写入**,而不是循环单条删除:每次删除都会持久化整份文档
并推给 host,20 行一条一条删就是 20 次往返。**每条仍然各留一个墓碑**——否则 host 那份副本会把它们全部搬回来。

## 词条分组:一层一层看

词条可以放进**分组**。分组不是标签,而是**目录**——它决定你在哪一层,而不是给词条贴一个属性(那件事是
「分类 / 领域」在做,两者刻意分开)。

- **组是列表里的一行**,和词条同一处、同一层滚动、同一块行尺寸,因为组与词条是同一类东西:**点开它**。
  行里给出组名、里面有多少条(含所有下级)、一行**里面最新的几个词条名**(超过三条接「等 N 条」),
  以及一个 `›`。整行可点,行内按钮有自己的含义(点「改名」不会顺带进组)。
- **一次只看一层**:顶层显示"就在顶层"的词条与下级组;进去之后显示该层的词条与它自己的下级组,上层的
  词条不再出现。搜索与五个标签都在**当前层**里生效;「已删除」也是当前层的。
- **面包屑是回去的路**:`全部 › backend › net`,每段可点,当前层不可点(它不是动作)。只能进不能出的
  目录是陷阱。面包屑横跨整行,不占网格的一个单元。
- **站在哪一层,新建的词条就落在哪一层**;编辑器里的「分组」字段也能手改(用 `/` 分层,例如 `backend/net`)。
- **改名 / 移动**是同一件事,因为组就是一条路径:`backend → 服务端` 是改名,把 `net` 从 `backend` 挪到
  `infra` 是移动。改到已存在的组名会**合并两个组**;三种情况明确拒绝:空名、名字没变、移到自己里面。
  站在被改名的组里会**跟着它走**,而不是被扔回顶层。
- **删除分组 = 删除里面的词条**,且是**软删除**:它们进「已删除」并留墓碑(可逐条撤回,也挡住路过的包
  把它们放回来)。确认框里带条数——"删除这个分组?"与"删除 12 条词条?"是两个不同的提议。
- **一个组存在,只要里面有东西**:没有单独的"空组"清单要跟词条保持同步,所以删掉组里最后一条词条,
  这个组就消失了——那正是它出现的方式。
- **移动是一条决定,不是内容**:每个词条带 `groupAt` 时间戳(与 `pinnedAt`、`feedbackAt` 同一套),
  所以移动、移回顶层、同毫秒再移动都有确定结果,不会因为与宿主合并的顺序而被翻案。
- 分组**随导出走**(备份能恢复结构),但**不随词条包走**——包是别人给你的术语,怎么归类是读者自己的事。
  「导入文件夹」可以按文件夹路径建组(选中的文件夹名就是第一级)。

## 已删除:黑名单要看得见,也要撤得回

「已删除」是第五个视图,列出墓碑(词条删掉后留下的记录),按**删除时间倒序**,每行给出
「删除于 …」和一个「撤回」按钮。

为什么需要一个视图,而不是把删除做成彻底的抹除:

- 词典同时是**黑名单**——删掉的词不会被自动重新收录,导入默认也跳过它——而一份读不到的黑名单
  就是一份改不了的黑名单。删错一个词(或点错一行)此前没有任何补救路径:墓碑在 `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`,
每个块也只有单个文本节点)。

**一、指针覆盖在消息之上。** 原判断要求指针下最上层的元素必须是**该块的子孙**:

```js
if (!containsNode(hit.block.element, over)) return null;   // 宿主在消息上画悬停工具条 → 直接返回 null
```

宿主会在消息之上画悬停工具条、吸顶行、选择层,于是那个元素是块的**兄弟**而不是子孙,
这一条就把该消息里的**每一次悬停和点击**都判成「什么都没点到」。它真正想问的问题
(指针是不是在转录上)由紧随其后的 **caret 检查**精确回答。现在只在指针被**区域之外**的东西
盖住时(例如插件自己的气泡)才拒绝。

**二、精确偏移被丢掉,换成了几何估算。** caret 给出的字符偏移来自 `textRuns` 的走查,
而被扫描的文本是块的 `innerText`(`block.text`):

```js
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` 都只是插件自己注意到了这个词,不能复活任何东西。
- **一次有名字的恢复先于来源判定**:`restoreEntry`(导入撤回、以及「已删除」里的逐条撤回走的都是它)
  记下 `restoredAt`,并**重新封存文档**——墓碑与它的 key 一起消失,而不是「在一次比较里被否决」。
  被否决只意味着「这次没赢」,之后任何一次读到那块墓碑的合并都能把删除放回去;**撤回是让文档不再声称它被删过**。
  (`reviveEntry` 是更窄的一条路,见「已删除」一节。)
- **重新观察(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` | 调用模型生成一条解释并入库 |
| GET | `/dsh-term-dictionary/` | 与 `/state` 同义(少打几个字) |
| POST | `/dsh-term-dictionary/feedback` | 把一条反馈送进宿主的反馈通道(**不带**任何对话原文) |
| POST | `/dsh-term-dictionary/pack` | `build`(导出词条包)或 `decode`(解分享码) |
| GET | `/dsh-term-dictionary/source/index` | 取回一个源索引(https-only、有缓存与超时) |
| GET | `/dsh-term-dictionary/source/pack` | 取回一个包,并按索引承诺的 sha256 校验 |

## 配置

`cordis.patch.yml` 的 `config` 三项都可留空:

```yaml
- 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` 守住了这一点。

## 安装与启用

**从插件市场装**(推荐):设置 → **插件市场** → 搜「术语词典」→ 一键安装。也可以直接装包:

```sh
dsh plugin --profile <你的 profile> add dsh-plugin-term-dictionary
```

装完重启 Host 即可(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**(或跑 `dev:web` 让热替换生效)。**刷新页面不会换 bundle**:模块 URL
   按 revision 生成、且被 immutable 缓存,重问同一个地址只会拿到同一份旧字节。改 host 半边同样以重启为准。

怎么确认真的活着(两种都实测过):

```powershell
# 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/hover.js` | 悬停卡片与**标记词**的点击命中(caret 查表,不碰几何) |
| `lib/core/selection.js` | 选区守卫:结束一次选中的那一下不算点击 |
| `lib/core/pack.js` | 词条包与源索引的形状:构建、解析、URL 拒绝规则 |
| `lib/core/transfer.js` | 导入 / 导出 / 反馈报告:解析、预演(dry run)、包与文件夹读取 |
| `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 正文;每个**发布出去**的版本记一条 |

## 为什么要构建

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

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`
读取同一份核心逻辑,两半因此共用完全一致的合并规则。

```powershell
node tools/build-client.mjs          # 重新生成 lib/client.js
node tools/build-client.mjs --check  # 校验产物是否最新
node tools/test-term-dictionary.mjs  # 核心、词典、检测器、settings、bundle、清单、命令总线、主题令牌、模型路由、导入导出页(120 项)
node tools/test-interaction.mjs      # 点击 / 悬停 / 选中 / 行内代码 / 自动收录 / 代码块 / 开关 / 预算 / 命中阶段 / 标注(55 项)
node tools/test-host-routes.mjs      # host 半边:路由、状态序列化、解释请求的形状(29 项)
node tools/check-pointer-guards.mjs  # 19 个变异:把每条指针链的断言逐条改坏,看对应守卫是否真的变红
node tools/check-revive-guards.mjs   # 82 个变异:撤回 / 已删除视图 / 导入选项 / 控制台报告那几组守卫
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`,与本机安装记录一致。

## 还没做的(本次范围之外)

按「先做词典和手势功能」的分期,下面这一项**尚未实现**:

- **词典分享市场**:尚未开始。

已经可用的:搜索、五个视图(含**已标记**、**已删除**与**撤回**)、钉选、编辑、单条与批量删除、
词条分组(进入 / 改名 / 移动 / 删除)、
设置页的 13 项偏好、导入 / 导出(含「按分类导出」与「导入我删除过的词条」)、
面板底部的「清空词典」(跳过钉选的)与「复制 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 可以借此删掉一个正常词条(同一件事的两面)。

Install

dsh plugin --profile web add github:lakerian/dsh-plugin-term-dictionary

Profile: web

  • This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.
Source