让 OpenCode 里的 GPT5.2 自己翻译自己
前几天看到: 让 GPT5.2 在 OpenCode 里说人话思路,我进行了尝试,他使用了 gemini-3-flash 对 GPT5.2 的思考块进行翻译,我用过之后,经常翻译超时,于是想到,能不能让 GPT5.2 自己翻译自己,基于这位佬友的代码进行了二开,效果嘎嘎好。效果如图:
安装:
1、复制本帖最下方代码,保存为 think-translator.ts(文件名随意)
2、打开 think-translator.ts
3、把 GPT5.2 的中转 API 信息写到 6~8 行(加了注释了,一眼就能看到)
4、把 think-translator.ts 放到 C:\Users\xxxx (你的名字).config\opencode\plugin 里,opencode 会自动加载
5、完事,开蹬
import type { Plugin } from "@opencode-ai/plugin";
import type { Message, Part } from "@opencode-ai/sdk";
declare function require(id: string): unknown;
// OpenAI 兼容接口(可用 /v1、/v1/chat/completions 或 /v1/responses 作为 baseURL)
const TRANSLATION_BASE_URL = "https://www.right.codes/codex/v1";// 填你的订阅 地址(示例是right code)
const TRANSLATION_API_KEY = ""; // 填你的订阅 Key
const TRANSLATION_MODEL_ID = "gpt-5.2"; // 可选:gpt-5.2 | gpt-5.2-low | gpt-5.2-medium
type PartEvent = Part & {
time?: {
end?: unknown;
};
text?: string;
};
type PatchResult = {
response?: {
status?: number;
};
};
type ProviderConfig = {
baseURL: string;
apiKey: string;
modelID: string;
};
const START_TAG = "[〔翻译开始〕]";
const END_TAG = "[〔翻译结束〕]";
const TITLE_TRANSLATION_PREFIX = "〔译: ";
const TITLE_TRANSLATION_SUFFIX = "〕";
function stripTranslationBlocks(text: string): string {
while (true) {
const start = text.indexOf(START_TAG);
if (start === -1) return text;
const end = text.indexOf(END_TAG, start + START_TAG.length);
if (end === -1) return text.slice(0, start).trimEnd();
const after = end + END_TAG.length;
let restStart = after;
while (restStart < text.length && text[restStart] === "\n") restStart += 1;
const left = text.slice(0, start).trimEnd();
const right = text.slice(restStart);
text = left ? left + "\n\n" + right : right;
}
}
function stripTitleTranslation(title: string): string {
const start = title.indexOf(TITLE_TRANSLATION_PREFIX);
if (start === -1) return title.trim();
const end = title.indexOf(TITLE_TRANSLATION_SUFFIX, start + TITLE_TRANSLATION_PREFIX.length);
if (end === -1) return title.slice(0, start).trim();
return (title.slice(0, start) + title.slice(end + TITLE_TRANSLATION_SUFFIX.length)).trim();
}
function applyTitleTranslation(base: string, translation: string): string {
if (!base.startsWith("**")) return base;
const end = base.indexOf("**", 2);
if (end === -1) return base;
const rawTitle = base.slice(2, end);
const title = stripTitleTranslation(rawTitle);
if (!title) return base;
const suffix = translation ? ` ${TITLE_TRANSLATION_PREFIX}${translation}${TITLE_TRANSLATION_SUFFIX}` : "";
const combined = `**${title}${suffix}**`;
return combined + base.slice(end + 2);
}
function extractTitleAndBody(base: string): { title: string; body: string } {
if (!base.startsWith("**")) return { title: "", body: base };
const end = base.indexOf("**", 2);
if (end === -1) return { title: "", body: base };
const rawTitle = base.slice(2, end);
const title = stripTitleTranslation(rawTitle);
const body = base.slice(end + 2).replace(/^\s+/, "");
return { title, body };
}
function buildTranslationBlockPending(): string {
return `${START_TAG}\n译文生成中…\n${END_TAG}`;
}
function buildTranslationBlockDone(bodyCn: string): string {
const text = bodyCn.trim();
return `${START_TAG}\n${text}\n${END_TAG}`;
}
function loadProviderConfigFromOpencodeConfig(_directory: string): ProviderConfig {
return {
baseURL: TRANSLATION_BASE_URL,
apiKey: TRANSLATION_API_KEY,
modelID: TRANSLATION_MODEL_ID,
};
}
function extractSseTextParts(data: string): string {
let parsed: unknown;
try {
parsed = JSON.parse(data);
} catch {
return "";
}
// OpenAI Chat Completions streaming: { choices: [{ delta: { content: "..." } }] }
const chat = parsed as {
choices?: Array<{
delta?: { content?: string; text?: string };
message?: { content?: string };
text?: string;
}>;
};
let out = "";
for (const choice of chat.choices ?? []) {
const d = choice.delta;
if (d && typeof d.content === "string") out += d.content;
else if (d && typeof d.text === "string") out += d.text;
else if (choice.message && typeof choice.message.content === "string") out += choice.message.content;
else if (typeof choice.text === "string") out += choice.text;
}
if (out) return out;
// OpenAI Responses streaming (common proxy format): { type: "response.output_text.delta", delta: "..." }
const resp = parsed as {
type?: string;
delta?: string;
output_text?: string;
text?: string;
output?: Array<{
content?: Array<{ type?: string; text?: string }>;
}>;
response?: {
output?: Array<{
content?: Array<{ type?: string; text?: string }>; // input_text / output_text
}>;
};
};
if (resp.type && typeof resp.delta === "string") return resp.delta;
if (typeof resp.output_text === "string") return resp.output_text;
if (resp.type && typeof resp.text === "string" && resp.type.endsWith(".delta")) return resp.text;
// Non-stream JSON fallbacks (responses/chat) can also land here in some proxies.
if (resp.response?.output) {
let joined = "";
for (const item of resp.response.output) {
for (const c of item.content ?? []) {
if (typeof c.text === "string") joined += c.text;
}
}
if (joined) return joined;
}
if (resp.output) {
let joined = "";
for (const item of resp.output) {
for (const c of item.content ?? []) {
if (typeof c.text === "string") joined += c.text;
}
}
if (joined) return joined;
}
return "";
}
type OpenAIEndpointKind = "chat.completions" | "responses";
function resolveOpenAIEndpoint(baseURL: string): { url: string; kind: OpenAIEndpointKind } {
const base = baseURL.replace(/\/+$/, "");
if (base.endsWith("/chat/completions")) return { url: base, kind: "chat.completions" };
if (base.endsWith("/responses")) return { url: base, kind: "responses" };
return { url: `${base}/chat/completions`, kind: "chat.completions" };
}
function buildTranslationPrompt(english: string): string {
return (
"You are a translation engine. Translate the English content to Simplified Chinese.\n" +
"Rules (STRICT):\n" +
"- Output ONLY the Chinese translation.\n" +
"- No labels, no commentary.\n" +
"- Preserve line breaks.\n" +
"- Keep bullets as bullets.\n" +
"- Do NOT omit or summarize any content.\n" +
"\n" +
"English:\n" +
english
);
}
async function* streamTextFromResponse(res: Response, signal?: AbortSignal): AsyncGenerator<string> {
const contentType = res.headers.get("content-type") ?? "";
if (contentType.includes("text/event-stream")) {
for await (const chunk of streamSseTextParts(res, signal)) yield chunk;
return;
}
if (!res.ok) {
const msg = await res.text().catch(() => "");
throw new Error(`http_${res.status}:${msg.slice(0, 160)}`);
}
const json = await res.json().catch(() => null);
if (!json) return;
// Reuse the same extractor for non-stream JSON.
const text = extractSseTextParts(JSON.stringify(json));
if (text) yield text;
}
async function* streamSseTextParts(response: Response, signal?: AbortSignal): AsyncGenerator<string> {
if (!response.ok) {
const msg = await response.text().catch(() => "");
throw new Error(`http_${response.status}:${msg.slice(0, 160)}`);
}
const stream = response.body;
if (!stream) return;
const reader = stream.getReader();
const decoder = new TextDecoder();
let aborted = false;
const abortError = () => new Error(String(signal?.reason ?? "aborted"));
const onAbort = () => {
aborted = true;
try {
reader.cancel();
} catch {
return;
}
};
if (signal) {
if (signal.aborted) onAbort();
else signal.addEventListener("abort", onAbort, { once: true });
}
let buffer = "";
const consume = function* () {
while (true) {
let sep = buffer.indexOf("\n\n");
let advance = 2;
if (sep === -1) {
sep = buffer.indexOf("\r\n\r\n");
advance = 4;
}
if (sep === -1) break;
const chunk = buffer.slice(0, sep);
buffer = buffer.slice(sep + advance);
const lines = chunk.split(/\r?\n/);
for (const line of lines) {
const prefix = "data:";
if (!line.startsWith(prefix)) continue;
const data = line.slice(prefix.length).trim();
if (!data) continue;
if (data === "[DONE]") continue;
const text = extractSseTextParts(data);
if (text) yield text;
}
}
};
while (true) {
if (aborted) throw abortError();
const { done, value } = await reader.read();
if (done) break;
if (aborted) throw abortError();
buffer += decoder.decode(value, { stream: true });
for (const piece of consume()) yield piece;
}
if (aborted) throw abortError();
buffer += "\n\n";
for (const piece of consume()) yield piece;
}
function splitForTranslation(input: string): string[] {
const text = input.replace(/\r\n/g, "\n").trim();
if (!text) return [];
const out: string[] = [];
for (const para of text.split(/\n{2,}/g)) {
const p = para.trim();
if (!p) continue;
const lines = p.split("\n");
let buf: string[] = [];
const flush = () => {
const s = buf.join("\n").trim();
if (s) out.push(s);
buf = [];
};
for (const line of lines) {
const l = line.trimEnd();
if (/^\s*[-*•]\s+/.test(l) || /^\s*\d+\./.test(l)) {
flush();
out.push(l.trim());
} else {
buf.push(l);
}
}
flush();
}
return out;
}
type StreamUpdate = {
titleChunk?: string;
bodyChunk?: string;
done?: boolean;
};
async function translateViaOpenAIStream(
cfg: ProviderConfig,
input: string,
signal?: AbortSignal
): Promise<AsyncGenerator<string>> {
const { url, kind } = resolveOpenAIEndpoint(cfg.baseURL);
const prompt = buildTranslationPrompt(input);
const body =
kind === "responses"
? {
model: cfg.modelID,
input: prompt,
temperature: 0,
max_output_tokens: 1024,
stream: true,
}
: {
model: cfg.modelID,
messages: [
{ role: "system", content: "You are a translation engine." },
{ role: "user", content: prompt },
],
temperature: 0,
max_tokens: 1024,
stream: true,
};
let res: Response;
try {
res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
Authorization: `Bearer ${cfg.apiKey}`,
},
body: JSON.stringify(body),
signal,
});
} catch (e) {
if (signal?.aborted) throw new Error(String(signal.reason ?? "aborted"));
throw e;
}
async function* gen(): AsyncGenerator<string> {
for await (const chunk of streamTextFromResponse(res, signal)) yield chunk;
}
return gen();
}
function renderTranslationBlock(body: string): string {
const content = body.trim() ? body : "译文生成中…";
return `${START_TAG}\n${content}\n${END_TAG}`;
}
function shouldFlush(lastFlush: number): boolean {
return Date.now() - lastFlush >= 300;
}
async function translateReasoningStreamed(
cfg: ProviderConfig,
titleEn: string,
bodyEn: string,
onUpdate: (u: { titleCn?: string; bodyCn?: string; done?: boolean }) => void,
signal?: AbortSignal
): Promise<void> {
let titleCn = "";
let bodyCn = "";
let lastFlush = 0;
const flush = (done?: boolean) => {
onUpdate({
titleCn: titleCn ? titleCn : "译文生成中…",
bodyCn: bodyCn ? bodyCn : "译文生成中…",
done,
});
lastFlush = Date.now();
};
flush(false);
const titleTask = (async () => {
if (!titleEn.trim()) return;
const titleStream = await translateViaOpenAIStream(cfg, titleEn, signal);
for await (const chunk of titleStream) {
titleCn += chunk;
if (shouldFlush(lastFlush)) flush(false);
}
})();
const bodyTask = (async () => {
if (!bodyEn.trim()) return;
const segments = splitForTranslation(bodyEn);
for (let i = 0; i < segments.length; i += 1) {
const seg = segments[i];
const segStream = await translateViaOpenAIStream(cfg, seg, signal);
let segOut = "";
for await (const chunk of segStream) {
segOut += chunk;
if (shouldFlush(lastFlush)) {
const combined = (bodyCn + (bodyCn ? "\n\n" : "") + segOut).trim();
onUpdate({ titleCn: titleCn ? titleCn : "译文生成中…", bodyCn: combined, done: false });
lastFlush = Date.now();
}
}
if (segOut.trim()) {
bodyCn = (bodyCn + (bodyCn ? "\n\n" : "") + segOut.trim()).trim();
}
flush(false);
}
})();
await Promise.all([titleTask, bodyTask]);
flush(true);
}
export const ThinkTranslator: Plugin = async ({ directory, client }) => {
const providerCfg = loadProviderConfigFromOpencodeConfig(directory);
const patch = (client as unknown as {
_client?: {
patch?: (opts: { url: string; body?: unknown }) => Promise<PatchResult>;
};
})._client?.patch;
const lastAssistantMessageBySession = new Map<string, string>();
const reasoningByPartID = new Map<string, string>();
const patchedPartIDs = new Set<string>();
const pendingPatchByPartID = new Map<string, { part: PartEvent; text: string }>();
const patchInFlightByPartID = new Set<string>();
const MAX_CONCURRENT_TRANSLATIONS = 3;
const MAX_RETRIES = 5;
const FIRST_TOKEN_TIMEOUT_MS = 10_000;
const TOTAL_TIMEOUT_MS = 120_000;
type Task = {
part: PartEvent;
base: string;
title: string;
body: string;
createdAt: number;
attempt: number;
generation: number;
};
const queue: Task[] = [];
let running = 0;
const flushPatch = (partID: string) => {
if (!patch) return;
if (patchInFlightByPartID.has(partID)) return;
const pending = pendingPatchByPartID.get(partID);
if (!pending) return;
pendingPatchByPartID.delete(partID);
patchInFlightByPartID.add(partID);
const url = `/session/${pending.part.sessionID}/message/${pending.part.messageID}/part/${partID}`;
const body = { ...pending.part, text: pending.text };
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_r, reject) => {
timer = setTimeout(() => reject(new Error("patch.timeout")), 2000);
});
Promise.race([patch({ url, body }), timeout])
.catch(() => {
return;
})
.finally(() => {
if (timer) clearTimeout(timer);
patchInFlightByPartID.delete(partID);
if (pendingPatchByPartID.has(partID)) flushPatch(partID);
});
};
const enqueuePatch = (p: PartEvent, updatedText: string) => {
if (!patch) return;
const prev = pendingPatchByPartID.get(p.id);
if (prev && prev.text === updatedText) return;
pendingPatchByPartID.set(p.id, { part: p, text: updatedText });
flushPatch(p.id);
};
const runQueue = () => {
while (running < MAX_CONCURRENT_TRANSLATIONS && queue.length) {
const task = queue.shift()!;
running += 1;
const { part, base, title, body, createdAt } = task;
const startAttempt = (attempt: number, generation: number) => {
let sawFirstToken = false;
const attemptStartedAt = Date.now();
let titleCn = "";
let bodyCn = "";
let lastFlush = 0;
const update = (u: { titleCn?: string; bodyCn?: string; done?: boolean }) => {
const nextTitleCn = u.titleCn ?? (sawFirstToken ? titleCn : "译文生成中…");
const nextBodyCn = u.bodyCn ?? (sawFirstToken ? bodyCn : "译文生成中…");
const nextBase = applyTitleTranslation(base, nextTitleCn);
const nextBlock = renderTranslationBlock(nextBodyCn);
const nextText = nextBase + "\n\n" + nextBlock + "\n";
enqueuePatch(part, nextText);
if (typeof u.titleCn === "string") titleCn = u.titleCn;
if (typeof u.bodyCn === "string") bodyCn = u.bodyCn;
};
const controller = new AbortController();
const attemptSignal = controller.signal;
let firstTokenTimer: ReturnType<typeof setTimeout> | undefined;
firstTokenTimer = setTimeout(() => {
if (sawFirstToken) return;
controller.abort("first_token_timeout");
}, FIRST_TOKEN_TIMEOUT_MS);
const totalTimer = setTimeout(() => {
controller.abort("total_timeout");
}, TOTAL_TIMEOUT_MS);
const onToken = () => {
if (sawFirstToken) return;
sawFirstToken = true;
if (firstTokenTimer) {
clearTimeout(firstTokenTimer);
firstTokenTimer = undefined;
}
};
const cleanupTimers = () => {
if (firstTokenTimer) {
clearTimeout(firstTokenTimer);
firstTokenTimer = undefined;
}
clearTimeout(totalTimer);
};
const done = (finalTitle: string, finalBody: string) => {
cleanupTimers();
update({ titleCn: finalTitle, bodyCn: finalBody, done: true });
};
const fail = (msg: string) => {
cleanupTimers();
update({ titleCn: msg, bodyCn: msg, done: true });
};
(async () => {
try {
if (!providerCfg.baseURL.trim() || !providerCfg.apiKey.trim() || !providerCfg.modelID.trim()) {
fail("未配置翻译接口(baseURL/apiKey/modelID)");
return;
}
update({ titleCn: "译文生成中…", bodyCn: "译文生成中…", done: false });
await translateReasoningStreamed(providerCfg, title, body, (u) => {
if ((u.titleCn && u.titleCn.trim()) || (u.bodyCn && u.bodyCn.trim())) onToken();
const now = Date.now();
if (now - lastFlush < 300 && !u.done) return;
lastFlush = now;
update(u);
}, attemptSignal);
done(titleCn || "", bodyCn || "");
} catch (e) {
const elapsed = Date.now() - createdAt;
const nextAttempt = attempt + 1;
const err = String(e);
if (elapsed >= TOTAL_TIMEOUT_MS || err.includes("total_timeout")) {
fail("翻译超时");
return;
}
if (nextAttempt <= MAX_RETRIES) {
setTimeout(() => startAttempt(nextAttempt, generation + 1), 300 * nextAttempt);
return;
}
if (err.includes("http_")) {
const code = err.match(/http_(\d{3})/)?.[1] ?? "";
fail(code ? `翻译失败(HTTP ${code})` : "翻译失败");
return;
}
if (err.includes("first_token_timeout")) {
fail("翻译无响应");
return;
}
fail("翻译失败");
} finally {
cleanupTimers();
running -= 1;
runQueue();
}
})();
};
startAttempt(task.attempt, task.generation);
}
};
const enqueueTranslateAndPatch = (p: PartEvent, base: string) => {
const { title, body } = extractTitleAndBody(base);
queue.push({
part: p,
base,
title,
body,
createdAt: Date.now(),
attempt: 1,
generation: 1,
});
runQueue();
};
return {
"experimental.chat.messages.transform": async (_input, output) => {
for (const m of output.messages ?? []) {
if (m.info.role !== "assistant") continue;
for (const p of m.parts ?? []) {
if (p.type !== "text" && p.type !== "reasoning") continue;
if (typeof p.text !== "string") continue;
p.text = stripTranslationBlocks(p.text);
if (p.type === "reasoning") p.text = stripTitleTranslation(p.text);
}
}
},
event: async ({ event }) => {
if (event.type === "message.updated") {
const info = (event.properties as { info: Message }).info;
if (info.role === "assistant") lastAssistantMessageBySession.set(info.sessionID, info.id);
return;
}
if (event.type !== "message.part.updated") return;
const { part, delta } = event.properties as { part: Part; delta?: string };
const p = part as unknown as PartEvent;
const expectedMessageID = lastAssistantMessageBySession.get(p.sessionID);
if (!expectedMessageID || p.messageID !== expectedMessageID) return;
if (patchedPartIDs.has(p.id)) return;
if (p.type === "reasoning" && typeof delta === "string" && delta.length) {
const prev = reasoningByPartID.get(p.id) ?? "";
reasoningByPartID.set(p.id, prev + delta);
}
if (p.type !== "reasoning" || !p.time?.end) return;
const full = reasoningByPartID.get(p.id) ?? p.text ?? "";
reasoningByPartID.delete(p.id);
const base = stripTranslationBlocks(full).trimEnd();
if (!base) return;
const pendingBase = applyTitleTranslation(base, "标题译文生成中…");
const pendingBlock = buildTranslationBlockPending();
const pendingText = pendingBase + "\n\n" + pendingBlock + "\n";
patchedPartIDs.add(p.id);
enqueuePatch(p, pendingText);
enqueueTranslateAndPatch(p, base);
},
};
};







