# @lynn123411/dsh-chat-translate 详情

> 本文档是 [README.md](./README.md) 的补充：配置项、装配形态、开发副本与验证。

## 配置

### 1. API Key

打开「插件」→ 本插件详情页（配置就在描述之下的 AI 翻译卡片里），直接填写 API Key（离开输入框即自动保存，经 DSH 凭据服务写入 `~/.dsh/.credentials.yaml` 的 `TRANSLATE_API_KEY`，权限 0600，保存后立即生效；留空离开 = 清除该键）。也可以手动编辑该文件：

```yaml
refs:
  TRANSLATE_API_KEY: sk-xxx
```

> ⚠️ **注意**：DSH 的凭据加载器要求 refs 键值非空。手动编辑时要么填写真实 Key，要么**直接删除该行**——不要写成 `TRANSLATE_API_KEY: ""` 这类空值，否则 DSH 启动会失败。未配置时插件不翻译、正文保持原文，但**如实报出「设置没配好」这一类**：那一段左缘留下 ↻，气泡与读屏都指向配置页。

### 2. Base URL、模型、超时与提示词

在本插件的配置页里填写 Base URL（如 `https://api.openai.com/v1` 或 `https://api.deepseek.com/v1`）与模型名（如 `gpt-4o-mini`、`deepseek-chat`），离开输入框即自动保存。**改动先停在页面上**：打字期间一个字都不写盘，离开那个输入框才提交；卡片上没有保存按钮。想验一次通道，点「测试 AI 通道」。

所有设置都是本插件在 profile 里的插件配置：写入当前 profile 的 patch（`~/.dsh/profiles/<profile>/cordis.patch.yml`）中 `dsh-chat-translate` 条目的 `config`：

```yaml
- id: dsh-chat-translate
  name: '@lynn123411/dsh-chat-translate'
  config:
    enabled: true
    aiTimeoutMs: 600000
    baseUrl: https://api.deepseek.com/v1
    model: deepseek-chat
    prompt: 把收到的正文整理成简体中文，外文照译，中文原样抄写。
```

| 键 | 默认值 | 含义 |
| --- | --- | --- |
| `enabled` | `true` | 总开关。与输入框下方那一行的「译」胶囊读写同一个字段，两处状态同步；拨一下就写，不用等离开输入框。这一行只在会话形态渲染，新建会话的 Hero 形态没有它。 |
| `aiTimeoutMs` | `600000` | 单次请求超时（毫秒），范围 500–900000。每行首跑与每次手点都等到真实结果，嫌等就调小。 |
| `baseUrl` | 空 | OpenAI 兼容服务的接入地址。 |
| `model` | 空 | 翻译用的模型名。 |
| `prompt` | 空 | 拼在待译正文前面的那段指令。空着 = 用内置默认（`src/prompt.ts` 的 `DEFAULT_PROMPT`），配置页上看到的就是这份；写了就整段以配置页那段为准。多段打包时追加的标记约定由插件自己接在后面，不占这一项。请求里只有一条 user 消息：这一段在前，待译正文在后。 |

目标语言固定为简体中文，没有配置项。空着离开等于清除该项（`unset`，回落到默认值）；超时草稿填了非数字时不写盘，页面报出原因、草稿留在页面上供改正。提示词与内置默认一字不差时同样存成空值：配置页的输入框始终显示「生效的那份」，读者不会看到一个空框却不知道模型收到什么。

### 2.1 提示词

提示词是配置页上单独的一张卡片，平时**只读**：一进页面看得到模型实际收到的那整段指令，改不动；点标题右侧「编辑」才进改写态，焦点直接落进输入框，同排多出「恢复默认」与「完成」。点「完成」（或直接离开输入框）收起改写态并把改动写盘；把框清空再点「完成」时，框里填回生效中的指令——只读态永远显示模型实际收到的那一份，不会停在一个空框上。只读态不摆「恢复默认」，免得看着像能直接改。

内置默认那一段的写法有几条承载行为，改文案时别删掉（`src/prompt.ts` 的注释里逐条写了理由）：开头先用两个短例立住行为，示例必须排在规则**前面**且写成「输入/输出」的对话样式（放到后面或写成「原文/译文」，模型会把示例连同规则一起复读进译文）；首句说「原样抄写」而不是「翻译」或「整理」（这两类写法让模型回到改写模式，已通顺的中文会被换一套说法）；中文说的是「原样抄写，只有句子读不通时才改那几句」，那是它唯一被允许改动中文的入口；Markdown 记号只提醒一句，逐行对齐与链接回填由代码兜底。

