dsh-word-translate
在 DSH Web 界面里选中英文单词、短语或整段文字,鼠标右键弹出菜单:
| 菜单项 | 行为 | 花 token |
|---|---|---|
| 复制 | 把选中文字放进剪贴板 | 不花 |
| 记录 | 弹出翻译记录浮层(查看 / 搜索 / 删除) | 不花 |
| 词典 | 查内置离线词典:中文释义、词性、音标、柯林斯星级、考试标签、词频排名 | 不花 |
| AI 翻译 | 把选中文字连同它所在的句子发给当前会话的模型,结合上下文翻译;支持整段 | 花 |
| 朗读 | 用浏览器自带的 SpeechSynthesis API 朗读英文原文 | 不花 |
AI 翻译的结果会自动存进「翻译记录」。记录以浮层卡片弹出:
- 可以拖动:抓住顶栏拖到任意位置,不会被挡住的会话内容遮住
- 关闭方式:点遮罩、点 ✕、或按 Esc,回到会话——全程不离开当前会话
- 每条卡片:查的词(大字)、译文、带高亮的原句
「复制」是必需的,不是锦上添花。 菜单为了替换系统右键菜单会调用
preventDefault(),这同时拿掉了系统的"复制"——所以在加上这个按钮之前, 选中英文后根本没法复制。剪贴板 API 不可用时回退到execCommand。
记录卡片为什么这样排版
初版把上下文拆成 before 和 after 两行显示,结果把查的词本身漏掉了:
菜单为了替换系统右键菜单会调用
0,这同时把系统的"复制"也拿掉了 ← 读者看不出查的是哪个词
现在合成一句,查的词在中间高亮,读起来就是完整句子:
菜单为了替换系统右键菜单会调用 preventDefault,这同时把系统的"复制"也拿掉了
字号也整体调大(查的词 17px、译文 15px、上下文 14px),并去掉了卡片底部那行 时间 / token / 模型——那些是调试信息,不是学习时要看的东西。
两个按钮的设计意图
「词典」不花 token,因为它是本地数据。 查词是高频动作,如果每次都调用模型, 成本会随着阅读量线性增长——而"这个词什么意思"大部分时候并不需要模型。
「AI 翻译」才用模型,因为上下文需要理解。 bank 在 river bank 里是河岸、
在 balance at the bank 里是银行——这不是查表能解决的,必须读懂句子。
两者互补:先用词典(免费、秒回),词典没收录或需要看语境时再用 AI 翻译。
词典:为什么值得内置
数据来自 ECDICT(MIT,77 万词条),构建时裁到 26,974 词 / 3.05 MB。 保留的判据用的是 ECDICT 自带的评级,不是我自己编的:
| 字段 | 作用 |
|---|---|
translation |
中文释义(最多 4 个义项) |
phonetic / pos |
音标、词性 |
collins |
柯林斯星级(1–5) |
oxford |
是否 Oxford 3000 |
tag |
考试标签:中考/高考/四级/六级/考研/托福/雅思/GRE |
bnc / frq |
BNC / COCA 词频排名(数字越小越常用) |
最后三行才是关键。 一个词的释义机器也能翻,但"这是四级词、柯林斯 5 星、词频 排第 406"回答的是另一个问题:这个词值不值得记。学习时间有限,应该优先学高频词—— 这些字段让插件能直接告诉你该不该学。
查 banks 会自动跳到 bank:词典里存了 27,241 个屈折形式的映射
(banks→bank、running→run、better→good),因为词根条目才有完整的释义和评级。
上游数据来源与协议声明见 data/NOTICE.md。
翻译记录:既是历史,也是缓存
每一条成功的 AI 翻译都会存进宿主端的持久化存储
(ctx.storage → dsh-storage-json,落在 $DSH_HOME/storages/word_translate.json),
而不是浏览器 localStorage。理由是:
- 数据活过浏览器 profile,是可读的 JSON 文件,能备份能看
- 换浏览器/清缓存不会丢
- 写入是原子替换(先写临时文件再 rename),带版本号
同一条内容(原文 + 前后文一起做 key)如果没被删过,再次翻译会直接调出来, 不再调用模型——这就是持久缓存。上下文参与 key 是必须的:同一个词在不同句子里 是不同答案。
记录可以在面板里搜索、单条删除、一键清空。
选区可以是整段
选一整段(多句话)时,整段都会被翻译。这一条曾经是坏的,值得记下来:
原来的系统提示词通篇把选区称作 "selected term"(词/术语),模型就照字面理解, 只翻译了第一句。实测 227 个字符的三句话选区,只返回了 14 个字符——第一小句的译文。 模型不是"偷懒",是提示词就是这么要求的。
修法是两处:提示词改称 "selection" 并明确"必须整段翻译、长度绝不是缩短的理由",
用户消息里再按句数提醒一次(The selection is N sentences long)。
同时加了一道兜底:如果所有尝试都只译出开头(译文长度不足原文的 15%), 就报错让用户重试,而不是把第一句当成整段译文显示出来——那比报错更误导。
修复后实测同一段:8/8 完整(122–130 字符),之前是 14 字符。
为什么是"前后各一段"
同一个词在不同上下文里是完全不同的词。插件把选区所在段落的前一个和后一个同级段落一并送出,所以:
grassy bank→ 河岸check his account balance at the bank→ 银行mount the React root里的mount→ 挂载
两个已修复的模型行为陷阱
这两条都是实测发现的,不是推断出来的;写在这里是因为它们直接决定了代码的形状。
1. 模型有时把答案写在"推理块"里
deepseek-v4.1-flash 是推理模型。它有时完全不在正文块里回答,把答案写进推理就结束了:
reasoning: 'The term "mount" in React context means 挂载.'
finish: stop ← 没有任何 text 块
按协议只读 text-delta 的话,这里会看到一个"空答案",于是一次成功的调用被误报成失败。
2. 模型有时吐出被截断的正文
同一个请求的另一次尝试会给出:
text: '挂' ← 被截断的正文
reasoning: '…"mount" = 挂载.' ← 模型自己知道完整答案
模型知道正确答案,只是没写全。 实测同一请求在 挂 与 挂载 之间随机摆动(8 次里 4:4)。
长句上更明显:7 个单词的英文短语曾被答成 一键(2 个字)就停了。
处理分两层:
第一层:对账(reconcileAnswer)——正文与推理之间取更完整的那个:
| 正文 | 推理里点名的结论 | 结果 |
|---|---|---|
| 空 | 挂载 |
用 挂载 |
挂(单字) |
挂载 |
用 挂载 |
…绑定到选择器(长句截断) |
…绑定到选择器钩子 |
用完整的那个 |
配置(完整) |
推理里只是提到过 配置文件 |
保留 配置 |
最后一行是关键:只有正文是某个被明确标记为结论的答案(Final: / Answer: /
最终: / = X)的前缀时才会替换。推理里顺带提到的更长词组不算——那会引入新错误
而不是修复。
第二层:判为残句就重试(最多 4 次)。三种信号之一成立即认为正文不完整:
| 信号 | 例子 | 为什么能看出来 |
|---|---|---|
| 单字 | 挂 |
一个汉字几乎不可能是完整译文 |
| 末尾是不能收句的虚词 | 一键重启按钮被 |
被 不能结束句子 |
| 末尾是必须带动词的副词 | 一键重启按钮被故意 |
故意 后面必须还有动词 |
| 明显过短 | 一键(源文 7 个单词) |
多词短语不可能只用 2 个字承载 |
重试取最长的一次作为兜底;如果每次都是残句,就返回最长的那个残句(好过报错), 但完全没产出正文时仍然报错,不会拿一段乱码充数。
修复后实测:mount / bank / volume 各 3/3 稳定;7 个单词的长短语从 4/10 完整提升到
10/10 完整。
一个我查错过、值得记下来的点。 我一度以为长句被截断是"读 chunk 的代码丢了尾巴", 于是加了个临时调试路由把原始 chunk 序列打出来。结果证明读取没问题:模型只是有时 少写一个句末虚词(
…选择器钩子vs…选择器钩子中)——那是风格差异,不是截断。 先拿证据再改代码,这一步省掉的话我会去修一个不存在的问题。
架构
插件是标准的 DSH 双半 bundle:
宿主半(
lib/index.js)注册三条 loopback-only 路由:路由 作用 /dictionary查内置词典,不调用模型 /translate用 ctx.llm.stream()发起侧信道模型调用——和会话标题生成走同一条辅助调用路径,不写入会话日志,不产生对话轮次/history翻译记录的增 / 删 / 查 浏览器半(
lib/client.js)注册两个shell.overlay条目:右键菜单,和记录浮层。 记录入口就在右键菜单里,没有额外的工具栏按钮。
记录界面绝对不能放进
main插槽。 会话(连同输入框)就渲染在main的conversationkey 下面,注册自己的mainkey 并选中它,会把整个会话卸载掉; 而"会话"是activePanelId === null时的默认值、不是侧边栏的一行,所以点了之后 没有任何入口能回去——表现就是输入框失效、只能重启。我第一版正是这么做的(
main+sidebar.panellist),踩了这个坑。 现在记录是shell.overlay上的浮层,浮在框架之上、不参与main的 key 分发, 所以打开和关闭都不可能影响会话。
第二个"输入框失效"的坑:window.confirm
修好 main 之后,清空记录又出现了同样的症状——输入框打不了字,只能重启。
单条删除一直正常,唯一的差别是清空用了 window.confirm。
根因:window.confirm 是同步阻塞的原生对话框,会卡住渲染进程的主线程。
DSH 的编辑器是 contenteditable,对话框期间浏览器丢失 selection / 焦点状态,
回来之后编辑器再也拿不回焦点——表现就是打不了字。
这不是猜测:整个 DSH 自带 UI 里 window.confirm 出现 0 次(我在打包产物里
grep 过)。DSH 有正规的 Modal 组件(plugin-manager、workspace 都在用)。
是我用了一个整个项目都刻意回避的 API。
修法:改成两步按钮,完全不用对话框——第一次点击按钮变成红色的 「确认清空 N 条」,第二次点击才真的清空,4 秒后自动解除。清空记录本来也不值得 为它弹一个模态框。
tools/client-eval.test.mjs 现在会扫描源码禁止 window.confirm / alert /
prompt(tools/guard-selftest.mjs 反过来验证这条护栏真的会触发,而不是空转)。
教训:在这个项目里,"别的框架能用"不等于"这里能用"。
window.confirm在普通 网页里完全正常,但在 Electron +contenteditable编辑器里会毁掉输入焦点。 判断依据应该是项目自己的既有做法,而不是通用经验。
一个静默的数据丢失 bug(值得记)
dsh-storage-json 的 KV 后端每个 unit 只允许一个活句柄,第二次 open 会直接抛
unit '...' is already open。
我第一版 openHistory() 只 open、不 close。后果非常隐蔽:
- 第一次激活拿到句柄,然后永远持有;
- 之后每次激活(包括热重载产生的)
open都失败; - 失败被 catch 住并降级到内存存储——UI 完全正常,翻译也正常返回;
- 但新记录再也不会写进磁盘,而界面上看不出任何异常。
只有把 unit 的 close() 注册成 ctx.effect 的 disposer 才正确:fiber 卸载时释放
句柄,下一次激活才能重新打开。tools/history-reopen.test.mjs 用「单句柄」规则
的桩把这个生命周期钉住了——它会先卸载再重开,正是热重载做的事。
教训:catch 之后静默降级,会把"持久化失效"变成"看起来一切正常"。
所以记录界面会显示一条 durable: false 的警告,而不是假装写成功了。
模型选择
翻译使用的模型按优先级解析:
- 当前会话自己的模型选择(
modelSelection投影的pending/lastUsed); - 该会话最后一次真实请求头里的 provider/model;
- 部署默认模型。
会话冷启动或已销毁时逐级回退,不会让一次翻译失败。
上下文采集
这是本插件改过的第二个严重缺陷。 原来的实现只取选区的上一个和下一个兄弟块, 完全不看选区在自己那段里的位置——于是选区所在句子的其余部分一个字都没被送出去。
实测(选中 false,它在 重启后如果 durable 还是 false,告诉我,我再查。 里):
| 送出去的上下文 | 结果 | |
|---|---|---|
| 旧 | before = 上一个 <ol> 列表(两条 <li> 被空格拼成一行)after = 下一段关于素材的话 |
模型答 假(当形容词翻了) |
| 新 | before = 重启后如果 durable 还是after = ,告诉我,我再查。 |
模型答 false(认出是代码值) |
现在的优先级,最相关的排第一:
| 优先级 | 内容 | 说明 |
|---|---|---|
| 1 | 选区所在的句子 | 从块文本里定位选区,再向前/向后切到最近的句末标点 |
| 2 | 同块剩余文字 | 仅当句子太短(不足 12 字)不足以消歧时 |
| 3 | 邻居块(限长 400 字) | 仅当块内什么都取不到时的兜底 |
句末标点同时认半角和全角(.!?。!?;;),因为周围是中文、选中的是英文,
句子边界两种都可能出现。
输入框(编辑器、<textarea>)走同一条句子切分逻辑,只是文本源是
selectionStart/selectionEnd 切片。两侧都限长 2000 字符。
为什么原来会那么写:插件最初的目标是"查英文单词",对孤立的单词来说 前后各一段确实够用。但当用户选中句子里的词(
false、durable), 同一句话才是真正的上下文——而它恰好是被丢掉的那部分。
菜单显示
| 情况 | 菜单显示 |
|---|---|
| 选区 ≤ 40 字 | 顶部回显"选中 <文字>" |
| 选区 > 40 字 | 不回显(长文本来就在菜单后面看得见,回显会把操作挤出屏幕) |
| 菜单顺序 | 复制 / 记录 / 词典 / AI 翻译 / 朗读,下面是音量(和语音) |
| 点「复制」 | 按钮变成 已复制,不额外占一行 |
| 点「词典」 | 音标 + 词性 + 评级徽章(牛津3000/柯林斯星级/考试标签)+ 释义 + 词频 |
| 词典没收录 | 内置词典没有收录这个词。可以用「AI 翻译」。 |
| 翻译成功 | 译文 + 本次翻译使用 N token(输入 x / 输出 y) |
| 命中内存缓存 | 译文 + 缓存 |
| 命中历史记录 | 译文 + 来自记录(重启后仍生效) |
| 点「记录」 | 菜单关闭,记录浮层弹出 |
| 正在朗读 | 按钮变成 停止朗读,再按一次即停止 |
朗读不会清掉查词结果
内容状态和播放状态必须分开。 初版把两者挤在同一个 result 里,所以点「朗读」
会用 {status:"spoken"} 覆盖掉词典条目或译文——刚查完想听发音,结果内容没了。
现在拆成两个状态:
| 状态 | 管什么 |
|---|---|
result |
卡片内容:词典条目 / 译文 / 错误 |
speaking |
是否正在播放,只驱动按钮文案 |
所以「查词典 → 朗读」是叠加的,词典条目始终留在卡片上。切换新选区时会
stopSpeaking() 并复位,避免残留"停止朗读"。
菜单不显示模型名——用的就是当前会话的模型,不需要重复告知。
长译文不会溢出屏幕
菜单原本只有 max-width、没有 max-height,所以一段长译文会一直往下长、
跑出屏幕底部,后面的内容再也看不到。修法是三层配合:
- 卡片限高:
max-height: calc(100vh - 16px),永远不会超出视口。 - 译文可滚动:只有译文区允许被压缩(
flex: 1、overflow-y: auto), 按钮和音量条保持原尺寸不被挤掉。 - 按真实尺寸定位:
useLayoutEffect量出卡片的实际宽高再决定位置, 而不是用写死的估算值——长译文和短词的实际高度差很多,估算必然出错。 量完在绘制前修正位置,所以看不到跳动。
朗读设置
菜单里有音量滑块和语音选择,两项都记在 localStorage,下次打开还在。
关于"音量拉满还是太轻": SpeechSynthesisUtterance.volume 的上限就是
1.0(W3C 规范硬限制),插件无法超过它。所以滑块只能调小,不能把系统音量
之上的响度提上去。真正影响响度的是语音本身——Windows 上不同语音的响度差别很
大——所以菜单提供了语音下拉框。如果仍然偏轻,请调系统音量(或换一个语音)。
语音 下拉框只在浏览器报告多于一个英语语音时出现;只有一个时显示它没有意义。
缓存与重试
分两层,都是按 (选中文字, 前文, 后文) 做 key——上下文参与 key 是必须的,
同一个词在不同句子里是不同答案。
| 层 | 位置 | 寿命 | 命中时 |
|---|---|---|---|
| 内存缓存 | 插件 fiber 内 | 10 分钟 / 200 条,fiber 销毁即清 | 缓存 |
| 历史记录 | $DSH_HOME/storages/word_translate.json |
永久(直到你删除) | 来自记录 |
先查内存(免费),未命中再查历史(要读盘但不用调模型),都没有才调用模型。
- 重试:一次失败会再试一次。模型对"想多久"并不稳定,同一次请求可能第一次 推理完就结束、第二次正常作答,重试把这种抖动变成成功。
- 失败不缓存,稍后重试仍有机会成功。
- token 统计:重试时会把各次尝试的用量相加,所以报告的是这次翻译的真实花费, 而不只是成功那一次的花费。
- 删除记录会同时清掉内存缓存。否则删掉后重新翻译会命中内存、记录不再重建, 面板上看起来"删了就永远找不回来"。
安装
从本地目录:
dsh plugin --profile desktop add <本目录路径>
发布到 npm 后也可以按包名安装:
dsh plugin --profile desktop add dsh-word-translate
本插件零运行时依赖,所以安装不需要 allowBuilds 构建授权——比需要编译的插件省事。
改宿主半代码需要重启 DSH。 本 profile 的 HMR 默认
root: [](不监听任何 模块根目录),所以lib/index.js只在启动时读一次;用插件管理器禁用再启用 不会重新加载它,会继续跑缓存里的旧模块。浏览器半不受影响,由 client-modules 的 HMR 通道热发布。
上架到插件市场
DSH 没有"一键上传"。上架是两步,完整步骤见 submission/README.md:
- 代码推到 GitHub 仓库
- 往 awesome-dsh-plugin
提一个 PR,加一个 YAML 文件(
data/plugins/<owner>__<repo>.yml)
合并后市场自动收录,通常一天内生效。不要往 dsh-market 仓库提插件条目。
投稿内容已准备好:submission/awesome-dsh-plugin.yml
(把 OWNER/REPO 换成真实地址即可)。
node tools/check-listing.mjs # 逐条核对收录要求(31 项)
配置
无。插件不注册任何设置命名空间,也不需要凭据——它复用当前会话已有的模型通道, 词典是内置数据,翻译记录用宿主自带的存储栈。
测试
node tools/host-logic.test.mjs # 翻译逻辑、对账、重试(16 项)
node tools/dictionary.test.mjs # 词典查询、词形还原(19 项)
node tools/routes.test.mjs # 三条路由 + 历史存储语义(38 项)
node tools/history-reopen.test.mjs # 持久化句柄生命周期(7 项)
node tools/client-eval.test.mjs # 客户端模块求值 + 槽位注册 + 禁用阻塞对话框(21 项)
node tools/context-capture.test.mjs # 上下文句子优先 + 菜单顺序 + 记录卡片 + 朗读不覆盖内容(35 项)
node tools/guard-selftest.mjs # 验证上面的禁用对话框护栏真的会触发(6 项)
node tools/check-listing.mjs # 核对插件市场的收录要求(31 项)
其中几项是回归护栏,专门钉住踩过的坑:
client-eval.test.mjs断言没有注册main或sidebar.panellist—— 因为注册它们会把会话卸载掉(见"架构")。history-reopen.test.mjs断言 unit 在 fiber 卸载时被释放 —— 否则后续激活会静默降级到内存,记录不再落盘。context-capture.test.mjs断言菜单按钮顺序是 复制/词典/AI翻译/记录/朗读 —— 顺序是需求,不是细节(复制必须在最前,因为它替代了系统菜单里的复制)。
边界与已知限制
- 三条路由都只接受 loopback 来源,非本机 origin 一律 403。历史路由也在此列, 因为它暴露的是你的阅读记录。
- 单次翻译超时 45 秒;输出上限 8192 token(推理与译文共用该预算)。
- 词典是离线快照:只收录 26,974 个常用词,冷僻词、专有名词、新词查不到——
此时用「AI 翻译」。数据更新需重新运行
tools/build-dict.mjs。 - 朗读依赖浏览器的语音合成;没有任何已安装语音时,Chromium 可能静默失败, 此时菜单会显示错误而不是假装成功。
- 音量上限 1.0 是规范限制,插件无法让朗读比系统音量更响;见上文"朗读设置"。
- 上下文只在块内和邻居块之间取,不跨容器边界(例如表格单元格之间互不作为上下文)。 句子切分是标点驱动的启发式,遇到没有标点的长句会退化为整块。
- 推理对账(
reconcileAnswer)是针对deepseek-v4.1-flash实测行为写的启发式: 它只在"正文为空"或"正文是单字且推理给出了更长的同前缀答案"时介入。换用其他 模型时这段逻辑不会造成伤害(大多数模型本来就把答案放在正文块),但也不再必要。
No comments yet. Be the first to write one.