dsh-plugin-term-dictionary 0.0.0-stage → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +189 -0
- package/LICENSE +21 -0
- package/README.md +776 -2
- package/cordis.patch.yml +14 -0
- package/icon.svg +13 -0
- package/lib/ROADMAP-lexicon.md +52 -0
- package/lib/client.js +13354 -0
- package/lib/core/api.js +278 -0
- package/lib/core/bus.js +98 -0
- package/lib/core/copy.js +614 -0
- package/lib/core/core.js +309 -0
- package/lib/core/dictionary.js +1187 -0
- package/lib/core/entries.js +454 -0
- package/lib/core/highlight.js +282 -0
- package/lib/core/hover.js +470 -0
- package/lib/core/hovercard.js +173 -0
- package/lib/core/interact.js +1802 -0
- package/lib/core/lexicon.en.js +872 -0
- package/lib/core/lexicon.zh.js +249 -0
- package/lib/core/overlay.js +239 -0
- package/lib/core/pack.js +372 -0
- package/lib/core/package.json +4 -0
- package/lib/core/selection.js +83 -0
- package/lib/core/settings.js +366 -0
- package/lib/core/shell.js +1003 -0
- package/lib/core/stopwords.js +147 -0
- package/lib/core/store.js +397 -0
- package/lib/core/styles.js +574 -0
- package/lib/core/terms.js +398 -0
- package/lib/core/transfer.js +382 -0
- package/lib/core/views.js +2428 -0
- package/lib/index.js +1110 -0
- package/lib/pack-code.js +84 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +71 -3
package/README.md
CHANGED
|
@@ -1,3 +1,777 @@
|
|
|
1
|
-
#
|
|
1
|
+
# 术语词典(dsh-plugin-term-dictionary)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
在 DSH 对话里自动识别专业术语、建立词典条目,并在对话中直接查看解释的插件。
|
|
4
|
+
|
|
5
|
+
## 它做什么
|
|
6
|
+
|
|
7
|
+
1. **自动收录**:agent 回复渲染完成时,插件扫描其中的英文技术词汇、缩写、代码标识符风格
|
|
8
|
+
的命名(`camelCase`、`snake_case`)以及内置词库里的中文行业术语,为其中置信度最高的
|
|
9
|
+
若干条建立词条。
|
|
10
|
+
2. **左侧插件区入口**:左侧边栏的插件行出现「术语词典」,点击后中间主区域显示词典面板,
|
|
11
|
+
可搜索、按「全部 / 待补充 / 已钉选 / 已删除」四个视图查看,编辑、钉选、删除、**撤回删除**、
|
|
12
|
+
导入 / 导出;面板内另有两个二级页:**设置**与**导入 / 导出**。
|
|
13
|
+
3. **选中即建条**:在 agent 回复里用鼠标选中一个词或短语,选区旁会出现「添加词条」按钮,
|
|
14
|
+
点击后词典面板打开并预填该词条。
|
|
15
|
+
4. **收录即标注**:已收录的词在回复中被划出(虚线下划线 + 轻微底色),提示「这个词在词典里」。
|
|
16
|
+
|
|
17
|
+
### 三个鼠标手势(对已收录的词)
|
|
18
|
+
|
|
19
|
+
| 手势 | 行为 |
|
|
20
|
+
|---|---|
|
|
21
|
+
| **鼠标靠近**(指针停在词上 110ms,设置页可调 0–200ms) | 在指针旁浮出**简短解释**:术语、中文译名、最多三行的解释,以及一行提示。气泡不接收指针事件,不会挡住下面的文字。 |
|
|
22
|
+
| **点击** | **进入词典对应词条**:切换到词典面板并把那条词条滚动到视野中,短暂高亮。 |
|
|
23
|
+
| **选中词句** | 出现「添加词条」按钮,点击后打开编辑器并预填。 |
|
|
24
|
+
|
|
25
|
+
判断「这个词在词典里」用的是检测器给出的来源:`source === "dictionary"` 是你自己的词条,
|
|
26
|
+
`source === "glossary"` 是内置词库。所以:
|
|
27
|
+
|
|
28
|
+
- 内置词库就能解释的词(`quorum`、`idempotent`)**靠近会有解释**,但**点击弹出解释气泡**
|
|
29
|
+
而不是进入面板——它没有对应的词条页可进;
|
|
30
|
+
- 词典里还没有的词,点击弹出气泡并提供「创建词条 / 用模型生成解释」。
|
|
31
|
+
|
|
32
|
+
## 术语从哪里来
|
|
33
|
+
|
|
34
|
+
插件按证据强弱分四层判断,全部在浏览器本地完成,不发网络请求:
|
|
35
|
+
|
|
36
|
+
| 证据 | 说明 | 置信度 |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| 你自己的词条 | `data` 里的词典条目与别名命中 | 1.00 |
|
|
39
|
+
| 内置词库 | `lib/lexicon.en.js`、`lib/lexicon.zh.js`,开箱即用 | 0.80 |
|
|
40
|
+
| 命名形式 | `camelCase` / `snake_case` / `ALL_CAPS` 等标识符写法 | 0.50–0.55 |
|
|
41
|
+
| 构词特征 | `-ization`、`-ology`、`meta-`、`poly-` 等技术构词前后缀 | 0.45 |
|
|
42
|
+
|
|
43
|
+
`lib/stopwords.js` 里的常用词永远不会被单独当作术语,因此正常英文散文不会被误标。
|
|
44
|
+
|
|
45
|
+
### 什么时候**主动**建词条(自动收录的门槛)
|
|
46
|
+
|
|
47
|
+
重点是**对话里真正生僻的那个词**,不是把 agent 说过的一切都搬进词典。自动收录因此有两道门槛:
|
|
48
|
+
|
|
49
|
+
1. **必须有术语证据**:缩写(`DSN`、`CRDT`)或标识符写法(`WriteAheadLog`、`snake_case`)。
|
|
50
|
+
普通单词、常用词、英文散文都不会自动建条——即使它们看起来「像个词」。
|
|
51
|
+
2. **内置词库已经能解释的,不收录**。`quorum`、`idempotent`、`durability`、`Kubernetes`
|
|
52
|
+
这类词插件本来就能解释,再抄一份进你的个人词典只会把面板塞满、把真正生僻的词埋掉。
|
|
53
|
+
它们**仍然可以点击查看解释**(读的是内置词库),也**仍然可以**用鼠标选中后手动加入。
|
|
54
|
+
3. **你删掉过的词,不再自动收录**。删除是一次明确的操作,而「删掉的词」不等于「没见过的词」:
|
|
55
|
+
把带释义的 agent 回复重新变成词条,等于让你的删除自己撤销自己。删掉的词只有在
|
|
56
|
+
**你手动加回来**(选中它、或在面板里编辑)时才会回来 —— 自动通道永远不碰它。
|
|
57
|
+
|
|
58
|
+
实测一段技术叙述的效果:
|
|
59
|
+
|
|
60
|
+
| 词 | 自动建条? | 为什么 |
|
|
61
|
+
|---|---|---|
|
|
62
|
+
| `WriteAheadLog` | ✅ | 标识符写法,内置词库没有 |
|
|
63
|
+
| `DSN` | ✅ | 缩写 |
|
|
64
|
+
| `quorum` / `idempotent` / `durability` / `Kubernetes` | ❌ | 内置词库已能解释(可点击、可手动加) |
|
|
65
|
+
| `the` / `happy` / 普通散文 | ❌ | 常用词表命中 |
|
|
66
|
+
|
|
67
|
+
所以「生僻就主动建,其他不建,用户也可以拉选添加」这条规则,是**门槛 1 + 门槛 2 + 手动通道**三者一起实现的。
|
|
68
|
+
|
|
69
|
+
## 标注是怎么画上去的(为什么不是包一层 span)
|
|
70
|
+
|
|
71
|
+
「把已收录的词变成词条块」最直觉的做法是把匹配到的文字包进 `<span>`。这里**没有**这么做,
|
|
72
|
+
原因是那段 DOM 属于宿主的渲染器:
|
|
73
|
+
|
|
74
|
+
- React 拥有那棵树,下一次 re-render 会把包进去的节点丢掉,而流式回复一直在 re-render;
|
|
75
|
+
- 往虚拟化列表里插节点会干扰它自己的测量。
|
|
76
|
+
|
|
77
|
+
所以标注走 **CSS Custom Highlight API**:插件只创建 `Range` 并把它交给
|
|
78
|
+
`CSS.highlights`(键名 `term-dictionary-entry`),由样式层着色。宿主 DOM 一个节点都没被动过,
|
|
79
|
+
插件卸载时 `clear()` 掉注册项,什么也不残留。样式规则由 overlay 组件渲染成一个 `<style>`
|
|
80
|
+
元素(`::highlight()` 只能写在样式表里),颜色只用 `--dsw-alias-*` 主题令牌。
|
|
81
|
+
|
|
82
|
+
**只标注 `source === "dictionary"` 的词**,也就是你自己词典里的词条。内置词库能解释的词不标注:
|
|
83
|
+
它们没有词条页可进,而且给一篇技术文章里每个能查到的词都划线,等于把整篇划满——这和采集器
|
|
84
|
+
「别把词典堆肥」是同一条规则用在页面上。
|
|
85
|
+
|
|
86
|
+
运行时没有这个 API(老版本)时,`supported` 为 false,标注整层静默降级:不画线、不报错,
|
|
87
|
+
**靠近解释与点击进词条照常工作**。
|
|
88
|
+
|
|
89
|
+
## 解释从哪里来
|
|
90
|
+
|
|
91
|
+
- **内置词库**:约 860 条英文技术词与 160 条中文行业词,直接给出中文译名、领域和一句解释。
|
|
92
|
+
- **模型生成**:点「用模型生成解释」时,host 半侧调用当前 profile 的 `llm` 服务,
|
|
93
|
+
要求模型返回一个 JSON 对象,再逐字段校验后写入词典。没有可用模型时,界面提示手动填写。
|
|
94
|
+
|
|
95
|
+
### 用哪条模型路由(这里踩过一次坑)
|
|
96
|
+
|
|
97
|
+
选择顺序是三条,**顺序本身就是修复**:
|
|
98
|
+
|
|
99
|
+
1. 插件自己的 `config.provider` / `config.model`(写了就用);
|
|
100
|
+
2. **profile 自己的默认模型选择**(`agentDefaultModel.currentSelection()`)——也就是这个窗口里
|
|
101
|
+
agent 正在用的那条路由,因此它一定是**本部署里已配置、已授权**的那条;
|
|
102
|
+
3. 最后才是「注册表里的第一个 provider + 它广告的第一个模型」。
|
|
103
|
+
|
|
104
|
+
第 3 条单独用会选错。`llm.listProviders()` 返回的是**注册顺序**,本 profile 里官方 API key 路由
|
|
105
|
+
(`deepseek-official`)注册在已登录账号路由(`deepseek-account`)之前,于是「用模型生成解释」报:
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
llm-deepseek: no API key for provider route "deepseek-official";
|
|
109
|
+
store DEEPSEEK_API_KEY through the credentials service ...
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
而窗口自己正好好地走 `deepseek-account`。问部署它自己在用什么,是唯一能确定「哪条路由真能应答」
|
|
113
|
+
的办法。选择逻辑由 `host: the model route prefers the profile's own selection` 测试守住
|
|
114
|
+
(含「选择里写的 provider 本部署没注册就回退」「不跨 provider 借模型 id」「选择服务抛异常也能生成」)。
|
|
115
|
+
|
|
116
|
+
## 样式只用平台真的定义了的令牌
|
|
117
|
+
|
|
118
|
+
所有颜色走 `--dsw-alias-*` / `--dsw-specific-*`,但**名字必须真实存在**。踩过的坑:
|
|
119
|
+
`primaryButton` 原本写的是
|
|
120
|
+
|
|
121
|
+
```js
|
|
122
|
+
color: "var(--dsw-alias-label-inverse, #fff)",
|
|
123
|
+
background: "var(--dsw-alias-brand-primary, #4d6bfe)",
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
两个名字里 `--dsw-alias-label-inverse` **在整套设计平台里根本不存在**,于是标签颜色落到
|
|
127
|
+
硬编码的 `#fff`;而**暗色主题**下 `--dsw-alias-brand-primary` 解析到
|
|
128
|
+
`--dsw-static-neutral-bluish-50`——近白色,和主题给 `--dsw-alias-label-primary` 的是同一个值。
|
|
129
|
+
结果就是**白底白字**:保存按钮渲染成一个空白矩形。
|
|
130
|
+
|
|
131
|
+
现在用平台为这件事定义的那一对令牌(取值均从随包发布的主题 CSS 实测):
|
|
132
|
+
|
|
133
|
+
| 用途 | 令牌 | 亮色 | 暗色 |
|
|
134
|
+
|---|---|---|---|
|
|
135
|
+
| 主按钮填充 | `--dsw-alias-button-primary-fill` | bluish-1000 近黑 | bluish-50 近白 |
|
|
136
|
+
| 主按钮文字 | `--dsw-alias-label-primary-inverted` | bluish-00 白 | bluish-800 深 |
|
|
137
|
+
| 输入框底色 | `--dsw-specific-input-major` | bluish-00 | bluish-850 |
|
|
138
|
+
|
|
139
|
+
同类问题还有:`--dsw-alias-bg-l1`(不存在,且 `var()` 没有回退会让整条声明失效,
|
|
140
|
+
输入框因此丢了底色)、`--dsw-alias-state-warning-primary`(真名是 `state-warn-primary`)。
|
|
141
|
+
标注的下划线也从 `brand-primary` 换成 `label-secondary`——暗色下 brand 与正文同色,
|
|
142
|
+
等于没画。
|
|
143
|
+
|
|
144
|
+
审计方式(可复现):从 `app.asar` 抽出 `@deepseek-ai/dsh-client-ui-theme/lib/client.js`,
|
|
145
|
+
列出全部 `--dsw-(alias|specific)-*:` 定义共 **118** 个,再核对插件引用的每一个名字。
|
|
146
|
+
|
|
147
|
+
## 一个词条的「上下文」有多大,以及从哪里取
|
|
148
|
+
|
|
149
|
+
上下文(存进词条、显示在编辑器标题下、弹窗里那一段)统一由 `core.contextAround(text, start, end, 240)`
|
|
150
|
+
产生:**向句子边界扩展,但硬性封顶 240 字符**。三条路径(自动收录 / 点击 / 选中)都走它,
|
|
151
|
+
所以插件里只有一条取上下文的规则。
|
|
152
|
+
|
|
153
|
+
这里出过一次很严重的错,值得记下来:
|
|
154
|
+
|
|
155
|
+
- **选中路径**原来写的是 `readBlock(regionFor(node))`,而 `regionFor` 返回的是**区域的直接子元素**。
|
|
156
|
+
真实 DOM 里那个直接子元素是**装载全部消息的滚动容器**,于是 `readBlock` 取到的是
|
|
157
|
+
**整段会话**(`innerText` 连工具调用的小标签一起算进去)。
|
|
158
|
+
- 编辑器把上下文直接渲染在标题下面,所以「添加词条」一按,面板就被整段会话填满,
|
|
159
|
+
下面的字段和「保存」按钮全被顶到看不见——编辑器直接不可用。
|
|
160
|
+
- 实测:修复前该测试测得 **2434 字符**,修复后 ≤ 240 且不含其他句。
|
|
161
|
+
- 保险起见,编辑器/弹窗的上下文另有 3 行 clamp:即使将来有历史数据或导入的词条带着超长上下文,
|
|
162
|
+
也再不会把面板顶爆。
|
|
163
|
+
|
|
164
|
+
`regionFor` 已删除。
|
|
165
|
+
|
|
166
|
+
## 代码块不参与收录,也不参与标注
|
|
167
|
+
|
|
168
|
+
`SKIP_SELECTOR`(`pre, code, a, [contenteditable=true], [data-term-dictionary]`)过去只用在
|
|
169
|
+
**点击 / 悬停 / 选中**三条路径上,**采集器没有用它**——于是它扫描了 `readRegion` 产出的每一个块,
|
|
170
|
+
而转录里的代码块和工具输出也是块。结果是一次会话就把 `Invoke-WebRequest`、`StatusCode`、
|
|
171
|
+
`StartTime`、`data-chat-flow-key` 之类 **25 个 shell/工具输出来标识符**收进了词典,
|
|
172
|
+
正是这个插件本该避免的「词典臃肿」。现在 `isProseBlock(block)` 同时把关:
|
|
173
|
+
|
|
174
|
+
- 块**本身**在 skip 容器里 → 跳过;
|
|
175
|
+
- 块里**所有**文本 run 都在 skip 容器里(整块就是代码)→ 跳过;
|
|
176
|
+
- 段落在正文里**夹了一段行内代码**(技术回复的常态)→ 仍然收录。
|
|
177
|
+
|
|
178
|
+
标注层用同一个判定,所以整块代码不会被划线(**行内代码仍会被划**——见下一节,那个差别本身就是个 bug)。
|
|
179
|
+
测试:`collect: a code block contributes no terms` 与 `collect: prose that merely contains inline code is still collected`
|
|
180
|
+
(两边都测,才说明这扇门是「是不是代码块」而不是「有没有提到代码」)。
|
|
181
|
+
|
|
182
|
+
## 行内代码:标注和交互必须对同一件事表态
|
|
183
|
+
|
|
184
|
+
用户看到的现象是「**有下划线,但悬停没解释、点击不跳词条,什么都做不了**」。
|
|
185
|
+
根因是标注与命中**对行内代码的判断不一致**:
|
|
186
|
+
|
|
187
|
+
| | 行内代码里的词(`runInTransaction`、`elementFromPoint`) |
|
|
188
|
+
|---|---|
|
|
189
|
+
| 采集 | **收录**(它所在的块是正文段落,`isProseBlock` 通过) |
|
|
190
|
+
| 标注 | **划线**(同一个块被扫描,range 覆盖到代码 span) |
|
|
191
|
+
| 命中测试 | **拒绝**(`SKIP_SELECTOR` 里有 `code`,`isSkipped` 直接返回 null) |
|
|
192
|
+
|
|
193
|
+
于是一篇技术回复里**最显眼、读者最想去点的那些词**——恰恰是行内代码里的标识符——
|
|
194
|
+
被划了线却完全点不动。这不是「某个函数有 bug」,而是**同一件事有两份互相矛盾的定义**。
|
|
195
|
+
|
|
196
|
+
修法是让两边对同一件事表态,但**只在「已知词条」这一档**放宽:
|
|
197
|
+
|
|
198
|
+
| 容器 | 已知词条(词典/内置词库) | 未知词(可建条) |
|
|
199
|
+
|---|---|---|
|
|
200
|
+
| 正文 | 悬停解释 + 点击进词条 | 点击弹「创建词条」 |
|
|
201
|
+
| **行内代码 / 代码块** | **悬停解释 + 点击进词条** | **什么都不做** |
|
|
202
|
+
| 链接 / 输入区 / 插件自己的 UI | 不介入 | 不介入 |
|
|
203
|
+
|
|
204
|
+
选择器因此拆成两个:`BLOCKED_SELECTOR`(链接、编辑器、插件 UI——永不介入)与
|
|
205
|
+
`DATA_SELECTOR`(`pre, code`——**只**禁掉建条那条路)。代码里的词仍然进不了词典
|
|
206
|
+
(`collect: an uncollected identifier inside inline code never offers to create`),
|
|
207
|
+
但已经收进来的词在任何地方都能读、都能跳(`click: a collected term inside inline code is explained, not refused`)。
|
|
208
|
+
|
|
209
|
+
## 为什么「已经添加的词条」以前不显示为词条块
|
|
210
|
+
|
|
211
|
+
`scan` 是**按 `keys` 里的每个 key 去文本里搜**来发现命中的,而 `keys` 只由**内置词库**填充过。
|
|
212
|
+
用户自己的词条只被塞进了 `known` 这张查找表,**从来没有进 `keys`**。于是:
|
|
213
|
+
|
|
214
|
+
- 一个不在词库里的已收录词(例如 `WriteAheadLog`)会被报成**未知的 `identifier` 候选**
|
|
215
|
+
(`known: false`),而不是词典命中;
|
|
216
|
+
- 后果正好是看到的三条:**对话里不划线**、悬停没有解释、点击时还劝你「创建」一个已经存在的词条;
|
|
217
|
+
- 只有恰好也是词库词的词条(`quorum` 这类)是好的——所以测试全绿:测试用的都是词库词。
|
|
218
|
+
|
|
219
|
+
修复:用户词条与其**别名**的 key 一并加入匹配表(仍按长度从长到短排序,短语优先)。
|
|
220
|
+
测试 `terms: the user's own entry is matched, not merely remembered` 守住这一点,
|
|
221
|
+
含别名命中与「词库词仍然报 glossary」两侧。
|
|
222
|
+
|
|
223
|
+
## 钉选为什么以前取消不掉
|
|
224
|
+
|
|
225
|
+
`mergeEntry` 里写的是 `pinned: current.pinned || incoming.pinned`。**OR 只能把钉子加上,永远去不掉**:
|
|
226
|
+
取消钉选后本地写 `false`,host 那一份还是 `true`,合并回 `true`,而页面**采用合并后的文档**,
|
|
227
|
+
于是每次点击都「弹回去」。
|
|
228
|
+
|
|
229
|
+
改成「后一次决定胜出」还撞上一个更隐蔽的问题:**钉选没有自己的时钟**。
|
|
230
|
+
合并只在「到达的内容胜出」时才推进 `updatedAt`,而单纯改 `pinned` 不改任何解释文本,
|
|
231
|
+
所以时间戳根本不会变——即使把 OR 换成比较 `updatedAt`,取消钉选仍然会被丢掉
|
|
232
|
+
(这正是第一次修复时测试报出的 `actual: true`)。
|
|
233
|
+
|
|
234
|
+
所以记录里多了一个字段 **`pinnedAt`**,仅在调用方明确给出 `pinned` 时盖章;合并按它比较,
|
|
235
|
+
并对时间戳取 `max`,**结果与合并顺序无关**(两边都会跑这个函数,必须收敛)。
|
|
236
|
+
|
|
237
|
+
## 三个功能开关
|
|
238
|
+
|
|
239
|
+
面板搜索框下方三个开关,状态就写在按钮上(`role="switch"` + `aria-checked`):
|
|
240
|
+
|
|
241
|
+
| 开关 | 关掉之后 |
|
|
242
|
+
|---|---|
|
|
243
|
+
| **自动收录** | 不再建词条;**且不把消息标记为已读**,所以再打开时这条会话仍然会被看到 |
|
|
244
|
+
| **划出术语** | 清掉对话里的标注(词条块) |
|
|
245
|
+
| **自动解释** | 收录后不再调模型,词条停在「待补充」 |
|
|
246
|
+
|
|
247
|
+
开关存在**自己的 `localStorage` 键**(`dsh-plugin-term-dictionary:settings:v1`),**不进词典文档**:
|
|
248
|
+
文档会被合并、会往返 host,而偏好只有一个用户、一个页面,没有东西需要对账;
|
|
249
|
+
把开关塞进合并文档等于把它拖进墓碑/复活那套机制里。存储不可用时退回默认值,面板照常工作。
|
|
250
|
+
|
|
251
|
+
### 自动解释
|
|
252
|
+
|
|
253
|
+
收录到新词条后,shell 把它们排队交给模型(`onCollected` → `/explain`),成功则按与
|
|
254
|
+
「用模型生成解释」**完全相同的路径**写入(`source: "llm"`),因此享有同样的复活权限。约束是刻意的:
|
|
255
|
+
|
|
256
|
+
- 同时最多 **2 个**请求在飞、队列最多 **6** 条——一次 settle 可能新建好几个词条,
|
|
257
|
+
不排队就会堆起用户没要求的请求;
|
|
258
|
+
- 一次失败就**整个会话停止**再试:常见原因是那条路由没有凭据,下一个词条会一模一样地失败。
|
|
259
|
+
|
|
260
|
+
## 收录频次的两道闸
|
|
261
|
+
|
|
262
|
+
| 闸 | 数值 | 挡住的是 |
|
|
263
|
+
|---|---|---|
|
|
264
|
+
| 每条消息 | 3 | 一条消息里一堆术语时灌满面板 |
|
|
265
|
+
| 每分钟(全会话) | 8 | 长会话里「每来一条回复收一个」,即「收录过程太频繁」 |
|
|
266
|
+
|
|
267
|
+
每分钟预算会**过期**(窗口 60 秒)。这条测试顺带挖出一个旧 bug:`MAX_AUTO_PER_MESSAGE`
|
|
268
|
+
的守卫用的是**整趟 pass 的总数** `created`,所以名字写着「每条消息」的常量实际上把一趟 settle
|
|
269
|
+
卡在 3 条,五条消息的一趟会静默丢掉两条消息的术语。现在按**每个块**计数。
|
|
270
|
+
|
|
271
|
+
## 反馈:一条留在本机,一条带得出去
|
|
272
|
+
|
|
273
|
+
标记和修正走的是同一次访问:编辑器里就有「反馈」那一块,所以说着"这条解释不对"的人,正看着他要改的那个字段。
|
|
274
|
+
|
|
275
|
+
**词条上的两个字段,两个不同的意思**
|
|
276
|
+
|
|
277
|
+
| 字段 | 意思 | 谁听它的 |
|
|
278
|
+
|---|---|---|
|
|
279
|
+
| `feedback: { kind, note }` | 你要说的话:类型 + 自己的措辞 | 「已标记」视图与反馈报告 |
|
|
280
|
+
| `untrusted: true` | **别再拿这条解释当准** | 自动解释器——它不会再覆盖这条词条 |
|
|
281
|
+
|
|
282
|
+
两者各有自己的时间戳(`feedbackAt` / `untrustedAt`),跟 `pinnedAt` 同一个理由:它们都不改定义,
|
|
283
|
+
`updatedAt` 带不动它们,而"撤回"必须能跟"没说"区分开——否则另一台机器上那份还带着标记的副本
|
|
284
|
+
会在下次同步里把它带回来。撤回标记(`{ feedback: null, untrusted: false }`)会把时间戳推到
|
|
285
|
+
**严格更晚**,所以同一毫秒里点两下也不会丢。
|
|
286
|
+
|
|
287
|
+
**质量回路**:`recordSighting` 有一条硬规则——被标为不可信的词条,**自动写入不能填它的解释**。
|
|
288
|
+
规则放在数据层而不是解释队列里,因为每一条自动写入(页面的队列、将来的后台刷新、宿主自己的
|
|
289
|
+
`record` 动作)都从那里过;`sighting.overridesUntrusted` 是唯一的例外,给"用户按了按钮"的生成用。
|
|
290
|
+
解释队列另有一份同样的判断,只是为了不花掉一次注定被丢弃的模型调用。
|
|
291
|
+
|
|
292
|
+
重新生成时,页面发的是 `retry: true`(不是一个字符串),宿主据此在提示词里**追加一句固定的话**——
|
|
293
|
+
不把用户自己的措辞发给模型,那是把备注变成指令。
|
|
294
|
+
|
|
295
|
+
**反馈报告**(导入 / 导出页):把标记导出成一个文件,可以下载或复制。
|
|
296
|
+
|
|
297
|
+
```json
|
|
298
|
+
{ "kind": "dsh-term-dictionary-feedback", "version": 1, "exportedAt": 0, "count": 1,
|
|
299
|
+
"entries": [{ "term": "Quorum", "key": "quorum", "untrusted": true, "untrustedAt": 0,
|
|
300
|
+
"kind": "wrong-gloss", "note": "太笼统", "at": 0 }] }
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
两件事是刻意的:**报告里永远没有会话原文**——词条记着术语出现的那句话,而那是私人对话的一部分;
|
|
304
|
+
这不是一个可以勾选的选项,是构造上就没有这个字段。**当前解释要另外勾选才附上**(默认不附),因为
|
|
305
|
+
它是模型写的、往往正是被抱怨的东西,但它仍然不是用户的话。
|
|
306
|
+
|
|
307
|
+
另外还有一条**走的不是这个页面**:宿主自己的反馈通道。它记在**这台机器**的会话日志里、不进模型上下文,所以它到不了作者手里——内容级反馈必须靠报告带出去。这条通道是**宿主服务**(`sessionFeedback`),不是客户端的 remote:页面的 `remote.sessionFeedback` 需要写进客户端 `inject`,而一个永远不来的服务会把**整个包挂起**(面板、下划线、所有手势一起消失)。实测这台机器的桌面端**根本没有 `remote` 服务**,所以那样写就等于在自己写代码的机器上把插件杀死。宿主半边用的是运行时给出的可选写法——`ctx.inject(["sessionFeedback"], …)`,和 `llm`、`agentDefaultModel` 同一个模式:服务在就接上,不在就由页面如实报告"这个组合没有反馈通道"(报告照样能导出)。
|
|
308
|
+
|
|
309
|
+
```
|
|
310
|
+
POST /dsh-term-dictionary/feedback { sessionId, text, category }
|
|
311
|
+
→ { ok: true }
|
|
312
|
+
→ { ok: false, error: "unavailable" } 这个组合没有反馈通道
|
|
313
|
+
→ { ok: false, error: "session-not-found" } 宿主已经没有这个会话了
|
|
314
|
+
```
|
|
315
|
+
页面对这几种结果分别给不同的话,不合并成一句"失败了":`unavailable` 是"这个版本做不到",`session-not-found` 是"那个会话没了",`no-session` 是"还没看到过会话,先回对话里点一下"。
|
|
316
|
+
|
|
317
|
+
## 面板的五个视图与三个二级页
|
|
318
|
+
|
|
319
|
+
标题栏右侧依次是「设置」「导入 / 导出」「词条包」、新建词条、批量选择;下面是搜索框与五个视图标签。
|
|
320
|
+
|
|
321
|
+
| 视图 / 页面 | 内容 |
|
|
322
|
+
|---|---|
|
|
323
|
+
| **全部 / 待补充 / 已钉选** | 同一份词条列表的三种看法:全部、还没有解释的、你钉选的。 |
|
|
324
|
+
| **已标记** | 你标过的词条:写了什么问题、有没有判为不可信,一行一个「撤回标记」。见「反馈」一节。 |
|
|
325
|
+
| **已删除** | 删掉的词条(墓碑),可以撤回。见下一节。 |
|
|
326
|
+
| **设置**(二级页) | 12 项偏好,每项一行:名字(不随状态变化)+ 它做什么 + 控件。 |
|
|
327
|
+
| **导入 / 导出**(二级页) | 按全部或按分类导出成文件;从文件导入;导出反馈报告。见「导入 / 导出」一节。 |
|
|
328
|
+
|
|
329
|
+
二级页开在面板内部而不是弹窗里:面板占着主区域,弹窗会盖住它正在处理的东西。
|
|
330
|
+
|
|
331
|
+
### 复制词条是按钮,不是"选中再按 Ctrl+C"
|
|
332
|
+
|
|
333
|
+
每一行有一个**复制**图标,编辑器里也有一个**复制词条**按钮:一次点击就把词条按可读文本写进剪贴板。
|
|
334
|
+
|
|
335
|
+
```
|
|
336
|
+
Quorum(多数派确认)
|
|
337
|
+
写入需要多少个副本确认。
|
|
338
|
+
例:reached quorum
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
别名与分类不进这段文本——那是用来在面板里检索的元数据,不是往对话里粘的东西。
|
|
342
|
+
|
|
343
|
+
**为什么不用右键菜单**:右键需要宿主提供 context menu 扩展点(它没有),而且不可发现、不可键盘操作、和浏览器自带菜单抢位置。一个按钮解决了同一件事,还多一个好处——**不需要选中**。
|
|
344
|
+
|
|
345
|
+
**顺带修掉的两个同类 bug**:整行的点击会打开编辑器、标记词的点击会切进词典,而**结束一次选中的那个 click 也是 click**——所以"选中一句话想复制"(选区正好结束在标记词上)会被换成词条页,"双击词条里的词想复制"会被换成编辑器,选中的文字随之消失。现在的规则统一在一处(`lib/core/selection.js`):
|
|
346
|
+
|
|
347
|
+
- 有非空选区(去掉空白后)→ **这一下算选中手势**,不打开编辑器、不切面板、不弹未收录词的卡片;
|
|
348
|
+
- 空白选区、折叠成光标的选区 → 仍然算普通点击(拖过句尾的空格不该吃掉激活);
|
|
349
|
+
- 面板那一路还多一层:选区必须**在那一行里**才拦(刚选中一段回复再点这一行,仍然是点这一行)。
|
|
350
|
+
|
|
351
|
+
三条路(面板行、标记词、未收录词)现在问的是同一个函数,所以这类"补了一条路、漏了另一条"的差异不会再发生——`interact.js` 的未收录词那条从写下起就有这个守卫,而 `hover.js` 的标记词那条是后来搬进来的,**漏了**,你踩到的就是它。
|
|
352
|
+
|
|
353
|
+
## 词条包与分享码(尚未接上界面)
|
|
354
|
+
|
|
355
|
+
一个**词条包(pack)**是一份可以递给别人的词典:某个领域的术语表、一个团队的黑话、某类项目的词汇。
|
|
356
|
+
|
|
357
|
+
**包不是导出文件**。导出是"这台机器的一份快照",包里只有读的人需要的东西——这条界线是构造出来的,不是靠某处判断:
|
|
358
|
+
|
|
359
|
+
| 字段 | 会不会进包 | 为什么 |
|
|
360
|
+
|---|---|---|
|
|
361
|
+
| `term` `key` `definition.{zh,gloss,usage}` `domain` `aliases` | ✅ | 用这个词需要的内容 |
|
|
362
|
+
| `context`(术语出现的那句话) | ❌ | 那是一段私人对话 |
|
|
363
|
+
| `notes`(你自己写的备注) | ❌ | 私人笔记 |
|
|
364
|
+
| `feedback` / `untrusted` | ❌ | 别人的判断当成你的判断,比没有判断更糟;而且合并会把它们当"决定"处理 |
|
|
365
|
+
| `pinned` `source` `seen` `lastSeenAt` `createdAt` | ❌ | 本机偏好与历史;包自己钉选自己的词条会打乱读者的列表 |
|
|
366
|
+
| 墓碑(已删除的词条) | ❌ | 发布别人的删除记录 |
|
|
367
|
+
| 包自己的 `id/name/description/author/license/homepage/build/createdAt/scope/count` | ✅ | 包自己的元信息 |
|
|
368
|
+
|
|
369
|
+
用例里断言的是**允许键的精确集合**(`["aliases","definition","domain","key","term"]`),不是"这些字段不在"——以后加字段必须是一次有意的决定,而不是测试忘了禁。
|
|
370
|
+
|
|
371
|
+
**分享码**是把包压成一个可粘贴的字符串:`dshpack1:<base64url(raw-deflate(pack JSON))>`。
|
|
372
|
+
|
|
373
|
+
- 编码/解码只在**宿主半边**(`lib/pack-code.js`,`node:zlib` + `node:crypto`)——页面两样都没有,而在浏览器里再写一份 `CompressionStream` 实现就是第二份要同步的代码。页面通过自己的路由请宿主代劳。
|
|
374
|
+
- 解码先去掉所有空白:码很长,凡是它真正走过的地方(聊天窗口、终端、编辑器)都会把它折行。
|
|
375
|
+
- 失败分得很细,因为不同的失败要去不同的地方查:`not-a-code`(这压根不是分享码)、`corrupt`(粘贴丢了字符)、`too-large`(解出来的东西超过 4 MiB 上限)。
|
|
376
|
+
- **解压有上限**:deflate 能把几 KB 压成几字节,没有上限的话,陌生人粘来的一串字就是对粘贴它的应用的内存攻击。
|
|
377
|
+
|
|
378
|
+
### 源(index)
|
|
379
|
+
|
|
380
|
+
没有服务器。一个「源」就是**别人托管的一个静态 JSON**,页面从它列出可用的包:
|
|
381
|
+
|
|
382
|
+
```json
|
|
383
|
+
{ "kind": "dsh-term-dictionary-index", "version": 1, "updatedAt": 0, "packs": [
|
|
384
|
+
{ "id": "lakerian/backend", "name": "后端黑话", "description": "…", "author": "lakerian",
|
|
385
|
+
"license": "CC-BY-4.0", "domains": ["分布式"], "count": 128, "sha256": "…", "url": "https://…/backend.json" } ] }
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
三条规则,都在宿主半边:
|
|
389
|
+
|
|
390
|
+
1. **只取 https**(`refuseUrl`):`http://`、`file://`、`data:`、URL 里带账号密码,一律在**发起任何请求之前**拒绝。规则检查的是 URL **字符串**,所以缓存里的副本和测试注入的传输方式都绕不过它。
|
|
391
|
+
2. **有磁盘缓存**:索引 1 小时、包 24 小时(写在插件的 dataDir,`sources.json`)。源暂时连不上而缓存还在,就**返回上次的内容并标 `stale`**——"昨天那份列表"比一句报错有用。索引里的行不可用就**丢掉并计数**(`dropped`),一行坏数据不该让另外四十个包消失。
|
|
392
|
+
3. **校验和**:索引里带 `sha256`,取到包之后核对;不符就拒绝、并把实际摘要报出来,让"不符"可诊断。校验和覆盖的是**下载到的那些字节**,所以不需要规范化 JSON。
|
|
393
|
+
|
|
394
|
+
推荐用 jsDelivr 托管(`https://cdn.jsdelivr.net/gh/<owner>/<repo>@<tag>/index.json`):它是静态、可缓存、对国内网络也通的那条路。
|
|
395
|
+
|
|
396
|
+
**新装自带一个源**:本仓库的 `packs/index.json`(`@main`,在「源」列表里标着**内置**,一键可移除)。打开词条包页时会自动取一次——没有这一步,新装用户看到的是一个源加一片空白,而「刷新」是个没人猜得出目的的按钮。取的是**你自己配置的那些源**,宿主负责取并缓存一小时。
|
|
397
|
+
|
|
398
|
+
设置层面有一处必须分清:**缺这个键**(从没设过)用内置默认,**存着空数组**(我全删了)就是空——少了这个区分,清空源列表的人每次打开都会被内置源"复活"一次。
|
|
399
|
+
|
|
400
|
+
### 自己发一个源(四步)
|
|
401
|
+
|
|
402
|
+
```
|
|
403
|
+
1. 面板「词条包」→「生成文件」→ 下载 term-pack-<id>.json
|
|
404
|
+
2. 放进仓库的 packs/(文件名随意),提交
|
|
405
|
+
3. node tools/make-pack-index.mjs --base https://cdn.jsdelivr.net/gh/<owner>/<repo>@main/packs/
|
|
406
|
+
读每个包、算各自的 sha256、写 packs/index.json,并打印要粘进面板的地址
|
|
407
|
+
4. 提交 index.json 并推送 → 把那个地址加进面板的「源」→ 刷新
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
两条硬规则:**`--base` 必须是 https**(用的就是读者取回前那条 `refuseUrl`,所以生成不出读者会拒绝的索引),**`sha256` 覆盖文件本身的字节**(改了包就必须重新生成,否则读者看到 `checksum-mismatch`)。
|
|
411
|
+
|
|
412
|
+
索引是**确定性**的——`updatedAt` 取目录里最新那个包的时间,而不是"什么时候跑的脚本"——所以它还能当检查用:
|
|
413
|
+
|
|
414
|
+
```
|
|
415
|
+
node tools/make-pack-index.mjs --base <同一个 base> --check # 不写文件,不一致就非零退出
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
**为什么必须是确定性的**:第一版不是,于是"写完之后立刻 `--check`"永远失败(每次跑 `updatedAt` 都变)。这个 bug 是端到端那条用例逼出来的——它先跑真正的生成器,再把生成出来的索引与包喂给真实路由。
|
|
419
|
+
|
|
420
|
+
**重发一个包**:同一个地址换了内容、索引里的 `sha256` 也随之改变。宿主取回时若发现**缓存那份对不上新摘要**,会**重新取一次**再判定——摘要才是权威,缓存不是。没有这一步,重发包之后所有读者会在约一天里看到 `checksum-mismatch`,看起来就像包坏了。这条有正反两个方向:同 URL 新字节新摘要必须通过(用例),以及一个专门朝它去的变异。
|
|
421
|
+
|
|
422
|
+
**界面**:面板里的第三个二级页「词条包」——生成分享码 / 下载包文件、粘贴分享码并**先预览再导入**、源的增删与刷新(每个包显示名称、作者、许可、条数、分类、校验和,点「预览」才去取;取回来仍然先预览)。
|
|
423
|
+
|
|
424
|
+
**一份包进来时走的是同一条导入路径**:`importEntries()` 由导入 / 导出页与词条包页共用,所以"包里带了我删过的词怎么办"这类决定只有一个实现——重复项报告、已删除词条的勾选、以及"精心构造的复活"(让撤回的词条在下次合并里活下来)自动一致。这次共用是被变异 harness 逼出来的:它指出分类勾选行**有了两份**,那份也一并提成了共享组件(`CategoryChecklist`)。
|
|
425
|
+
|
|
426
|
+
源的地址与"能不能取"用的是**同一个规则**(`pack.js` 的 `refuseUrl`):设置存储校验它、页面添加源时校验它、宿主在发起任何请求前再校验一次——三处一个实现,所以页面不可能接受一个传输层会拒绝的地址。
|
|
427
|
+
|
|
428
|
+
### 控件形状由值的形状决定
|
|
429
|
+
|
|
430
|
+
| 值的形状 | 控件 | 出现在哪些偏好上 |
|
|
431
|
+
|---|---|---|
|
|
432
|
+
| 两态 | 开关(`role="switch"`) | 自动收录、划出术语、自动解释、参考段落、收代码术语、收中文术语 |
|
|
433
|
+
| 三个以上固定选项 | 选项条(`role="radiogroup"`,所有取值都摆在上面) | 解释语言、详细程度、最短词长 |
|
|
434
|
+
| 一个范围内的数 | 滑杆 + 数字框(两者绑同一个值) | 悬停读入延迟、悬停读出延迟(0–200ms) |
|
|
435
|
+
|
|
436
|
+
三件事是刻意的:
|
|
437
|
+
|
|
438
|
+
- **行的名字不随状态变化**。早先每个开关是一枚 chip,标签自己带状态(「收代码术语」/「不收代码术语」),
|
|
439
|
+
于是一行里再也说不出「这一项是干什么的」——名字会随着你点它而改变。现在状态在控件上,名字恒定。
|
|
440
|
+
- **多值的用选项条,不用循环 chip**。循环 chip 只能告诉你当前值,要选别的值得盲点若干次;
|
|
441
|
+
选项条把全部取值摆出来,一次点到。
|
|
442
|
+
- **数值用滑杆 + 数字框**。滑杆管「差不多」,数字框用来精确输入同一个值,两者都夹在 0–200ms。
|
|
443
|
+
默认读入 110ms、读出 0ms:读出延迟一旦不为 0,指针离开后卡片还挂着,读起来像卡顿而不是从容。
|
|
444
|
+
|
|
445
|
+
## 批量选择与删除
|
|
446
|
+
|
|
447
|
+
面板标题右侧的方框图标进入选择模式:每行变复选框,工具条给出已选数量、全选/取消全选、删除所选。
|
|
448
|
+
删除走 **`store.deleteEntries(ids)` 一次写入**,而不是循环单条删除:每次删除都会持久化整份文档
|
|
449
|
+
并推给 host,20 行一条一条删就是 20 次往返。**每条仍然各留一个墓碑**——否则 host 那份副本会把它们全部搬回来。
|
|
450
|
+
|
|
451
|
+
## 已删除:黑名单要看得见,也要撤得回
|
|
452
|
+
|
|
453
|
+
「已删除」是第四个视图,列出墓碑(词条删掉后留下的记录),按**删除时间倒序**,每行给出
|
|
454
|
+
「删除于 …」和一个「撤回」按钮。
|
|
455
|
+
|
|
456
|
+
为什么需要一个视图,而不是把删除做成彻底的抹除:
|
|
457
|
+
|
|
458
|
+
- 词典同时是**黑名单**——删掉的词不会被自动重新收录,导入默认也跳过它——而一份读不到的黑名单
|
|
459
|
+
就是一份改不了的黑名单。删错一个词(或点错一行)此前没有任何补救路径:墓碑在 `backing` 里,
|
|
460
|
+
对所有普通读者不可见,`listEntries` 永远不会返回它,面板自然也就列不出来。
|
|
461
|
+
- 所以删除视图不是「第四个筛选器」,而是**另一份列表**:`store.list(query, "deleted")` 走
|
|
462
|
+
`dictionary.listDeleted(state, query)`,读的是 `backing`——对 `entries` 做任何谓词都不可能显示一条墓碑。
|
|
463
|
+
- **撤回**是 `store.reviveEntry(id)` → `dictionary.reviveEntry`。它只把墓碑变回活词条,**不接受任何补丁**,
|
|
464
|
+
所以词条按原样回来,而不是按编辑器里碰巧有的内容回来。撤回后那一行从「已删除」消失、回到词条列表,
|
|
465
|
+
并弹一条提示说明它去了哪:行消失是唯一的其他迹象,没有提示就等于「点了一下,行没了」。
|
|
466
|
+
- 撤回必须满足合并的那条规则(**用户要过的记录、且严格晚于删除**,见下一节):`reviveEntry` 把
|
|
467
|
+
`source` 记成 `user`、时间戳取 `max(now, 墓碑时间 + 1)`,并把墓碑从 `backing` 里摘掉。三条缺一条,
|
|
468
|
+
按钮看起来有反应,下次同步又没了——这正是它需要单测而不能只靠手点的原因。
|
|
469
|
+
- 删除视图里**没有批量选择**:选择模式的复选框只挂在活词条行上,对墓碑没有意义。
|
|
470
|
+
- 删除后 `clearAll` 也看不见它(墓碑不算「还有的词条」),但**已删除视图能**:清空词典之后
|
|
471
|
+
想找回某一条,路仍然在。
|
|
472
|
+
|
|
473
|
+
## 导入 / 导出
|
|
474
|
+
|
|
475
|
+
面板标题栏的「导入 / 导出」进入二级页。
|
|
476
|
+
|
|
477
|
+
- **导出**:范围是**全部**或**按分类**(分类就是词条的 `domain`;清单由 `domainsIn` 从数据里统计,
|
|
478
|
+
按条数从多到少)。一个分类都不选 = 导出**空文件**,而不是「那就全导」——不选是一个表态。
|
|
479
|
+
- 文件是 `{kind:"dsh-term-dictionary", version:1, exportedAt, scope, domains, entries}`。每条只带走
|
|
480
|
+
可移植的部分:术语、解释、领域、别名,以及 `pinned`(那是用户的判断);**不带** `seen` /
|
|
481
|
+
`lastSeenAt` / `createdAt`——那是本机的历史,带过去等于替这个词声称它在这里出现过。
|
|
482
|
+
- **导入**接受本插件写的信封、裸数组,以及只有 `entries` 的对象。逐条判定归宿,并**报告各自条数**:
|
|
483
|
+
「导入 12 条」和「导入 12 条、跳过 3 条」是两个不同的事实,只有后者是真的。
|
|
484
|
+
|
|
485
|
+
| 归宿 | 处理 |
|
|
486
|
+
|---|---|
|
|
487
|
+
| 词典里没有 | 建条 |
|
|
488
|
+
| 词典里已有 | **原样保留**:本机的解释可能被编辑过、钉选过,或者就是比文件里的好 |
|
|
489
|
+
| 被用户删过 | **默认跳过**(墓碑就是用户的决定,一个文件不是推翻它的理由)。勾选「导入我删除过的词条」才导入,并从已删除列表里撤回 |
|
|
490
|
+
|
|
491
|
+
- 导入**不读**文件里的 `source`:导入的词条由保存路径记成 `user`,那是唯一被合并当作「意图」的来源。
|
|
492
|
+
一个声称 `source: "auto"` 的文件会产出下次同步就被静默丢掉的词条。
|
|
493
|
+
- 「已存在」按 `key` 判定,文件内部重复的键也只算一条(与 store 的合并规则一致)。
|
|
494
|
+
|
|
495
|
+
## 悬停与点击:两条只有真实浏览器才会走到的路径
|
|
496
|
+
|
|
497
|
+
有下划线、但**悬停不出解释、点击不跳词条**——两个原因都在 `termAtPoint`,而且都在
|
|
498
|
+
**真实页面才会执行、测试夹具从来不覆盖**的分支里(夹具的假 DOM 没有 `elementFromPoint`,
|
|
499
|
+
每个块也只有单个文本节点)。
|
|
500
|
+
|
|
501
|
+
**一、指针覆盖在消息之上。** 原判断要求指针下最上层的元素必须是**该块的子孙**:
|
|
502
|
+
|
|
503
|
+
```js
|
|
504
|
+
if (!containsNode(hit.block.element, over)) return null; // 宿主在消息上画悬停工具条 → 直接返回 null
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
宿主会在消息之上画悬停工具条、吸顶行、选择层,于是那个元素是块的**兄弟**而不是子孙,
|
|
508
|
+
这一条就把该消息里的**每一次悬停和点击**都判成「什么都没点到」。它真正想问的问题
|
|
509
|
+
(指针是不是在转录上)由紧随其后的 **caret 检查**精确回答。现在只在指针被**区域之外**的东西
|
|
510
|
+
盖住时(例如插件自己的气泡)才拒绝。
|
|
511
|
+
|
|
512
|
+
**二、精确偏移被丢掉,换成了几何估算。** caret 给出的字符偏移来自 `textRuns` 的走查,
|
|
513
|
+
而被扫描的文本是块的 `innerText`(`block.text`):
|
|
514
|
+
|
|
515
|
+
```js
|
|
516
|
+
const live = collapse(runs.text), stale = collapse(blockText);
|
|
517
|
+
const offset = exact === null || live !== stale ? hit.offset : exact; // 两者不等就丢掉 exact
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
单个文本节点时两者必然相等——**所以测试里永远相等**;真实回复里有嵌套元素与不可见子节点,
|
|
521
|
+
两者合法地不同,于是代码丢掉 caret 的**精确**偏移,改用 `hit.offset`:由指针到块左边缘的
|
|
522
|
+
距离按**比例**估算字符位置。这对**多行段落**不是「不够准」,而是**系统性错误**——
|
|
523
|
+
12 行里第 5 行、横向 20% 处的点,其实是全文约 40% 的位置,不是 20%。落点因此跑到无关的词上:
|
|
524
|
+
悬停找不到术语,点击要么没反应,要么劝你创建一个词典里已有的词。
|
|
525
|
+
|
|
526
|
+
修法是**消除分歧而不是绕开它**:caret 有精确偏移时,就扫描 `runs.text` 并使用 `runs.text` 的偏移,
|
|
527
|
+
两者来自同一次走查。`block.text` 只在 caret API 真的帮不上忙(caret 所在节点没有对应的 run)时兜底。
|
|
528
|
+
|
|
529
|
+
两条都有回归测试,且**用变异验证过**:把任一条改回旧行为,对应测试立刻失败。
|
|
530
|
+
|
|
531
|
+
### 现状(2026-10-09 两个插件合并之后)
|
|
532
|
+
|
|
533
|
+
上面两条是**历史**,记的是当时怎么修的。合并之后指针层搬进了 `hover.js`,**悬停**不再走几何:
|
|
534
|
+
`matchAt` 直接在标注层留下的 `(节点, 起点, 终点)` 区间表里查 caret 的 `(节点, 偏移)`,既不读包围盒,
|
|
535
|
+
也不拼整条消息的文本。所以这一节里的 `termAtPoint` / `hit.offset` / `block.text`:
|
|
536
|
+
**悬停**半边已无对应物,**点击**半边仍然在 `interact.js` 里(`blockAtPoint` → `elementFromPoint` 复核
|
|
537
|
+
→ `offsetFromCaret` 优先取 caret 精确偏移),它服务的是「词典里没有这个词」的弹窗与选区入口,
|
|
538
|
+
而词典里**有**的词的点击由 `hover.js` 的 `onActivate` 直接接管。
|
|
539
|
+
|
|
540
|
+
## 数据落在哪
|
|
541
|
+
|
|
542
|
+
| 位置 | 内容 |
|
|
543
|
+
|---|---|
|
|
544
|
+
| `$DSH_HOME/dsh-plugin-term-dictionary/dictionary.json` | host 半侧的权威词典文件(原子写入) |
|
|
545
|
+
| 浏览器 `localStorage` | 页面副本,host 不可用时依然可用 |
|
|
546
|
+
| host 路由 `/dsh-term-dictionary/*` | 页面与 host 之间的读写通道 |
|
|
547
|
+
|
|
548
|
+
浏览器先写本地副本,再异步推给 host;host 按 term 合并并把结果回给页面,两边因此收敛。
|
|
549
|
+
**用户自己写的解释永远不会被自动收录或模型结果覆盖。**
|
|
550
|
+
|
|
551
|
+
`$DSH_HOME` 不可写时(受保护的安装目录、沙箱进程等),host 会依次尝试候选目录并选用第一个
|
|
552
|
+
可写的,面板底部会显示实际使用的路径;全都不行时面板会明确提示「词典无法写入磁盘」,
|
|
553
|
+
而不是静默丢弃每次保存。
|
|
554
|
+
|
|
555
|
+
### 删除是怎么同步的
|
|
556
|
+
|
|
557
|
+
合并天然只能做并集,所以「删除」如果只从文档里抹掉一条记录,下次合并就会把另一侧的旧副本
|
|
558
|
+
搬回来。文档因此分成三个字段:
|
|
559
|
+
|
|
560
|
+
| 字段 | 内容 | 谁能看到 |
|
|
561
|
+
|---|---|---|
|
|
562
|
+
| `entries` | **只有活词条** | 所有读者(面板、弹窗、检测器、导出、HTTP) |
|
|
563
|
+
| `deletedKeys` | 已删除术语的键 | 合并(以及需要知道「这个词被删过」的调用方) |
|
|
564
|
+
| `backing` | 墓碑本体(含删除时间) | **只有合并** |
|
|
565
|
+
|
|
566
|
+
规则(**删除和复活都由 `dictionary.js` 的 `mergeRecords` 一个函数裁决**):
|
|
567
|
+
|
|
568
|
+
- 裁决比较两件事:**删除时间**,以及**用户真正要过这份内容的时间**。
|
|
569
|
+
- **只有用户要过的内容能复活词条**。这就是为什么光比时间不够:一个「不知道这个词被删过」的
|
|
570
|
+
窗口会自动收录它并盖上「现在」的时间戳,而「现在」永远比删除新——如果只比时间,插件的
|
|
571
|
+
自动检测就能撤销用户的删除。授权复活的是**来源**(`source`,本来就在网线上、上盘,不会像
|
|
572
|
+
临时标记一样丢):`user`(用户在编辑器里写的)和 `llm`(用户让模型解释的)。
|
|
573
|
+
`glossary` / `heuristic` / `auto` 都只是插件自己注意到了这个词,不能复活任何东西。
|
|
574
|
+
- **重新观察(sighting)永远不复活**:它不推进 `updatedAt`;对已删除术语的观察直接
|
|
575
|
+
**不写入**(连计数都不加),因为墓碑本身就是那道闸。
|
|
576
|
+
- **编辑器里的保存一定复活**:用户正在为这个词条输入解释。复活时间戳取
|
|
577
|
+
**`max(now, 墓碑时间 + 1)`**——删除那台机器时钟走得快时,否则用户的文字会在下次同步被丢掉。
|
|
578
|
+
复活必须「严格更新」才能站得住:临时标记过不了序列化。
|
|
579
|
+
- **删除时间戳取 `max(now, 内容时间 + 1)`**:一次删除必须严格新于它删掉的内容,否则一台时钟
|
|
580
|
+
落后的客户端会出现「点了删除却没反应」。
|
|
581
|
+
- **同一时刻算删除**(不是「不早于」——严格更新才复活)。
|
|
582
|
+
- **`observed` 这个临时标记不上盘、不上网**:它只表示「这次到达是一次观察,计一次数」,
|
|
583
|
+
`serializeState` 会剥掉它,否则每次加载都会重复计数。
|
|
584
|
+
- 只有键、没有时间的通知(`deletedKeys`)不能压过一条整理过的词条:**「整理过」= 用户写过释义**,
|
|
585
|
+
所以自动收录(哪怕释义来自内置词库)仍然可以被它删掉。
|
|
586
|
+
- 文件名保持不变,墓碑随文件一起保存,所以删除能跨重启。
|
|
587
|
+
|
|
588
|
+
### 路由
|
|
589
|
+
|
|
590
|
+
| 方法 | 路径 | 作用 |
|
|
591
|
+
|---|---|---|
|
|
592
|
+
| GET | `/dsh-term-dictionary/state` | 读取整份词典(含数据目录与模型可用性) |
|
|
593
|
+
| POST | `/dsh-term-dictionary/entries` | `replace` / `record` / `update` / `remove` |
|
|
594
|
+
| POST | `/dsh-term-dictionary/explain` | 调用模型生成一条解释并入库 |
|
|
595
|
+
|
|
596
|
+
## 配置
|
|
597
|
+
|
|
598
|
+
`cordis.patch.yml` 的 `config` 三项都可留空:
|
|
599
|
+
|
|
600
|
+
```yaml
|
|
601
|
+
- insert:
|
|
602
|
+
- id: term-dictionary
|
|
603
|
+
name: 'dsh-plugin-term-dictionary'
|
|
604
|
+
config:
|
|
605
|
+
provider: deepseek-account # 留空则用 profile 的默认模型路由(agentDefaultModel)
|
|
606
|
+
model: deepseek-flash # 留空则用该路由自己的默认模型
|
|
607
|
+
dataDir: '' # 留空则用 $DSH_HOME/dsh-plugin-term-dictionary
|
|
608
|
+
```
|
|
609
|
+
|
|
610
|
+
**本插件刻意不导出 `Config`。** Cordis 用
|
|
611
|
+
`runtime.Config["~standard"].validate(config)` 校验行配置,也就是要求一个 **Standard Schema**;
|
|
612
|
+
而 `@deepseek-ai/schemastery`(DSH 自己声明 schema 用的库)是**宿主包**,从 link 进 profile 的
|
|
613
|
+
插件里解析不到——本机已安装的第三方插件没有一个引用它。
|
|
614
|
+
|
|
615
|
+
这里曾经导出过一份 JSON Schema,后果不是「配置不校验」,而是**整个插件无法激活**:
|
|
616
|
+
|
|
617
|
+
```
|
|
618
|
+
TypeError: Cannot read properties of undefined (reading 'validate')
|
|
619
|
+
at resolveConfig (.../cordis/lib/index.js:958:45)
|
|
620
|
+
```
|
|
621
|
+
|
|
622
|
+
看起来像插件坏了,实际是 schema 形状不对。不导出 `Config` 时 `resolveConfig` 原样返回行配置,
|
|
623
|
+
而上面三个字段在 `lib/index.js` 里都被防御性读取(类型不对就用回退值),所以空配置、错类型、
|
|
624
|
+
缺字段都不会让插件挂掉。代价是插件管理器里**没有**这张配置表单,改配置要写 `cordis.patch.yml`。
|
|
625
|
+
测试 `manifest: the host half exports no activation-blocking Config` 守住了这一点。
|
|
626
|
+
|
|
627
|
+
## 安装与启用
|
|
628
|
+
|
|
629
|
+
**从插件市场装**(推荐):设置 → **插件市场** → 搜「术语词典」→ 一键安装。也可以直接装包:
|
|
630
|
+
|
|
631
|
+
```sh
|
|
632
|
+
dsh plugin --profile <你的 profile> add dsh-plugin-term-dictionary
|
|
633
|
+
```
|
|
634
|
+
|
|
635
|
+
装完刷新一次页面即可(host 半边与浏览器半边都会在运行中的进程里生效)。包不带任何 npm 依赖,
|
|
636
|
+
也不需要执行构建脚本。
|
|
637
|
+
|
|
638
|
+
### 开发时:`link:` 安装与何时重启
|
|
639
|
+
|
|
640
|
+
下面这些是**改代码时**才需要知道的。包已用 `link:` 装进 `desktop` profile。启用要走插件管理器(不要手改 profile 文件):
|
|
641
|
+
|
|
642
|
+
1. `plugin_manager` `install_bundle`(或 `dsh plugin --profile desktop add`)——装包 + 选中 bundle;
|
|
643
|
+
2. `set_bundle` / `set_plugin` **两处都要开**:bundle 被选中不等于行被启用。
|
|
644
|
+
本插件曾在「bundle 已装、行 `enabled: false`」的状态下静默什么都不做。
|
|
645
|
+
3. **改过 `lib/index.js` 之后要重启应用**:宿主已把上一代模块留在进程里,
|
|
646
|
+
重新 enable 不会重新 import。这一点实测过两次(去掉 `Config`、修模型路由),
|
|
647
|
+
两次 `set_plugin` 都返回 `applied`,但跑的仍是旧代码——`POST /explain` 报的还是修复前的错。
|
|
648
|
+
平台行为:替换已安装的包需要重启才能加载新的 JS 模块代。
|
|
649
|
+
重启后 `C:\Users\user\.dsh\profiles\desktop\package.json` 的 `dsh.profile.bundles` 与
|
|
650
|
+
`cordis.patch.yml` 里的 `term-dictionary: disabled: false` 会让它自动激活。
|
|
651
|
+
|
|
652
|
+
**浏览器半边通常不用重启**:`lib/client.js` 是按产物 stat 重算 rev 后由 host 提供的,
|
|
653
|
+
重新构建后 host 立刻就在供新字节(`node tools/verify-plugin-row.mjs dsh-plugin-term-dictionary <新特征串>`
|
|
654
|
+
能证明:实测 rev 从 `16ff8ae5c296` 变为 `2d9aa8179993`,取回的正文与磁盘产物逐字节相同,
|
|
655
|
+
只差 host 追加的一行 `sourceMappingURL`)。**已经打开的页面是否换到新字节,取决于它启动时拿到的那份
|
|
656
|
+
启动图**:刷新一次即可;若刷新后仍是旧行为,说明启动图还指着旧 rev,重启一次就对了。
|
|
657
|
+
所以 **改样式/交互 → 先刷新;改 host 半边 → 重启应用**。
|
|
658
|
+
|
|
659
|
+
怎么确认真的活着(两种都实测过):
|
|
660
|
+
|
|
661
|
+
```powershell
|
|
662
|
+
# host 半边:路由是否在
|
|
663
|
+
(Invoke-WebRequest http://127.0.0.1:19387/dsh-term-dictionary/state -UseBasicParsing).StatusCode # 期望 200
|
|
664
|
+
|
|
665
|
+
# 浏览器半边:host 是否在提供本插件的 client 模块(rev 由产物 stat 重算)
|
|
666
|
+
node tools/verify-plugin-row.mjs dsh-plugin-term-dictionary
|
|
667
|
+
```
|
|
668
|
+
|
|
669
|
+
## 目录结构
|
|
670
|
+
|
|
671
|
+
| 路径 | 作用 |
|
|
672
|
+
|---|---|
|
|
673
|
+
| `lib/index.js` | host 半侧(ESM):词典文件、HTTP 路由、可选的模型调用 |
|
|
674
|
+
| `lib/client.js` | **构建产物**,浏览器半侧(由 `tools/build-client.mjs` 生成,不要手改) |
|
|
675
|
+
| `lib/client.template.js` | 浏览器 bundle 的外壳模板 |
|
|
676
|
+
| `lib/core/shell.js` | 浏览器半侧的装配:store、检测器、交互层、标注层、三个 slot |
|
|
677
|
+
| `lib/core/core.js` | 分词、术语形状判断、上下文截取(无环境依赖) |
|
|
678
|
+
| `lib/core/terms.js` | 术语检测器 |
|
|
679
|
+
| `lib/core/entries.js` | 词条模型与合并规则 |
|
|
680
|
+
| `lib/core/dictionary.js` | 词典文档操作与两种存储后端 |
|
|
681
|
+
| `lib/core/store.js` | 页面侧可订阅状态与持久化编排 |
|
|
682
|
+
| `lib/core/settings.js` | 三个功能开关(独立 `localStorage` 键,不进合并文档) |
|
|
683
|
+
| `lib/core/interact.js` | 对话区交互:自动收录、点击命中、悬停解释、选中建条 |
|
|
684
|
+
| `lib/core/highlight.js` | 标注层:把已收录的词算成 `Range` 并交给 CSS Custom Highlight |
|
|
685
|
+
| `lib/core/bus.js` | 会话区 → 面板的单槽命令总线(建条 / 定位词条) |
|
|
686
|
+
| `lib/core/views.js` · `overlay.js` · `styles.js` | 面板、气泡、样式 |
|
|
687
|
+
| `lib/core/lexicon.*.js` · `stopwords.js` | 内置数据 |
|
|
688
|
+
| `lib/core/package.json` | 把 `lib/core/` 标记为 CommonJS(不是包) |
|
|
689
|
+
| `tools/` | 构建、用例、检查、变异 harness、宿主探针(`tools/paths.mjs` 按 `dsh.bundle` 向上定位本包,所以 `tools/` 放在包里还是包外都能跑) |
|
|
690
|
+
| `LICENSE` · `CHANGELOG.md` | MIT 正文;每个**发布出去**的版本记一条 |
|
|
691
|
+
|
|
692
|
+
## 为什么要构建
|
|
693
|
+
|
|
694
|
+
两个约束共同决定了这个形状:
|
|
695
|
+
|
|
696
|
+
1. DSH 的客户端模块表交给插件 bundle 的同步 `require` **只能**解析平台种子模块
|
|
697
|
+
(`react` 等)和已注册的包工厂,**不能解析同目录文件**;`require.async` 也只按
|
|
698
|
+
`client.<name>.js` 的分块约定取文件。
|
|
699
|
+
2. 本包是 ESM,但浏览器 bundle 需要可调用的 `module.exports`。
|
|
700
|
+
|
|
701
|
+
因此可测试的 CommonJS 模块放在 `lib/core/`(有自己的 `"type": "commonjs"` 作用域标记),
|
|
702
|
+
由 `tools/build-client.mjs` 在构建时内联进唯一的 `lib/client.js`,并生成一个本地模块注册表
|
|
703
|
+
让源码里的 `require("./x.js")` 原样可用。`lib/index.js` 保持 ESM,通过 `createRequire`
|
|
704
|
+
读取同一份核心逻辑,两半因此共用完全一致的合并规则。
|
|
705
|
+
|
|
706
|
+
```powershell
|
|
707
|
+
node tools/build-client.mjs # 重新生成 lib/client.js
|
|
708
|
+
node tools/build-client.mjs --check # 校验产物是否最新
|
|
709
|
+
node tools/test-term-dictionary.mjs # 核心、词典、检测器、settings、bundle、清单、命令总线、主题令牌、模型路由、导入导出页(91 项)
|
|
710
|
+
node tools/test-interaction.mjs # 点击 / 悬停 / 选中 / 行内代码 / 自动收录 / 代码块 / 开关 / 预算 / 命中阶段 / 标注(55 项)
|
|
711
|
+
node tools/test-host-routes.mjs # host 半边:路由、状态序列化、解释请求的形状(15 项)
|
|
712
|
+
node tools/check-pointer-guards.mjs # 19 个变异:把每条指针链的断言逐条改坏,看对应守卫是否真的变红
|
|
713
|
+
node tools/check-revive-guards.mjs # 16 个变异:撤回 / 已删除视图 / 导入选项 / 控制台报告那几组守卫
|
|
714
|
+
node tools/check-display-meta.mjs # 宿主读到的名称/简介/图标(含 `exports` 与 `en` 回退层)
|
|
715
|
+
node tools/check-release-ready.mjs # 能不能发布:清单字段 + 真的打包 + 装进干净目录再 import
|
|
716
|
+
node tools/verify-plugin-row.mjs dsh-plugin-term-dictionary "术语词典" # 运行中的 host 是否在提供本插件的 client 行
|
|
717
|
+
```
|
|
718
|
+
|
|
719
|
+
这些命令都在**本仓库根目录**下跑(也就是插件包目录)。如果你是从上一层的开发工作区敲,
|
|
720
|
+
路径加前缀即可:`node dsh-plugin-term-dictionary/tools/test-term-dictionary.mjs`。
|
|
721
|
+
|
|
722
|
+
两个 `check-*-guards.mjs` 都在 `.tmp/<名字>/` 里各放一份整树副本,只在副本里改坏、重建、跑相应的用例,
|
|
723
|
+
最后比对源码校验和——源码不动,退出码非 0 就说明有变异没咬动。两者都把结果分三类:**BIT/咬动**
|
|
724
|
+
(点名的守卫真的红了)、**STALE**(点名的检查已经不存在了,说明变异清单该重定向或删除这一条)、
|
|
725
|
+
**ESCAPED**(检查还在但没咬动,那才是插件的问题)。变异条目对不上代码时**不要顺手删**:先看那句
|
|
726
|
+
代码的行为还在不在(合并通常只是换了文件),确实随功能消失的就在原处留一条 `RETIRED Mn` 注释。
|
|
727
|
+
|
|
728
|
+
`verify-plugin-row.mjs` 按宿主自己的算法重算 row rev(`sha1("plugin-artifact" + NUL +
|
|
729
|
+
framed(mtimeMs, ctimeMs, size))`),再把它取回来;取得到就说明这条 row 已经在**运行中的
|
|
730
|
+
host 的 client 图**里,而不是「磁盘上有文件」。实测它复现了 dshmarket 的 rev
|
|
731
|
+
`bb4970215ee3`,与本机安装记录一致。
|
|
732
|
+
|
|
733
|
+
## 还没做的(本次范围之外)
|
|
734
|
+
|
|
735
|
+
按「先做词典和手势功能」的分期,下面这一项**尚未实现**:
|
|
736
|
+
|
|
737
|
+
- **词典分享市场**:尚未开始。
|
|
738
|
+
|
|
739
|
+
已经可用的:搜索、四个视图(含**已删除**与**撤回**)、钉选、编辑、单条与批量删除、
|
|
740
|
+
设置页的 12 项偏好、导入 / 导出(含「按分类导出」与「导入我删除过的词条」)、
|
|
741
|
+
面板底部的「清空词典」(跳过钉选的)与「复制 JSON」、`用模型生成解释`。
|
|
742
|
+
|
|
743
|
+
## 已知限制
|
|
744
|
+
|
|
745
|
+
- 点击与悬停的命中都是**同一次走查**的查表:caret 给出 `(节点, 偏移)`,标注层把每段标记的
|
|
746
|
+
`(节点, 起点, 终点)` 记在 `segments()` 里,命中就是在这个区间表里查一次,不做几何估算,
|
|
747
|
+
也不拼整条消息的文本。仍然存在的限制是**时序**而不是精度:标注要等宿主渲染完(DOM 变化后
|
|
748
|
+
约 700ms 的 settle 节流)才画上去,指针在那之前到达同一个词是命不中的;另外它依赖
|
|
749
|
+
`document.caretRangeFromPoint` / `caretPositionFromPoint`,两者都没有时命中层不工作。
|
|
750
|
+
- 悬停读入延迟默认 **110ms**(`hover.js` 的 `HOVER_DELAY_MS`,设置页可调 0–200ms)。这是刻意的:
|
|
751
|
+
跟着每个 `pointermove` 出气泡会在鼠标移动时闪个不停。停留在同一个词上不会重复弹。
|
|
752
|
+
**读出延迟默认 0**:给「离开」加延迟读起来像卡顿,所以它默认关闭,需要时才在设置页打开。
|
|
753
|
+
- 标注只在宿主渲染完文本后生效,且在**滚动或 DOM 变化时重新计算**(沿用采集器那套 700ms
|
|
754
|
+
的 settle 节流),所以流式回复进行中标注会滞后一点,稳定后补齐。
|
|
755
|
+
- 术语只在**渲染后的对话文本**上识别,代码块、链接、输入区(composer)内的文本会被跳过。
|
|
756
|
+
- 消息块的划分是结构式的(下钻到「文本直接落在自己文本节点上」的元素),没有可依赖的
|
|
757
|
+
逐消息属性。这会在布局异常时**过度切分**而不是漏切——每条消息仍是一个块,但极端布局下
|
|
758
|
+
一条消息可能被切成多块。方向是刻意选择的:切多了每块的语义和指针几何仍然成立,
|
|
759
|
+
而把整份会话当成一块则不成立。
|
|
760
|
+
- 自动收录每条消息最多新建 3 个词条,避免一次刷屏。
|
|
761
|
+
- 未收录的词汇只有「看起来像术语」时才会弹出建条入口(命名形式、构词特征、缩写,或
|
|
762
|
+
中文 2–8 字词);在普通英文词、以及句首的 The/We 之类上点击不会弹窗。
|
|
763
|
+
- 模型解释依赖 profile 里挂载了 `llm` 服务和至少一个 provider adapter;没有时
|
|
764
|
+
「用模型生成解释」会明确报告不可用,其余功能不受影响。
|
|
765
|
+
- **配色只在「令牌是否真实存在」这一层被验证过,没有在真实页面里用眼睛确认。** 令牌审计(见上文)
|
|
766
|
+
能证明每个名字都被主题定义、以及亮/暗两套取值分别是什么,但「看起来对不对」需要在页面里看。
|
|
767
|
+
标注的 `::highlight()` 与主按钮的填充色都属于这一类:单测覆盖了 range 计算与令牌正确性,
|
|
768
|
+
观感要靠刷新页面确认。
|
|
769
|
+
- **host 半侧路由没有鉴权。** 见下一条。
|
|
770
|
+
- **复活词条的授权来自 `source` 字段,而 `source` 是发送方自己声明的。** 这是设计选择,
|
|
771
|
+
不是疏漏:插件要靠某个**已经上盘、已经过网线**的字段区分「用户要的内容」和「插件的自动检测」,
|
|
772
|
+
临时标记过不了序列化,时间戳又表达不了意图。代价是任何能写 `/dsh-term-dictionary/entries`
|
|
773
|
+
的进程都可以声称 `source: "user"` 来复活一个已删除的词条。该路由只监听本机回环地址,
|
|
774
|
+
且不经过任何鉴权中间件——如果将来要多用户或远程访问,这一条必须先收紧。
|
|
775
|
+
- 一个 payload 如果让某条记录的 `deletedAt` 和它所在的通道(`entries` / `backing`)自相矛盾,
|
|
776
|
+
插件按**通道**解释它:`backing` 里的记录一律当墓碑。这样「把活记录塞进墓碑通道」无法绕过删除规则,
|
|
777
|
+
代价是一个畸形 payload 可以借此删掉一个正常词条(同一件事的两面)。
|