SLOPSHOPPER

parrot-translate

Translate Claude's English replies into the configured language (hotkey toggles); ensure outgoing prompts are natural English (translate or grammar-fix), shown…

newrowscommandtoastpromptmodel
★ 7v0.1.0no licenseupdated 2026-10-07jhao0413/parrot-agent-extensions/claude/parrot-translate
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · parrot-translate
› fix the failing auth test and add an audit log call ╭─────────────────────────────────────╮ │ parrot-translate │ ⏺ Read(src/auth.ts) │ 提示词英文转换失败,已原样发送(见 /tmp/pt-live.log) │ ⎿ Read 6 lines ╰─────────────────────────────────────╯ ⏺ Update(src/auth.ts) ╭──────────────────╮ ⎿ Added 2 lines, removed 1 line │ parrot-translate │ ⏺ Bash(bun test) │ 译文:隐藏 │ ⎿ 3 pass, 1 fail ╰──────────────────╯ ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /translate ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

parrot-translate (Claude Code)

写给想用英文跟模型对话的非英语用户。

Claude 的英文回复在后台翻成你配置的语言,Ctrl+Y 切换显示,译文跟在对应的原文段落后面。你输入的提示词在发出前会被改写成英文:是外文就翻译,已经是英文就只修语法,意思不变。发出后保留原文和英文的对照。

译文和对照都只在显示层,整个对话上下文里始终只有英文。

配置

/config 面板可以直接改,或者改 ~/.claude/settings.json(键名与安装方式对应,marketplace 装的就是 @parrot-agent-extensions):

{
  "pluginConfigs": {
    "parrot-translate@parrot-agent-extensions": {
      "options": {
        "show_by_default": true,
        "outbound": true,
        "lang": "zh-Hans",
        "provider": "microsoft",
        "model": "haiku"
      }
    }
  }
}

值要包在 "options" 里,直接写在插件名下面不生效。

选项默认说明
show_by_defaulttrue译文随回复直接显示;关掉则 Ctrl+Y 展开
outboundtrue出站链路总开关
langzh-Hans你的语言,微软语言码:zh-Hant、ja、ko、fr、de、es、ru 等
providermicrosoft翻译服务,见下
modelhaikusession / openai 用的模型;填别名 haiku / sonnet 或完整 id
base_urlhttp://127.0.0.1:8021/v1openai 的接口地址
api_key空openai 用;本地 llama.cpp 随便填,不校验

provider 三选一:

  • microsoft:Edge 免费接口,同 parrot 扩展那套,免 key、快。只会翻译,英文输入没有语法检查,会原样放行
  • session:$.model.complete 走本会话凭证,免配置,质量更好,耗 token。旧值 model 仍被接受
  • openai:OpenAI 兼容端点,本地 llama.cpp 或远端服务

以使用本地 index-translate-2b 模型为例:

"pluginConfigs": {
  "parrot-translate@parrot-agent-extensions": {
    "options": {
      "provider": "openai",
      "model": "index-translate-2b",
      "base_url": "http://127.0.0.1:8021/v1",
      "api_key": "sk-local"
    }
  }
}

同一段 5000 字符的回复:微软 ~3s;haiku ~32s、译文更自然;index-translate-2b ~46s、免费,代码块保留得干净。

出站

prompt.submit 在提示词进会话之前改写它:外文翻成英文,英文只修语法、拼写、排版,没有问题就原样放行。代码围栏、标识符、文件路径、命令、URL 不碰,散文段并发 4 路。改写完成 turn 才开始,session / openai 下内容较长会先 toast 提示;任何一步失败都不拦提示词,原文照发。

改写结果会拒绝源语言润色、未翻译的非拉丁文字和元评论,混合输入也会校验,代码和引用里的外文允许保留。可能是英文且不含非拉丁正文的输入按完整词数检查改写幅度,混合语言翻译与法语等其他拉丁语言不套英文词重合规则。含非拉丁正文的输入首轮用明确的英文翻译指令;其他输入同时说明外文翻译与英文语法修正,避免短英文片段被当成外文,失败后再重试一次;仍失败则保留该段原文并提示查看日志。报错前缀(Error:、Exception:、panic: 等)归入技术性内容,两侧原样跳过。诊断日志会记录重试与失败原因。

粘贴的技术性内容会自动识别、原样放行:错误信息、堆栈、JSON、日志、diff(两侧都适用,回复侧同样不送翻);俄文、阿拉伯文等 Unicode 正文正常翻译。反引号或 ~~~ 围栏(包括更长、未闭合的围栏)及缩进代码块在结构层保护,列表和引用里的代码同样保护;列表续段按容器内的相对缩进识别为正文。超长段落切成 ≤3000 字符的块再送,保留原有分隔符;每块独立检测语言,英文前缀不会让后面的外文跳过。输出被截断时视为失败、放行原文。

只拦本机敲 Enter 的提交(origin.kind === 'composer');斜杠命令、插件、peer、通知的提交不碰。原来做手动检查的 parrot-grammar 已退役,被这条链路取代。

屏幕上的对照是「原文在上、实际发出的英文引用在下」,Ctrl+Y 管不到它,那个键只管回复侧。session / openai 的改写提示词会带上 lang 作为作者语言背景,帮模型译得更地道;出站目标始终是英文,不随 lang 变。

