Docs: add zh-CN entrypoint translations (#6300)

* Docs: add zh-CN entrypoint translations

* Docs: harden docs-i18n parsing
main
Josh Palmer 2026-02-01 15:22:05 +01:00 committed by GitHub
parent 7a8a39a141
commit 0e0e395b9e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 3334 additions and 0 deletions

30
docs/.i18n/README.md Normal file
View File

@ -0,0 +1,30 @@
# OpenClaw docs i18n assets
This folder stores **generated** and **config** files for documentation translations.
## Files
- `glossary.<lang>.json` — preferred term mappings (used in prompt guidance).
- `<lang>.tm.jsonl` — translation memory (cache) keyed by workflow + model + text hash.
## Glossary format
`glossary.<lang>.json` is an array of entries:
```json
{
"source": "troubleshooting",
"target": "故障排除",
"ignore_case": true,
"whole_word": false
}
```
Fields:
- `source`: English (or source) phrase to prefer.
- `target`: preferred translation output.
## Notes
- Glossary entries are passed to the model as **prompt guidance** (no deterministic rewrites).
- The translation memory is updated by `scripts/docs-i18n`.

View File

@ -0,0 +1,82 @@
[
{
"source": "OpenClaw",
"target": "OpenClaw"
},
{
"source": "Gateway",
"target": "Gateway"
},
{
"source": "Pi",
"target": "Pi"
},
{
"source": "agent",
"target": "智能体"
},
{
"source": "channel",
"target": "渠道"
},
{
"source": "session",
"target": "会话"
},
{
"source": "provider",
"target": "提供商"
},
{
"source": "model",
"target": "模型"
},
{
"source": "tool",
"target": "工具"
},
{
"source": "CLI",
"target": "CLI"
},
{
"source": "install sanity",
"target": "安装完整性检查"
},
{
"source": "get unstuck",
"target": "快速排障"
},
{
"source": "troubleshooting",
"target": "故障排除"
},
{
"source": "FAQ",
"target": "常见问题"
},
{
"source": "onboarding",
"target": "上手引导"
},
{
"source": "wizard",
"target": "向导"
},
{
"source": "environment variables",
"target": "环境变量"
},
{
"source": "environment variable",
"target": "环境变量"
},
{
"source": "env vars",
"target": "环境变量"
},
{
"source": "env var",
"target": "环境变量"
}
]

1371
docs/.i18n/zh-CN.tm.jsonl Normal file

File diff suppressed because it is too large Load Diff

View File

@ -11,6 +11,10 @@
"primary": "#FF5A36"
},
"topbarLinks": [
{
"name": "中文",
"url": "/zh-CN"
},
{
"name": "GitHub",
"url": "https://github.com/openclaw/openclaw"

262
docs/zh-CN/index.md Normal file
View File

@ -0,0 +1,262 @@
---
read_when:
- 向新用户介绍 OpenClaw
summary: OpenClaw 的顶层概述、功能特性与用途
x-i18n:
generated_at: "2026-02-01T13:34:09Z"
model: claude-opus-4-5
provider: pi
source_hash: 92462177964ac72c344d3e8613a3756bc8e06eb7844cda20a38cd43e7cadd3b2
source_path: index.md
workflow: 9
---
# OpenClaw 🦞
> _"EXFOLIATE! EXFOLIATE!"_ — 大概是一只太空龙虾说的
<p align="center">
<img
src="/assets/openclaw-logo-text-dark.png"
alt="OpenClaw"
width="500"
class="dark:hidden"
/>
<img
src="/assets/openclaw-logo-text.png"
alt="OpenClaw"
width="500"
class="hidden dark:block"
/>
</p>
<p align="center">
<strong>适用于任意操作系统,通过 WhatsApp/Telegram/Discord/iMessage Gateway 连接 AI 智能体 (Pi)。</strong><br />
插件可添加 Mattermost 等更多渠道支持。
发送一条消息,即可获得智能体回复——随时随地,触手可及。
</p>
<p align="center">
<a href="https://github.com/openclaw/openclaw">GitHub</a> ·
<a href="https://github.com/openclaw/openclaw/releases">版本发布</a> ·
<a href="/">文档</a> ·
<a href="/start/openclaw">OpenClaw 助手设置</a>
</p>
OpenClaw 将 WhatsApp通过 WhatsApp Web / Baileys、TelegramBot API / grammY、DiscordBot API / channels.discord.js和 iMessageimsg CLI桥接至编程智能体例如 [Pi](https://github.com/badlogic/pi-mono)。插件可添加 MattermostBot API + WebSocket等更多渠道支持。
OpenClaw 同时也驱动着 OpenClaw 助手。
## 从这里开始
- **从零开始全新安装:** [快速入门](/start/getting-started)
- **引导式设置(推荐):** [向导](/start/wizard) `openclaw onboard`
- **打开仪表盘(本地 Gateway** http://127.0.0.1:18789/(或 http://localhost:18789/
如果 Gateway 运行在同一台计算机上,该链接会立即打开浏览器控制界面。如果无法打开,请先启动 Gateway `openclaw gateway`.
## 仪表盘(浏览器控制界面)
仪表盘是用于聊天、配置、节点、会话等功能的浏览器控制界面。
本地默认地址http://127.0.0.1:18789/
远程访问: [Web 界面](/web) 和 [Tailscale](/gateway/tailscale)
<p align="center">
<img src="whatsapp-openclaw.jpg" alt="OpenClaw" width="420" />
</p>
## 工作原理
```
WhatsApp / Telegram / Discord / iMessage (+ plugins)
┌───────────────────────────┐
│ Gateway │ ws://127.0.0.1:18789 (loopback-only)
│ (single source) │
│ │ http://<gateway-host>:18793
│ │ /__openclaw__/canvas/ (Canvas host)
└───────────┬───────────────┘
├─ Pi agent (RPC)
├─ CLI (openclaw …)
├─ Chat UI (SwiftUI)
├─ macOS app (OpenClaw.app)
├─ iOS node via Gateway WS + pairing
└─ Android node via Gateway WS + pairing
```
大多数操作通过 **Gateway** `openclaw gateway`进行,它是一个长期运行的单进程,负责管理渠道连接和 WebSocket 控制面。
## 网络模型
- **每台主机一个 Gateway推荐**:它是唯一允许持有 WhatsApp Web 会话的进程。如果需要备用机器人或严格隔离,可使用独立配置文件和端口运行多个 Gateway请参阅 [多 Gateway 部署](/gateway/multiple-gateways).
- **优先回环**Gateway WS 默认监听 `ws://127.0.0.1:18789`.
- 向导现在默认会生成一个 Gateway 令牌(即使在回环模式下也是如此)。
- 如需 Tailnet 访问,请运行 `openclaw gateway --bind tailnet --token ...` (非回环绑定时必须提供令牌)。
- **节点**:通过 WebSocket 连接到 Gateway根据需要使用局域网/Tailnet/SSH旧版 TCP 桥接已弃用/移除。
- **Canvas 主机**HTTP 文件服务器运行在 `canvasHost.port` (默认 `18793`),提供 `/__openclaw__/canvas/` 用于节点 WebView请参阅 [Gateway 配置](/gateway/configuration) `canvasHost`)。
- **远程使用**SSH 隧道或 Tailnet/VPN请参阅 [远程访问](/gateway/remote) 和 [发现机制](/gateway/discovery).
## 功能特性(概览)
- 📱 **WhatsApp 集成** — 使用 Baileys 实现 WhatsApp Web 协议
- ✈️ **Telegram 机器人** — 通过 grammY 支持私聊和群组
- 🎮 **Discord 机器人** — 通过 channels.discord.js 支持私聊和服务器频道
- 🧩 **Mattermost 机器人(插件)** — Bot 令牌 + WebSocket 事件
- 💬 **iMessage** — 本地 imsg CLI 集成macOS
- 🤖 **智能体桥接** — PiRPC 模式),支持工具流式传输
- ⏱️ **流式传输与分块** — 块流式传输 + Telegram 草稿流式传输详情([/concepts/streaming](/concepts/streaming)
- 🧠 **多智能体路由** — 将提供商账户/对等方路由到隔离的智能体(工作区 + 每智能体会话)
- 🔐 **订阅认证** — 通过 OAuth 支持 AnthropicClaude Pro/Max+ OpenAIChatGPT/Codex
- 💬 **会话** — 私聊折叠为共享 `main` (默认);群组为隔离
- 👥 **群聊支持** — 默认基于提及触发;所有者可切换 `/activation always|mention`
- 📎 **媒体支持** — 收发图片、音频、文档
- 🎤 **语音消息** — 可选的转录钩子
- 🖥️ **网页聊天 + macOS 应用** — 本地界面 + 菜单栏辅助工具,支持操作和语音唤醒
- 📱 **iOS 节点** — 作为节点配对并提供 Canvas 界面
- 📱 **Android 节点** — 作为节点配对并提供 Canvas + 聊天 + 相机
注意:旧版 Claude/Codex/Gemini/Opencode 路径已移除Pi 是唯一的编程智能体路径。
## 快速开始
运行时要求: **Node ≥ 22**.
```bash
# Recommended: global install (npm/pnpm)
npm install -g openclaw@latest
# or: pnpm add -g openclaw@latest
# Onboard + install the service (launchd/systemd user service)
openclaw onboard --install-daemon
# Pair WhatsApp Web (shows QR)
openclaw channels login
# Gateway runs via the service after onboarding; manual run is still possible:
openclaw gateway --port 18789
```
之后在 npm 安装和 git 安装之间切换很简单:安装另一种方式并运行 `openclaw doctor` 以更新 Gateway 服务入口点。
从源码安装(开发):
```bash
git clone https://github.com/openclaw/openclaw.git
cd openclaw
pnpm install
pnpm ui:build # auto-installs UI deps on first run
pnpm build
openclaw onboard --install-daemon
```
如果尚未进行全局安装,请通过以下方式运行上手引导步骤 `pnpm openclaw ...` (在仓库目录中执行)。
多实例快速开始(可选):
```bash
OPENCLAW_CONFIG_PATH=~/.openclaw/a.json \
OPENCLAW_STATE_DIR=~/.openclaw-a \
openclaw gateway --port 19001
```
发送测试消息(需要 Gateway 正在运行):
```bash
openclaw message send --target +15555550123 --message "Hello from OpenClaw"
```
## 配置(可选)
配置文件位于 `~/.openclaw/openclaw.json`.
- 如果你 **不做任何操作**OpenClaw 将使用内置的 Pi 二进制文件以 RPC 模式运行,并采用按发送者区分的会话。
- 如果你想锁定访问权限,请从以下内容开始 `channels.whatsapp.allowFrom` 以及(针对群组的)提及规则。
示例:
```json5
{
channels: {
whatsapp: {
allowFrom: ["+15555550123"],
groups: { "*": { requireMention: true } },
},
},
messages: { groupChat: { mentionPatterns: ["@openclaw"] } },
}
```
## 文档
- 从这里开始:
- [文档中心(所有页面链接)](/start/hubs)
- [帮助](/help) ← _常见修复方案 + 故障排除_
- [配置](/gateway/configuration)
- [配置示例](/gateway/configuration-examples)
- [斜杠命令](/tools/slash-commands)
- [多智能体路由](/concepts/multi-agent)
- [更新 / 回滚](/install/updating)
- [配对(私聊 + 节点)](/start/pairing)
- [Nix 模式](/install/nix)
- [OpenClaw 助手设置](/start/openclaw)
- [技能](/tools/skills)
- [技能配置](/tools/skills-config)
- [工作区模板](/reference/templates/AGENTS)
- [RPC 适配器](/reference/rpc)
- [Gateway 运维手册](/gateway)
- [节点iOS/Android](/nodes)
- [Web 界面(控制界面)](/web)
- [发现机制 + 传输方式](/gateway/discovery)
- [远程访问](/gateway/remote)
- 提供商与用户体验:
- [网页聊天](/web/webchat)
- [控制界面(浏览器)](/web/control-ui)
- [Telegram](/channels/telegram)
- [Discord](/channels/discord)
- [Mattermost插件](/channels/mattermost)
- [iMessage](/channels/imessage)
- [群组](/concepts/groups)
- [WhatsApp 群组消息](/concepts/group-messages)
- [媒体:图片](/nodes/images)
- [媒体:音频](/nodes/audio)
- 伴侣应用:
- [macOS 应用](/platforms/macos)
- [iOS 应用](/platforms/ios)
- [Android 应用](/platforms/android)
- [Windows (WSL2)](/platforms/windows)
- [Linux 应用](/platforms/linux)
- 运维与安全:
- [会话](/concepts/session)
- [定时任务](/automation/cron-jobs)
- [Webhooks](/automation/webhook)
- [Gmail 钩子Pub/Sub](/automation/gmail-pubsub)
- [安全](/gateway/security)
- [故障排除](/gateway/troubleshooting)
## 名称由来
**OpenClaw = CLAW + TARDIS** — 因为每只太空龙虾都需要一台时空机器。
---
_"我们都只是在玩弄自己的提示词罢了。"_ — 大概是一个嗑多了 token 的 AI 说的
## 致谢
- **Peter Steinberger** [@steipete](https://twitter.com/steipete))— 创作者,龙虾低语者
- **Mario Zechner** [@badlogicc](https://twitter.com/badlogicgames))— Pi 创作者,安全渗透测试员
- **Clawd** — 那只要求取个更好名字的太空龙虾
## 核心贡献者
- **Maxim Vovshin** (@Hyaxia, 36747317+Hyaxia@users.noreply.github.com— Blogwatcher 技能
- **Nacho Iacovino** (@nachoiacovino, nacho.iacovino@gmail.com— 位置解析Telegram + WhatsApp
## 许可证
MIT — 像大海中的龙虾一样自由 🦞
---
_"我们都只是在玩弄自己的提示词罢了。"_ — 大概是一个嗑多了 token 的 AI 说的

View File

@ -0,0 +1,211 @@
---
read_when:
- 从零开始的首次设置
- 您希望找到从安装 → 上手引导 → 发送第一条消息的最快路径
summary: 新手指南:从零开始到发送第一条消息(向导、认证、渠道、配对)
x-i18n:
generated_at: "2026-02-01T13:38:44Z"
model: claude-opus-4-5
provider: pi
source_hash: d0ebc83c10efc569eaf6fb32368a29ef75a373f15da61f3499621462f08aff63
source_path: start/getting-started.md
workflow: 9
---
# 快速入门
目标:从 **零开始** → **第一次成功聊天** (使用合理的默认配置)尽可能快地完成。
最快聊天方式:打开控制界面(无需设置渠道)。运行 `openclaw dashboard`
然后在浏览器中聊天,或打开 `http://127.0.0.1:18789/` (在 Gateway 主机上)。
文档: [仪表盘](/web/dashboard) 和 [控制界面](/web/control-ui)。
推荐路径:使用 **CLI 上手引导向导** `openclaw onboard`)。它会设置:
- 模型/认证(推荐使用 OAuth
- Gateway 设置
- 渠道WhatsApp/Telegram/Discord/Mattermost插件/...
- 配对默认设置(安全私信)
- 工作区引导 + 技能
- 可选的后台服务
如果您需要更详细的参考页面,请跳转至: [向导](/start/wizard), [设置](/start/setup), [配对](/start/pairing), [安全](/gateway/security)。
沙箱注意事项: `agents.defaults.sandbox.mode: "non-main"` 使用 `session.mainKey` (默认 `"main"`),因此群组/渠道会话是沙箱化的。如果您希望主智能体始终在主机上运行,请设置显式的逐智能体覆盖:
```json
{
"routing": {
"agents": {
"main": {
"workspace": "~/.openclaw/workspace",
"sandbox": { "mode": "off" }
}
}
}
}
```
## 0前提条件
- Node `>=22`
- `pnpm` (可选;如果从源码构建则推荐安装)
- **推荐:** Brave Search API 密钥用于网络搜索。最简单的方式:
`openclaw configure --section web` (存储 `tools.web.search.apiKey`)。
参见 [网络工具](/tools/web)。
macOS如果您计划构建应用程序请安装 Xcode / CLT。如果仅使用 CLI + GatewayNode 就足够了。
Windows使用 **WSL2** (推荐 Ubuntu。强烈推荐使用 WSL2原生 Windows 未经测试,问题较多,且工具兼容性较差。请先安装 WSL2然后在 WSL 内执行 Linux 步骤。参见 [Windows (WSL2)](/platforms/windows)。
## 1安装 CLI推荐
```bash
curl -fsSL https://openclaw.bot/install.sh | bash
```
安装选项(安装方式、非交互式、从 GitHub 安装): [安装](/install)。
Windows (PowerShell)
```powershell
iwr -useb https://openclaw.ai/install.ps1 | iex
```
替代方式(全局安装):
```bash
npm install -g openclaw@latest
```
```bash
pnpm add -g openclaw@latest
```
## 2运行上手引导向导并安装服务
```bash
openclaw onboard --install-daemon
```
您需要选择的内容:
- **本地 vs 远程** Gateway
- **认证**OpenAI Code (Codex) 订阅OAuth或 API 密钥。对于 Anthropic我们推荐使用 API 密钥; `claude setup-token` 也受支持。
- **提供商**WhatsApp 二维码登录、Telegram/Discord 机器人令牌、Mattermost 插件令牌等。
- **守护进程**后台安装launchd/systemdWSL2 使用 systemd
- **运行时**Node推荐WhatsApp/Telegram 必需。Bun 为 **不推荐**。
- **Gateway 令牌**:向导默认会生成一个(即使在回环地址上)并将其存储在 `gateway.auth.token`
向导文档: [向导](/start/wizard)
### 认证:存储位置(重要)
- **推荐的 Anthropic 路径:** 设置 API 密钥(向导可以将其存储以供服务使用)。 `claude setup-token` 如果您想复用 Claude Code 凭据,也受支持。
- OAuth 凭据(旧版导入): `~/.openclaw/credentials/oauth.json`
- 认证配置文件OAuth + API 密钥): `~/.openclaw/agents/<agentId>/agent/auth-profiles.json`
无头/服务器提示:先在普通机器上完成 OAuth然后复制 `oauth.json` 到 Gateway 主机上。
## 3启动 Gateway
如果您在上手引导过程中安装了服务Gateway 应该已经在运行:
```bash
openclaw gateway status
```
手动运行(前台):
```bash
openclaw gateway --port 18789 --verbose
```
仪表盘(本地回环): `http://127.0.0.1:18789/`
如果配置了令牌,请将其粘贴到控制界面设置中(存储为 `connect.params.auth.token`)。
⚠️ **Bun 警告WhatsApp + Telegram** Bun 在这些渠道上存在已知问题。如果您使用 WhatsApp 或 Telegram请使用 **Node **
## 3.5快速验证2 分钟)
```bash
openclaw status
openclaw health
openclaw security audit --deep
```
## 4配对 + 连接您的第一个聊天界面
### WhatsApp二维码登录
```bash
openclaw channels login
```
通过 WhatsApp → 设置 → 已关联设备 进行扫描。
WhatsApp 文档: [WhatsApp](/channels/whatsapp)
### Telegram / Discord / 其他
向导可以为您写入令牌/配置。如果您更喜欢手动配置,请从以下内容开始:
- Telegram [Telegram](/channels/telegram)
- Discord [Discord](/channels/discord)
- Mattermost插件 [Mattermost](/channels/mattermost)
**Telegram 私信提示:** 您的第一条私信会返回一个配对码。请批准它(参见下一步),否则机器人将不会响应。
## 5私信安全配对审批
默认策略:未知私信会收到一个短码,消息在批准之前不会被处理。
如果您的第一条私信没有收到回复,请批准配对:
```bash
openclaw pairing list whatsapp
openclaw pairing approve whatsapp <code>
```
配对文档: [配对](/start/pairing)
## 从源码安装(开发)
如果您正在开发 OpenClaw 本身,请从源码运行:
```bash
git clone https://github.com/openclaw/openclaw.git
cd openclaw
pnpm install
pnpm ui:build # auto-installs UI deps on first run
pnpm build
openclaw onboard --install-daemon
```
如果您尚未进行全局安装,请通过以下方式运行上手引导步骤 `pnpm openclaw ...` (从仓库中)。
`pnpm build` 也会打包 A2UI 资源;如果您只需要运行该步骤,请使用 `pnpm canvas:a2ui:bundle`
Gateway从此仓库
```bash
node openclaw.mjs gateway --port 18789 --verbose
```
## 7端到端验证
在新终端中,发送一条测试消息:
```bash
openclaw message send --target +15555550123 --message "Hello from OpenClaw"
```
如果 `openclaw health` 显示"未配置认证",请返回向导设置 OAuth/密钥认证——智能体在没有认证的情况下将无法响应。
提示: `openclaw status --all` 是最佳的可粘贴只读调试报告。
健康探针: `openclaw health` (或 `openclaw status --deep`)向运行中的 Gateway 请求健康快照。
## 后续步骤(可选,但强烈推荐)
- macOS 菜单栏应用 + 语音唤醒: [macOS 应用](/platforms/macos)
- iOS/Android 节点Canvas/相机/语音): [节点](/nodes)
- 远程访问SSH 隧道 / Tailscale Serve [远程访问](/gateway/remote) 和 [Tailscale](/gateway/tailscale)
- 常驻运行 / VPN 设置: [远程访问](/gateway/remote), [exe.dev](/platforms/exe-dev), [Hetzner](/platforms/hetzner), [macOS 远程](/platforms/mac/remote)

330
docs/zh-CN/start/wizard.md Normal file
View File

@ -0,0 +1,330 @@
---
read_when:
- 运行或配置上手引导向导
- 设置新机器
summary: CLI 上手引导向导Gateway、工作区、渠道和技能的引导式设置
x-i18n:
generated_at: "2026-02-01T13:49:20Z"
model: claude-opus-4-5
provider: pi
source_hash: 571302dcf63a0c700cab6b54964e524d75d98315d3b35fafe7232d2ce8199e83
source_path: start/wizard.md
workflow: 9
---
# 上手引导向导 (CLI)
上手引导向导是 **推荐的** 在 macOS、Linux 或 Windows通过 WSL2强烈推荐上设置 OpenClaw 的方式。它通过一个引导式流程配置本地 Gateway 或远程 Gateway 连接,以及渠道、技能和工作区默认设置。
主要入口:
```bash
openclaw onboard
```
最快的首次对话方式:打开 Control UI无需设置渠道。运行
`openclaw dashboard` 然后在浏览器中对话。文档: [仪表盘](/web/dashboard)。
后续重新配置:
```bash
openclaw configure
```
推荐:设置 Brave Search API 密钥,以便智能体可以使用 `web_search`
`web_fetch` 无需密钥也可使用)。最简单的方式: `openclaw configure --section web`
它会将 `tools.web.search.apiKey`存储。文档: [网页工具](/tools/web)。
## 快速入门与高级模式
向导以 **快速入门** (默认设置)与 **高级** (完全控制)模式开始。
**快速入门** 保留默认设置:
- 本地 Gateway回环地址
- 默认工作区(或现有工作区)
- Gateway 端口 **18789**
- Gateway 认证 **令牌** (自动生成,即使在回环地址上也是如此)
- Tailscale 暴露 **关闭**
- Telegram + WhatsApp 私信默认为 **允许名单** (系统会提示您输入手机号码)
**高级** 展示每个步骤模式、工作区、Gateway、渠道、守护进程、技能
## 向导的功能
**本地模式(默认)** 引导您完成:
- 模型/认证OpenAI Code (Codex) 订阅 OAuth、Anthropic API 密钥(推荐)或 setup-token粘贴以及 MiniMax/GLM/Moonshot/AI Gateway 选项)
- 工作区位置 + 引导文件
- Gateway 设置(端口/绑定/认证/Tailscale
- 提供商Telegram、WhatsApp、Discord、Google Chat、Mattermost插件、Signal
- 守护进程安装LaunchAgent / systemd 用户单元)
- 健康检查
- 技能(推荐)
**远程模式** 仅配置本地客户端以连接到其他位置的 Gateway。它 **不会** 在远程主机上安装或更改任何内容。
要添加更多隔离的智能体(独立的工作区 + 会话 + 认证),请使用:
```bash
openclaw agents add <name>
```
提示: `--json`**不会** 意味着非交互模式。请使用 `--non-interactive` (以及 `--workspace`)用于脚本。
## 流程详情(本地)
1. **现有配置检测**
- 如果 `~/.openclaw/openclaw.json` 存在,请选择 **保留 / 修改 / 重置**。
- 重新运行向导 **不会** 不会删除任何内容,除非您明确选择 **重置**
(或传入 `--reset`)。
- 如果配置无效或包含遗留键,向导会停止并要求您运行 `openclaw doctor` 后再继续。
- 重置使用 `trash` (绝不使用 `rm`)并提供作用域:
- 仅配置
- 配置 + 凭据 + 会话
- 完全重置(同时移除工作区)
2. **模型/认证**
- **Anthropic API 密钥(推荐)**:使用 `ANTHROPIC_API_KEY` (如果存在)或提示输入密钥,然后保存供守护进程使用。
- **Anthropic OAuth (Claude Code CLI)**:在 macOS 上,向导会检查钥匙串项 "Claude Code-credentials"(请选择"始终允许"以避免 launchd 启动时被阻止);在 Linux/Windows 上,它会复用 `~/.claude/.credentials.json` (如果存在)。
- **Anthropic 令牌(粘贴 setup-token**:运行 `claude setup-token` 在任意机器上执行,然后粘贴令牌(可以命名;留空 = 默认)。
- **OpenAI Code (Codex) 订阅 (Codex CLI)**:如果 `~/.codex/auth.json` 存在,向导可以复用它。
- **OpenAI Code (Codex) 订阅 (OAuth)**:浏览器流程;粘贴 `code#state`
- 设置 `agents.defaults.model``openai-codex/gpt-5.2` (当模型未设置或为 `openai/*`
- **OpenAI API 密钥**:使用 `OPENAI_API_KEY` (如果存在)或提示输入密钥,然后保存到 `~/.openclaw/.env` 以便 launchd 可以读取。
- **OpenCode Zen多模型代理**:提示输入 `OPENCODE_API_KEY` (或 `OPENCODE_ZEN_API_KEY`,请在 https://opencode.ai/auth)。
- **API 密钥**:为您存储密钥。
- **Vercel AI Gateway多模型代理**:提示输入 `AI_GATEWAY_API_KEY`
- 更多详情: [Vercel AI Gateway](/providers/vercel-ai-gateway)
- **MiniMax M2.1**:配置会自动写入。
- 更多详情: [MiniMax](/providers/minimax)
- **SyntheticAnthropic 兼容)**:提示输入 `SYNTHETIC_API_KEY`
- 更多详情: [Synthetic](/providers/synthetic)
- **Moonshot (Kimi K2)**:配置会自动写入。
- **Kimi Coding**:配置会自动写入。
- 更多详情: [Moonshot AI (Kimi + Kimi Coding)](/providers/moonshot)
- **跳过**:暂不配置认证。
- 从检测到的选项中选择默认模型(或手动输入提供商/模型)。
- 向导会运行模型检查,如果配置的模型未知或缺少认证则发出警告。
- OAuth 凭据存储在 `~/.openclaw/credentials/oauth.json`;认证配置存储在 `~/.openclaw/agents/<agentId>/agent/auth-profiles.json` API 密钥 + OAuth
- 更多详情: [/concepts/oauth](/concepts/oauth)
3. **工作区**
- 默认 `~/.openclaw/workspace` (可配置)。
- 生成智能体引导启动仪式所需的工作区文件。
- 完整工作区布局 + 备份指南: [智能体工作区](/concepts/agent-workspace)
4. **Gateway**
- 端口、绑定、认证模式、Tailscale 暴露。
- 认证建议:保持 **令牌** 即使在回环地址上也使用,以确保本地 WS 客户端必须进行认证。
- 仅在您完全信任每个本地进程时才禁用认证。
- 非回环绑定仍需认证。
5. **渠道**
- [WhatsApp](/channels/whatsapp):可选二维码登录。
- [Telegram](/channels/telegram):机器人令牌。
- [Discord](/channels/discord):机器人令牌。
- [Google Chat](/channels/googlechat):服务账户 JSON + webhook 受众。
- [Mattermost](/channels/mattermost) (插件):机器人令牌 + 基础 URL。
- [Signal](/channels/signal):可选 `signal-cli` 安装 + 账户配置。
- [iMessage](/channels/imessage):本地 `imsg` CLI 路径 + 数据库访问。
- 私信安全:默认为配对模式。首次私信会发送一个验证码;通过 `openclaw pairing approve <channel> <code>` 批准,或使用允许名单。
6. **守护进程安装**
- macOSLaunchAgent
- 需要已登录的用户会话;对于无头模式,请使用自定义 LaunchDaemon未随附
- Linux以及通过 WSL2 的 Windowssystemd 用户单元
- 向导会尝试通过 `loginctl enable-linger <user>` 启用驻留,以便在注销后 Gateway 保持运行。
- 可能会提示输入 sudo写入 `/var/lib/systemd/linger`);它会先尝试不使用 sudo。
- **运行时选择:** Node推荐WhatsApp/Telegram 需要。Bun **不推荐**。
7. **健康检查**
- 启动 Gateway如需并运行 `openclaw health`
- 提示: `openclaw status --deep` 将 Gateway 健康探测添加到状态输出中(需要可达的 Gateway
8. **技能(推荐)**
- 读取可用技能并检查依赖条件。
- 让您选择一个 Node 管理器: **npm / pnpm** (不推荐 bun
- 安装可选依赖项(部分在 macOS 上使用 Homebrew
9. **完成**
- 摘要 + 后续步骤,包括 iOS/Android/macOS 应用以获取额外功能。
- 如果未检测到 GUI向导会打印 Control UI 的 SSH 端口转发说明,而不是打开浏览器。
- 如果 Control UI 资源文件缺失,向导会尝试构建它们;后备方案是 `pnpm ui:build` (自动安装 UI 依赖项)。
## 远程模式
远程模式配置本地客户端以连接到其他位置的 Gateway。
您需要设置的内容:
- 远程 Gateway URL`ws://...`
- 如果远程 Gateway 需要认证,则需提供令牌(推荐)
注意事项:
- 不会执行远程安装或守护进程更改。
- 如果 Gateway 仅绑定回环地址,请使用 SSH 隧道或 tailnet。
- 发现提示:
- macOSBonjour`dns-sd`
- LinuxAvahi`avahi-browse`
## 添加另一个智能体
使用 `openclaw agents add <name>` 创建一个拥有独立工作区、会话和认证配置的单独智能体。不使用 `--workspace` 运行会启动向导。
它会设置:
- `agents.list[].name`
- `agents.list[].workspace`
- `agents.list[].agentDir`
注意事项:
- 默认工作区遵循 `~/.openclaw/workspace-<agentId>`
- 添加 `bindings` 以路由入站消息(向导可以执行此操作)。
- 非交互标志: `--model` `--agent-dir` `--bind` `--non-interactive`
## 非交互模式
使用 `--non-interactive` 用于自动化或脚本化上手引导:
```bash
openclaw onboard --non-interactive \
--mode local \
--auth-choice apiKey \
--anthropic-api-key "$ANTHROPIC_API_KEY" \
--gateway-port 18789 \
--gateway-bind loopback \
--install-daemon \
--daemon-runtime node \
--skip-skills
```
添加 `--json` 以获取机器可读的摘要。
Gemini 示例:
```bash
openclaw onboard --non-interactive \
--mode local \
--auth-choice gemini-api-key \
--gemini-api-key "$GEMINI_API_KEY" \
--gateway-port 18789 \
--gateway-bind loopback
```
Z.AI 示例:
```bash
openclaw onboard --non-interactive \
--mode local \
--auth-choice zai-api-key \
--zai-api-key "$ZAI_API_KEY" \
--gateway-port 18789 \
--gateway-bind loopback
```
Vercel AI Gateway 示例:
```bash
openclaw onboard --non-interactive \
--mode local \
--auth-choice ai-gateway-api-key \
--ai-gateway-api-key "$AI_GATEWAY_API_KEY" \
--gateway-port 18789 \
--gateway-bind loopback
```
Moonshot 示例:
```bash
openclaw onboard --non-interactive \
--mode local \
--auth-choice moonshot-api-key \
--moonshot-api-key "$MOONSHOT_API_KEY" \
--gateway-port 18789 \
--gateway-bind loopback
```
Synthetic 示例:
```bash
openclaw onboard --non-interactive \
--mode local \
--auth-choice synthetic-api-key \
--synthetic-api-key "$SYNTHETIC_API_KEY" \
--gateway-port 18789 \
--gateway-bind loopback
```
OpenCode Zen 示例:
```bash
openclaw onboard --non-interactive \
--mode local \
--auth-choice opencode-zen \
--opencode-zen-api-key "$OPENCODE_API_KEY" \
--gateway-port 18789 \
--gateway-bind loopback
```
添加智能体(非交互)示例:
```bash
openclaw agents add work \
--workspace ~/.openclaw/workspace-work \
--model openai/gpt-5.2 \
--bind whatsapp:biz \
--non-interactive \
--json
```
## Gateway 向导 RPC
Gateway 通过 RPC 暴露向导流程(`wizard.start` `wizard.next` `wizard.cancel` `wizard.status`。客户端macOS 应用、Control UI可以渲染步骤而无需重新实现上手引导逻辑。
## Signal 设置 (signal-cli)
向导可以安装 `signal-cli` (从 GitHub 发布版本):
- 下载相应的发布资源。
- 将其存储在 `~/.openclaw/tools/signal-cli/<version>/`
- 写入 `channels.signal.cliPath` 到您的配置中。
注意事项:
- JVM 构建需要 **Java 21**
- 如有原生构建则优先使用。
- Windows 使用 WSL2signal-cli 安装遵循 WSL 内的 Linux 流程。
## 向导写入的内容
中的典型字段 `~/.openclaw/openclaw.json`
- `agents.defaults.workspace`
- `agents.defaults.model` / `models.providers` (如果选择了 Minimax
- `gateway.*` 模式、绑定、认证、Tailscale
- `channels.telegram.botToken` `channels.discord.token` `channels.signal.*` `channels.imessage.*`
- 渠道允许名单Slack/Discord/Matrix/Microsoft Teams在提示期间选择启用时生效名称会尽可能解析为 ID
- `skills.install.nodeManager`
- `wizard.lastRunAt`
- `wizard.lastRunVersion`
- `wizard.lastRunCommit`
- `wizard.lastRunCommand`
- `wizard.lastRunMode`
`openclaw agents add` 写入 `agents.list[]` 和可选的 `bindings`
WhatsApp 凭据存储在 `~/.openclaw/credentials/whatsapp/<accountId>/`下。会话存储在 `~/.openclaw/agents/<agentId>/sessions/`
部分渠道以插件形式提供。当您在上手引导期间选择某个渠道时,向导会提示先安装它(通过 npm 或本地路径),然后才能进行配置。
## 相关文档
- macOS 应用上手引导: [上手引导](/start/onboarding)
- 配置参考: [Gateway 配置](/gateway/configuration)
- 提供商: [WhatsApp](/channels/whatsapp) [Telegram](/channels/telegram) [Discord](/channels/discord) [Google Chat](/channels/googlechat) [Signal](/channels/signal) [iMessage](/channels/imessage)
- 技能: [技能](/tools/skills) [技能配置](/tools/skills-config)

View File

@ -0,0 +1,29 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"os"
)
type GlossaryEntry struct {
Source string `json:"source"`
Target string `json:"target"`
}
func LoadGlossary(path string) ([]GlossaryEntry, error) {
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, err
}
var entries []GlossaryEntry
if err := json.Unmarshal(data, &entries); err != nil {
return nil, fmt.Errorf("glossary parse failed: %w", err)
}
return entries, nil
}

10
scripts/docs-i18n/go.mod Normal file
View File

@ -0,0 +1,10 @@
module github.com/openclaw/openclaw/scripts/docs-i18n
go 1.22
require (
github.com/joshp123/pi-golang v0.0.4
github.com/yuin/goldmark v1.7.8
golang.org/x/net v0.24.0
gopkg.in/yaml.v3 v3.0.1
)

10
scripts/docs-i18n/go.sum Normal file
View File

@ -0,0 +1,10 @@
github.com/joshp123/pi-golang v0.0.4 h1:82HISyKNN8bIl2lvAd65462LVCQIsjhaUFQxyQgg5Xk=
github.com/joshp123/pi-golang v0.0.4/go.mod h1:9mHEQkeJELYzubXU3b86/T8yedI/iAOKx0Tz0c41qes=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -0,0 +1,160 @@
package main
import (
"context"
"io"
"strings"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/text"
"golang.org/x/net/html"
"sort"
)
type htmlReplacement struct {
Start int
Stop int
Value string
}
func translateHTMLBlocks(ctx context.Context, translator *PiTranslator, body, srcLang, tgtLang string) (string, error) {
source := []byte(body)
r := text.NewReader(source)
md := goldmark.New(
goldmark.WithExtensions(extension.GFM),
)
doc := md.Parser().Parse(r)
replacements := make([]htmlReplacement, 0, 8)
_ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
block, ok := n.(*ast.HTMLBlock)
if !ok {
return ast.WalkContinue, nil
}
start, stop, ok := htmlBlockSpan(block, source)
if !ok {
return ast.WalkSkipChildren, nil
}
htmlText := string(source[start:stop])
translated, err := translateHTMLBlock(ctx, translator, htmlText, srcLang, tgtLang)
if err != nil {
return ast.WalkStop, err
}
replacements = append(replacements, htmlReplacement{Start: start, Stop: stop, Value: translated})
return ast.WalkSkipChildren, nil
})
if len(replacements) == 0 {
return body, nil
}
return applyHTMLReplacements(body, replacements), nil
}
func htmlBlockSpan(block *ast.HTMLBlock, source []byte) (int, int, bool) {
lines := block.Lines()
if lines.Len() == 0 {
return 0, 0, false
}
start := lines.At(0).Start
stop := lines.At(lines.Len() - 1).Stop
if start >= stop {
return 0, 0, false
}
return start, stop, true
}
func applyHTMLReplacements(body string, replacements []htmlReplacement) string {
if len(replacements) == 0 {
return body
}
sortHTMLReplacements(replacements)
var out strings.Builder
last := 0
for _, rep := range replacements {
if rep.Start < last {
continue
}
out.WriteString(body[last:rep.Start])
out.WriteString(rep.Value)
last = rep.Stop
}
out.WriteString(body[last:])
return out.String()
}
func sortHTMLReplacements(replacements []htmlReplacement) {
sort.Slice(replacements, func(i, j int) bool {
return replacements[i].Start < replacements[j].Start
})
}
func translateHTMLBlock(ctx context.Context, translator *PiTranslator, htmlText, srcLang, tgtLang string) (string, error) {
tokenizer := html.NewTokenizer(strings.NewReader(htmlText))
var out strings.Builder
skipDepth := 0
for {
tt := tokenizer.Next()
if tt == html.ErrorToken {
if err := tokenizer.Err(); err != nil && err != io.EOF {
return "", err
}
break
}
raw := string(tokenizer.Raw())
tok := tokenizer.Token()
switch tt {
case html.StartTagToken:
out.WriteString(raw)
if isSkipTag(strings.ToLower(tok.Data)) {
skipDepth++
}
case html.EndTagToken:
out.WriteString(raw)
if isSkipTag(strings.ToLower(tok.Data)) && skipDepth > 0 {
skipDepth--
}
case html.SelfClosingTagToken:
out.WriteString(raw)
case html.TextToken:
if shouldTranslateHTMLText(skipDepth, raw) {
translated, err := translator.Translate(ctx, raw, srcLang, tgtLang)
if err != nil {
return "", err
}
out.WriteString(translated)
} else {
out.WriteString(raw)
}
default:
out.WriteString(raw)
}
}
return out.String(), nil
}
func shouldTranslateHTMLText(skipDepth int, text string) bool {
if strings.TrimSpace(text) == "" {
return false
}
return skipDepth == 0
}
func isSkipTag(tag string) bool {
switch tag {
case "code", "pre", "script", "style":
return true
default:
return false
}
}

58
scripts/docs-i18n/main.go Normal file
View File

@ -0,0 +1,58 @@
package main
import (
"context"
"flag"
"fmt"
"path/filepath"
)
func main() {
var (
targetLang = flag.String("lang", "zh-CN", "target language (e.g., zh-CN)")
sourceLang = flag.String("src", "en", "source language")
docsRoot = flag.String("docs", "docs", "docs root")
tmPath = flag.String("tm", "", "translation memory path")
)
flag.Parse()
files := flag.Args()
if len(files) == 0 {
fatal(fmt.Errorf("no doc files provided"))
}
resolvedDocsRoot, err := filepath.Abs(*docsRoot)
if err != nil {
fatal(err)
}
if *tmPath == "" {
*tmPath = filepath.Join(resolvedDocsRoot, ".i18n", fmt.Sprintf("%s.tm.jsonl", *targetLang))
}
glossaryPath := filepath.Join(resolvedDocsRoot, ".i18n", fmt.Sprintf("glossary.%s.json", *targetLang))
glossary, err := LoadGlossary(glossaryPath)
if err != nil {
fatal(err)
}
translator, err := NewPiTranslator(*sourceLang, *targetLang, glossary)
if err != nil {
fatal(err)
}
defer translator.Close()
tm, err := LoadTranslationMemory(*tmPath)
if err != nil {
fatal(err)
}
for _, file := range files {
if err := processFile(context.Background(), translator, tm, resolvedDocsRoot, file, *sourceLang, *targetLang); err != nil {
fatal(err)
}
}
if err := tm.Save(); err != nil {
fatal(err)
}
}

View File

@ -0,0 +1,131 @@
package main
import (
"sort"
"strings"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/text"
)
func extractSegments(body, relPath string) ([]Segment, error) {
source := []byte(body)
r := text.NewReader(source)
md := goldmark.New(
goldmark.WithExtensions(extension.GFM),
)
doc := md.Parser().Parse(r)
segments := make([]Segment, 0, 128)
skipDepth := 0
var lastBlock ast.Node
err := ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
switch n.(type) {
case *ast.CodeBlock, *ast.FencedCodeBlock, *ast.CodeSpan, *ast.HTMLBlock, *ast.RawHTML:
if entering {
skipDepth++
} else {
skipDepth--
}
return ast.WalkContinue, nil
}
if !entering || skipDepth > 0 {
return ast.WalkContinue, nil
}
textNode, ok := n.(*ast.Text)
if !ok {
return ast.WalkContinue, nil
}
block := blockParent(textNode)
if block == nil {
return ast.WalkContinue, nil
}
textValue := string(textNode.Segment.Value(source))
if strings.TrimSpace(textValue) == "" {
return ast.WalkContinue, nil
}
start := textNode.Segment.Start
stop := textNode.Segment.Stop
if len(segments) > 0 && lastBlock == block {
last := &segments[len(segments)-1]
gap := string(source[last.Stop:start])
if strings.TrimSpace(gap) == "" {
last.Stop = stop
return ast.WalkContinue, nil
}
}
segments = append(segments, Segment{Start: start, Stop: stop})
lastBlock = block
return ast.WalkContinue, nil
})
if err != nil {
return nil, err
}
filtered := make([]Segment, 0, len(segments))
for _, seg := range segments {
textValue := string(source[seg.Start:seg.Stop])
trimmed := strings.TrimSpace(textValue)
if trimmed == "" {
continue
}
textHash := hashText(textValue)
segmentID := segmentID(relPath, textHash)
filtered = append(filtered, Segment{
Start: seg.Start,
Stop: seg.Stop,
Text: textValue,
TextHash: textHash,
SegmentID: segmentID,
})
}
sort.Slice(filtered, func(i, j int) bool {
return filtered[i].Start < filtered[j].Start
})
return filtered, nil
}
func blockParent(n ast.Node) ast.Node {
for node := n.Parent(); node != nil; node = node.Parent() {
if isTranslatableBlock(node) {
return node
}
}
return nil
}
func isTranslatableBlock(n ast.Node) bool {
switch n.(type) {
case *ast.Paragraph, *ast.Heading, *ast.ListItem:
return true
default:
return false
}
}
func applyTranslations(body string, segments []Segment) string {
if len(segments) == 0 {
return body
}
var out strings.Builder
last := 0
for _, seg := range segments {
if seg.Start < last {
continue
}
out.WriteString(body[last:seg.Start])
out.WriteString(seg.Translated)
last = seg.Stop
}
out.WriteString(body[last:])
return out.String()
}

View File

@ -0,0 +1,89 @@
package main
import (
"fmt"
"regexp"
"strings"
)
var (
inlineCodeRe = regexp.MustCompile("`[^`]+`")
angleLinkRe = regexp.MustCompile(`<https?://[^>]+>`)
linkURLRe = regexp.MustCompile(`\[[^\]]*\]\(([^)]+)\)`)
placeholderRe = regexp.MustCompile(`__OC_I18N_\d+__`)
)
func maskMarkdown(text string, nextPlaceholder func() string, placeholders *[]string, mapping map[string]string) string {
masked := maskMatches(text, inlineCodeRe, nextPlaceholder, placeholders, mapping)
masked = maskMatches(masked, angleLinkRe, nextPlaceholder, placeholders, mapping)
masked = maskLinkURLs(masked, nextPlaceholder, placeholders, mapping)
return masked
}
func maskMatches(text string, re *regexp.Regexp, nextPlaceholder func() string, placeholders *[]string, mapping map[string]string) string {
matches := re.FindAllStringIndex(text, -1)
if len(matches) == 0 {
return text
}
var out strings.Builder
pos := 0
for _, span := range matches {
start, end := span[0], span[1]
if start < pos {
continue
}
out.WriteString(text[pos:start])
placeholder := nextPlaceholder()
mapping[placeholder] = text[start:end]
*placeholders = append(*placeholders, placeholder)
out.WriteString(placeholder)
pos = end
}
out.WriteString(text[pos:])
return out.String()
}
func maskLinkURLs(text string, nextPlaceholder func() string, placeholders *[]string, mapping map[string]string) string {
matches := linkURLRe.FindAllStringSubmatchIndex(text, -1)
if len(matches) == 0 {
return text
}
var out strings.Builder
pos := 0
for _, span := range matches {
fullStart := span[0]
urlStart, urlEnd := span[2], span[3]
if urlStart < 0 || urlEnd < 0 {
continue
}
if fullStart < pos {
continue
}
out.WriteString(text[pos:urlStart])
placeholder := nextPlaceholder()
mapping[placeholder] = text[urlStart:urlEnd]
*placeholders = append(*placeholders, placeholder)
out.WriteString(placeholder)
pos = urlEnd
}
out.WriteString(text[pos:])
return out.String()
}
func unmaskMarkdown(text string, placeholders []string, mapping map[string]string) string {
out := text
for _, placeholder := range placeholders {
original := mapping[placeholder]
out = strings.ReplaceAll(out, placeholder, original)
}
return out
}
func validatePlaceholders(text string, placeholders []string) error {
for _, placeholder := range placeholders {
if !strings.Contains(text, placeholder) {
return fmt.Errorf("placeholder missing: %s", placeholder)
}
}
return nil
}

View File

@ -0,0 +1,30 @@
package main
import (
"fmt"
)
type PlaceholderState struct {
counter int
used map[string]struct{}
}
func NewPlaceholderState(text string) *PlaceholderState {
used := map[string]struct{}{}
for _, hit := range placeholderRe.FindAllString(text, -1) {
used[hit] = struct{}{}
}
return &PlaceholderState{counter: 900000, used: used}
}
func (s *PlaceholderState) Next() string {
for {
candidate := fmt.Sprintf("__OC_I18N_%d__", s.counter)
s.counter++
if _, ok := s.used[candidate]; ok {
continue
}
s.used[candidate] = struct{}{}
return candidate
}
}

View File

@ -0,0 +1,205 @@
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"gopkg.in/yaml.v3"
)
func processFile(ctx context.Context, translator *PiTranslator, tm *TranslationMemory, docsRoot, filePath, srcLang, tgtLang string) error {
absPath, err := filepath.Abs(filePath)
if err != nil {
return err
}
relPath, err := filepath.Rel(docsRoot, absPath)
if err != nil {
return err
}
if relPath == "." || relPath == "" {
return fmt.Errorf("file %s resolves to docs root %s", absPath, docsRoot)
}
if filepath.IsAbs(relPath) || relPath == ".." || strings.HasPrefix(relPath, ".."+string(filepath.Separator)) {
return fmt.Errorf("file %s not under docs root %s", absPath, docsRoot)
}
content, err := os.ReadFile(absPath)
if err != nil {
return err
}
frontMatter, body := splitFrontMatter(string(content))
frontData := map[string]any{}
if frontMatter != "" {
if err := yaml.Unmarshal([]byte(frontMatter), &frontData); err != nil {
return fmt.Errorf("frontmatter parse failed for %s: %w", relPath, err)
}
}
if err := translateFrontMatter(ctx, translator, tm, frontData, relPath, srcLang, tgtLang); err != nil {
return err
}
body, err = translateHTMLBlocks(ctx, translator, body, srcLang, tgtLang)
if err != nil {
return err
}
segments, err := extractSegments(body, relPath)
if err != nil {
return err
}
namespace := cacheNamespace()
for i := range segments {
seg := &segments[i]
seg.CacheKey = cacheKey(namespace, srcLang, tgtLang, seg.SegmentID, seg.TextHash)
if entry, ok := tm.Get(seg.CacheKey); ok {
seg.Translated = entry.Translated
continue
}
translated, err := translator.Translate(ctx, seg.Text, srcLang, tgtLang)
if err != nil {
return fmt.Errorf("translate failed (%s): %w", relPath, err)
}
seg.Translated = translated
entry := TMEntry{
CacheKey: seg.CacheKey,
SegmentID: seg.SegmentID,
SourcePath: relPath,
TextHash: seg.TextHash,
Text: seg.Text,
Translated: translated,
Provider: providerName,
Model: modelVersion,
SrcLang: srcLang,
TgtLang: tgtLang,
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
}
tm.Put(entry)
}
translatedBody := applyTranslations(body, segments)
updatedFront, err := encodeFrontMatter(frontData, relPath, content)
if err != nil {
return err
}
outputPath := filepath.Join(docsRoot, tgtLang, relPath)
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
return err
}
output := updatedFront + translatedBody
return os.WriteFile(outputPath, []byte(output), 0o644)
}
func splitFrontMatter(content string) (string, string) {
if !strings.HasPrefix(content, "---") {
return "", content
}
lines := strings.Split(content, "\n")
if len(lines) < 2 {
return "", content
}
endIndex := -1
for i := 1; i < len(lines); i++ {
if strings.TrimSpace(lines[i]) == "---" {
endIndex = i
break
}
}
if endIndex == -1 {
return "", content
}
front := strings.Join(lines[1:endIndex], "\n")
body := strings.Join(lines[endIndex+1:], "\n")
if strings.HasPrefix(body, "\n") {
body = body[1:]
}
return front, body
}
func encodeFrontMatter(frontData map[string]any, relPath string, source []byte) (string, error) {
if len(frontData) == 0 {
return "", nil
}
frontData["x-i18n"] = map[string]any{
"source_path": relPath,
"source_hash": hashBytes(source),
"provider": providerName,
"model": modelVersion,
"workflow": workflowVersion,
"generated_at": time.Now().UTC().Format(time.RFC3339),
}
encoded, err := yaml.Marshal(frontData)
if err != nil {
return "", err
}
return fmt.Sprintf("---\n%s---\n\n", string(encoded)), nil
}
func translateFrontMatter(ctx context.Context, translator *PiTranslator, tm *TranslationMemory, data map[string]any, relPath, srcLang, tgtLang string) error {
if len(data) == 0 {
return nil
}
if summary, ok := data["summary"].(string); ok {
translated, err := translateSnippet(ctx, translator, tm, relPath+":frontmatter:summary", summary, srcLang, tgtLang)
if err != nil {
return err
}
data["summary"] = translated
}
if readWhen, ok := data["read_when"].([]any); ok {
translated := make([]any, 0, len(readWhen))
for idx, item := range readWhen {
textValue, ok := item.(string)
if !ok {
translated = append(translated, item)
continue
}
value, err := translateSnippet(ctx, translator, tm, fmt.Sprintf("%s:frontmatter:read_when:%d", relPath, idx), textValue, srcLang, tgtLang)
if err != nil {
return err
}
translated = append(translated, value)
}
data["read_when"] = translated
}
return nil
}
func translateSnippet(ctx context.Context, translator *PiTranslator, tm *TranslationMemory, segmentID, textValue, srcLang, tgtLang string) (string, error) {
if strings.TrimSpace(textValue) == "" {
return textValue, nil
}
namespace := cacheNamespace()
textHash := hashText(textValue)
ck := cacheKey(namespace, srcLang, tgtLang, segmentID, textHash)
if entry, ok := tm.Get(ck); ok {
return entry.Translated, nil
}
translated, err := translator.Translate(ctx, textValue, srcLang, tgtLang)
if err != nil {
return "", err
}
entry := TMEntry{
CacheKey: ck,
SegmentID: segmentID,
SourcePath: segmentID,
TextHash: textHash,
Text: textValue,
Translated: translated,
Provider: providerName,
Model: modelVersion,
SrcLang: srcLang,
TgtLang: tgtLang,
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
}
tm.Put(entry)
return translated, nil
}

View File

@ -0,0 +1,11 @@
package main
type Segment struct {
Start int
Stop int
Text string
TextHash string
SegmentID string
Translated string
CacheKey string
}

126
scripts/docs-i18n/tm.go Normal file
View File

@ -0,0 +1,126 @@
package main
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
)
type TMEntry struct {
CacheKey string `json:"cache_key"`
SegmentID string `json:"segment_id"`
SourcePath string `json:"source_path"`
TextHash string `json:"text_hash"`
Text string `json:"text"`
Translated string `json:"translated"`
Provider string `json:"provider"`
Model string `json:"model"`
SrcLang string `json:"src_lang"`
TgtLang string `json:"tgt_lang"`
UpdatedAt string `json:"updated_at"`
}
type TranslationMemory struct {
path string
entries map[string]TMEntry
}
func LoadTranslationMemory(path string) (*TranslationMemory, error) {
tm := &TranslationMemory{path: path, entries: map[string]TMEntry{}}
file, err := os.Open(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return tm, nil
}
return nil, err
}
defer file.Close()
reader := bufio.NewReader(file)
for {
line, err := reader.ReadBytes('\n')
if len(line) > 0 {
trimmed := strings.TrimSpace(string(line))
if trimmed != "" {
var entry TMEntry
if err := json.Unmarshal([]byte(trimmed), &entry); err != nil {
return nil, fmt.Errorf("translation memory decode failed: %w", err)
}
if entry.CacheKey != "" {
tm.entries[entry.CacheKey] = entry
}
}
}
if err != nil {
if errors.Is(err, io.EOF) {
break
}
return nil, err
}
}
return tm, nil
}
func (tm *TranslationMemory) Get(cacheKey string) (TMEntry, bool) {
entry, ok := tm.entries[cacheKey]
return entry, ok
}
func (tm *TranslationMemory) Put(entry TMEntry) {
if entry.CacheKey == "" {
return
}
tm.entries[entry.CacheKey] = entry
}
func (tm *TranslationMemory) Save() error {
if tm.path == "" {
return nil
}
if err := os.MkdirAll(filepath.Dir(tm.path), 0o755); err != nil {
return err
}
tmpPath := tm.path + ".tmp"
file, err := os.Create(tmpPath)
if err != nil {
return err
}
keys := make([]string, 0, len(tm.entries))
for key := range tm.entries {
keys = append(keys, key)
}
sort.Strings(keys)
writer := bufio.NewWriter(file)
for _, key := range keys {
entry := tm.entries[key]
payload, err := json.Marshal(entry)
if err != nil {
_ = file.Close()
return err
}
if _, err := writer.Write(payload); err != nil {
_ = file.Close()
return err
}
if _, err := writer.WriteString("\n"); err != nil {
_ = file.Close()
return err
}
}
if err := writer.Flush(); err != nil {
_ = file.Close()
return err
}
if err := file.Close(); err != nil {
return err
}
return os.Rename(tmpPath, tm.path)
}