正文分了段时，`promptFor()` 还会在生效指令末尾追加一句成段保真（「原文分几段的，译文也分几段，一段都不要少」），目的是压住「只写开头那段、后面几段整段不写」的退化。它不进默认那一份、也不进配置页的输入框：追加与否由正文段落数决定，读者改指令不用管这件事。实测对照见上面「提示词改动对照」。

## 提示词改动对照

在 `hy-mt2-1.8b` 上量过一次，同一套样本（`scripts/prompt-samples.mjs` 里五条已通顺中文、两条病句），五轮取平均：

| 写法 | 已通顺中文的平均改动率 | 两条病句改通 |
| --- | --- | --- |
| 八段英文，「改写成地道中文」 | 约 30% | 是 |
| 「翻译为简体中文，若原文通顺则原样输出」，无示例 | 约 28% | 是 |
| 首句用「整理成简体中文」 | 约 20% | 是 |
| **出厂默认**：两个短例 + 「外文译成简体中文；中文原样抄写」 | **约 2%** | 是 |
| 出厂默认去掉示例 | 约 27% | 是 |

删掉示例是最伤的一步，因为它决定模型把中文当待译内容还是当抄写对象。改完想重新量一次就手动跑 `node scripts/compare-prompt.mjs`（不联网的那份测试不加载它）。

另有一条量的是**成段保真**：多段中文里，后几段会不会整段不写回来。`hy-mt2-1.8b` 上四条多段样本各五轮：

| 写法 | 多段中文整段丢失 | 单段中文的平均改动率 |
| --- | --- | --- |
| 不加这一句 | 10/20 | 约 2% |
| 那一句常驻（单段正文也带上） | 8/20 | 约 9% |
| **现行**：正文分了段才追加 | **5/20** | **约 2%** |

单次请求仍会偶发缺段，所以这一句只负责把概率压下来，真正的保证在宿主那层（正文少了不照收，见下面「请求方式」）：端到端跑整条管道（条件追加 + 缺段重掷换形状 + 缺段不照收）时，读者看到缺段是 **0/16**。表里前两行是取舍依据——常驻会把本来只有一段的中文多改一遍（约 2% → 约 9%），所以「什么时候追加」和措辞一样是判据的一部分：它不在 `DEFAULT_PROMPT` 里，由 `src/prompt.ts` 的 `promptFor()` 按正文段落数追加，接在生效指令的末尾（读者自己改过的指令同样在后面接上，保真不归读者维护）。

两层各管一段：提示词让模型一开始就写全，宿主保证即使模型还是写少了，缺段也不会被当成成品收下。

内置默认本身改动时改 `src/prompt.ts` 并顺手升 `src/server/prompt-revision.ts` 的 `PROMPT_REVISION`。读者在配置页改的提示词不用升那个号：缓存世代是 `PROMPT_REVISION` 加生效提示词与采样参数的摘要（`cacheGeneration`），改了措辞就自动换一代，旧译文不再命中。浏览器半边那池按原文内容登记的译文跟着一起丢掉（`chatTranslate.dropTranslations()`，接线在 `src/client/index.ts`），否则本页里翻过的段落会一直用旧措辞的产物、一个请求都不发。已经显示在屏幕上的译文不动——下一块要翻的正文才用新指令。

### 3. 翻译范围与时机

插件接管助手行的渲染（keyed slot `conversation.chat.node` / `assistant-step`，priority -1）。**过程步骤**的正文块一律送模型——「用时 XXX」大折叠块里每一步露出的正文照送，整段中文同样照送（中文当抄写对象：能读通就一个字都不动，只有句子读不通时才改那几句）。模型返回与原文一模一样的文字时同样算翻译过：照常显示、照常可切换。trim 后为空的块不送译、不占下标、永不标记；块内的代码围栏逐字保留、从不送模型。

**结论步骤的正文一律不译**：那一步由模型直接写给读者，通常已经是写好的中文，再送一遍只换来等待与改坏的风险。整行按原文呈现，左缘什么都不出现——没有窄带、没有 ↻，也不报「设置没配好」，与总开关关着时是同一种「这里不翻译」，不是失败。