行为细节

  • 翻译默认一直在后台做,快捷键只管显示;译文按消息块缓存,翻一次后切换即时;缓存和双语对照各保留最近 500 条,更早的滚回时会重翻/回落英文行
  • 防抖 1.5s:流式渲染期间计时器不断重置,回复稳定约 1.5 秒后才真正去翻。没有依赖 turn.start / turn.complete,实测这对事件不一定触发,一旦不触发整条管线就死掉
  • 按空行分段,列表、标题整段翻;代码块不送翻也不插译文;长段按空行切块,单块 ≤3000 字符
  • 已是目标语言的块跳过:微软靠接口的 detectedLanguage 和 lang 比对;session / openai 对 zh 系目标先本地数 CJK 字符,省一次调用;其他语言靠提示词约定「已是目标语言则原样返回」再逐块比对
  • 诊断日志在 /tmp/pt-live.log,保留最近 60 条,排查看它
  • /translate 命令等价 Ctrl+Y,另带状态反馈:还有块在翻时提示剩余块数,有失败时提示看日志;显示开着时翻完会 toast「译文就绪」。键位在 ~/.claude/keybindings.json

不影响上下文

译文只改渲染层:ui.render 的 AssistantMessage 站点改的是「这块怎么画」,存储和发给模型走另一条路,session.append 才能改落盘,本插件没用它。实测:transcript .jsonl 里搜不到屏幕上出现过的译文句子,原文能搜到;下一轮请求不含译文,不占 token;$.model.complete 是无历史的独立补全,也不进会话。出站改写发生在进会话之前,落盘的是英文,原文同样只活在渲染层,重开会话后对照就没有了。

实现备注

  • hooks module 只能 import 相对路径和 "claude-code",无外部依赖
  • 微软路径的 parseBody 兼容宿主把 JSON 预解析成对象塞进 text 的情况
  • 本地地址必须走 curl:$.http.fetch 连 127.0.0.1 会被 reset(SSRF 防护),openai 改用 $.process.run + curl 直连
  • OpenAI 兼容输出会剥 <think>...</think>
  • 配置由 register(on, options) 第二参传入,plugin.json 的 userConfig 声明

卸载

  • marketplace 装的:claude plugin uninstall parrot-translate@parrot-agent-extensions
  • 从 ~/.claude/settings.json 的 pluginConfigs 删掉 parrot-translate@parrot-agent-extensions(如有);claude plugin marketplace remove parrot-agent-extensions 移除源
  • 从 ~/.claude/keybindings.json 删掉 ctrl+y 一行;删掉 pluginConfigs 里的 parrot-translate@parrot-agent-extensions(如有)
