javascript

OpenClaw на русском — как я перевёл интерфейс, не трогая исходники

  • понедельник, 30 марта 2026 г. в 00:00:04
https://habr.com/ru/articles/1016372/

У меня OpenClaw крутится на VPS уже два месяца. Штука крутая, но есть один момент, который бесит: весь интерфейс личного кабинета на английском. Я-то ладно, привык, но когда показываешь коллегам или знакомым — сразу «а что тут нажимать» и «а это что за Settings».

Казалось бы, ну загугли «openclaw на русском» — и найдёшь готовую локализацию. Нет. Нету. В roadmap проекта тоже ничего про i18n. Строки захардкожены прямо в JSX, никакого react-intl или i18next там и близко нет. То есть даже если отправить PR — его некуда класть, фреймворка для переводов просто не существует в проекте.

Форкать OpenClaw и вручную менять строки — вариант для мазохистов. Проект обновляется чуть ли не каждый день, мержить свои правки в каждый апдейт я точно не буду.

В итоге я сделал проще.

Что придумал

Идея тупая до безобразия: поставить между nginx и OpenClaw прокси, которая будет на лету подсовывать в HTML скрипт с переводом. OpenClaw ничего не знает про прокси, прокси ничего не меняет в логике OpenClaw. Просто берёт HTML, дописывает <script> перед </body>, и всё.

nginx (:443)
  → раньше шло на OpenClaw (:18789)
  → теперь идёт на мою прослойку (:18790)
      → та проксирует всё на OpenClaw (:18789)
      → но в HTML-ответы втыкает скрипт с переводом

Прослойка — это ~150 строк на Node.js. Назвал openclaw-ru-layer, потому что фантазия кончилась.

Серверная часть

Обычный http-proxy. Ничего хитрого, кроме одного нюанса — нужно перехватывать res.write и res.end, чтобы модифицировать тело ответа до того, как оно уйдёт клиенту.

import http from "node:http";
import httpProxy from "http-proxy";

const TARGET = process.env.TARGET_ORIGIN || "http://127.0.0.1:18789";
const proxy = httpProxy.createProxyServer({ target: TARGET, ws: true });

const server = http.createServer((req, res) => {
  if (req.url === "/ru-overlay.js") {
    // отдаём скрипт перевода
    res.writeHead(200, { "Content-Type": "application/javascript" });
    res.end(overlayScript);
    return;
  }

  // перехватываем ответ
  const _write = res.write.bind(res);
  const _end = res.end.bind(res);
  let chunks = [];

  res.write = (chunk) => { chunks.push(chunk); };
  res.end = (chunk) => {
    if (chunk) chunks.push(chunk);
    let body = Buffer.concat(chunks).toString("utf-8");

    if (res.getHeader("content-type")?.includes("text/html")) {
      body = body.replace(
        "</body>",
        `<script src="/ru-overlay.js"></script></body>`
      );
    }
    // обязательно убрать content-length, иначе браузер
    // обрежет ответ — длина-то изменилась
    res.removeHeader("content-length");
    _write(body);
    _end();
  };

  proxy.web(req, res);
});

Один момент, на котором я потерял час: WebSocket. OpenClaw использует WS для чата, и если не пробросить upgrade — интерфейс загружается, но чат молчит. Лечится одной строчкой:

server.on("upgrade", (req, socket, head) => {
  proxy.ws(req, socket, head);
});

Клиентский скрипт — перевод через MutationObserver

Вот тут было интереснее. Первая версия была примитивная: при загрузке страницы пробежаться по всем текстовым нодам и заменить английские строки на русские из словаря.

Работало ровно до первого клика. OpenClaw — SPA, весь контент рендерится динамически. Переключаешь вкладку — DOM перестраивается, и мой перевод пропадает.

Пришлось вешать MutationObserver:

const dict = {
  "Settings": "Настройки",
  "New conversation": "Новый диалог",
  "Send a message": "Отправить сообщение",
  "Skills": "Навыки",
  "Memory": "Память",
  "Schedule": "Расписание",
  "Delete": "Удалить",
  "Cancel": "Отмена",
  "Save": "Сохранить",
  // ... ещё строк 200
};

function translateNode(node) {
  if (node.nodeType !== Node.TEXT_NODE) return;
  const key = node.textContent.trim();
  if (dict[key]) {
    node.textContent = node.textContent.replace(key, dict[key]);
  }
}

// первичный проход
const walker = document.createTreeWalker(
  document.body, NodeFilter.SHOW_TEXT
);
while (walker.nextNode()) translateNode(walker.currentNode);

// следим за новыми элементами
new MutationObserver((mutations) => {
  for (const m of mutations) {
    for (const node of m.addedNodes) {
      if (node.nodeType === Node.ELEMENT_NODE) {
        const w = document.createTreeWalker(
          node, NodeFilter.SHOW_TEXT
        );
        while (w.nextNode()) translateNode(w.currentNode);
      } else {
        translateNode(node);
      }
    }
  }
}).observe(document.body, { childList: true, subtree: true });

Да, это грубо. Да, на долю секунды виден английский текст до того, как скрипт отработает. Но на практике это незаметно — MutationObserver срабатывает почти мгновенно.

Что не работает (честно)

Не буду делать вид, что это идеальное решение. Проблемы есть:

Placeholder’ы. Текст внутри <input placeholder="Search..."> — это атрибут, а не текстовая нода. TreeWalker его не видит. Нужно отдельно обрабатывать через тот же MutationObserver, но на атрибутах. Я пока забил — руки не дошли.

Контекст. Слово “Run” в кнопке и “Run” в тексте диалога — это разные вещи. Словарь не различает контекст. Пока не мешает, потому что интерфейсные строки обычно уникальны, но теоретически может стрельнуть.

Обновления OpenClaw. Если в новой версии появится кнопка с текстом, которого нет в словаре — она останется на английском. Не сломается, просто не переведётся. Добавляешь строку в словарь, перезапускаешь сервис — готово. За два месяца пришлось дописывать раз пять-шесть.

Почему не PR в основной репо

Я серьёзно думал об этом. Но посмотрел на кодовую базу OpenClaw и передумал. Там нет никакой инфраструктуры для локализации — строки лежат прямо в компонентах, вот так:

<Button>Save changes</Button>
<h2>Conversation history</h2>

Чтобы сделать нормальный PR с i18n, нужно:

  1. Выбрать библиотеку (react-intl? i18next?).

  2. Вынести все строки в JSON-файлы.

  3. Обернуть каждый компонент.

  4. Добавить переключатель языка.

Это рефакторинг на несколько дней. И непонятно, примут ли его — у core-команды другие приоритеты. А моя прослойка решает проблему прямо сейчас, за полчаса установки, без зависимости от чьих-то решений.

Установка

Если кому-то нужен перевод OpenClaw на русский — вот:

git clone https://github.com/perfectinn/openclaw-ru-layer
cd openclaw-ru-layer
sudo bash scripts/install.sh --patch-nginx

Скрипт ставит npm-зависимости, создаёт systemd-сервис и (с флагом --patch-nginx) переключает nginx на прослойку. Удаление — sudo bash scripts/uninstall.sh, откатывает всё назад.

Для Docker:

docker build -t openclaw-ru-layer .
docker run --rm -p 18790:18790 \
  -e TARGET_ORIGIN=http://host.docker.internal:18789 \
  openclaw-ru-layer

Репозиторий: github.com/perfectinn/openclaw-ru-layer

Если пользуетесь — кидайте PR с новыми строками для словаря. Там реально нечего делать, одна строчка в JSON.


У кого-нибудь был опыт подобного runtime-перевода для других приложений? Интересно, есть ли более элегантный способ, чем MutationObserver по всему DOM.