**哪一步是结论，权威信号来自宿主**。宿主的 `TurnProcessSpec.answerStep`（`packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts` 的 `latestAnswer()`）取本轮最后一个 step，要求它带着 `finalNode`（消息已落定）、有读者看得见的正文、且不含 tool-call。这个字段在流式期间是 null（本轮消息还没落定），插件因此只能自己补上「还没落定」那一段的判断——`src/client/chat/conclusion.ts` 的 `isConclusionRow()` 复刻了宿主那三条条件，并按从严到宽分档：

| 情形 | 判定 |
| --- | --- |
| `answerStep` 有值 | 直接比 `answerStep === 本行 step`，宿主的答案就是权威 |
| 没有轮次投影（`answerStep === undefined`） | 判不了，照旧送译——不能因为拿不到投影就把整段正文悄悄关掉 |
| `answerStep` 是 null，且本轮 step 序列里本行不是最后一步 | 不是结论，后面还有一步要走 |
| 最后一步、还在写、块里没有 tool-call | 按结论处理：只剩正文了，先译再撤会让读者眼前的中文闪回原文 |
| 最后一步、有 tool-call | 不是结论：按宿主判据，带工具调用的 step 永远成不了结论，当场送译 |
| 已落定（含中断），最后一步、有正文、不含工具调用 | 是结论——宿主判据的镜像 |

`isConclusionRow()` 复刻的那三条条件与宿主逐条等价（`hasReplyContent()` 对 `hasAssistantReplyContent()`，`hasToolCall()` 那一条，以及 `status` 与 `finalNode` 的对应关系）；`scripts/test-host-parity.mjs` 里有一条断言钉住宿主的 `latestAnswer()` 仍是这三条，宿主改动时那条会红，不会静默漂移。

唯一的取舍在第四档：一个只有正文、没有 tool-call 的过程步骤（例如「我先看一眼实现」）会等到下一步开始时才送译。这是为了让结论行从第一帧起就是原文——代价是一段中文晚到，换来的是不会先出中文再闪回原文。

**正文左缘不画任何东西**：没有竖线、没有脉动、也没有缩进，正文字号、行距、列表与引用的缩进与宿主默认逐字一致。插件自己的可点物都不在正文里（窄带与 ↻ 都落在正文左缘之外）。

分组判据是座次传入的 `groupPart`（`response` 分组排除思考块，`reasoning` 分组只渲染思考块，不送译），流式判据是行数据的 `status === "running"`。正文只往后追加：一个新块出现，它之前的块就再也不会变。行进入视口（150px 缓冲）后，**每一块在写定的那一刻**就送去翻译——回答还在生成时，先写定的块已经开始出中文，只有正在长的尾块保持原文。这是每行的首跑，也是唯一自动发生的请求；首跑期间界面不显示任何提示，中文回来直接替换原文。

- 不送译的内容：结论步骤的正文、Think 卡的思考链正文、工具调用行、用户消息、代码块（`pre`）及其内部、空白块——这些与宿主渲染逐分支等价，接管渲染器抛错时按框架退位规则由宿主渲染器自动回位。
- 译文用宿主基线组件 `MarkdownText` 重新渲染，表格、列表、加粗、行内代码的结构保持原样。
- 上游模型重试（整次尝试清空从零重来）时按块判断：内容一字未变的块其中文留在原地，真变了的块才重翻。

### 4. 切换与重试

正文之外有两个可点物，绝不叠在同一小片地方：

