Bundle
dsh-word-translate
Right-click selected English in the DSH Web UI for a bundled offline dictionary (POS, phonetics, Collins rating, exam tags, frequency rank), a context-aware AI translation, and speech playback. Translations are saved to a searchable history, opened from a 翻译记录 row in the sidebar.
- Source
- Gty2408
- License
- MIT
- Updated
- Updated 2 days ago
Readme
# 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](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`(右键菜单、记录浮层),
和一个 `sidebar.panellist` 行 **翻译记录**(在「插件」正下方)。
> **记录界面绝对不能放进 `main` 插槽。** 会话(连同输入框)就渲染在 `main` 的
> `conversation` key 下面,注册自己的 `main` key 并选中它,会**把整个会话卸载掉**;
> 而"会话"是 `activePanelId === null` 时的默认值、**不是侧边栏的一行**,所以点了之后
> 没有任何入口能回去——表现就是**输入框失效、只能重启**。
>
> 我第一版正是这么做的(`main` + `sidebar.panellist`),踩了这个坑。
> 现在记录是 `shell.overlay` 上的浮层,浮在框架之上、不参与 `main` 的 key 分发,
> 所以**打开和关闭都不可能影响会话**。
### 侧边栏那一行怎么绕开 `selectPanel`
`sidebar.panellist` 的行由侧边栏自己渲染,它的点击回调固定调用
`ctx.layout.selectPanel(id)`,而该方法在**没有同名 `main` 面板时会直接抛错**:
```
layout.selectPanel: main panel "…" is not registered
```
所以"只想开一个浮层"的行不能直接注册——点了要么抛错,要么(照老办法注册 `main`)
把会话卸掉。两条路都不能走。
**做法**:行本身照常注册,拿到侧边栏给的 `<button>`,在**捕获阶段**挂一个自己的
click 监听,先 `preventDefault()` + `stopPropagation()`,再打开浮层。
捕获阶段早于侧边栏自己的 React 回调,所以 `selectPanel` **永远不会被调用**,
当前面板也就不会变——会话不受影响。
> 图标是行内画的 SVG(时钟 + 逆时针箭头),没有引用 DSH 的图标包:
> 客户端插件**无法 import `@deepseek-ai/*`**。用 `currentColor` 描边,
> 所以悬停和主题切换会自动跟随。
### 第二个"输入框失效"的坑:`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>` 被空格拼成一行)<br>after = 下一段关于素材的话 | 模型答 **`假`**(当形容词翻了) |
| **新** | before = `重启后如果 durable 还是`<br>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 统计**:重试时会把各次尝试的用量相加,所以报告的是这次翻译的真实花费,
而不只是成功那一次的花费。
- **删除记录会同时清掉内存缓存**。否则删掉后重新翻译会命中内存、记录不再重建,
面板上看起来"删了就永远找不回来"。
## 安装
从本地目录:
```sh
dsh plugin --profile desktop add <本目录路径>
```
发布到 npm 后也可以按包名安装:
```sh
dsh plugin --profile desktop add dsh-word-translate
```
本插件**零运行时依赖**,所以安装不需要 `allowBuilds` 构建授权——比需要编译的插件省事。
> **改宿主半代码需要重启 DSH。** 本 profile 的 HMR 默认 `root: []`(不监听任何
> 模块根目录),所以 `lib/index.js` 只在启动时读一次;用插件管理器禁用再启用
> **不会**重新加载它,会继续跑缓存里的旧模块。浏览器半不受影响,由 client-modules
> 的 HMR 通道热发布。
## 上架到插件市场
DSH **没有"一键上传"**。上架是两步,完整步骤见 **[submission/README.md](submission/README.md)**:
1. 代码推到 GitHub 仓库
2. 往 [awesome-dsh-plugin](https://github.com/Gty2408/dsh-word-translate)
提一个 PR,加**一个** YAML 文件(`data/plugins/<owner>__<repo>.yml`)
合并后市场自动收录,通常一天内生效。**不要**往 `dsh-market` 仓库提插件条目。
投稿内容已准备好:[submission/awesome-dsh-plugin.yml](submission/awesome-dsh-plugin.yml)
(把 `OWNER/REPO` 换成真实地址即可)。
```sh
node tools/check-listing.mjs # 逐条核对收录要求(31 项)
```
## 配置
无。插件不注册任何设置命名空间,也不需要凭据——它复用当前会话已有的模型通道,
词典是内置数据,翻译记录用宿主自带的存储栈。
## 测试
> ⚠️ **下面的测试文件目前不在本仓库里。** 开发时它们位于 `tools/`,但从未提交,
> 所以从 GitHub 装的副本里没有它们。这些数字记录的是开发期间的覆盖情况,
> 想跑请自行准备对应文件。
```sh
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` 那一行是允许的,但断言它的点击**不会**走到
`selectPanel`(捕获阶段就被拦掉)。
- `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` 实测行为写的启发式:
它只在"正文为空"或"正文是单字且推理给出了更长的同前缀答案"时介入。换用其他
模型时这段逻辑不会造成伤害(大多数模型本来就把答案放在正文块),但也不再必要。
Install
dsh plugin --profile web add github:Gty2408/dsh-word-translate
Profile: web
With the hub plugin installed, ask your agent to install it by name — it resolves the same plan shown here.
dsh plugin --profile web add github:stvlynn/dsh.fish#path:packages/dsh-plugin-hub
install dsh-word-translate from the hub
- This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.