![[CCometixLine (ccline) fork] 添加功能:显示 CliProxyApi 的反重力额度 (cc 专用插件)3](https://xiaohack.oss-cn-zhangjiakou.aliyuncs.com/typecho/images/2026/01/12/20260112153943_6964a53f74438.png!mark)
![[CCometixLine (ccline) fork] 添加功能:显示 CliProxyApi 的反重力额度 (cc 专用插件)5](https://xiaohack.oss-cn-zhangjiakou.aliyuncs.com/typecho/images/2026/01/12/20260112153949_6964a545af1d2.png!mark)
![[CCometixLine (ccline) fork] 添加功能:显示 CliProxyApi 的反重力额度 (cc 专用插件)2](https://xiaohack.oss-cn-zhangjiakou.aliyuncs.com/typecho/images/2026/01/12/20260112153941_6964a53d5296f.png!mark)
![[CCometixLine (ccline) fork] 添加功能:显示 CliProxyApi 的反重力额度 (cc 专用插件)4](https://xiaohack.oss-cn-zhangjiakou.aliyuncs.com/typecho/images/2026/01/12/20260112153947_6964a5430d425.png!mark)




![[开源分享] NoneBot2 敏感词拦截插件:支持正则、实时热更1](https://xiaohack.oss-cn-zhangjiakou.aliyuncs.com/typecho/images/2026/01/04/20260104100836_6959cba419e07.png!mark)
![[开源分享] NoneBot2 敏感词拦截插件:支持正则、实时热更2](https://xiaohack.oss-cn-zhangjiakou.aliyuncs.com/typecho/images/2026/01/04/20260104100838_6959cba6d6ee6.png!mark)
![[开源分享] NoneBot2 敏感词拦截插件:支持正则、实时热更3](https://xiaohack.oss-cn-zhangjiakou.aliyuncs.com/typecho/images/2026/01/04/20260104100840_6959cba8e61ef.png!mark)
