标签 AI翻译 下的文章

前几天看到: 让 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

关于我的 App

前几个月写了一个用大模型处理文字内容的 App。可以在 Windows 和 macOS 里通过快捷键 + 选中文字来快速的调用大模型。

比如,我在阅读一个技术文档,内容实在是太多了,我想让 AI 给我总结一下内容。那么我就选中所有的文字,然后通过快捷键让 AI 给我解释。

又或者,你看到一段非中文的内容,想让 AI 翻译一下。你再也不需要复制粘帖了,或者像谷歌翻译那样,要选择输入 + 输出的语言。现在只要选中文字再按下快捷键就可以了!

用法

1,下载,安装
2,给快捷键绑定提示词。比如,我想把翻译的功能绑定到 cmd+shift+T
3,在系统的任何地方选中文字,按下快捷键。
4,享受效率的提升!

展示

总结一段文字

Screen Recording 2026-01-22 at 10.28.52

或者,把你随意写的一段中文翻译成任何语言。也可以把任何语言翻译成中文,无需设置源语言。

Screen Recording 2026-01-22 at 10.33.29

为什么要做这个 App

我日常工作会经常的用到翻译之类的功能,一般是我写一段中文,然后让 AI 帮助我翻译成英文,或者是直接写英文,然后让 AI 帮助我挑一下语法错误。以前每次都要打开一个聊天界面然后把选中的文字输入进去,效率非常低。寻找一番后也没有找到我想要的软件,所以就自己做了一个。

后来我发现其实豆包和 CherryStudio 也有类似的功能,但是跟我想要的还是有差距。他们都是通过划词实现的,我无法接受划词弹出的那个小框。平时工作需要经常给别人共享屏幕,那个小窗口着实令人尴尬。而我的应用非常的 “隐形”,而且界面和功能很简单。对我这种有点强迫症的人非常友好。

下载


📌 转载信息
原作者:
notLouee
转载时间:
2026/1/22 13:11:54

OpenCode 中文汉化版 - 双语版本 - v6.1

基于 @QinTian 的汉化项目进行改进

这个是大佬的帖子: 【汉化】OpenCode 开源汉化脚本!

由于每次 opencode 官方更新的很勤快,会有新的文件增加,

花了半天时间弄了个自动更新,然后加了个 ai 检查并且自动写入语言包里面。

然后发现有时候翻译的会导致官方源文件报错,又逐步增加了 ai 审查一些乱七八糟的。

反正现在凑合用。不怕官方在更新了。

用的 ai 是用的反重力的,接口,

如果你需要接入 ai, 你启动 ai 后,让 ai 自己把接口接进来。就 ok

新增 AI 自动翻译和质量检查功能。

项目地址

效果展示

交互式菜单覆盖率报告质量检查

| 新增功能 |
| ------ 功能 -------| ---------------- 说明 --------------------------------- |
| **AI 自动翻译 ** | 官方更新后自动检测新文本,调用 AI 翻译 ------------- |
| ** 增量翻译 ** | opencodenpm apply --incremental,仅翻译 git 变更文件 |
| ** 质量检查 ** | opencodenpm check --quality,语法检查 + AI 语义审查 |
| ** 自动修复 ** | 发现语法问题时 AI 自动修复 ------------------------------ |
| ** 覆盖率报告 ** | 显示翻译统计 + AI 智能总结 ----------------------------|
| ** 跨平台支持 ** | Node.js CLI 替代 PowerShell,macOS/Linux/Windows 通用 |

技术改进

  • ** 语法安全检查 **:引号、花括号、{highlight} 标签匹配检测
  • ** 双语格式 **:统一为 中文 (English) 格式,便于理解原义
  • ** 交互式菜单 **:分类清晰,键盘导航
    翻译统计
  • 605 条翻译,质量评分 100/100
  • 对话框:34 文件 / 186 条
  • 组件:16 文件 / 212 条
  • 路由:11 文件 / 149 条
  • 通用:10 文件 / 54 条
    快速开始

克隆

git clone GitHub - xiaolajiaoyyds/OpenCodeChineseTranslation: opencode 汉化脚本 - mac
cd OpenCodeChineseTranslation

安装

cd scripts && npm install && npm link

运行(交互式菜单)

opencodenpm

编译

