标签 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);
    },
  };
};


📌 转载信息
原作者:
Tongz
转载时间:
2026/1/23 15:34:53

win11 中内置 copilot GPT5.1 中的 juice 值,其中 Think Deeper 以及 Smart 模式下都是 16

edge 中内置 copilot 里的 GPT5.2, Smart Plus 模式下也是 16(我是 M365 会员,可能有点影响)

github copilot 里的 GPT5.2 是 64

windsurf 家的 GPT5.2 low,juice 是 16

windsurf 家的 GPT5.2 medium,juice 是 64 (high 和 xhigh 性价比太低,就不测了)


📌 转载信息
原作者:
ikb
转载时间:
2025/12/31 12:36:49