View File

@ -0,0 +1,104 @@
package main
import (
"context"
"errors"
"fmt"
"strings"
pi "github.com/joshp123/pi-golang"
)
type PiTranslator struct {
client *pi.OneShotClient
}
func NewPiTranslator(srcLang, tgtLang string, glossary []GlossaryEntry) (*PiTranslator, error) {
options := pi.DefaultOneShotOptions()
options.AppName = "openclaw-docs-i18n"
options.Mode = pi.ModeDragons
options.Dragons = pi.DragonsOptions{
Provider: "anthropic",
Model: modelVersion,
Thinking: "high",
}
options.SystemPrompt = translationPrompt(srcLang, tgtLang, glossary)
client, err := pi.StartOneShot(options)
if err != nil {
return nil, err
}
return &PiTranslator{client: client}, nil
}
func (t *PiTranslator) Translate(ctx context.Context, text, srcLang, tgtLang string) (string, error) {
if t.client == nil {
return "", errors.New("pi client unavailable")
}
prefix, core, suffix := splitWhitespace(text)
if core == "" {
return text, nil
}
state := NewPlaceholderState(core)
placeholders := make([]string, 0, 8)
mapping := map[string]string{}
masked := maskMarkdown(core, state.Next, &placeholders, mapping)
res, err := t.client.Run(ctx, masked)
if err != nil {
return "", err
}
translated := strings.TrimSpace(res.Text)
if err := validatePlaceholders(translated, placeholders); err != nil {
return "", err
}
translated = unmaskMarkdown(translated, placeholders, mapping)
return prefix + translated + suffix, nil
}
func (t *PiTranslator) Close() {
if t.client != nil {
_ = t.client.Close()
}
}
func translationPrompt(srcLang, tgtLang string, glossary []GlossaryEntry) string {
srcLabel := srcLang
tgtLabel := tgtLang
if strings.EqualFold(srcLang, "en") {
srcLabel = "English"
}
if strings.EqualFold(tgtLang, "zh-CN") {
tgtLabel = "Simplified Chinese"
}
glossaryBlock := buildGlossaryPrompt(glossary)
return strings.TrimSpace(fmt.Sprintf(`You are a translation function, not a chat assistant.
Translate from %s to %s.
Rules:
- Output ONLY the translated text. No preamble, no questions, no commentary.
- Preserve Markdown syntax exactly (headings, lists, tables, emphasis).
- Do not translate code spans/blocks, config keys, CLI flags, or env vars.
- Do not alter URLs or anchors.
- Preserve placeholders exactly: __OC_I18N_####__.
- Use neutral technical Chinese; avoid slang or jokes.
- Keep product names in English: OpenClaw, Gateway, Pi, WhatsApp, Telegram, Discord, iMessage, Slack, Microsoft Teams, Google Chat, Signal.
%s
If the input is empty, output empty.
If the input contains only placeholders, output it unchanged.`, srcLabel, tgtLabel, glossaryBlock))
}
func buildGlossaryPrompt(glossary []GlossaryEntry) string {
if len(glossary) == 0 {
return ""
}
var lines []string
lines = append(lines, "Preferred translations (use when natural):")
for _, entry := range glossary {
if entry.Source == "" || entry.Target == "" {
continue
}
lines = append(lines, fmt.Sprintf("- %s -> %s", entry.Source, entry.Target))
}
return strings.Join(lines, "\n")
}

