本节摘要:工程保障舱的收官三件套。i18n:
src/i18n/translation.json约 1.8MB、41,569 行,收录 2445 个 UI 键,每个键在全部支持语言(含 zh-CN 与 zh-TW)中都有译文——产品有完整中文界面;make-i18n-translations.cjs把这份"键 → {语言 → 文案}"的宽表,按语言拆成public/locales/<lng>/openhands.json,并生成I18nKey枚举;运行时 i18next-http-backend 按需加载语言包,1.8MB 不会进主包。遥测:src/services/telemetry.ts898 行,PostHog 客户端的唯一持有者——事件分级(install 事件先于同意发送)、多层退出开关(VITE_DO_NOT_TRACK、浏览器 DNT、运行时注入)、防广告拦截器的反向代理域名。AGENTS.md:全仓库 120KB 的 AI 协作规范——仓库地图、API 访问规则、遥测架构、live/mock-LLM 测试框架、PR 规范全在其中,它本身是"如何为 AI 贡献者写工程文档"的绝佳教材。另外补上 winston 日志层。注:教程规划口径称"16 语言",当前源码实测为 15 种语言全量翻译,本节以源码为准。
内容来源:原项目
src/i18n/translation.json(前 50 行结构与统计)、src/i18n/index.ts、scripts/make-i18n-translations.cjs(53 行全文)、src/services/telemetry.ts(头部与关键函数)、AGENTS.md、scripts/logger.mjs,精读整理。
⚠️ 注意:translation.json 是编辑源,不是运行时资源。直接改
public/locales/下的产物会被下次make-i18n覆盖;src/i18n/declaration.ts头部写着"this file generate by script, don't modify it manually!!!"。一切改动回到宽表,产物交给脚本。
阅读完本节,你应当能够:
SECTION$KEY 的命名组织方式。I18nKey 枚举)以及为什么 npm test 前要先跑它。translation.json 的组织是"键 → 各语言译文"的宽表,看前两个键就能懂:
{ "COMMON$PATTERN": { "ar": "النمط:", "de": "Muster:", "en": "Pattern:", "fr": "Motif :", "ja": "パターン:", "ko-KR": "패턴:", "uk": "Шаблон:", "zh-CN": "模式:", "zh-TW": "模式:" }, "COMMON$INCLUDE": { "ar": "تضمين:", "de": "Einschließen:", "en": "Include:", "fr": "Inclure :", "ja": "対象:", "ko-KR": "포함:", "uk": "Включити:", "zh-CN": "包含:", "zh-TW": "包含:" } }
两个设计决定值得注意:
COMMON$PATTERN、LANDING$TITLE 这类 区$键 命名把 2445 个键按 UI 区域分组——搜一个界面文案,先看前缀就知道属于哪个功能区,键空间不会退化成扁平的字符串海。"COMMON$RESULTS": { ..., "en": "Results ({{count}})", "zh-CN": "结果 ({{count}})" }——i18next 标准插值语法,count 等参数由组件在调用时传入,译文永远只含占位符,不拼字符串。实测统计:2445 个键 × 15 种语言(ar/ca/de/en/es/fr/it/ja/ko-KR/no/pt/tr/uk/zh-CN/zh-TW)全量覆盖——不存在"某语言缺这缺那"的半吊子状态,简繁中文都是一等公民。这也是为什么第 7 章、第 8 章反复出现的 i18next/no-literal-string lint 规则敢设为 error:所有 UI 文案都有家可归,组件里写死英文才是违规。
scripts/make-i18n-translations.cjs 全文只有一个职责——把宽表"转置":
const fs = require("fs"); const path = require("path"); const i18n = require("../src/i18n/translation.json"); const namespace = "openhands"; // { [lang]: { [key]: content } } const translationMap = {}; Object.entries(i18n).forEach(([key, transMap]) => { Object.entries(transMap).forEach(([lang, content]) => { if (!translationMap[lang]) { translationMap[lang] = {}; } translationMap[lang][key] = content; }); }); // remove old locales directory const localesPath = path.join(__dirname, "../public/locales"); if (fs.existsSync(localesPath)) { fs.rmSync(localesPath, { recursive: true }); } // write translation files Object.entries(translationMap).forEach(([lang, transMap]) => { const filePath = path.join( __dirname, `../public/locales/${lang}/${namespace}.json`, ); if (!fs.existsSync(filePath)) { fs.mkdirSync(path.dirname(filePath), { recursive: true }); } fs.writeFileSync(filePath, JSON.stringify(transMap, null, 2)); }); // write translation key enum const transKeys = Object.keys(translationMap.en); fs.writeFileSync( path.join(__dirname, "../src/i18n/declaration.ts"), ` // this file generate by script, don't modify it manually!!! export enum I18nKey { ${transKeys.map((key) => ` ${key} = "${key}",`).join("\n")} }`.trim() + "\n", );
三步:遍历宽表把"键 → 语言 → 文案"转置成"语言 → 键 → 文案";删旧目录后按语言写 public/locales/<lang>/openhands.json;最后以英文键序为基准生成 declaration.ts 的 I18nKey 枚举。第二份产物是点睛之笔——业务代码里写 t(I18nKey.LANDING$TITLE) 而不是裸字符串,拼错键名编译期报错;新增文案必须先进宽表再重新生成,枚举与翻译永远同步。这也解释了前两节反复看到的怪现象:npm test、npm run dev、甚至变异测试都要先跑 make-i18n——没生成枚举,引用它的代码根本编译不过。
src/i18n/index.ts 展示加载策略:
import Backend from "i18next-http-backend"; import LanguageDetector from "i18next-browser-languagedetector"; import { initReactI18next } from "react-i18next"; export const OPENHANDS_I18N_NAMESPACE = "openhands"; const initializeI18n = (instance: I18nInstance) => { // ... const initPromise = instance .use(Backend) .use(LanguageDetector) .use(initReactI18next) .init({ fallbackLng: "en", supportedLngs: AvailableLanguages.map((lang) => lang.value), ns: [OPENHANDS_I18N_NAMESPACE], defaultNS: OPENHANDS_I18N_NAMESPACE, backend: { loadPath: buildAgentCanvasPath("/locales/{{lng}}/{{ns}}.json"), }, interpolation: { escapeValue: false }, }); // ... };
关键在 loadPath:语言包运行时从 /locales/<语言>/openhands.json 拉取(路径还经 buildAgentCanvasPath 处理,兼容第 9 章会讲的 /canvas 子路径部署)。用户用中文,浏览器只下载中文那一份(几十 KB 到一两百 KB);1.8MB 的全量宽表从不进 bundle。文件头部的注释还讲了另一半故事:library 消费者如果不想用 HTTP 后端,可以从 resources.ts re-export 内嵌资源(Rollup 会在没人引用时把它摇掉)——同一份翻译,app 走网络、库走打包,两种形态一个来源。
💡 驾驶舱要点:这套 i18n 的完成度体现在"闭环":文案改动只进宽表一处;脚本产出语言包与类型枚举;枚举让键名拼写编译期受检;lint 禁止裸字符串堵住漏网;HTTP 后端保证运行时只付当前语言的成本。每个环节都在把"翻译遗漏"从运行时丢字幕,变成编译/CI 阶段的硬错误。
telemetry.ts 头部注释就是一份跟踪政策宣言:
/** * TRACKING PHILOSOPHY: * - Install event (canvas_install): Sent immediately on first use, regardless of consent. * This is anonymous and contains no PII - just basic browser info and a random ID. * - Session/custom events: Only sent after user grants consent via the consent modal. * - Users can opt out of all future tracking by declining consent. * * ADBLOCKER BYPASS: telemetry is routed through OpenHands' reverse proxy * (z.openhands.dev) ... */
落地代码在 trackInstall():
export async function trackInstall(): Promise<void> { // Respect hard opt-out via environment variable or browser setting if (isDoNotTrackEnabled()) { return; } // Already sent install event (persisted in localStorage - survives app relaunches) if (hasFirstUseSent()) { return; } const posthog = await initializePostHogClient(true); if (!posthog || isDoNotTrackEnabled()) { return; } // Capture the install event posthog.capture("canvas_install", { platform: typeof navigator !== "undefined" ? navigator.platform : "unknown", user_agent: typeof navigator !== "undefined" ? navigator.userAgent : "unknown", referrer: typeof document !== "undefined" ? document.referrer : "", url_origin: typeof window !== "undefined" ? window.location.origin : "", embedded: typeof window !== "undefined" && window.self !== window.top, }); markFirstUseSent(); // Restore opt-out state if user hasn't granted consent yet const currentConsent = getTelemetryConsent(); if (currentConsent !== "granted") { posthog.opt_out_capturing(); } }
细读这份"例外中的例外":install 事件虽先于同意发送,但(1)只发一次(localStorage 记账,跨会话有效);(2)载荷仅平台/UA/来源等环境字段加随机 ID,无 PII;(3)发完立刻检查同意状态,未授权马上 opt_out_capturing() 恢复静默。除此之外的一切事件(trackEvent、useTelemetry 生命周期)都必须以 getTelemetryConsent() === "granted" 为前提。
不想被跟踪的用户有四条退路,代码全部落实:
| 开关 | 层级 | 效果 |
|---|---|---|
VITE_DO_NOT_TRACK=1 |
构建环境变量 | install 事件也不发 |
| 浏览器 Do Not Track 设置 | 浏览器 | 同上,isDoNotTrackEnabled() 统一判定 |
window.__AGENT_CANVAS_DO_NOT_TRACK__ = true |
运行时注入 | 静态服务器可按 AGENT_CANVAS_DISABLE_TELEMETRY=1 注入;此 flag 下 PostHog 客户端从不初始化,后端镜像来的同意也无法把它打开 |
| 拒绝同意弹窗 | 产品 UI | install 已发,后续全停 |
还有一层给嵌入方的:configureTelemetry(config | false) 允许 npm 消费者运行时替换 apiKey/apiHost/uiHost,或传 false 硬关。默认 key 来自 config/defaults.json 的 telemetry 段,数据经自家反向代理 z.openhands.dev 中转以绕开广告拦截器。AGENTS.md 的"Tracking / Analytics Architecture"一节(近 80 行)进一步规定:telemetry.ts 是唯一能摸 agent-canvas 命名 PostHog 客户端的模块;React 组件禁止裸调 posthog.capture(),一律经 useTracking 的类型化函数;事件字典(如 onboarding_link_clicked 的 link_id/destination_type/surface 枚举属性)明文列出,"新埋点必须复用既有契约,不许每个按钮造一个事件"。遥测在这个仓库里不是随手 capture,而是一套有宪法、有字典、有唯一入口的子系统。
scripts/logger.mjs 提供 dev 脚本与 Electron 端的文件日志:winston 按天滚动写到 <状态目录>/logs/agent-canvas.YYYY-MM-DD.log(保留 7 天),写前剥离 ANSI 颜色码;winston 用动态 import 加载并在解析失败时降级为 no-op——打包后的桌面应用剥掉 node_modules 也不至于崩日志。前端侧另有 src/utils/event-logger.ts 记录事件流。日志与遥测分工明确:遥测回答"用户怎么用",日志回答"这台机器怎么了"。
AGENTS.md 是 CLAUDE.md/AGENTS.md 约定的仓库级 AI 指令文件——AI 编码代理进入仓库后读的第一份文档。这个仓库把它写到了 120,363 字节(约 714 行)的规模,目录横跨:
/server_info.runtime_services 的 JSON 形状与注入 agent 系统提示的 <RUNTIME_SERVICES> 块样例;注意一个特征:它不是教程式文档,而是规则 + 理由 + 例外清单的规范文体。每条规则都写清"为什么"(如 live 测试为何不进普通 e2e:凭据与不确定性)、"违反了会怎样"(CI 守卫击落)、"哪里例外"(白名单三个文件)。AI 代理需要的正是这种零歧义的硬约束——比喻和愿景对模型没有约束力,"violation breaks CI"才有。
这份文件值得每个团队精读,不因为你用 OpenHands,而因为它示范了 agent 协作时代的工程文档该怎么写:
sleep & wait 而不是 wait -n、公共模式静态服务器为什么不注册编辑器路由(Referer 泄漏分析)——AI 与人一样需要 why 才能在新场景正确外推。传统 README 写给人("这个项目是什么、怎么上手"),AGENTS.md 写给 agent 与人的混合团队("这个仓库的边界在哪、什么不能做、做了会被什么拦住")。当你的仓库每天有 AI 提交的 PR,这份文件就是新"同事"的入职手册——OpenHands 把它当核心基础设施维护,120KB 不是冗长,是把协作成本前置成了文档成本。
💡 驾驶舱要点:工程保障舱至此完整:单测/MSW/变异(逻辑与数据流)、mock-LLM e2e/TESTING_MATRIX(全链路与交付物)、i18n(全球用户)、遥测(产品度量)、winston(运行诊断)、AGENTS.md(人类与 AI 的协作纪律)。六种设施共同回答一个问题——当贡献者从"人"扩展为"人 + AI agent",怎么让每一次改动都安全落地。这是 Agent Canvas 作为"agent 时代产品"在自身工程实践上的自洽:用 agent 协作的方式,开发一个给 agent 用的驾驶舱。
区$键 分区组织,插值用 {{count}}。public/locales/<lng>/openhands.json + 生成 I18nKey 枚举;枚举让键名拼错编译期报错,这也是所有测试/构建命令前置 make-i18n 的原因;产物禁止手改。/locales/{{lng}}/{{ns}}.json 按需拉取(路径经 base-path 处理),主包不含翻译;library 形态可走内嵌 resources 的 re-export。telemetry.ts 898 行是 PostHog 客户端唯一持有者;install 事件先于同意但一次性、无 PII、发后即恢复退出;四层退出开关(环境变量/DNT/运行时注入/拒绝弹窗)+ configureTelemetry 运行时覆盖;组件禁止裸 capture,事件按字典复用契约。下一节:第 9 章 · 01 五种部署形态——离开保障舱,进入停机坪:npm 全局包、Docker 三合一、Electron 三平台、Helm 与 Vercel,看这个产品怎么交付到用户手里。