- **成功块**是宽 10px 的透明窄带（`.dsh-ct-hotspot`，`left:-10px;width:10px`，随块高铺满），整条落在正文左缘之外、不占正文一个像素、不参与排版；点它时读译文切原文、读原文切译文（键盘聚焦后 Enter/Space 同样），块与块互不影响。鼠标停在窄带上时，光标右边出一条气泡，报出此刻能做的那个动作——气泡是插件自己的浮层（`.dsh-ct-hint`，投在 `body` 下、`position: fixed`），位置由 `hint-bubble.ts` 从鼠标事件的坐标算出：默认落在光标右侧 12px、与光标同高，右边放不下就翻到光标左边，上下贴边时收回视口内；鼠标离开这条带子（哪怕是移进正文）就收起。它外观取宿主 Tooltip 的同款令牌与尺寸，自己不接指针事件；读屏忽略它——窄带自己的 `aria-label` 才是读屏的通道。
- **失败块**是一个真的 ↻ 按钮（`.dsh-ct-retry`，官方 `IconRefreshOutlineRegular`，`left:-18px` 浮在正文左侧的空隙里），失败段**不放窄带**——一段落只对应一个动作。鼠标停在这个按钮上出 Tooltip，报**哪一类**失败加服务端原样带回的技术一句（如 `channel timed out after 20000ms`，或 `not configured yet: API key`），读屏听到按钮自己 `aria-label` 上的同一句。设置没配好时按钮换次要色（`data-unconfigured`；错误红是「通道伤了」的颜色，设置问题不是通道伤），位置、可点性与重试行为都不变。

正文整块不响应点击——选中文字、拖选复制都不会误触；在窄带上按下、往右拖进正文再松手算选字，不触发切换。悬停不改任何视觉（鼠标移到窄带或 ↻ 上才变手型），键盘聚焦不给任何可见标记（明说的取舍：键盘能操作，但界面不加标记）。关闭总开关即全部回到原文，可点物一并消失。

失败分两类，都保持原文、悬停报细节，重试归手点：

- **通道伤**：超时、断流、空返回。
- **设置没配好**：缺 Key / Base URL / 模型。

块内任一片段失败就让整块按原文渲染，块内不出现半中半英。译文的结构漂移不是失败理由——它由形状修复消化，修不齐的自动重掷一次、仍不齐则照收，永不因格式留下失败标记。**正文少了是例外**：模型只写回开头那段、后面几段整段不写时，收下就是把那几段永久丢掉，所以它按失败处理（整块保持原文、悬停说「content came back short」、↻ 等你手点），重掷的第二问还会换成带标记的形状去救。控制台各留一条通道失败警告。失败结果不进缓存；通道失败**不自动重试**——滚动、重挂载都不会重发已落定的块，救活由点击发起：点某一块的 ↻ **只重发这一块**，无限次不设额度，已成功的块原地不动（原文命中内容池的块同样直接复用，不花模型调用）。补跑在途时 ↻ **原地转圈**（同一个按钮元素，只换属性，不重挂载——键盘焦点不掉），且不能重复点；补跑失败时上一次的失败结果一直挂着，↻ 因此不消失、标签也不变。一块失败不连坐：同一行后面还没翻的块照常翻。

**排版**：插件不给正文加任何 padding、margin 或 border——可翻译与不可翻译时排版完全相同。宿主正文列左侧恒定有 32px（侧栏嵌入 24px）内边距，因此窄带与 ↻ 落在空白里、不压任何宿主元素。已知的小重叠：两位以上序号的有序列表（`10.`、`100.`）的序号会往左伸出正文左缘，与那条 10px 窄带重叠——点那几像素会切换译文而不是选中文字（刻意接受，优先保证排版原生）。

### 5. 请求方式

按块请求——行进入视口后，每一块在写定的那一刻（或落定行的全部块在落定后）送出去。宿主先按 markdown 结构切段：代码围栏与纯空白段逐字保留、永不送模型；散文段超过输入上限（4096 估算 token）时按空行、单行、句末依次切开，相邻片段打包成一个请求（输出上限 8192 token），译回后按原结构拼回——片段头尾的空行由拆下的部分补回，`[文字](URL)` 的 URL 按出现序逐字拼回。

每片段过**形状修复**（`repairShape`）：按段落块对齐，块内结构行（记号类别、表格竖线数、链接个数、缩进代码缩进）逐一对齐并把行首前缀修回原文同款（标题层级、列表符号、序号、引用深度、列表缩进的漂移就此消失），散文行自由重排（软换行渲染无感、只查整块链接总数不丢），空行布局一律取原文。修不齐的片段自动重掷一次；仍不齐则照收模型的译文（链接数不等时不回填，其余原样）。

**正文少了不照收**：模型把开头那段当整段交差、后面几段一个字都不写，是弱模型的真实退化；收下它就是把原文那几段永久丢掉，读者还会以为是模型没话说。非空行数变少的那一份按失败处理：重掷一次，而且第二问换成带标记的打包形状——标记把这一段框成「一整段待译内容」，同一段原文、同一个模型就不再只写开头；仍然少就整块保持原文并留下 ↻，重翻由手点发起。只有骨架漂移（记号、竖线、链接数）才走照收那句。