81
scripts/docs-i18n/util.go Normal file
View File

@ -0,0 +1,81 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"strings"
)
const (
workflowVersion = 9
providerName = "pi"
modelVersion = "claude-opus-4-5"
)
func cacheNamespace() string {
return fmt.Sprintf("wf=%d|provider=%s|model=%s", workflowVersion, providerName, modelVersion)
}
func cacheKey(namespace, srcLang, tgtLang, segmentID, textHash string) string {
raw := fmt.Sprintf("%s|%s|%s|%s|%s", namespace, srcLang, tgtLang, segmentID, textHash)
hash := sha256.Sum256([]byte(raw))
return hex.EncodeToString(hash[:])
}
func hashText(text string) string {
normalized := normalizeText(text)
hash := sha256.Sum256([]byte(normalized))
return hex.EncodeToString(hash[:])
}
func hashBytes(data []byte) string {
hash := sha256.Sum256(data)
return hex.EncodeToString(hash[:])
}
func normalizeText(text string) string {
return strings.Join(strings.Fields(strings.TrimSpace(text)), " ")
}
func segmentID(relPath, textHash string) string {
shortHash := textHash
if len(shortHash) > 16 {
shortHash = shortHash[:16]
}
return fmt.Sprintf("%s:%s", relPath, shortHash)
}
func splitWhitespace(text string) (string, string, string) {
if text == "" {
return "", "", ""
}
start := 0
for start < len(text) && isWhitespace(text[start]) {
start++
}
end := len(text)
for end > start && isWhitespace(text[end-1]) {
end--
}
return text[:start], text[start:end], text[end:]
}
func isWhitespace(b byte) bool {
switch b {
case ' ', '\t', '\n', '\r':
return true
default:
return false
}
}
func fatal(err error) {
if err == nil {
return
}
_, _ = io.WriteString(os.Stderr, err.Error()+"\n")
os.Exit(1)
}