Source 1 files
hooks/register.js 740 lines
1/**
2 * parrot-translate — Claude 的英文回复自动翻成配置的目标语言;发出的提示词保证是地道英文。
3 *
4 * 行为:回复稳定约 1.5s 后后台翻好缓存;快捷键(Ctrl+Y)切换显示。
5 * 逐段穿插:每段原文下面直接跟它自己的 `> ` 译文。
6 * 出站:prompt.submit 时把提示词改写成英文(其他语言忠实翻译;英文只修语法,
7 * 不改意思),模型与 transcript 收到的都是英文;用户消息行渲染成双语对照。
8 * 粘贴的技术性内容(错误信息/堆栈/JSON/日志/diff)两侧都不送翻,原样放行。
9 *
10 * 翻译服务(/config 里换,见 plugin.json 的 userConfig):
11 *  - microsoft(默认):Edge 免费接口(与 parrot 扩展同款),无需 key
12 *  - session:$.model.complete 走本会话凭证跑一次独立补全(默认 haiku),免配置、质量更好、耗 token
13 *
14 * 注:hooks module 只能 import 相对路径和 "claude-code",所以没有外部依赖。
15 */
16const MAX_CHARS = 3000 // 单次请求的字符上限(微软同款留余量;出站超长段也按它切块)
17const MODEL_TIMEOUT = 30_000
18const MAX_TOKENS = 16_000 // 单次补全输出上限:3000 字符块的重写远用不满,防静默截断
19const MAP_CAP = 500 // cache / outboundMap 条目上限(FIFO 淘汰最旧,防长会话无限增长)
20
21/** userConfig 传入的配置(register 时初始化) */
22const cfg = { showByDefault: false, outbound: true, lang: 'zh-Hans', provider: 'microsoft', model: 'haiku', baseUrl: 'http://127.0.0.1:8021/v1', apiKey: '' }
23
24/** 显示开关(快捷键翻转;初始值来自 showByDefault 配置) */
25let show = false
26
27/** 原文 -> { state: 'pending'|'done'|'skip'|'error', md? },按消息块缓存 */
28const cache = new Map()
29
30/** 发出的英文 -> 用户原话:UserMessage 渲染层做双语对照(不落盘、不进上下文) */
31const outboundMap = new Map()
32
33/** Map.set + FIFO 上限。代价:滚回很早的消息会丢缓存(回复侧重翻一次)或丢双语对照(回落英文行) */
34function putCapped(map, key, val) {
35  if (!map.has(key) && map.size >= MAP_CAP) map.delete(map.keys().next().value)
36  map.set(key, val)
37}
38
39function putOutboundMap(en, orig) {
40  const old = outboundMap.get(en)
41  if (old === undefined) {
42    putCapped(outboundMap, en, orig)
43  } else if (old !== orig) {
44    // 渲染层只给文本、不给稳定 message id;同一英文来自不同原文时无法安全归属。
45    // 标成 ambiguous,避免把旧消息重标成最新原文。
46    putCapped(outboundMap, en, null)
47  }
48}
49
50/**
51 * 防抖调度:流式期间每次渲染都会重置 1.5s 计时器,文本稳定(流结束)后才真正去翻。
52 * 不依赖 turn.start/turn.complete —— 实测它们不一定触发,一旦不触发整条管线就死掉。
53 */
54let stableTimer = null
55const seen = new Set() // 待调度的原文(一次稳定后批量调度)
56
57/* ---------------- 目标语言(用户语言,/config 可换) ---------------- */
58
59/** 常见微软语言码 -> 英文名(提示词用);不在表里就直接用码本身 */
60const LANG_NAMES = {
61  'zh-Hans': 'Simplified Chinese', 'zh-Hant': 'Traditional Chinese',
62  en: 'English', ja: 'Japanese', ko: 'Korean', fr: 'French', de: 'German',
63  es: 'Spanish', it: 'Italian', pt: 'Portuguese', ru: 'Russian', ar: 'Arabic',
64  hi: 'Hindi', th: 'Thai', vi: 'Vietnamese', id: 'Indonesian', tr: 'Turkish',
65  nl: 'Dutch', pl: 'Polish', uk: 'Ukrainian',
66}
67const langName = () => LANG_NAMES[cfg.lang] ?? cfg.lang
68/** 同一语言(比主子标签:zh-Hans 与 zh-Hant 都算 zh) */
69const sameLang = (a, b) =>
70  !!a && !!b && String(a).toLowerCase().split('-')[0] === String(b).toLowerCase().split('-')[0]
71
72/* ---------------- 微软(Edge 免费接口,同 parrot microsoft.ts) ---------------- */
73
74/* 宿主有时会把 JSON 响应预解析成对象塞进 text(类型声明说是 string,别信);两头都兼容 */
75function parseBody(res) {
76  return typeof res.text === 'string' ? JSON.parse(res.text) : res.text
77}
78
79async function msFetch($, text, to = cfg.lang) {
80  const qs = new URLSearchParams({ from: '', to, isEnterpriseClient: 'false' })
81  const res = await $.http.fetch(`https://edge.microsoft.com/translate/translatetext?${qs}`, {
82    method: 'POST',
83    headers: { 'Content-Type': 'application/json' },
84    body: JSON.stringify([text]),
85  })
86  if (!res.ok) throw new Error(`microsoft HTTP ${res.status}`)
87  const body = parseBody(res)
88  if (!Array.isArray(body) || body.length !== 1) throw new Error('microsoft bad response')
89  const out = body[0].translations?.[0]?.text
90  if (typeof out !== 'string' || !out.trim()) throw new Error('microsoft empty translation')
91  return { out, from: body[0].detectedLanguage?.language ?? '' }
92}
93
94/* ---------------- 模型($.model.complete,走本会话凭证) ---------------- */
95
96const MODEL_SYSTEM = () =>
97  `Translate the user message into ${langName()}. ` +
98  'Preserve the markdown structure exactly: lists, headings, tables, inline code, emphasis, links. ' +
99  'Keep code, identifiers, file paths, commands and URLs unchanged. ' +
100  `If the message is already entirely in ${langName()}, return it exactly unchanged. ` +
101  'Return ONLY the translation, no preamble, no notes.'
102
103async function modelFetch($, text, system) {
104  const r = await $.model.complete({
105    model: cfg.model,
106    system: system ?? MODEL_SYSTEM(),
107    prompt: text,
108    maxTokens: MAX_TOKENS,
109    timeoutMs: MODEL_TIMEOUT,
110  })
111  if (!r.isAnswered) throw new Error(`model did not answer (${r.reason ?? 'unknown'})`)
112  return { out: r.text.trim(), from: '' }
113}
114
115/* ---------------- OpenAI 兼容(本地 llama.cpp / 远端兼容服务) ---------------- */
116
117const OPENAI_SYSTEM = () =>
118  `Translate into ${langName()}. Keep code, identifiers, file paths, commands and URLs unchanged. ` +
119  `If it is already entirely in ${langName()}, return it exactly unchanged. ` +
120  'Preserve the markdown structure. Return ONLY the translation.'
121
122/** 剥掉推理模型可能带的 <think>...</think>(哪怕为空) */
123function stripThink(s) {
124  return s.replace(/<think>[\s\S]*?<\/think>/g, '').trim()
125}
126
127async function openaiFetch($, text, system) {
128  const base = cfg.baseUrl.replace(/\/+$/, '')
129  const payload = JSON.stringify({
130    model: cfg.model,
131    stream: false,
132    temperature: 0.2,
133    messages: [
134      { role: 'system', content: system ?? OPENAI_SYSTEM() },
135      { role: 'user', content: text },
136    ],
137  })
138  // 宿主的 $.http.fetch 连 localhost 会被 reset(SSRF 防护),curl 直连没问题
139  const args = ['curl', '-s', '--max-time', '120', '-X', 'POST', `${base}/chat/completions`,
140    '-H', 'Content-Type: application/json', '--data-binary', payload]
141  if (cfg.apiKey) args.push('-H', `Authorization: Bearer ${cfg.apiKey}`)
142  const r = await $.process.run(args)
143  if (r.exitCode !== 0) throw new Error(`openai curl exit ${r.exitCode}`)
144  const body = JSON.parse(r.stdout)
145  if (body?.choices?.[0]?.finish_reason === 'length') throw new Error('openai truncated (finish_reason=length)')
146  const en = stripThink(String(body?.choices?.[0]?.message?.content ?? ''))
147  if (!en) throw new Error('openai empty completion')
148  return { out: en, from: '' }
149}
150
151/** 段落是否是 CJK 为主(zh 系目标翻译前先本地判断,省 token) */
152function isMostlyZh(s) {
153  const cjk = (s.match(/[\u4e00-\u9fff]/g) || []).length
154  const letters = (s.match(/[A-Za-z]/g) || []).length
155  return cjk > 0 && cjk >= letters
156}
157
158/**
159 * 回复侧:段落是否已经是目标语言为主。只有 zh 系目标有可靠的本地判断(CJK 字符好数);
160 * 其他目标语言没有便宜的本地判断,交给接口的源语言检测 / 提示词的「已是目标语言
161 * 则原样返回」约定(见 translateProse 的逐块比对)。
162 */
163function isMostlyTarget(s) {
164  return /^zh/i.test(cfg.lang) ? isMostlyZh(s) : false
165}
166
167/**
168 * 段落是否是「技术性粘贴」:错误信息、堆栈、JSON、日志、diff、十六进制/表格等。
169 * 这些内容翻译/改写只会帮倒忙,两侧(出站与回复)都直接跳过;要百分之百确保
170 * 原样,用 ``` 围栏包住(围栏在结构层就不送翻)。规则各自独立、偏保守,
171 * 像散文的内容一条都不该命中。
172 */
173function looksTechnical(s) {
174  const t = s.trim()
175  if (!t) return false
176  // JSON(或接近 JSON:粘贴时头尾缺行很常见)
177  if (/^[[{]/.test(t)) {
178    try { JSON.parse(t); return true } catch { /* 不完整,看下面的规则 */ }
179    if ((t.match(/":\s/g) || []).length >= 2) return true
180  }
181  // 堆栈:JS 的 at fn (file:1:2) / Python 的 Traceback + File "...", line N
182  if (/^\s*at\s+[\w$.#<>-]+\s*\(.*:\d+:\d+\)/m.test(t)) return true
183  if (/Traceback \(most recent call last\)|File ".*", line \d+/.test(t)) return true
184  // 日志行:时间戳或等级开头的行
185  if (/^\[?\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}/m.test(t)) return true
186  if (/^\[?(ERROR|WARN|WARNING|INFO|DEBUG|FATAL|CRITICAL|TRACE|NOTICE)[\]:]/m.test(t)) return true
187  if (/^\[?(error|exception|panic)\]?:/im.test(t)) return true // Error:/Exception: 等常见报错前缀(标题大小写)
188  if (/^npm (ERR!|WARN)/m.test(t)) return true
189  // diff / patch
190  if (/^(diff --git |@@ -\d+(,\d+)? \+\d+(,\d+)? @@|--- a\/|\+\+\+ b\/)/m.test(t)) return true
191  // 符号密度:Unicode 字母/组合符号占非空白字符不到 35%。
192  const ns = t.replace(/\s/g, '')
193  const word = (ns.match(/[\p{L}\p{M}]/gu) || []).length
194  return ns.length >= 40 && word / ns.length < 0.35
195}
196
197/* ---------------- 管线 ---------------- */
198
199/** Preserve raw text while recognizing code relative to list and blockquote containers. */
200function splitParas(text) {
201  const paras = []
202  const lists = new Map()
203  let previousQuoteDepth = 0
204  let prose = ""
205  let code = ""
206  let fence
207  let indented
208  const flushProse = () => {
209    if (prose) paras.push({ text: prose })
210    prose = ""
211  }
212  const flushCode = () => {
213    if (code) paras.push({ code })
214    code = ""
215  }
216  // Expand tabs only in the parsing view. The original bytes always form the output.
217  const viewLine = (body, allowLists, maxQuotes = Infinity) => {
218    let rest = ""
219    for (const ch of body) rest += ch === "\t" ? " ".repeat(4 - rest.length % 4) : ch
220    let quoteDepth = 0
221    let listIndent = 0
222    // Lists and quotes can alternate at any depth, e.g. "- > - ```".
223    while (true) {
224      let quote
225      while (quoteDepth < maxQuotes && (quote = rest.match(/^ {0,3}> ?/))) {
226        rest = rest.slice(quote[0].length)
227        quoteDepth++
228      }
229      const stack = lists.get(quoteDepth) ?? []
230      lists.set(quoteDepth, stack)
231      const blank = !rest.trim()
232      const indent = rest.match(/^ */)[0].length
233      if (!blank) while (stack.length && stack[stack.length - 1] > indent) stack.pop()
234      listIndent = stack[stack.length - 1] ?? 0
235      let offset = 0
236      let marker
237      while (allowLists && (marker = rest.match(/^( *)([-+*]|\d{1,9}[.)])( +|$)/)) &&
238        marker[1].length <= (offset ? 0 : listIndent) + 3) {
239        const padding = marker[3].length > 4 ? 1 : marker[3].length || 1
240        const width = marker[1].length + marker[2].length + padding
241        offset += width
242        listIndent = offset
243        stack.push(listIndent)
244        rest = rest.slice(width)
245      }
246      if (!offset) rest = rest.slice(listIndent)
247      if (quoteDepth >= maxQuotes || !/^ {0,3}> ?/.test(rest)) break
248    }
249    if (quoteDepth < previousQuoteDepth) {
250      for (const depth of lists.keys()) if (depth > quoteDepth) lists.delete(depth)
251    }
252    previousQuoteDepth = quoteDepth
253    return { body: rest, quoteDepth, listIndent, blank: !rest.trim() }
254  }
255  const inContainer = (line, start) =>
256    line.quoteDepth >= start.quoteDepth && (line.blank || line.listIndent >= start.listIndent)
257  for (const raw of text.match(/[^\n]*(?:\n|$)/g)?.filter(Boolean) ?? []) {
258    const body = raw.replace(/\r?\n$/, "")
259    // Once code starts, further quote markers belong to its literal contents.
260    let line = viewLine(body, !fence && !indented, (fence ?? indented)?.quoteDepth)
261    if (fence) {
262      if (inContainer(line, fence)) {
263        code += raw
264        if (fence.closing.test(line.body)) { flushCode(); fence = undefined }
265        continue
266      }
267      flushCode()
268      fence = undefined
269      line = viewLine(body, true)
270    }
271    if (indented) {
272      if (inContainer(line, indented) && (line.blank || /^ {4}/.test(line.body))) {
273        code += raw
274        continue
275      }
276      flushCode()
277      indented = undefined
278      line = viewLine(body, true)
279    }
280    const opening = line.body.match(/^ {0,3}(`{3,}|~{3,})(.*)$/)
281    if (opening && !(opening[1][0] === "`" && opening[2].includes("`"))) {
282      flushProse()
283      code = raw
284      fence = { ...line, closing: new RegExp(`^ {0,3}${opening[1][0]}{${opening[1].length},}[ ]*$`) }
285    } else if (/^(`+)[^\n]*\1[ ]*$/.test(line.body)) {
286      // Pi's Mermaid renderer emits standalone inline-code rows.
287      flushProse()
288      paras.push({ code: raw })
289    } else if (!line.blank && /^ {4}/.test(line.body) && !prose.trim()) {
290      code = raw
291      indented = line
292    } else if (line.blank) {
293      flushProse()
294      paras.push({ code: raw })
295    } else {
296      prose += raw
297    }
298  }
299  flushProse()
300  flushCode()
301  return paras
302}
303
304/** 固定并发跑一批任务(worker 内部自行 try/catch,单条失败不外溢) */
305async function runPool(items, size, worker) {
306  let cursor = 0
307  const run = async () => {
308    while (cursor < items.length) await worker(items[cursor++])
309  }
310  await Promise.all(Array.from({ length: Math.min(size, items.length) }, run))
311}
312
313/** Bound requests while retaining every separator and avoiding split surrogate pairs. */
314function chunkParagraph(s, max = MAX_CHARS) {
315  const chunks = []
316  for (let start = 0; start < s.length;) {
317    let end = Math.min(start + max, s.length)
318    if (end < s.length) {
319      const newline = s.lastIndexOf('\n', end - 1)
320      const space = s.lastIndexOf(' ', end - 1)
321      const boundary = newline >= start ? newline : space
322      if (boundary >= start) end = boundary + 1
323      else if (/[\uD800-\uDBFF]/.test(s[end - 1])) end--
324    }
325    chunks.push(s.slice(start, end))
326    start = end
327  }
328  return chunks.length ? chunks : [s]
329}
330
331function preserveWhitespace(source, replacement) {
332  return (source.match(/^\s*/)?.[0] ?? '') + replacement.trim() + (source.match(/\s*$/)?.[0] ?? '')
333}
334
335function quoteTranslation(text, translation) {
336  const trailing = text.match(/\s*$/)?.[0] ?? ""
337  return text.slice(0, text.length - trailing.length) + "\n\n" +
338    translation.split("\n").map((line) => `> ${line}`).join("\n") + trailing
339}
340
341const fetchOne = ($, text, opts = {}) =>
342  cfg.provider === 'session'
343    ? modelFetch($, text, opts.system)
344    : cfg.provider === 'openai'
345      ? openaiFetch($, text, opts.system)
346      : msFetch($, text, opts.to)
347
348/** 一段散文按长度切块翻译;源语言已是目标语言的块原样保留 */
349async function translateProse($, text) {
350  if (looksTechnical(text)) return null
351  if (cfg.provider !== 'microsoft' && isMostlyTarget(text)) return null
352
353  const out = []
354  let any = false
355  for (const chunk of chunkParagraph(text)) {
356    const r = await fetchOne($, chunk)
357    // 微软的检测是逐请求返回的:只能跳过当前块,不能因为某一块是目标语言就跳过整段。
358    if (cfg.provider === 'microsoft' && sameLang(r.from, cfg.lang)) {
359      out.push(chunk)
360      continue
361    }
362    // 模型判定「已是目标语言」会原样返回:该块保持原文、不算翻过
363    if (r.out.trim() && r.out.trim() !== chunk.trim()) {
364      out.push(preserveWhitespace(chunk, r.out))
365      any = true
366    } else {
367      out.push(chunk)
368    }
369  }
370  return any ? out.join('') : null
371}
372
373/**
374 * 翻一个消息块,逐段穿插:每段原文下面直接跟它自己的 `> ` 译文;
375 * 代码块/原始分隔符不送翻且原样拼回。返回 { md, errors }。
376 * 全部跳过(已是目标语言/无散文)时 md 为 null。
377 * 段落并行翻(并发 4):本地 llama.cpp 有连续 batching,串行会让长回复等几十秒。
378 */
379async function translateBlock($, text) {
380  const paras = splitParas(text)
381  await runPool(paras, 4, async (p) => {
382    if (p.text === undefined) return
383    try {
384      const translation = await translateProse($, p.text)
385      if (translation) p.translation = translation
386    } catch (err) {
387      p.error = err
388      diag($, `translate paragraph error len=${p.text.length}: ${String((err && err.message) || err).slice(0, 150)}`)
389    }
390  })
391
392  let any = false
393  let errors = 0
394  const md = paras.map((p) => {
395    if (p.code !== undefined) return p.code
396    if (p.separator !== undefined) return p.separator
397    if (p.error) errors++
398    if (p.translation) {
399      any = true
400      return quoteTranslation(p.text, p.translation)
401    }
402    return p.text
403  }).join('')
404  return { md: any ? md : null, errors }
405}
406
407/* ---------------- 出站:保证发给模型的一定是英文 ---------------- */
408
409const OUT_UNCHANGED_INSTRUCTION =
410  'If nothing needs changing, return the original text exactly unchanged. Never replace it with an assessment such as "No changes are needed". '
411
412const OUT_MODEL_SYSTEM = () =>
413  'The message is a prompt on its way to a coding agent. Rewrite it into natural, grammatically correct English: ' +
414  'if it is in another language, translate it faithfully without changing the meaning; if it is already in English, ' +
415  'fix only grammar, spelling and typography errors. Never change the meaning, tone or technical content. ' +
416  `The author's first language is ${langName()}; keep the English plain and idiomatic. ` +
417  'Preserve the markdown structure; keep code, identifiers, file paths, commands, flags and URLs unchanged. ' +
418  OUT_UNCHANGED_INSTRUCTION +
419  'Return ONLY the rewritten text, no preamble, no notes.'
420
421const OUT_OPENAI_SYSTEM = () =>
422  'Rewrite the prompt into natural, grammatically correct English: translate it faithfully if it is in another ' +
423  'language, or fix only grammar/spelling/typo errors if it is already English. Never change the meaning. ' +
424  `The author's first language is ${langName()}. ` +
425  'Keep code, identifiers, file paths, commands and URLs unchanged. Preserve the markdown structure. ' +
426  OUT_UNCHANGED_INSTRUCTION + 'Return ONLY the rewritten text.'
427
428/** 数非拉丁字母(任意文字系统),出站校验用 */
429function nonLatinLetterCount(s) {
430  const prose = s.replace(/(`+)[\s\S]*?\1|“[^”]*”|‘[^’]*’|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g, '')
431  return (prose.match(/(?!\p{Script=Latin})\p{L}/gu) ?? []).length
432}
433
434const asciiTokens = (s) => s.toLowerCase().match(/[a-z0-9]+(?:'[a-z0-9]+)?/g) || []
435
436const EN_FUNCTION_WORDS = new Set([
437  'the', 'this', 'that', 'these', 'those', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'to', 'of',
438  'and', 'with', 'for', 'it', 'you', 'your', 'my', 'please',
439])
440
441/** 保守英文判断:函数词至少两个,或常见编程祈使句/问候。避免把法/西等拉丁语言套英文重合约束。 */
442function isLikelyEnglish(s) {
443  const tokens = asciiTokens(s)
444  let hits = 0
445  for (const t of tokens) if (EN_FUNCTION_WORDS.has(t) && ++hits >= 2) return true
446  const t = s.trim().toLowerCase()
447  return /^(please\s+)?(fix|check|review|implement|add|remove|update|explain|translate|write|show|help|hello|hi)\b/.test(t)
448}
449
450function hasMetaPreamble(output) {
451  return /^(?:(?:sure[,!.]?\s+)?here(?:'s| is) (?:the |your )?(?:translation|rewritten|corrected|revised)(?: text| prompt)?\s*:|(?:the|your) (?:(?:provided|given|input|original) )?(?:text|prompt|message|sentence) (?:contains|is (?:already|grammatically|correct|natural))|no (?:changes|corrections|edits) (?:are |were )?(?:needed|required|necessary))/i.test(output.trim())
452}
453
454/**
455 * 出站改写结果校验:翻译服务可能不翻译、只做源语言润色,或返回元评论。
456 * 只有输出真的像「这段话的英文改写」才认:
457 *  - 任意非拉丁输入/混合输入 → 输出的非拉丁字母须降到一半以下,且包含 ASCII 英文字母;
458 *  - 可能是英文且不含非拉丁正文的输入 → 输出须与输入按完整 token 多重集至少重合一半,长度在合理范围;
459 *  - 其他拉丁源语言不套英文重合约束,允许法/西/德等正确翻译成英文时零重合。
460 */
461function okRewrite(input, output) {
462  const inp = input.trim()
463  const out = output.trim()
464  if (!out) return false
465  const foreign = nonLatinLetterCount(inp)
466  const resultForeign = nonLatinLetterCount(out)
467  if (foreign ? resultForeign >= Math.max(1, foreign / 2) : resultForeign > 0) return false
468  const code = inp.match(/(`+)[\s\S]*?\1/g) ?? []
469  if (code.some(span => !out.includes(span))) return false
470  if (inp === out) return true
471  if (!/[A-Za-z]/.test(out)) return false
472  if (hasMetaPreamble(out)) return false
473
474  // Translating foreign prose can add English words beyond the original ASCII prefix.
475  if (foreign || !isLikelyEnglish(inp)) return true
476  const ti = asciiTokens(inp)
477  if (ti.length === 0) return true
478  const to = asciiTokens(out)
479  const counts = new Map()
480  for (const t of ti) counts.set(t, (counts.get(t) || 0) + 1)
481  let hit = 0
482  for (const t of to) {
483    const n = counts.get(t) || 0
484    if (n > 0) {
485      hit++
486      counts.set(t, n - 1)
487    }
488  }
489  // 修语法是轻改:词重合要过半,词数也得在 0.6–1.5 倍之间(元评论动辄长出三倍)
490  return hit / ti.length >= 0.5 && to.length >= ti.length * 0.6 && to.length <= ti.length * 1.5
491}
492
493/** 校验失败重试:用明确系统提示分开「翻译成英文」和「只修英文语法」,不复用首轮混合提示。 */
494const OUT_TRANSLATE_RETRY_SYSTEM = () =>
495  'Translate the user message into English faithfully. Do not polish it in the source language. ' +
496  'Preserve markdown structure, code, identifiers, file paths, commands, flags and URLs unchanged. ' +
497  OUT_UNCHANGED_INSTRUCTION + 'Return ONLY the English translation, no preamble, no notes.'
498
499const OUT_GRAMMAR_RETRY_SYSTEM = () =>
500  'Fix only grammar, spelling and typography errors in this English prompt. Do not translate, explain, summarize or add notes. ' +
501  'Preserve markdown structure, code, identifiers, file paths, commands, flags and URLs unchanged. ' +
502  OUT_UNCHANGED_INSTRUCTION + 'Return ONLY the corrected text.'
503
504/** Hide file mentions from providers, including quoted paths containing spaces. */
505function protectFileReferences(text) {
506  let prefix = 'PARROT_FILE_REF_'
507  while (text.includes(prefix)) prefix = '_' + prefix
508  const refs = []
509  const masked = text.replace(/(?<![\p{L}\p{N}_@])@(?:"(?:\\.|[^"\r\n])*"|'(?:\\.|[^'\r\n])*'|[^\s`"'<>()[\]{},;!?,。;!?、]+)/gu, (match) => {
510    // Sentence punctuation is not part of an unquoted path.
511    const ref = /^@["']/.test(match) ? match : match.replace(/[.:]+$/, '')
512    if (ref.length < 2) return match
513    const token = '`' + prefix + refs.length + '`'
514    refs.push({ token, ref })
515    return token + match.slice(ref.length)
516  })
517  return { masked, restore(output) {
518    const tokens = output.match(new RegExp('`' + prefix + '\\d+`', 'g')) ?? []
519    if (tokens.length !== refs.length || tokens.some((token, i) => token !== refs[i].token)) {
520      throw new Error('outbound rewrite changed file references')
521    }
522    return output.replace(new RegExp('`' + prefix + '\\d+`', 'g'), token => refs.find(ref => ref.token === token).ref)
523  } }
524}
525
526/** Rewrite outbound prose while restoring file mentions exactly. */
527async function ensureEnglishProse($, text) {
528  const protectedText = protectFileReferences(text)
529  const result = await rewriteEnglishProse($, protectedText.masked)
530  return result === null ? null : protectedText.restore(result)
531}
532
533async function rewriteEnglishProse($, text) {
534  if (looksTechnical(text)) return null // 粘贴的错误信息/JSON/日志等原样放行
535  if (cfg.provider === 'microsoft') {
536    // 超长段按行边界切块逐块送翻;检测出英文的块只保留该块(免费接口没有语法检查能力)
537    const out = []
538    let any = false
539    for (const chunk of chunkParagraph(text)) {
540      const r = await msFetch($, chunk, 'en')
541      if (/^en(-|$)/i.test(r.from) && !nonLatinLetterCount(chunk)) {
542        out.push(chunk)
543        continue
544      }
545      const en = r.out.trim()
546      if (!okRewrite(chunk, en)) throw new Error('microsoft did not return an English translation')
547      if (en !== chunk.trim()) { out.push(preserveWhitespace(chunk, en)); any = true } else out.push(chunk)
548    }
549    return any ? out.join('') : null
550  }
551  const system = nonLatinLetterCount(text)
552    ? OUT_TRANSLATE_RETRY_SYSTEM()
553    : cfg.provider === 'session' ? OUT_MODEL_SYSTEM() : OUT_OPENAI_SYSTEM()
554  const out = []
555  let any = false
556  for (const chunk of chunkParagraph(text)) {
557    const likelyEnglish = isLikelyEnglish(chunk)
558    let en = (await fetchOne($, chunk, { system })).out.trim()
559    // 不认的改写:输出还是源语言(只润色没翻译)、非拉丁原样返回、英文输入收到元评论。
560    // 用明确系统提示重试一次,仍不过就放行该段原文——绝不让废话冒充英文发出。
561    const invalid = (candidate) => !okRewrite(chunk, candidate)
562    if (invalid(en)) {
563      diag($, `outbound suspect in="${chunk.slice(0, 40)}" out="${en.slice(0, 40)}"`)
564      const retrySystem = likelyEnglish && nonLatinLetterCount(chunk) === 0 ? OUT_GRAMMAR_RETRY_SYSTEM() : OUT_TRANSLATE_RETRY_SYSTEM()
565      const retry = (await fetchOne($, chunk, { system: retrySystem })).out.trim()
566      if (retry && !invalid(retry)) {
567        en = retry
568        diag($, `outbound retry ok out="${retry.slice(0, 40)}"`)
569      } else {
570        diag($, `outbound retry failed; fallback original len=${chunk.length}`)
571        throw new Error('outbound retry did not return an English rewrite')
572      }
573    }
574    if (en && en !== chunk.trim()) { out.push(preserveWhitespace(chunk, en)); any = true } else out.push(chunk)
575  }
576  return any ? out.join('') : null
577}
578
579/** 出站整块:代码/分隔符不动,散文段并发处理;返回 { text, changed, errors }(没改动时 text 即原文) */
580async function outboundBlock($, text) {
581  const paras = splitParas(text)
582  await runPool(paras, 4, async (p) => {
583    if (p.text === undefined) return
584    try {
585      const en = await ensureEnglishProse($, p.text)
586      if (en) p.en = preserveWhitespace(p.text, en)
587    } catch (err) {
588      p.error = err
589      diag($, `outbound paragraph error len=${p.text.length}: ${String((err && err.message) || err).slice(0, 150)}`)
590    }
591  })
592
593  let any = false
594  let errors = 0
595  const out = paras.map((p) => {
596    if (p.code !== undefined) return p.code
597    if (p.separator !== undefined) return p.separator
598    if (p.error) errors++
599    if (p.en) any = true
600    return p.en ?? p.text
601  }).join('')
602  return { text: any ? out : text, changed: any, errors }
603}
604
605/** 诊断:写 /tmp/pt-live.log(只记关键转移,保留最近 60 条) */
606const dbg = []
607function diag($, msg) {
608  dbg.push(`${new Date().toISOString().slice(11, 23)} ${msg}`)
609  if (dbg.length > 60) dbg.shift()
610  $.fs.write('/tmp/pt-live.log', dbg.join('\n') + '\n').catch(() => {})
611}
612
613function scheduleOne($, text) {
614  if (cache.has(text)) return
615  putCapped(cache, text, { state: 'pending' })
616  diag($, `schedule len=${text.length}`)
617  translateBlock($, text)
618    .then(({ md, errors }) => {
619      const state = md ? 'done' : errors ? 'error' : 'skip'
620      putCapped(cache, text, { state, md, errors })
621      diag($, `done len=${text.length} state=${state} errors=${errors}${md ? ' md=' + md.length : ''}`)
622      if (show) {
623        $.ui.invalidate('ui.render')
624        if (state === 'done' && errors) $.ui.toast(`部分译文就绪(${errors} 段失败,见 /tmp/pt-live.log)`)
625        else if (state === 'done') $.ui.toast('译文就绪')
626        else if (state === 'error') $.ui.toast('翻译失败,见 /tmp/pt-live.log')
627      }
628    })
629    .catch((err) => {
630      putCapped(cache, text, { state: 'error', errors: 1 })
631      diag($, `error len=${text.length}: ${String((err && err.message) || err).slice(0, 150)}`)
632      if (show) $.ui.toast('翻译失败,见 /tmp/pt-live.log')
633    })
634}
635
636function onRenderText($, text) {
637  seen.add(text)
638  if (stableTimer) stableTimer.cancel()
639  stableTimer = $.clock.after(1500, () => {
640    stableTimer = null
641    const texts = [...seen]
642    seen.clear()
643    for (const t of texts) scheduleOne($, t)
644  })
645}
646
647export function register(on, options) {
648  if (stableTimer) stableTimer.cancel()
649  stableTimer = null
650  seen.clear()
651  cache.clear()
652  outboundMap.clear()
653
654  // userConfig(/config 面板或 settings.json 的 pluginConfigs["parrot-translate@inline"])
655  cfg.showByDefault = options?.show_by_default !== false // 默认 true(0.4.3 起)
656  cfg.outbound = options?.outbound !== false
657  cfg.lang = typeof options?.lang === 'string' && options.lang.trim() ? options.lang.trim() : 'zh-Hans'
658  // 旧值 model(≤0.4.1)兼容:映射为 session
659  const providerRaw = typeof options?.provider === 'string' ? options.provider.trim() : ''
660  cfg.provider = providerRaw === 'model' ? 'session' : ['session', 'openai'].includes(providerRaw) ? providerRaw : 'microsoft'
661  cfg.model = typeof options?.model === 'string' && options.model.trim() ? options.model.trim() : 'haiku'
662  cfg.baseUrl = typeof options?.base_url === 'string' && options.base_url.trim() ? options.base_url.trim() : 'http://127.0.0.1:8021/v1'
663  cfg.apiKey = typeof options?.api_key === 'string' ? options.api_key : ''
664  show = cfg.showByDefault
665
666  on('session.start', async ($, e, next) => {
667    outboundMap.clear()
668    await $.command.register({ name: 'translate', description: 'Show/hide translation of replies' })
669    diag($, `loaded provider=${cfg.provider} model=${cfg.model} baseUrl=${cfg.baseUrl} showByDefault=${cfg.showByDefault} outbound=${cfg.outbound} lang=${cfg.lang}`)
670    return next(e)
671  })
672
673  on('command.run', { command: 'translate' }, async ($) => {
674    show = !show
675    diag($, `toggle show=${show}`)
676    if (show) {
677      // 等待反馈:还有段在翻时直接告诉用户,免得对着空白狂按
678      const pending = [...cache.values()].filter((e) => e.state === 'pending').length
679      const error = [...cache.values()].filter((e) => e.state === 'error' || e.errors).length
680      if (pending) $.ui.toast(`翻译中(还有 ${pending} 块,本地模型较慢)…`)
681      else if (error) $.ui.toast('部分翻译失败,详见 /tmp/pt-live.log')
682      else $.ui.toast('译文:显示')
683    } else {
684      $.ui.toast('译文:隐藏')
685    }
686    $.ui.invalidate('ui.render')
687    return {}
688  })
689
690  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
691    const text = e.props?.text
692    if (!text || !text.trim()) return next(e)
693
694    // 默认就翻(与显示无关):防抖 1.5s,文本稳定后才调度(见 onRenderText)。
695    // onRenderText 只登记防抖,不会同步写 cache,这里不必重读。
696    const entry = cache.get(text)
697    if (!entry) onRenderText($, text)
698
699    if (!show) return next(e)
700
701    // 诊断:show=true 时记录每次渲染到达,用于排查「切了显示但没重画」
702    diag($, `render len=${text.length} state=${entry ? entry.state : 'none'}`)
703    if (!entry || entry.state !== 'done') return next(e)
704
705    // 缓存里已是拼好的逐段穿插版本,直接替换显示文本(只影响渲染,不落盘不进上下文)
706    return next({ ...e, props: { ...e.props, text: entry.md } })
707  })
708
709  // 出站:本机敲 Enter 的提示词改写成英文再进会话(插件/peer/通知的提交不动)
710  on('prompt.submit', { origin: { kind: 'composer' } }, async ($, e, next) => {
711    const text = e.text ?? ''
712    if (!cfg.outbound || !text.trim() || text.startsWith('/')) return next(e)
713    try {
714      if (cfg.provider !== 'microsoft' && text.length > 120) $.ui.toast('正在把提示词转成英文…')
715      const { text: en, changed, errors } = await outboundBlock($, text)
716      if (!changed || !en.trim()) {
717        if (errors) $.ui.toast('提示词英文转换失败,已原样发送(见 /tmp/pt-live.log)')
718        return next(e)
719      }
720      if (errors) $.ui.toast(`提示词已部分转成英文(${errors} 段保留原文,见 /tmp/pt-live.log)`)
721      putOutboundMap(en, text) // 先入映射再 next,跟上的重渲染直接能画对照;冲突时标 ambiguous
722      diag($, `outbound len=${text.length} -> ${en.length} errors=${errors}`)
723      // 模型与落盘都是英文;屏幕上的用户行跟着变英文,由下面的渲染钩子画成双语对照
724      return next({ ...e, text: en })
725    } catch (err) {
726      diag($, `outbound error: ${String((err && err.message) || err).slice(0, 150)}`)
727      return next(e) // 任何失败都原样放行,绝不拦提示词
728    }
729  })
730
731  // 用户消息行的双语对照:原文在上,实际发出的英文 `> ` 引用在下(只影响渲染)
732  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
733    const text = e.props?.text
734    const orig = text ? outboundMap.get(text) : undefined
735    if (orig === undefined || orig === null) return next(e)
736    const quote = text.split('\n').map((l) => `> ${l}`).join('\n')
737    return next({ ...e, props: { ...e.props, text: `${orig}\n\n${quote}` } })
738  })
739}
740