opencodenpm build && opencodenpm deploy && opencode
AI 翻译配置
创建 .env 文件,支持任何 OpenAI 兼容 API:
OPENAI_API_KEY=your-key
OPENAI_API_BASE=http://127.0.0.1:8045/v1
OPENAI_MODEL=claude-sonnet-4-20250514
推荐使用 Antigravity Tools (https://agtools.cc) 本地反代,支持 Claude/GPT/Gemini 等模型。

有啥问题在留言,我抽空解决看看~

说明一下,配置文件都在用户的根目录下面,所以不用担心每次更新后会把配置文件丢失


感觉有问题的话,先提前做一个备份。


📌 转载信息
原作者:
xiaolajiao
转载时间:
2026/1/18 08:44:07

2026.1.1 日 发布新版本,能否被称为卷王

本项目基于 VocabMeld 深度改进的沉浸式语言学习插件,智能替换网页词汇,在阅读中自然习得外语

由于原项目存在一些问题,功能不够完善,且迭代更新较慢,提 pr 也不反馈,故自行 fork 进行维护和功能改进

本次更新的核心亮点

多节点故障转移系统

这是本次更新的最大亮点。支持配置多个 API 节点,实现:

  • 自动故障转移 - 节点失败(网络异常、额度耗尽、RPM 限制)时自动切换
  • 智能健康检查 - 5 分钟内 3 次失败标记异常,定期自动恢复
  • 速率限制轮询 - 多节点轮流处理请求,突破单节点 RPM 限制
  • 自定义优先级 - 按顺序配置节点优先级,优先使用最优节点

典型场景

  • 配置多个魔搭社区账号(每天 2000 次 / 账号),轮询叠加免费额度
  • 配置不同服务商(DeepSeek + 魔搭)互为备份,提升可用性
  • 免费节点优先,额度用完自动切换付费节点兜底

智能语义分词优化

针对不同语言优化分词策略,避免错误切分:

  • 中文优化 - 按语义边界识别,避免「对方面无表情」被错误切分为「方面」
  • 英文优化 - 识别短语动词(give up、look forward to)和固定搭配
  • 提示词自定义 - 支持自定义 AI 翻译提示词,完全控制翻译效果
  • 完整预览 - 可预览发送给 AI 的完整提示词,包含所有动态参数

用户可个性化自定义提示词:

工程化能力增强

  • OpenSpec 集成 - 引入规范管理系统,支持结构化的变更提案
  • GitHub Actions - 自动化构建和发布流程
  • 完善文档 - 新增功能演示截图、API 配置说明、提示词设置说明

推荐配置:魔搭社区免费额度

魔搭社区(ModelScope)提供免费的 AI 推理服务,非常适合 Lingrove:

  • 单账号每天 2000 次总额度
  • 单模型限制 500 次
  • 可配置 4 个节点使用不同模型(DeepSeek-V3、DeepSeek-V3.2、Qwen2.5-72B、Qwen3-235B)
  • 充分利用 2000 次额度,完全免费!

进阶玩法:申请多个魔搭账号,配置多节点轮询,叠加免费额度


隐私优先

  • 所有数据存储在浏览器本地,不上传任何服务器
  • 仅在翻译时发送文本片段到您配置的 AI 服务
  • API 密钥由您自行提供和管理
  • 无追踪、无分析、无广告代码


开源信息

快速开始

安装

  1. 前往 Releases 页面 下载最新版本

  2. 解压 zip 文件到本地目录

  3. 打开 Chrome,访问 chrome://extensions/

  4. 开启 "开发者模式"

  5. 点击 "加载已解压的扩展程序",选择解压后的文件夹

配置

  1. 点击扩展图标 → 设置

  2. 选择预设服务(推荐魔搭社区)或自定义配置

  3. 填入 API 密钥,测试连接

  4. 开始享受沉浸式学习!


📌 转载信息
原作者:
fyf980921
转载时间:
2026/1/1 16:12:25

相信站内不少佬平时都得啃论文,我自己也是。但总得在各种翻译,ai 聊天软件里切换来切换去,索性自己动手搞了这个项目。

首先先介绍一下功能

第一:可以转换成 md 后进行翻译,格式不会丢失,还有中英对照页面
翻译采用 ReactAgent 架构,搜寻从 arxiv, 以及网上各种信息,从背景、动机、切入点:

  • 以及强相关论文给出链接和简单描述相关性
  • 最重要的创新点也会详细解析(是什么,为什么重要,与已有方法对比),以及关键模块等等
  • 实验结果,优势和局限性
  • 还有 ai 推断的可行方向

第二:不熟悉的专业术语,划词后让 ai 解析
解析完成后会在整个项目全局高亮,鼠标悬停则会出现解析

第三:拥有用户画像功能的就论文对话功能
ai 能根据用户的回答实时调整用户画像,给出让用户最能听懂最想要的回答

话不多说,拿一篇论文试试效果,拿最近新出的 step-deepresearch 举例

1. 解析效果:结构公式啥的都没毛

gif1_2x_2

2. Agent 翻译

[开源] PaperMate,让读论文看文档再优雅一点点3

类似于沉浸式那样的对照阅读也没问题:

[开源] PaperMate,让读论文看文档再优雅一点点2

3. 深度解析

gif4_2

4. 划词 & 记忆:遇见不懂的陌生的词?直接划词解析(这些都会进入 llm 的记忆,可以在对话中用到):

[开源] PaperMate,让读论文看文档再优雅一点点4

5. 智能问答: llm 会根据用户回答自动调整用户画像(beta,可能有 bug):

[开源] PaperMate,让读论文看文档再优雅一点点5

to_do_list:
未来想完善的功能:
添加项目级别的记忆,不止是术语和画像,提升 llm 回答的质量,让 llm 更懂你,也更懂论文
将整个项目里的所有论文,以及专业术语和记忆,作成 rag 知识库,在对话、翻译等地方运用上去
优化翻译 agent 和对话流程


大家在读论文里还有什么需要的可以提,如果好的我都会采纳放进去,还有 rag 我完全没整过,感觉有点复杂就先放到后边去了

最后,star 一下吧


📌 转载信息
转载时间:
2025/12/31 11:32:06