同时最多一个在途请求，超时由 `aiTimeoutMs` 控制。客户端只保留一个按行登记、按**原文内容**查结果的翻译池（行以会话锚点为键，LRU 200 行；同一段原文在别处出现直接复用，不再打通道）；持久缓存只有磁盘池 `~/.dsh/dsh-chat-translate/cache.json`（1000 条 LRU），文档形态是 `{ rev, entries }`，其中 `rev` 登记写入它的世代（`src/server/prompt-revision.ts` 的 `cacheGeneration`：`PROMPT_REVISION` 加生效提示词与采样参数的摘要）。世代与此刻生效的设置不符时整池作废重来，改提示词或换采样参数都不会让旧译文串代；读盘时按当前设置的世代比对，同一代下命中即复用。

### 6. 通道行为

只有一条通道——Key / Base URL / 模型三者齐备才翻译，缺任何一项都保持原文并如实报出「设置没配好」这一类（气泡与读屏都指向配置页），不做降级兜底。总开关关着是纯粹的「不要翻译」：正文保持原文，也不挂任何标记。

## 装配形态

一条命令装完即用，profile 侧不用手写任何补丁：

```bash
dsh plugin --profile web add @lynn123411/dsh-chat-translate
```

命令把包名写进 profile 的 `dependencies` 与 `dsh.profile.bundles`，包内 `cordis.patch.yml` 随之插入一条 root loader entry：

```yaml
- insert:
    - id: dsh-chat-translate
      name: '@lynn123411/dsh-chat-translate'
```

这一条行同时承载两个半边：宿主半边在它下面注册插件本体，浏览器半边由 Web shell 按 root loader 的 enabled entry 依据 `dsh.client` 声明服务 `lib/client.js`。客户端半边声明的服务（`dsh.client.inject`）：`@deepseek-ai/dsh-client-ui-primitives`、`@deepseek-ai/dsh-client-ui-plugin-manager`。

## 开发副本

运行副本用 `link:` 连到本仓库目录，改完即生效（细则见仓库 `docs/rules/dev-copy.md`）：

```bash
cd ~/.dsh/profiles/web && pnpm add "link:<my-dsh 绝对路径>/plugins/dsh-chat-translate" --offline
cd <my-dsh 绝对路径>/plugins/dsh-chat-translate && pnpm install && pnpm build
```

宿主半边改完要触发一次 profile 重新组合才会换上新代码——在插件页或 `plugin_manager` 里把本插件关一次再开一次，不必重启进程；客户端半边由 `dsh-client-hmr` 轮询到改动后热换，页面自行重载，必要时硬刷新一次。

## 验证

```bash
cd plugins/dsh-chat-translate && pnpm check
```

`check` 串起类型检查、构建、九份测试与产物门禁（`lib/` 不被索引跟踪）。测试覆盖：行渲染计划与宿主 `AssistantMarkdown` 的分支等价、翻译呈现单点（成功与失败两种标记、失败类别与就地补跑在途）、翻译池（流式增量送译、内容寻址复用、同代幂等、换代作废、换提示词时整池丢弃、按块补跑、逐批落定、LRU）、配置面策略跟随与**离开输入框即保存**（打字期间不写盘、离开即整批提交、被拒后留草稿、并发提交共享一批写、提示词按默认/自写两态存取、清空提示词框后回到只读态会填回生效指令、拨「译」胶囊不冲掉提示词草稿），以及真 react + jsdom 的渲染自检——窄带切换、拖选与点正文不误触、悬停气泡贴在光标右边并跟着走、失败段 ↻ 是唯一可点物、补跑在途原地转圈且不重挂载、两类失败各自的读屏文案，配置卡片上提示词那一格默认只读（点「编辑」才改得动、焦点跟着进框、点「完成」收起并写盘）、没有保存按钮、打字期间停在草稿、离开输入框即写盘、恢复默认只填回草稿。服务端半边另有结构重装配、缓存、通道契约与「设置没配好逐块如实回报」的回归；`test-host-parity.mjs` 额外盯住宿主渲染器的漂移（宿主多出一种块、或改了分组与守卫规则时当场失败，找不到 DSH 源码则明确跳过）。
