DSH HUB
HomePlugin StorePlugin PacksCommunityRankingsResourcesPublish Guide
Plugin source
Back to catalog

Gty2408 /

Gty2408/dsh-word-translate

Verified

Right-click selected English in the DSH Web UI for a bundled offline dictionary (part of speech, phonetics, Collins rating, exam tags and frequency rank), a context-aware AI translation, and speech playback. Translations are saved to a searchable, draggable history panel that doubles as a persistent cache.

★ 0 Stars0 Forks0 IssuesN/A Community rating0 Confirmed installs
View on GitHub
READMESource: main@a3e09578

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 的 conversation key 下面,注册自己的 main key 并选中它,会把整个会话卸载掉; 而"会话"是 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。后果非常隐蔽:

  1. 第一次激活拿到句柄,然后永远持有;
  2. 之后每次激活(包括热重载产生的)open 都失败;
  3. 失败被 catch 住并降级到内存存储——UI 完全正常,翻译也正常返回;
  4. 但新记录再也不会写进磁盘,而界面上看不出任何异常。

只有把 unit 的 close() 注册成 ctx.effect 的 disposer 才正确:fiber 卸载时释放 句柄,下一次激活才能重新打开。tools/history-reopen.test.mjs 用「单句柄」规则 的桩把这个生命周期钉住了——它会先卸载再重开,正是热重载做的事。

教训:catch 之后静默降级,会把"持久化失效"变成"看起来一切正常"。 所以记录界面会显示一条 durable: false 的警告,而不是假装写成功了。

模型选择

翻译使用的模型按优先级解析:

  1. 当前会话自己的模型选择(modelSelection 投影的 pending / lastUsed);
  2. 该会话最后一次真实请求头里的 provider/model;
  3. 部署默认模型。

会话冷启动或已销毁时逐级回退,不会让一次翻译失败。

上下文采集

这是本插件改过的第二个严重缺陷。 原来的实现只取选区的上一个和下一个兄弟块, 完全不看选区在自己那段里的位置——于是选区所在句子的其余部分一个字都没被送出去。

实测(选中 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,所以一段长译文会一直往下长、 跑出屏幕底部,后面的内容再也看不到。修法是三层配合:

  1. 卡片限高:max-height: calc(100vh - 16px),永远不会超出视口。
  2. 译文可滚动:只有译文区允许被压缩(flex: 1、overflow-y: auto), 按钮和音量条保持原尺寸不被挤掉。
  3. 按真实尺寸定位: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:

  1. 代码推到 GitHub 仓库
  2. 往 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 实测行为写的启发式: 它只在"正文为空"或"正文是单字且推理给出了更长的同前缀答案"时介入。换用其他 模型时这段逻辑不会造成伤害(大多数模型本来就把答案放在正文块),但也不再必要。
—/ 5

No ratings yet

Verified DSH bundle

Commit a3e09578e7ea

Community comments

No comments yet. Be the first to write one.

DSH HUB

A community index for DSH plugins. Not an official GitHub or DeepSeek AI product.

CommunityResourcesAPIAbout