Docs: add zh-CN entrypoint translations (#6300)
* Docs: add zh-CN entrypoint translations * Docs: harden docs-i18n parsingmain
parent
7a8a39a141
commit
0e0e395b9e
|
|
@ -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`.
|
||||
|
|
@ -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": "环境变量"
|
||||
}
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -11,6 +11,10 @@
|
|||
"primary": "#FF5A36"
|
||||
},
|
||||
"topbarLinks": [
|
||||
{
|
||||
"name": "中文",
|
||||
"url": "/zh-CN"
|
||||
},
|
||||
{
|
||||
"name": "GitHub",
|
||||
"url": "https://github.com/openclaw/openclaw"
|
||||
|
|
|
|||
|
|
@ -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)、Telegram(Bot API / grammY)、Discord(Bot API / channels.discord.js)和 iMessage(imsg CLI)桥接至编程智能体,例如 [Pi](https://github.com/badlogic/pi-mono)。插件可添加 Mattermost(Bot 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)
|
||||
- 🤖 **智能体桥接** — Pi(RPC 模式),支持工具流式传输
|
||||
- ⏱️ **流式传输与分块** — 块流式传输 + Telegram 草稿流式传输详情([/concepts/streaming](/concepts/streaming))
|
||||
- 🧠 **多智能体路由** — 将提供商账户/对等方路由到隔离的智能体(工作区 + 每智能体会话)
|
||||
- 🔐 **订阅认证** — 通过 OAuth 支持 Anthropic(Claude Pro/Max)+ OpenAI(ChatGPT/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 说的
|
||||
|
|
@ -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 + Gateway,Node 就足够了。
|
||||
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/systemd;WSL2 使用 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)
|
||||
|
|
@ -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)
|
||||
- **Synthetic(Anthropic 兼容)**:提示输入 `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. **守护进程安装**
|
||||
- macOS:LaunchAgent
|
||||
- 需要已登录的用户会话;对于无头模式,请使用自定义 LaunchDaemon(未随附)。
|
||||
- Linux(以及通过 WSL2 的 Windows):systemd 用户单元
|
||||
- 向导会尝试通过 `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。
|
||||
- 发现提示:
|
||||
- macOS:Bonjour(`dns-sd`)
|
||||
- Linux:Avahi(`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 使用 WSL2;signal-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)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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=
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package main
|
||||
|
||||
type Segment struct {
|
||||
Start int
|
||||
Stop int
|
||||
Text string
|
||||
TextHash string
|
||||
SegmentID string
|
||||
Translated string
|
||||
CacheKey string
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
Loading…
Reference in New Issue