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

写给想用英文跟模型对话的非英语用户。
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_default | true | 译文随回复直接显示;关掉则 Ctrl+Y 展开 |
outbound | true | 出站链路总开关 |
lang | zh-Hans | 你的语言,微软语言码:zh-Hant、ja、ko、fr、de、es、ru 等 |
provider | microsoft | 翻译服务,见下 |
model | haiku | session / openai 用的模型;填别名 haiku / sonnet 或完整 id |
base_url | http://127.0.0.1:8021/v1 | openai 的接口地址 |
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 变。
turn.start / turn.complete,实测这对事件不一定触发,一旦不触发整条管线就死掉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 是无历史的独立补全,也不进会话。出站改写发生在进会话之前,落盘的是英文,原文同样只活在渲染层,重开会话后对照就没有了。
"claude-code",无外部依赖parseBody 兼容宿主把 JSON 预解析成对象塞进 text 的情况$.http.fetch 连 127.0.0.1 会被 reset(SSRF 防护),openai 改用 $.process.run + curl 直连<think>...</think>register(on, options) 第二参传入,plugin.json 的 userConfig 声明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(如有)hooks/register.js 740 lines1/**
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